From c25849f8ff4072d2ca7992c3f398ce6ce7953f6f Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:36:11 +0100 Subject: [PATCH 1/5] fix: validate proposals against the intent they claim to fulfil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related holes, both on the path immediately before funds move. tokenPairMatches discarded its proposal argument. The check labelled "token pair matches" verified only that the intent's own fields were non-empty and differed from each other, which no proposal could fail. A proposal swapping an unrelated pair validated clean. Fixing it properly needed the data to compare: Proposal carried no token fields at all, so the pair an agent intended to execute was never recorded. Those fields are added and threaded through the DTO, and an omitted pair is now a failure rather than a pass, so leaving them blank cannot restore the old behaviour. Separately, the validate tags on every request DTO were inert — the module had no validator at all, so an empty proposal was persisted unchallenged. Tags are now enforced at all seven decode sites, with every failure reported at once so a caller fixing a malformed request does not discover the problems one round-trip at a time. Slippage and quality move from `required` to range checks: `required` rejects the zero value, and zero projected slippage is a legitimate claim. Adds the first tests in the module, covering both regressions. --- go.mod | 15 +- go.sum | 18 + internal/handlers/circle.go | 217 ++++++------ internal/handlers/executions.go | 514 ++++++++++++++-------------- internal/handlers/intents.go | 163 ++++----- internal/handlers/proposals.go | 229 +++++++------ internal/handlers/types.go | 182 +++++----- internal/handlers/validate.go | 108 ++++++ internal/handlers/validate_test.go | 99 ++++++ internal/models/models.go | 225 ++++++------ internal/validation/service.go | 260 +++++++------- internal/validation/service_test.go | 146 ++++++++ 12 files changed, 1316 insertions(+), 860 deletions(-) create mode 100644 internal/handlers/validate.go create mode 100644 internal/handlers/validate_test.go create mode 100644 internal/validation/service_test.go diff --git a/go.mod b/go.mod index f44bcaf..53c5c05 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,15 @@ require ( github.com/go-chi/chi/v5 v5.3.0 github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 +) + +require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.4 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -16,12 +23,14 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/leodido/go-urn v1.5.0 // indirect github.com/lib/pq v1.12.3 // indirect github.com/redis/go-redis/v9 v9.20.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect gorm.io/datatypes v1.2.7 // indirect gorm.io/driver/mysql v1.5.6 // indirect gorm.io/driver/postgres v1.6.0 // indirect diff --git a/go.sum b/go.sum index db67e8c..05d7326 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,16 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= +github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= @@ -26,6 +34,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0= +github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -38,10 +48,18 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= diff --git a/internal/handlers/circle.go b/internal/handlers/circle.go index 6ccb482..83dedeb 100644 --- a/internal/handlers/circle.go +++ b/internal/handlers/circle.go @@ -1,101 +1,116 @@ -package handlers - -import ( - "encoding/json" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "github.com/Cookit-labs/Backend/internal/circle" -) - -// circleClient returns a Circle client from the handler config. -// In production, this should be injected; for now it reads from the handler's cfg field. -func (h *Handler) circleClient() *circle.Client { - return h.circle -} - -// POST /api/v1/wallets -func (h *Handler) CreateWallet(w http.ResponseWriter, r *http.Request) { - var req struct { - UserWallet string `json:"user_wallet"` - Description string `json:"description"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - // Use the user's wallet address as idempotency key so we never double-create - idempotencyKey := uuid.NewSHA1(uuid.NameSpaceURL, []byte("wallet:"+req.UserWallet)).String() - - wallet, err := h.circleClient().CreateWallet(r.Context(), idempotencyKey, req.Description) - if err != nil { - respondError(w, http.StatusBadGateway, "failed to create Circle wallet: "+err.Error()) - return - } - - respondJSON(w, http.StatusCreated, wallet) -} - -// GET /api/v1/wallets/{walletID}/balance -func (h *Handler) GetWalletBalance(w http.ResponseWriter, r *http.Request) { - walletID := chi.URLParam(r, "walletID") - - balance, err := h.circleClient().GetBalance(r.Context(), walletID) - if err != nil { - respondError(w, http.StatusBadGateway, "failed to fetch balance: "+err.Error()) - return - } - - respondJSON(w, http.StatusOK, map[string]string{ - "wallet_id": walletID, - "amount": balance, - "currency": "USDC", - }) -} - -// POST /api/v1/wallets/transfer -func (h *Handler) TransferUSDC(w http.ResponseWriter, r *http.Request) { - var req struct { - FromWalletID string `json:"from_wallet_id"` - ToWalletID string `json:"to_wallet_id"` - Amount string `json:"amount"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - idempotencyKey := uuid.New().String() - - transfer, err := h.circleClient().SendUSDC(r.Context(), idempotencyKey, req.FromWalletID, req.ToWalletID, req.Amount) - if err != nil { - respondError(w, http.StatusBadGateway, "USDC transfer failed: "+err.Error()) - return - } - - respondJSON(w, http.StatusCreated, transfer) -} - -// POST /api/v1/wallets/cctp — cross-chain routing demo -func (h *Handler) CCTPSimulate(w http.ResponseWriter, r *http.Request) { - var req circle.CCTPBurnRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.IdempotencyKey == "" { - req.IdempotencyKey = uuid.New().String() - } - - transfer, err := h.circleClient().SimulateCCTPTransfer(r.Context(), req) - if err != nil { - respondError(w, http.StatusBadGateway, "CCTP simulation failed: "+err.Error()) - return - } - - respondJSON(w, http.StatusCreated, transfer) -} +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/Cookit-labs/Backend/internal/circle" +) + +// circleClient returns a Circle client from the handler config. +// In production, this should be injected; for now it reads from the handler's cfg field. +func (h *Handler) circleClient() *circle.Client { + return h.circle +} + +// POST /api/v1/wallets +func (h *Handler) CreateWallet(w http.ResponseWriter, r *http.Request) { + var req struct { + UserWallet string `json:"user_wallet"` + Description string `json:"description"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + // Use the user's wallet address as idempotency key so we never double-create + idempotencyKey := uuid.NewSHA1(uuid.NameSpaceURL, []byte("wallet:"+req.UserWallet)).String() + + wallet, err := h.circleClient().CreateWallet(r.Context(), idempotencyKey, req.Description) + if err != nil { + respondError(w, http.StatusBadGateway, "failed to create Circle wallet: "+err.Error()) + return + } + + respondJSON(w, http.StatusCreated, wallet) +} + +// GET /api/v1/wallets/{walletID}/balance +func (h *Handler) GetWalletBalance(w http.ResponseWriter, r *http.Request) { + walletID := chi.URLParam(r, "walletID") + + balance, err := h.circleClient().GetBalance(r.Context(), walletID) + if err != nil { + respondError(w, http.StatusBadGateway, "failed to fetch balance: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]string{ + "wallet_id": walletID, + "amount": balance, + "currency": "USDC", + }) +} + +// POST /api/v1/wallets/transfer +func (h *Handler) TransferUSDC(w http.ResponseWriter, r *http.Request) { + var req struct { + FromWalletID string `json:"from_wallet_id"` + ToWalletID string `json:"to_wallet_id"` + Amount string `json:"amount"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + idempotencyKey := uuid.New().String() + + transfer, err := h.circleClient().SendUSDC(r.Context(), idempotencyKey, req.FromWalletID, req.ToWalletID, req.Amount) + if err != nil { + respondError(w, http.StatusBadGateway, "USDC transfer failed: "+err.Error()) + return + } + + respondJSON(w, http.StatusCreated, transfer) +} + +// POST /api/v1/wallets/cctp — cross-chain routing demo +func (h *Handler) CCTPSimulate(w http.ResponseWriter, r *http.Request) { + var req circle.CCTPBurnRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + if req.IdempotencyKey == "" { + req.IdempotencyKey = uuid.New().String() + } + + transfer, err := h.circleClient().SimulateCCTPTransfer(r.Context(), req) + if err != nil { + respondError(w, http.StatusBadGateway, "CCTP simulation failed: "+err.Error()) + return + } + + respondJSON(w, http.StatusCreated, transfer) +} diff --git a/internal/handlers/executions.go b/internal/handlers/executions.go index 939f6e7..d30559b 100644 --- a/internal/handlers/executions.go +++ b/internal/handlers/executions.go @@ -1,252 +1,262 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "time" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "gorm.io/gorm" - - "github.com/Cookit-labs/Backend/internal/models" - "github.com/Cookit-labs/Backend/internal/scoring" - "github.com/Cookit-labs/Backend/internal/validation" -) - -func (h *Handler) SelectWinner(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - // Fetch all proposals for this intent - var proposals []models.Proposal - if err := h.db.Where("intent_id = ?", intentID).Find(&proposals).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to fetch proposals") - return - } - - if len(proposals) == 0 { - respondError(w, http.StatusBadRequest, "no proposals to select from") - return - } - - // Fetch reputation records for all agents in these proposals - agentIDs := make([]string, len(proposals)) - for i, p := range proposals { - agentIDs[i] = p.AgentID - } - - var reputations []models.AgentReputation - h.db.Where("agent_id IN ?", agentIDs).Find(&reputations) - - repMap := make(map[string]*models.AgentReputation, len(reputations)) - for i := range reputations { - repMap[reputations[i].AgentID] = &reputations[i] - } - - // Run deterministic scoring engine - engine := scoring.New() - result := engine.Score(proposals, repMap) - - if result.Winner == nil { - respondError(w, http.StatusInternalServerError, "scoring engine returned no winner") - return - } - - // Persist each proposal's computed score - for _, s := range result.AllScores { - h.db.Model(&models.Proposal{}).Where("id = ?", s.ProposalID).Update("score", s.Total) - } - - // Update intent with selected agent - if err := h.db.Model(&models.Intent{}).Where("id = ?", intentID).Updates(map[string]any{ - "status": "winner_selected", - "selected_agent_id": result.Winner.AgentID, - }).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to update intent") - return - } - - // Broadcast winner selection - h.hub.Broadcast(intentID, "winner_selected", map[string]any{ - "proposal_id": result.Winner.ProposalID, - "agent_id": result.Winner.AgentID, - "score": result.Winner.Total, - "execution_efficiency": result.Winner.ExecutionEfficiencyScore, - "win_rate_score": result.Winner.WinRateScore, - "slippage_score": result.Winner.SlippageScore, - "reputation_score": result.Winner.ReputationScore, - }) - - // Publish to Redis pub/sub - ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) - defer cancel() - _ = h.pubsub.Publish(ctx, intentID, "winner_selected", map[string]any{ - "proposal_id": result.Winner.ProposalID, - "agent_id": result.Winner.AgentID, - "score": result.Winner.Total, - }) - - respondJSON(w, http.StatusOK, map[string]any{ - "winner_proposal_id": result.Winner.ProposalID, - "agent_id": result.Winner.AgentID, - "total_score": result.Winner.Total, - "execution_efficiency": result.Winner.ExecutionEfficiencyScore, - "win_rate_score": result.Winner.WinRateScore, - "slippage_score": result.Winner.SlippageScore, - "reputation_score": result.Winner.ReputationScore, - "all_scores": result.AllScores, - }) -} - -func (h *Handler) ValidateExecution(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - var req ValidateExecutionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - // Fetch intent and proposal - var intent models.Intent - if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { - respondError(w, http.StatusNotFound, "intent not found") - return - } - - var proposal models.Proposal - if err := h.db.First(&proposal, "id = ?", req.ProposalID).Error; err != nil { - respondError(w, http.StatusNotFound, "proposal not found") - return - } - - // Run full validation service - svc := validation.New() - result := svc.Validate(&intent, &proposal) - - if !result.Valid { - // Record failed validation attempt against the agent - h.db.Model(&models.Proposal{}).Where("id = ?", req.ProposalID). - Update("score", 0) - - respondJSON(w, http.StatusUnprocessableEntity, map[string]any{ - "valid": false, - "errors": result.Errors, - }) - return - } - - respondJSON(w, http.StatusOK, map[string]any{ - "valid": true, - "message": "execution validation passed", - }) -} - -func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - var req SettleExecutionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - // Fetch intent and proposal - var intent models.Intent - if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { - respondError(w, http.StatusNotFound, "intent not found") - return - } - - var proposal models.Proposal - if err := h.db.First(&proposal, "id = ?", req.ProposalID).Error; err != nil { - respondError(w, http.StatusNotFound, "proposal not found") - return - } - - // Re-run validation gate before any settlement - svc := validation.New() - if vr := svc.Validate(&intent, &proposal); !vr.Valid { - respondJSON(w, http.StatusUnprocessableEntity, map[string]any{ - "error": "settlement blocked: validation failed", - "errors": vr.Errors, - }) - return - } - - // Create execution record - now := time.Now() - execution := &models.Execution{ - ID: uuid.New().String(), - IntentID: intentID, - WinningAgentID: proposal.AgentID, - ProposalID: req.ProposalID, - ActualSlippage: req.ActualSlippage, - TxHash: req.TxHash, - SettlementStatus: "confirmed", - ExecutionFeeUSDC: req.ExecutionFeeUSDC, - ExecutedAt: now, - SettledAt: &now, - } - - if err := h.db.Create(execution).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to create execution") - return - } - - // Update intent status - if err := h.db.Model(&models.Intent{}).Where("id = ?", intentID).Update("status", "settled").Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to update intent") - return - } - - // Pay agent execution fee via Circle if wallets are configured - if proposal.AgentID != "" && req.ExecutionFeeUSDC != "" { - // Fetch agent's Circle wallet address - var agent models.Agent - if err := h.db.First(&agent, "id = ?", proposal.AgentID).Error; err == nil && agent.WalletAddress != "" { - idempotencyKey := "settle-" + execution.ID - feeCtx, feeCancel := context.WithTimeout(context.Background(), 15*1e9) - defer feeCancel() - // Transfer fee from escrow (intent's wallet) to agent wallet - // In V0 we use the agent's WalletAddress as the Circle wallet ID - _, _ = h.circle.SendUSDC(feeCtx, idempotencyKey, intent.UserWallet, agent.WalletAddress, req.ExecutionFeeUSDC) - } - } - - // Update agent win stats and reputation - h.db.Model(&models.Agent{}).Where("id = ?", proposal.AgentID). - UpdateColumn("total_intents_won", gorm.Expr("total_intents_won + 1")) - - h.db.Model(&models.AgentReputation{}).Where("agent_id = ?", proposal.AgentID).Updates(map[string]any{ - "total_executions": gorm.Expr("total_executions + 1"), - "consecutive_successes": gorm.Expr("consecutive_successes + 1"), - }) - - // Broadcast settlement - h.hub.Broadcast(intentID, "execution_settled", map[string]any{ - "execution_id": execution.ID, - "agent_id": execution.WinningAgentID, - "slippage": execution.ActualSlippage, - "tx_hash": execution.TxHash, - }) - - // Publish to Redis pub/sub - ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) - defer cancel() - _ = h.pubsub.Publish(ctx, intentID, "execution_settled", map[string]any{ - "execution_id": execution.ID, - "agent_id": execution.WinningAgentID, - "slippage": execution.ActualSlippage, - "tx_hash": execution.TxHash, - }) - - respondJSON(w, http.StatusOK, map[string]any{ - "execution_id": execution.ID, - "status": execution.SettlementStatus, - "actual_slippage": execution.ActualSlippage, - "tx_hash": execution.TxHash, - }) -} +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "gorm.io/gorm" + + "github.com/Cookit-labs/Backend/internal/models" + "github.com/Cookit-labs/Backend/internal/scoring" + "github.com/Cookit-labs/Backend/internal/validation" +) + +func (h *Handler) SelectWinner(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + // Fetch all proposals for this intent + var proposals []models.Proposal + if err := h.db.Where("intent_id = ?", intentID).Find(&proposals).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch proposals") + return + } + + if len(proposals) == 0 { + respondError(w, http.StatusBadRequest, "no proposals to select from") + return + } + + // Fetch reputation records for all agents in these proposals + agentIDs := make([]string, len(proposals)) + for i, p := range proposals { + agentIDs[i] = p.AgentID + } + + var reputations []models.AgentReputation + h.db.Where("agent_id IN ?", agentIDs).Find(&reputations) + + repMap := make(map[string]*models.AgentReputation, len(reputations)) + for i := range reputations { + repMap[reputations[i].AgentID] = &reputations[i] + } + + // Run deterministic scoring engine + engine := scoring.New() + result := engine.Score(proposals, repMap) + + if result.Winner == nil { + respondError(w, http.StatusInternalServerError, "scoring engine returned no winner") + return + } + + // Persist each proposal's computed score + for _, s := range result.AllScores { + h.db.Model(&models.Proposal{}).Where("id = ?", s.ProposalID).Update("score", s.Total) + } + + // Update intent with selected agent + if err := h.db.Model(&models.Intent{}).Where("id = ?", intentID).Updates(map[string]any{ + "status": "winner_selected", + "selected_agent_id": result.Winner.AgentID, + }).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to update intent") + return + } + + // Broadcast winner selection + h.hub.Broadcast(intentID, "winner_selected", map[string]any{ + "proposal_id": result.Winner.ProposalID, + "agent_id": result.Winner.AgentID, + "score": result.Winner.Total, + "execution_efficiency": result.Winner.ExecutionEfficiencyScore, + "win_rate_score": result.Winner.WinRateScore, + "slippage_score": result.Winner.SlippageScore, + "reputation_score": result.Winner.ReputationScore, + }) + + // Publish to Redis pub/sub + ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) + defer cancel() + _ = h.pubsub.Publish(ctx, intentID, "winner_selected", map[string]any{ + "proposal_id": result.Winner.ProposalID, + "agent_id": result.Winner.AgentID, + "score": result.Winner.Total, + }) + + respondJSON(w, http.StatusOK, map[string]any{ + "winner_proposal_id": result.Winner.ProposalID, + "agent_id": result.Winner.AgentID, + "total_score": result.Winner.Total, + "execution_efficiency": result.Winner.ExecutionEfficiencyScore, + "win_rate_score": result.Winner.WinRateScore, + "slippage_score": result.Winner.SlippageScore, + "reputation_score": result.Winner.ReputationScore, + "all_scores": result.AllScores, + }) +} + +func (h *Handler) ValidateExecution(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var req ValidateExecutionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + // Fetch intent and proposal + var intent models.Intent + if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + var proposal models.Proposal + if err := h.db.First(&proposal, "id = ?", req.ProposalID).Error; err != nil { + respondError(w, http.StatusNotFound, "proposal not found") + return + } + + // Run full validation service + svc := validation.New() + result := svc.Validate(&intent, &proposal) + + if !result.Valid { + // Record failed validation attempt against the agent + h.db.Model(&models.Proposal{}).Where("id = ?", req.ProposalID). + Update("score", 0) + + respondJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "valid": false, + "errors": result.Errors, + }) + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "valid": true, + "message": "execution validation passed", + }) +} + +func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var req SettleExecutionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + // Fetch intent and proposal + var intent models.Intent + if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + var proposal models.Proposal + if err := h.db.First(&proposal, "id = ?", req.ProposalID).Error; err != nil { + respondError(w, http.StatusNotFound, "proposal not found") + return + } + + // Re-run validation gate before any settlement + svc := validation.New() + if vr := svc.Validate(&intent, &proposal); !vr.Valid { + respondJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "error": "settlement blocked: validation failed", + "errors": vr.Errors, + }) + return + } + + // Create execution record + now := time.Now() + execution := &models.Execution{ + ID: uuid.New().String(), + IntentID: intentID, + WinningAgentID: proposal.AgentID, + ProposalID: req.ProposalID, + ActualSlippage: req.ActualSlippage, + TxHash: req.TxHash, + SettlementStatus: "confirmed", + ExecutionFeeUSDC: req.ExecutionFeeUSDC, + ExecutedAt: now, + SettledAt: &now, + } + + if err := h.db.Create(execution).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create execution") + return + } + + // Update intent status + if err := h.db.Model(&models.Intent{}).Where("id = ?", intentID).Update("status", "settled").Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to update intent") + return + } + + // Pay agent execution fee via Circle if wallets are configured + if proposal.AgentID != "" && req.ExecutionFeeUSDC != "" { + // Fetch agent's Circle wallet address + var agent models.Agent + if err := h.db.First(&agent, "id = ?", proposal.AgentID).Error; err == nil && agent.WalletAddress != "" { + idempotencyKey := "settle-" + execution.ID + feeCtx, feeCancel := context.WithTimeout(context.Background(), 15*1e9) + defer feeCancel() + // Transfer fee from escrow (intent's wallet) to agent wallet + // In V0 we use the agent's WalletAddress as the Circle wallet ID + _, _ = h.circle.SendUSDC(feeCtx, idempotencyKey, intent.UserWallet, agent.WalletAddress, req.ExecutionFeeUSDC) + } + } + + // Update agent win stats and reputation + h.db.Model(&models.Agent{}).Where("id = ?", proposal.AgentID). + UpdateColumn("total_intents_won", gorm.Expr("total_intents_won + 1")) + + h.db.Model(&models.AgentReputation{}).Where("agent_id = ?", proposal.AgentID).Updates(map[string]any{ + "total_executions": gorm.Expr("total_executions + 1"), + "consecutive_successes": gorm.Expr("consecutive_successes + 1"), + }) + + // Broadcast settlement + h.hub.Broadcast(intentID, "execution_settled", map[string]any{ + "execution_id": execution.ID, + "agent_id": execution.WinningAgentID, + "slippage": execution.ActualSlippage, + "tx_hash": execution.TxHash, + }) + + // Publish to Redis pub/sub + ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) + defer cancel() + _ = h.pubsub.Publish(ctx, intentID, "execution_settled", map[string]any{ + "execution_id": execution.ID, + "agent_id": execution.WinningAgentID, + "slippage": execution.ActualSlippage, + "tx_hash": execution.TxHash, + }) + + respondJSON(w, http.StatusOK, map[string]any{ + "execution_id": execution.ID, + "status": execution.SettlementStatus, + "actual_slippage": execution.ActualSlippage, + "tx_hash": execution.TxHash, + }) +} diff --git a/internal/handlers/intents.go b/internal/handlers/intents.go index 4c22f48..3b8b286 100644 --- a/internal/handlers/intents.go +++ b/internal/handlers/intents.go @@ -1,79 +1,84 @@ -package handlers - -import ( - "encoding/json" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "github.com/Cookit-labs/Backend/internal/models" -) - -func (h *Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { - var req CreateIntentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - intent := &models.Intent{ - ID: uuid.New().String(), - UserWallet: req.UserWallet, - TokenIn: req.TokenIn, - TokenOut: req.TokenOut, - AmountIn: req.AmountIn, - MaxSlippage: req.MaxSlippage, - Deadline: req.Deadline, - Status: "pending", - Metadata: req.Metadata, - } - - if err := h.db.Create(intent).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to create intent") - return - } - - // Broadcast to WebSocket subscribers - h.hub.Broadcast(intent.ID, "intent_created", map[string]string{ - "intent_id": intent.ID, - "status": intent.Status, - }) - - respondJSON(w, http.StatusCreated, IntentResponse{ - ID: intent.ID, - UserWallet: intent.UserWallet, - TokenIn: intent.TokenIn, - TokenOut: intent.TokenOut, - AmountIn: intent.AmountIn, - MaxSlippage: intent.MaxSlippage, - Deadline: intent.Deadline, - Status: intent.Status, - CreatedAt: intent.CreatedAt, - UpdatedAt: intent.UpdatedAt, - }) -} - -func (h *Handler) GetIntent(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - var intent models.Intent - if err := h.db.Preload("Proposals").Preload("Execution").First(&intent, "id = ?", intentID).Error; err != nil { - respondError(w, http.StatusNotFound, "intent not found") - return - } - - respondJSON(w, http.StatusOK, IntentResponse{ - ID: intent.ID, - UserWallet: intent.UserWallet, - TokenIn: intent.TokenIn, - TokenOut: intent.TokenOut, - AmountIn: intent.AmountIn, - MaxSlippage: intent.MaxSlippage, - Deadline: intent.Deadline, - Status: intent.Status, - SelectedAgentID: intent.SelectedAgentID, - CreatedAt: intent.CreatedAt, - UpdatedAt: intent.UpdatedAt, - }) -} +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/Cookit-labs/Backend/internal/models" +) + +func (h *Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { + var req CreateIntentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + intent := &models.Intent{ + ID: uuid.New().String(), + UserWallet: req.UserWallet, + TokenIn: req.TokenIn, + TokenOut: req.TokenOut, + AmountIn: req.AmountIn, + MaxSlippage: req.MaxSlippage, + Deadline: req.Deadline, + Status: "pending", + Metadata: req.Metadata, + } + + if err := h.db.Create(intent).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create intent") + return + } + + // Broadcast to WebSocket subscribers + h.hub.Broadcast(intent.ID, "intent_created", map[string]string{ + "intent_id": intent.ID, + "status": intent.Status, + }) + + respondJSON(w, http.StatusCreated, IntentResponse{ + ID: intent.ID, + UserWallet: intent.UserWallet, + TokenIn: intent.TokenIn, + TokenOut: intent.TokenOut, + AmountIn: intent.AmountIn, + MaxSlippage: intent.MaxSlippage, + Deadline: intent.Deadline, + Status: intent.Status, + CreatedAt: intent.CreatedAt, + UpdatedAt: intent.UpdatedAt, + }) +} + +func (h *Handler) GetIntent(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var intent models.Intent + if err := h.db.Preload("Proposals").Preload("Execution").First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + respondJSON(w, http.StatusOK, IntentResponse{ + ID: intent.ID, + UserWallet: intent.UserWallet, + TokenIn: intent.TokenIn, + TokenOut: intent.TokenOut, + AmountIn: intent.AmountIn, + MaxSlippage: intent.MaxSlippage, + Deadline: intent.Deadline, + Status: intent.Status, + SelectedAgentID: intent.SelectedAgentID, + CreatedAt: intent.CreatedAt, + UpdatedAt: intent.UpdatedAt, + }) +} diff --git a/internal/handlers/proposals.go b/internal/handlers/proposals.go index 14d6675..2edf281 100644 --- a/internal/handlers/proposals.go +++ b/internal/handlers/proposals.go @@ -1,111 +1,118 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "github.com/Cookit-labs/Backend/internal/models" -) - -func (h *Handler) CreateProposal(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - // Verify intent exists - var intent models.Intent - if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { - respondError(w, http.StatusNotFound, "intent not found") - return - } - - var req CreateProposalRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - // Verify agent exists - var agent models.Agent - if err := h.db.First(&agent, "id = ?", req.AgentID).Error; err != nil { - respondError(w, http.StatusNotFound, "agent not found") - return - } - - proposal := &models.Proposal{ - ID: uuid.New().String(), - IntentID: intentID, - AgentID: req.AgentID, - StrategyType: req.StrategyType, - ProjectedSlippage: req.ProjectedSlippage, - ProjectedExecutionQuality: req.ProjectedExecutionQuality, - ProposedAmount: req.ProposedAmount, - ExecutionPath: req.ExecutionPath, - } - - if err := h.db.Create(proposal).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to create proposal") - return - } - - // Broadcast to WebSocket subscribers - h.hub.Broadcast(intentID, "proposal_received", map[string]any{ - "proposal_id": proposal.ID, - "agent_id": proposal.AgentID, - "strategy": proposal.StrategyType, - "slippage": proposal.ProjectedSlippage, - "quality": proposal.ProjectedExecutionQuality, - }) - - // Publish to Redis pub/sub (multi-instance broadcast) - ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) - defer cancel() - _ = h.pubsub.Publish(ctx, intentID, "proposal_received", map[string]any{ - "proposal_id": proposal.ID, - "agent_id": proposal.AgentID, - "strategy": proposal.StrategyType, - "slippage": proposal.ProjectedSlippage, - "quality": proposal.ProjectedExecutionQuality, - }) - - respondJSON(w, http.StatusCreated, ProposalResponse{ - ID: proposal.ID, - IntentID: proposal.IntentID, - AgentID: proposal.AgentID, - StrategyType: proposal.StrategyType, - ProjectedSlippage: proposal.ProjectedSlippage, - ProjectedExecutionQuality: proposal.ProjectedExecutionQuality, - CreatedAt: proposal.CreatedAt, - }) -} - -func (h *Handler) ListProposals(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - var proposals []models.Proposal - if err := h.db.Where("intent_id = ?", intentID).Find(&proposals).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to fetch proposals") - return - } - - responses := make([]ProposalResponse, len(proposals)) - for i, p := range proposals { - responses[i] = ProposalResponse{ - ID: p.ID, - IntentID: p.IntentID, - AgentID: p.AgentID, - StrategyType: p.StrategyType, - ProjectedSlippage: p.ProjectedSlippage, - ProjectedExecutionQuality: p.ProjectedExecutionQuality, - Score: p.Score, - CreatedAt: p.CreatedAt, - } - } - - respondJSON(w, http.StatusOK, map[string]any{ - "proposals": responses, - "total": len(proposals), - }) -} +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/Cookit-labs/Backend/internal/models" +) + +func (h *Handler) CreateProposal(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + // Verify intent exists + var intent models.Intent + if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + var req CreateProposalRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + // Verify agent exists + var agent models.Agent + if err := h.db.First(&agent, "id = ?", req.AgentID).Error; err != nil { + respondError(w, http.StatusNotFound, "agent not found") + return + } + + proposal := &models.Proposal{ + ID: uuid.New().String(), + IntentID: intentID, + AgentID: req.AgentID, + StrategyType: req.StrategyType, + TokenIn: req.TokenIn, + TokenOut: req.TokenOut, + ProjectedSlippage: req.ProjectedSlippage, + ProjectedExecutionQuality: req.ProjectedExecutionQuality, + ProposedAmount: req.ProposedAmount, + ExecutionPath: req.ExecutionPath, + } + + if err := h.db.Create(proposal).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create proposal") + return + } + + // Broadcast to WebSocket subscribers + h.hub.Broadcast(intentID, "proposal_received", map[string]any{ + "proposal_id": proposal.ID, + "agent_id": proposal.AgentID, + "strategy": proposal.StrategyType, + "slippage": proposal.ProjectedSlippage, + "quality": proposal.ProjectedExecutionQuality, + }) + + // Publish to Redis pub/sub (multi-instance broadcast) + ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) + defer cancel() + _ = h.pubsub.Publish(ctx, intentID, "proposal_received", map[string]any{ + "proposal_id": proposal.ID, + "agent_id": proposal.AgentID, + "strategy": proposal.StrategyType, + "slippage": proposal.ProjectedSlippage, + "quality": proposal.ProjectedExecutionQuality, + }) + + respondJSON(w, http.StatusCreated, ProposalResponse{ + ID: proposal.ID, + IntentID: proposal.IntentID, + AgentID: proposal.AgentID, + StrategyType: proposal.StrategyType, + ProjectedSlippage: proposal.ProjectedSlippage, + ProjectedExecutionQuality: proposal.ProjectedExecutionQuality, + CreatedAt: proposal.CreatedAt, + }) +} + +func (h *Handler) ListProposals(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var proposals []models.Proposal + if err := h.db.Where("intent_id = ?", intentID).Find(&proposals).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch proposals") + return + } + + responses := make([]ProposalResponse, len(proposals)) + for i, p := range proposals { + responses[i] = ProposalResponse{ + ID: p.ID, + IntentID: p.IntentID, + AgentID: p.AgentID, + StrategyType: p.StrategyType, + ProjectedSlippage: p.ProjectedSlippage, + ProjectedExecutionQuality: p.ProjectedExecutionQuality, + Score: p.Score, + CreatedAt: p.CreatedAt, + } + } + + respondJSON(w, http.StatusOK, map[string]any{ + "proposals": responses, + "total": len(proposals), + }) +} diff --git a/internal/handlers/types.go b/internal/handlers/types.go index e7b13b4..e11da7e 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -1,88 +1,94 @@ -package handlers - -import "time" - -// Intent requests/responses - -type CreateIntentRequest struct { - UserWallet string `json:"user_wallet" validate:"required"` - TokenIn string `json:"token_in" validate:"required"` - TokenOut string `json:"token_out" validate:"required"` - AmountIn string `json:"amount_in" validate:"required"` - MaxSlippage float64 `json:"max_slippage" validate:"required,gt=0"` - Deadline time.Time `json:"deadline" validate:"required"` - Metadata []byte `json:"metadata,omitempty"` -} - -type IntentResponse struct { - ID string `json:"id"` - UserWallet string `json:"user_wallet"` - TokenIn string `json:"token_in"` - TokenOut string `json:"token_out"` - AmountIn string `json:"amount_in"` - MaxSlippage float64 `json:"max_slippage"` - Deadline time.Time `json:"deadline"` - Status string `json:"status"` - SelectedAgentID string `json:"selected_agent_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Proposal requests/responses - -type CreateProposalRequest struct { - AgentID string `json:"agent_id" validate:"required"` - StrategyType string `json:"strategy_type" validate:"required"` - ProjectedSlippage float64 `json:"projected_slippage" validate:"required,gte=0"` - ProjectedExecutionQuality float64 `json:"projected_execution_quality" validate:"required,gte=0,lte=1"` - ProposedAmount string `json:"proposed_amount" validate:"required"` - ExecutionPath string `json:"execution_path" validate:"required"` -} - -type ProposalResponse struct { - ID string `json:"id"` - IntentID string `json:"intent_id"` - AgentID string `json:"agent_id"` - StrategyType string `json:"strategy_type"` - ProjectedSlippage float64 `json:"projected_slippage"` - ProjectedExecutionQuality float64 `json:"projected_execution_quality"` - Score float64 `json:"score"` - CreatedAt time.Time `json:"created_at"` -} - -// Execution/Settlement - -type ValidateExecutionRequest struct { - ProposalID string `json:"proposal_id" validate:"required"` -} - -type SettleExecutionRequest struct { - ProposalID string `json:"proposal_id" validate:"required"` - ActualSlippage float64 `json:"actual_slippage" validate:"required,gte=0"` - TxHash string `json:"tx_hash" validate:"required"` - ExecutionFeeUSDC string `json:"execution_fee_usdc" validate:"required"` -} - -// Leaderboard - -type LeaderboardEntry struct { - AgentID string `json:"agent_id"` - Name string `json:"name"` - StrategyType string `json:"strategy_type"` - TotalIntentsWon int64 `json:"total_intents_won"` - WinRate float64 `json:"win_rate"` - AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` - CompositeScore float64 `json:"composite_score"` -} - -type LeaderboardResponse struct { - Agents []LeaderboardEntry `json:"agents"` - Total int64 `json:"total"` -} - -// Error response - -type ErrorResponse struct { - Error string `json:"error"` - Status int `json:"status"` -} +package handlers + +import "time" + +// Intent requests/responses + +type CreateIntentRequest struct { + UserWallet string `json:"user_wallet" validate:"required"` + TokenIn string `json:"token_in" validate:"required"` + TokenOut string `json:"token_out" validate:"required"` + AmountIn string `json:"amount_in" validate:"required"` + MaxSlippage float64 `json:"max_slippage" validate:"required,gt=0"` + Deadline time.Time `json:"deadline" validate:"required"` + Metadata []byte `json:"metadata,omitempty"` +} + +type IntentResponse struct { + ID string `json:"id"` + UserWallet string `json:"user_wallet"` + TokenIn string `json:"token_in"` + TokenOut string `json:"token_out"` + AmountIn string `json:"amount_in"` + MaxSlippage float64 `json:"max_slippage"` + Deadline time.Time `json:"deadline"` + Status string `json:"status"` + SelectedAgentID string `json:"selected_agent_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Proposal requests/responses + +type CreateProposalRequest struct { + AgentID string `json:"agent_id" validate:"required"` + StrategyType string `json:"strategy_type" validate:"required"` + // The pair the agent will execute. Validated against the intent before + // settlement; omitting it is a validation failure, not a default. + TokenIn string `json:"token_in" validate:"required"` + TokenOut string `json:"token_out" validate:"required"` + // gte=0 rather than required: required rejects the zero value, and zero + // projected slippage is a legitimate claim. + ProjectedSlippage float64 `json:"projected_slippage" validate:"gte=0,lte=1"` + ProjectedExecutionQuality float64 `json:"projected_execution_quality" validate:"gte=0,lte=1"` + ProposedAmount string `json:"proposed_amount" validate:"required"` + ExecutionPath string `json:"execution_path" validate:"required"` +} + +type ProposalResponse struct { + ID string `json:"id"` + IntentID string `json:"intent_id"` + AgentID string `json:"agent_id"` + StrategyType string `json:"strategy_type"` + ProjectedSlippage float64 `json:"projected_slippage"` + ProjectedExecutionQuality float64 `json:"projected_execution_quality"` + Score float64 `json:"score"` + CreatedAt time.Time `json:"created_at"` +} + +// Execution/Settlement + +type ValidateExecutionRequest struct { + ProposalID string `json:"proposal_id" validate:"required"` +} + +type SettleExecutionRequest struct { + ProposalID string `json:"proposal_id" validate:"required"` + ActualSlippage float64 `json:"actual_slippage" validate:"required,gte=0"` + TxHash string `json:"tx_hash" validate:"required"` + ExecutionFeeUSDC string `json:"execution_fee_usdc" validate:"required"` +} + +// Leaderboard + +type LeaderboardEntry struct { + AgentID string `json:"agent_id"` + Name string `json:"name"` + StrategyType string `json:"strategy_type"` + TotalIntentsWon int64 `json:"total_intents_won"` + WinRate float64 `json:"win_rate"` + AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` + CompositeScore float64 `json:"composite_score"` +} + +type LeaderboardResponse struct { + Agents []LeaderboardEntry `json:"agents"` + Total int64 `json:"total"` +} + +// Error response + +type ErrorResponse struct { + Error string `json:"error"` + Status int `json:"status"` +} diff --git a/internal/handlers/validate.go b/internal/handlers/validate.go new file mode 100644 index 0000000..4971b7a --- /dev/null +++ b/internal/handlers/validate.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "fmt" + "strings" + + "github.com/go-playground/validator/v10" +) + +// The `validate` tags on the request DTOs described the intended contract but +// nothing read them: there was no validator in the module at all. Every tag was +// documentation. A proposal could arrive with no agent, no venue and a slippage +// of -5 and be persisted unchallenged. +// +// One shared instance: constructing a validator per request re-parses struct +// tags via reflection each time, and the library is designed to be reused. +var validate = validator.New(validator.WithRequiredStructEnabled()) + +// validateStruct returns a human-readable message naming every field that +// failed, or an empty string when the payload is acceptable. +// +// All failures are reported together rather than stopping at the first, so a +// caller fixing a malformed request does not have to discover the problems one +// round-trip at a time. +func validateStruct(payload any) string { + err := validate.Struct(payload) + if err == nil { + return "" + } + + var invalid *validator.InvalidValidationError + if ok := asInvalid(err, &invalid); ok { + // Programmer error — a non-struct was passed in. Surface it rather than + // reporting the request as invalid. + return "internal validation error" + } + + var msgs []string + for _, fe := range err.(validator.ValidationErrors) { + msgs = append(msgs, describe(fe)) + } + return strings.Join(msgs, "; ") +} + +func asInvalid(err error, target **validator.InvalidValidationError) bool { + v, ok := err.(*validator.InvalidValidationError) + if ok { + *target = v + } + return ok +} + +// describe turns a tag failure into something an API consumer can act on. +// The library's default message is the tag name alone, which does not say what +// the caller should have sent. +func describe(fe validator.FieldError) string { + field := jsonName(fe) + switch fe.Tag() { + case "required": + return fmt.Sprintf("%s is required", field) + case "gte": + return fmt.Sprintf("%s must be at least %s", field, fe.Param()) + case "lte": + return fmt.Sprintf("%s must be at most %s", field, fe.Param()) + case "gt": + return fmt.Sprintf("%s must be greater than %s", field, fe.Param()) + case "oneof": + return fmt.Sprintf("%s must be one of: %s", field, strings.ReplaceAll(fe.Param(), " ", ", ")) + default: + return fmt.Sprintf("%s is invalid (%s)", field, fe.Tag()) + } +} + +// jsonName reports the field as the caller sent it, not as Go spells it. +// Telling an API consumer that "ProjectedSlippage" is invalid is unhelpful when +// they sent "projected_slippage". +func jsonName(fe validator.FieldError) string { + name := fe.Field() + return toSnake(name) +} + +// toSnake converts a Go field name to its JSON spelling. +// +// Runs of capitals are kept together so that acronyms survive: AgentID becomes +// agent_id, not agent_i_d. An underscore is inserted before a capital only when +// it starts a new word — that is, when the previous character was lowercase, or +// when the capital is followed by a lowercase one (as the D in IDToken). +func toSnake(s string) string { + runes := []rune(s) + var b strings.Builder + + for i, r := range runes { + isUpper := r >= 'A' && r <= 'Z' + if isUpper && i > 0 { + prevLower := runes[i-1] >= 'a' && runes[i-1] <= 'z' + nextLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z' + if prevLower || nextLower { + b.WriteByte('_') + } + } + if isUpper { + b.WriteRune(r + 32) + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/handlers/validate_test.go b/internal/handlers/validate_test.go new file mode 100644 index 0000000..a8f7417 --- /dev/null +++ b/internal/handlers/validate_test.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "strings" + "testing" +) + +func TestValidateStructAcceptsAWellFormedProposal(t *testing.T) { + req := CreateProposalRequest{ + AgentID: "agent_1", + StrategyType: "twap", + TokenIn: "USDC", + TokenOut: "WETH", + ProjectedSlippage: 0.005, + ProjectedExecutionQuality: 0.9, + ProposedAmount: "1000", + ExecutionPath: "uniswap_v3", + } + if msg := validateStruct(&req); msg != "" { + t.Fatalf("expected valid, got: %s", msg) + } +} + +// Before this change the tags were inert: an empty payload was persisted. +func TestValidateStructRejectsAnEmptyProposal(t *testing.T) { + msg := validateStruct(&CreateProposalRequest{}) + if msg == "" { + t.Fatal("expected an empty proposal to be rejected") + } + for _, field := range []string{"agent_id", "strategy_type", "token_in", "token_out"} { + if !strings.Contains(msg, field) { + t.Errorf("expected %q to be reported, got: %s", field, msg) + } + } +} + +func TestValidateStructReportsFieldsByTheirJSONNames(t *testing.T) { + // An API consumer sent "agent_id"; telling them "AgentID" is unhelpful. + msg := validateStruct(&CreateProposalRequest{}) + if strings.Contains(msg, "AgentID") { + t.Errorf("expected snake_case field names, got: %s", msg) + } +} + +func TestValidateStructAllowsZeroSlippage(t *testing.T) { + // `required` on a float rejects 0, but zero projected slippage is a + // legitimate claim — hence gte=0 rather than required. + req := CreateProposalRequest{ + AgentID: "agent_1", + StrategyType: "twap", + TokenIn: "USDC", + TokenOut: "WETH", + ProjectedSlippage: 0, + ProposedAmount: "1000", + ExecutionPath: "uniswap_v3", + } + if msg := validateStruct(&req); msg != "" { + t.Fatalf("expected zero slippage to be accepted, got: %s", msg) + } +} + +func TestValidateStructRejectsOutOfRangeQuality(t *testing.T) { + req := CreateProposalRequest{ + AgentID: "agent_1", + StrategyType: "twap", + TokenIn: "USDC", + TokenOut: "WETH", + ProjectedExecutionQuality: 5, // capped at 1 + ProposedAmount: "1000", + ExecutionPath: "uniswap_v3", + } + if msg := validateStruct(&req); msg == "" { + t.Fatal("expected an out-of-range quality to be rejected") + } +} + +func TestValidateStructReportsEveryFailureTogether(t *testing.T) { + msg := validateStruct(&CreateProposalRequest{}) + if strings.Count(msg, ";") < 3 { + t.Fatalf("expected several failures in one message, got: %s", msg) + } +} + +func TestToSnakeKeepsAcronymsIntact(t *testing.T) { + // A naive implementation renders AgentID as agent_i_d. + cases := map[string]string{ + "AgentID": "agent_id", + "TokenIn": "token_in", + "ProjectedSlippage": "projected_slippage", + "ProjectedExecutionQuality": "projected_execution_quality", + "IDToken": "id_token", + "UserWallet": "user_wallet", + } + for in, want := range cases { + if got := toSnake(in); got != want { + t.Errorf("toSnake(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/models/models.go b/internal/models/models.go index 9a0c96f..30a2219 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,110 +1,115 @@ -package models - -import ( - "time" - - "gorm.io/gorm" -) - -// Intent represents a user's trading intention -type Intent struct { - ID string `gorm:"primaryKey" json:"id"` - UserWallet string `gorm:"index" json:"user_wallet"` - TokenIn string `json:"token_in"` - TokenOut string `json:"token_out"` - AmountIn string `json:"amount_in"` // stored as string to preserve precision - MaxSlippage float64 `json:"max_slippage"` // e.g., 0.01 = 1% - Deadline time.Time `json:"deadline"` - Status string `json:"status"` // pending, competition_open, winner_selected, executing, settled, failed - SelectedAgentID string `json:"selected_agent_id"` - Metadata []byte `gorm:"type:jsonb" json:"metadata"` // arbitrary user data - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - // Relationships - Proposals []Proposal `gorm:"foreignKey:IntentID;references:ID" json:"proposals,omitempty"` - Execution *Execution `gorm:"foreignKey:IntentID;references:ID" json:"execution,omitempty"` -} - -// Proposal represents an agent's bid to execute an intent -type Proposal struct { - ID string `gorm:"primaryKey" json:"id"` - IntentID string `gorm:"index" json:"intent_id"` - AgentID string `gorm:"index" json:"agent_id"` - StrategyType string `json:"strategy_type"` // TWAP, Momentum, Shadow - ProjectedSlippage float64 `json:"projected_slippage"` // e.g., 0.005 = 0.5% - ProjectedExecutionQuality float64 `json:"projected_execution_quality"` // 0-1 - ProposedAmount string `json:"proposed_amount"` - ExecutionPath string `json:"execution_path"` // e.g., "uniswap_v3" or "curve" - Score float64 `json:"score"` // computed by scoring engine - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - // Relationships - Intent *Intent `gorm:"foreignKey:IntentID;references:ID" json:"-"` - Agent *Agent `gorm:"foreignKey:AgentID;references:ID" json:"-"` -} - -// Execution represents the settled outcome of an intent -type Execution struct { - ID string `gorm:"primaryKey" json:"id"` - IntentID string `gorm:"uniqueIndex" json:"intent_id"` - WinningAgentID string `gorm:"index" json:"winning_agent_id"` - ProposalID string `json:"proposal_id"` - ExecutedAmount string `json:"executed_amount"` - ActualSlippage float64 `json:"actual_slippage"` - TxHash string `json:"tx_hash"` - SettlementStatus string `json:"settlement_status"` // pending, confirmed, failed - ExecutionFeeUSDC string `json:"execution_fee_usdc"` // agent fee - ArcContractAddr string `json:"arc_contract_addr"` - ExecutedAt time.Time `json:"executed_at"` - SettledAt *time.Time `json:"settled_at"` - FailureReason string `json:"failure_reason"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - // Relationships - Intent *Intent `gorm:"foreignKey:IntentID;references:ID" json:"-"` - Agent *Agent `gorm:"foreignKey:WinningAgentID;references:ID" json:"-"` -} - -// Agent represents an autonomous execution agent -type Agent struct { - ID string `gorm:"primaryKey" json:"id"` - Name string `json:"name"` - StrategyType string `json:"strategy_type"` // TWAP, Momentum, Shadow - Description string `json:"description"` - WalletAddress string `json:"wallet_address"` - IsActive bool `json:"is_active"` - TotalIntentsWon int64 `json:"total_intents_won"` - TotalCapitalUsdc string `json:"total_capital_usdc"` // lifetime volume - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - // Relationships - Reputation *AgentReputation `gorm:"foreignKey:AgentID;references:ID" json:"reputation,omitempty"` - Proposals []Proposal `gorm:"foreignKey:AgentID;references:ID" json:"proposals,omitempty"` -} - -// AgentReputation tracks agent performance metrics on-chain seed values -type AgentReputation struct { - ID string `gorm:"primaryKey" json:"id"` - AgentID string `gorm:"uniqueIndex" json:"agent_id"` - WinRate float64 `json:"win_rate"` // wins / total_proposals - AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` - TotalExecutions int64 `json:"total_executions"` - ConsecutiveSuccesses int64 `json:"consecutive_successes"` - CompositeScore float64 `json:"composite_score"` // weighted avg of metrics - OnChainReputationScore string `json:"on_chain_reputation_score"` // synced to Arc L1 - LastSyncedAt *time.Time `json:"last_synced_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - // Relationships - Agent *Agent `gorm:"foreignKey:AgentID;references:ID" json:"-"` -} +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Intent represents a user's trading intention +type Intent struct { + ID string `gorm:"primaryKey" json:"id"` + UserWallet string `gorm:"index" json:"user_wallet"` + TokenIn string `json:"token_in"` + TokenOut string `json:"token_out"` + AmountIn string `json:"amount_in"` // stored as string to preserve precision + MaxSlippage float64 `json:"max_slippage"` // e.g., 0.01 = 1% + Deadline time.Time `json:"deadline"` + Status string `json:"status"` // pending, competition_open, winner_selected, executing, settled, failed + SelectedAgentID string `json:"selected_agent_id"` + Metadata []byte `gorm:"type:jsonb" json:"metadata"` // arbitrary user data + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Proposals []Proposal `gorm:"foreignKey:IntentID;references:ID" json:"proposals,omitempty"` + Execution *Execution `gorm:"foreignKey:IntentID;references:ID" json:"execution,omitempty"` +} + +// Proposal represents an agent's bid to execute an intent +type Proposal struct { + ID string `gorm:"primaryKey" json:"id"` + IntentID string `gorm:"index" json:"intent_id"` + AgentID string `gorm:"index" json:"agent_id"` + StrategyType string `json:"strategy_type"` // TWAP, Momentum, Shadow + // The pair the agent intends to execute. Recorded on the proposal rather + // than inferred from the intent, so validation can catch an agent that + // proposes a different pair than the one the user asked for. + TokenIn string `json:"token_in"` + TokenOut string `json:"token_out"` + ProjectedSlippage float64 `json:"projected_slippage"` // e.g., 0.005 = 0.5% + ProjectedExecutionQuality float64 `json:"projected_execution_quality"` // 0-1 + ProposedAmount string `json:"proposed_amount"` + ExecutionPath string `json:"execution_path"` // e.g., "uniswap_v3" or "curve" + Score float64 `json:"score"` // computed by scoring engine + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Intent *Intent `gorm:"foreignKey:IntentID;references:ID" json:"-"` + Agent *Agent `gorm:"foreignKey:AgentID;references:ID" json:"-"` +} + +// Execution represents the settled outcome of an intent +type Execution struct { + ID string `gorm:"primaryKey" json:"id"` + IntentID string `gorm:"uniqueIndex" json:"intent_id"` + WinningAgentID string `gorm:"index" json:"winning_agent_id"` + ProposalID string `json:"proposal_id"` + ExecutedAmount string `json:"executed_amount"` + ActualSlippage float64 `json:"actual_slippage"` + TxHash string `json:"tx_hash"` + SettlementStatus string `json:"settlement_status"` // pending, confirmed, failed + ExecutionFeeUSDC string `json:"execution_fee_usdc"` // agent fee + ArcContractAddr string `json:"arc_contract_addr"` + ExecutedAt time.Time `json:"executed_at"` + SettledAt *time.Time `json:"settled_at"` + FailureReason string `json:"failure_reason"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Intent *Intent `gorm:"foreignKey:IntentID;references:ID" json:"-"` + Agent *Agent `gorm:"foreignKey:WinningAgentID;references:ID" json:"-"` +} + +// Agent represents an autonomous execution agent +type Agent struct { + ID string `gorm:"primaryKey" json:"id"` + Name string `json:"name"` + StrategyType string `json:"strategy_type"` // TWAP, Momentum, Shadow + Description string `json:"description"` + WalletAddress string `json:"wallet_address"` + IsActive bool `json:"is_active"` + TotalIntentsWon int64 `json:"total_intents_won"` + TotalCapitalUsdc string `json:"total_capital_usdc"` // lifetime volume + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Reputation *AgentReputation `gorm:"foreignKey:AgentID;references:ID" json:"reputation,omitempty"` + Proposals []Proposal `gorm:"foreignKey:AgentID;references:ID" json:"proposals,omitempty"` +} + +// AgentReputation tracks agent performance metrics on-chain seed values +type AgentReputation struct { + ID string `gorm:"primaryKey" json:"id"` + AgentID string `gorm:"uniqueIndex" json:"agent_id"` + WinRate float64 `json:"win_rate"` // wins / total_proposals + AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` + TotalExecutions int64 `json:"total_executions"` + ConsecutiveSuccesses int64 `json:"consecutive_successes"` + CompositeScore float64 `json:"composite_score"` // weighted avg of metrics + OnChainReputationScore string `json:"on_chain_reputation_score"` // synced to Arc L1 + LastSyncedAt *time.Time `json:"last_synced_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Agent *Agent `gorm:"foreignKey:AgentID;references:ID" json:"-"` +} diff --git a/internal/validation/service.go b/internal/validation/service.go index fe79e48..aa97679 100644 --- a/internal/validation/service.go +++ b/internal/validation/service.go @@ -1,116 +1,144 @@ -package validation - -import ( - "slices" - "fmt" - "strings" - "time" - - "github.com/Cookit-labs/Backend/internal/models" -) - -// ApprovedVenues is the list of permitted execution venues. -// The AI agent must have set a venue in this list on their proposal. -var ApprovedVenues = []string{ - "uniswap_v3", - "uniswap_v2", - "curve", - "balancer", - "1inch", - "arc_dex", -} - -// ValidationError represents a single failed constraint. -type ValidationError struct { - Field string `json:"field"` - Message string `json:"message"` -} - -func (e ValidationError) Error() string { - return fmt.Sprintf("%s: %s", e.Field, e.Message) -} - -// Result is the outcome of a full validation run. -type Result struct { - Valid bool `json:"valid"` - Errors []ValidationError `json:"errors,omitempty"` -} - -// Service runs all pre-settlement constraint checks. -type Service struct{} - -func New() *Service { - return &Service{} -} - -// Validate checks a proposal against an intent before any USDC moves. -// All checks run regardless — caller receives the full list of failures. -func (s *Service) Validate(intent *models.Intent, proposal *models.Proposal) Result { - var errs []ValidationError - - // 1. Token pair matches - if !tokenPairMatches(intent, proposal) { - errs = append(errs, ValidationError{ - Field: "token_pair", - Message: fmt.Sprintf("proposal token pair does not match intent (want %s→%s)", intent.TokenIn, intent.TokenOut), - }) - } - - // 2. Slippage within tolerance - if proposal.ProjectedSlippage > intent.MaxSlippage { - errs = append(errs, ValidationError{ - Field: "slippage", - Message: fmt.Sprintf( - "projected slippage %.4f%% exceeds max tolerance %.4f%%", - proposal.ProjectedSlippage*100, - intent.MaxSlippage*100, - ), - }) - } - - // 3. Deadline not passed - if time.Now().After(intent.Deadline) { - errs = append(errs, ValidationError{ - Field: "deadline", - Message: fmt.Sprintf("intent deadline passed at %s", intent.Deadline.UTC().Format(time.RFC3339)), - }) - } - - // 4. Execution venue is approved - if !venueApproved(proposal.ExecutionPath) { - errs = append(errs, ValidationError{ - Field: "venue", - Message: fmt.Sprintf("venue %q is not on the approved list: %s", proposal.ExecutionPath, strings.Join(ApprovedVenues, ", ")), - }) - } - - // 5. Proposal belongs to this intent - if proposal.IntentID != intent.ID { - errs = append(errs, ValidationError{ - Field: "intent_id", - Message: "proposal does not belong to this intent", - }) - } - - // 6. Agent on the proposal is active (non-empty agent ID) - if proposal.AgentID == "" { - errs = append(errs, ValidationError{ - Field: "agent_id", - Message: "proposal has no assigned agent", - }) - } - - return Result{ - Valid: len(errs) == 0, - Errors: errs, - } -} - -func tokenPairMatches(intent *models.Intent, _ *models.Proposal) bool { - return intent.TokenIn != "" && intent.TokenOut != "" && intent.TokenIn != intent.TokenOut -} - -func venueApproved(venue string) bool { - v := strings.ToLower(strings.TrimSpace(venue)) - return slices.Contains(ApprovedVenues, v) -} +package validation + +import ( + "slices" + "fmt" + "strings" + "time" + + "github.com/Cookit-labs/Backend/internal/models" +) + +// ApprovedVenues is the list of permitted execution venues. +// The AI agent must have set a venue in this list on their proposal. +var ApprovedVenues = []string{ + "uniswap_v3", + "uniswap_v2", + "curve", + "balancer", + "1inch", + "arc_dex", +} + +// ValidationError represents a single failed constraint. +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +// Result is the outcome of a full validation run. +type Result struct { + Valid bool `json:"valid"` + Errors []ValidationError `json:"errors,omitempty"` +} + +// Service runs all pre-settlement constraint checks. +type Service struct{} + +func New() *Service { + return &Service{} +} + +// Validate checks a proposal against an intent before any USDC moves. +// All checks run regardless — caller receives the full list of failures. +func (s *Service) Validate(intent *models.Intent, proposal *models.Proposal) Result { + var errs []ValidationError + + // 1. Token pair matches + if !tokenPairMatches(intent, proposal) { + errs = append(errs, ValidationError{ + Field: "token_pair", + Message: fmt.Sprintf( + "proposal token pair %s→%s does not match intent %s→%s", + proposal.TokenIn, proposal.TokenOut, intent.TokenIn, intent.TokenOut, + ), + }) + } + + // 2. Slippage within tolerance + if proposal.ProjectedSlippage > intent.MaxSlippage { + errs = append(errs, ValidationError{ + Field: "slippage", + Message: fmt.Sprintf( + "projected slippage %.4f%% exceeds max tolerance %.4f%%", + proposal.ProjectedSlippage*100, + intent.MaxSlippage*100, + ), + }) + } + + // 3. Deadline not passed + if time.Now().After(intent.Deadline) { + errs = append(errs, ValidationError{ + Field: "deadline", + Message: fmt.Sprintf("intent deadline passed at %s", intent.Deadline.UTC().Format(time.RFC3339)), + }) + } + + // 4. Execution venue is approved + if !venueApproved(proposal.ExecutionPath) { + errs = append(errs, ValidationError{ + Field: "venue", + Message: fmt.Sprintf("venue %q is not on the approved list: %s", proposal.ExecutionPath, strings.Join(ApprovedVenues, ", ")), + }) + } + + // 5. Proposal belongs to this intent + if proposal.IntentID != intent.ID { + errs = append(errs, ValidationError{ + Field: "intent_id", + Message: "proposal does not belong to this intent", + }) + } + + // 6. Agent on the proposal is active (non-empty agent ID) + if proposal.AgentID == "" { + errs = append(errs, ValidationError{ + Field: "agent_id", + Message: "proposal has no assigned agent", + }) + } + + return Result{ + Valid: len(errs) == 0, + Errors: errs, + } +} + +// tokenPairMatches reports whether the proposal executes the pair the user +// actually asked for. +// +// This previously ignored the proposal entirely and checked only that the +// intent's own fields were non-empty and different from each other, which no +// proposal could ever fail. A proposal swapping an unrelated pair validated +// clean and went to settlement. +// +// Comparison is case-insensitive and trims surrounding space: agents submit +// symbols as free text, and "usdc" naming the same asset as "USDC" is a +// formatting difference, not a mismatched pair. +func tokenPairMatches(intent *models.Intent, proposal *models.Proposal) bool { + if intent.TokenIn == "" || intent.TokenOut == "" { + return false + } + // An empty pair on the proposal is a mismatch, not a pass. Treating it as + // acceptable would restore the original bug for any agent that simply + // omits the fields. + if proposal.TokenIn == "" || proposal.TokenOut == "" { + return false + } + return symbolsEqual(intent.TokenIn, proposal.TokenIn) && + symbolsEqual(intent.TokenOut, proposal.TokenOut) +} + +func symbolsEqual(a, b string) bool { + return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) +} + +func venueApproved(venue string) bool { + v := strings.ToLower(strings.TrimSpace(venue)) + return slices.Contains(ApprovedVenues, v) +} diff --git a/internal/validation/service_test.go b/internal/validation/service_test.go new file mode 100644 index 0000000..763872e --- /dev/null +++ b/internal/validation/service_test.go @@ -0,0 +1,146 @@ +package validation + +import ( + "testing" + "time" + + "github.com/Cookit-labs/Backend/internal/models" +) + +func baseIntent() *models.Intent { + return &models.Intent{ + ID: "intent_1", + TokenIn: "USDC", + TokenOut: "WETH", + MaxSlippage: 0.01, + Deadline: time.Now().Add(time.Hour), + } +} + +func baseProposal() *models.Proposal { + return &models.Proposal{ + IntentID: "intent_1", + AgentID: "agent_1", + TokenIn: "USDC", + TokenOut: "WETH", + ProjectedSlippage: 0.005, + ExecutionPath: "uniswap_v3", + } +} + +func hasError(r Result, field string) bool { + for _, e := range r.Errors { + if e.Field == field { + return true + } + } + return false +} + +func TestValidateAcceptsMatchingProposal(t *testing.T) { + got := New().Validate(baseIntent(), baseProposal()) + if !got.Valid { + t.Fatalf("expected valid, got errors: %v", got.Errors) + } +} + +// The regression this whole change exists for: a proposal executing a +// completely different pair used to validate clean and go to settlement. +func TestValidateRejectsMismatchedTokenPair(t *testing.T) { + p := baseProposal() + p.TokenIn = "USDT" + p.TokenOut = "WBTC" + + got := New().Validate(baseIntent(), p) + if got.Valid { + t.Fatal("expected a mismatched pair to be rejected") + } + if !hasError(got, "token_pair") { + t.Fatalf("expected a token_pair error, got: %v", got.Errors) + } +} + +func TestValidateRejectsReversedPair(t *testing.T) { + // Selling WETH for USDC is not the same intent as buying WETH with USDC. + p := baseProposal() + p.TokenIn, p.TokenOut = p.TokenOut, p.TokenIn + + if New().Validate(baseIntent(), p).Valid { + t.Fatal("expected a reversed pair to be rejected") + } +} + +func TestValidateRejectsEmptyPairOnProposal(t *testing.T) { + // Omitting the fields must not be a way back to the old always-pass path. + p := baseProposal() + p.TokenIn = "" + p.TokenOut = "" + + if New().Validate(baseIntent(), p).Valid { + t.Fatal("expected an empty proposal pair to be rejected") + } +} + +func TestValidateIgnoresCaseAndSurroundingSpace(t *testing.T) { + p := baseProposal() + p.TokenIn = " usdc " + p.TokenOut = "weth" + + if got := New().Validate(baseIntent(), p); !got.Valid { + t.Fatalf("expected formatting differences to be tolerated, got: %v", got.Errors) + } +} + +func TestValidateRejectsExcessiveSlippage(t *testing.T) { + p := baseProposal() + p.ProjectedSlippage = 0.05 // 5% against a 1% tolerance + + got := New().Validate(baseIntent(), p) + if got.Valid || !hasError(got, "slippage") { + t.Fatalf("expected a slippage error, got: %+v", got) + } +} + +func TestValidateRejectsPassedDeadline(t *testing.T) { + i := baseIntent() + i.Deadline = time.Now().Add(-time.Minute) + + got := New().Validate(i, baseProposal()) + if got.Valid || !hasError(got, "deadline") { + t.Fatalf("expected a deadline error, got: %+v", got) + } +} + +func TestValidateRejectsUnapprovedVenue(t *testing.T) { + p := baseProposal() + p.ExecutionPath = "some_unlisted_dex" + + got := New().Validate(baseIntent(), p) + if got.Valid || !hasError(got, "venue") { + t.Fatalf("expected a venue error, got: %+v", got) + } +} + +func TestValidateRejectsProposalForAnotherIntent(t *testing.T) { + p := baseProposal() + p.IntentID = "intent_999" + + got := New().Validate(baseIntent(), p) + if got.Valid || !hasError(got, "intent_id") { + t.Fatalf("expected an intent_id error, got: %+v", got) + } +} + +func TestValidateReportsEveryFailureAtOnce(t *testing.T) { + // Callers fix malformed proposals faster when they see the whole list. + p := baseProposal() + p.TokenIn = "USDT" + p.ProjectedSlippage = 0.9 + p.ExecutionPath = "nope" + p.AgentID = "" + + got := New().Validate(baseIntent(), p) + if len(got.Errors) < 4 { + t.Fatalf("expected at least 4 errors, got %d: %v", len(got.Errors), got.Errors) + } +} From b304b3d82cb0dc0f7d8349fbc1e1766217a4932b Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:41:17 +0100 Subject: [PATCH 2/5] feat: allow agents to be registered, listed and deactivated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agents resource was read-only. Proposals require an existing agent row, but nothing could create one, so a fresh database could not accept a single proposal — the system was unusable from empty. Adds POST /agents, GET /agents and PATCH /agents/{id}/status. Reputation is created alongside the agent rather than lazily, because the leaderboard skips agents with no reputation row and a newly registered agent would otherwise be invisible until its first settlement. Wallet addresses are unique: a wallet identifies an agent on chain, so two agents sharing one would make settlement ambiguous. Status is a pointer to a bool. With a plain bool, "deactivate this agent" and "leave the status alone" arrive as the same JSON, and the deactivation would be silently ignored. Agents are deactivated rather than deleted, since proposals and executions reference them and removing the row would orphan settled history. Verified against a live Postgres: creation, duplicate rejection, listing, the active filter, and both status transitions. --- internal/handlers/agents.go | 270 +++++++++++++++++++++-------- internal/handlers/handler.go | 129 +++++++------- internal/handlers/types.go | 26 +++ internal/handlers/validate_test.go | 45 +++++ 4 files changed, 330 insertions(+), 140 deletions(-) diff --git a/internal/handlers/agents.go b/internal/handlers/agents.go index ed7a364..8520584 100644 --- a/internal/handlers/agents.go +++ b/internal/handlers/agents.go @@ -1,77 +1,193 @@ -package handlers - -import ( - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/Cookit-labs/Backend/internal/models" -) - -func (h *Handler) GetLeaderboard(w http.ResponseWriter, r *http.Request) { - var agents []models.Agent - if err := h.db.Preload("Reputation").Find(&agents).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to fetch agents") - return - } - - entries := make([]LeaderboardEntry, 0) - for _, agent := range agents { - if agent.Reputation == nil { - continue - } - - entry := LeaderboardEntry{ - AgentID: agent.ID, - Name: agent.Name, - StrategyType: agent.StrategyType, - TotalIntentsWon: agent.TotalIntentsWon, - WinRate: agent.Reputation.WinRate, - AvgSlippageDelivered: agent.Reputation.AvgSlippageDelivered, - CompositeScore: agent.Reputation.CompositeScore, - } - entries = append(entries, entry) - } - - respondJSON(w, http.StatusOK, LeaderboardResponse{ - Agents: entries, - Total: int64(len(entries)), - }) -} - -func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { - agentID := chi.URLParam(r, "agentID") - - var agent models.Agent - if err := h.db.Preload("Reputation").Preload("Proposals").First(&agent, "id = ?", agentID).Error; err != nil { - respondError(w, http.StatusNotFound, "agent not found") - return - } - - // Count total executions for this agent - var totalExecutions int64 - h.db.Model(&models.Execution{}).Where("winning_agent_id = ?", agentID).Count(&totalExecutions) - - rep := agent.Reputation - if rep == nil { - rep = &models.AgentReputation{} - } - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "id": agent.ID, - "name": agent.Name, - "strategy_type": agent.StrategyType, - "description": agent.Description, - "is_active": agent.IsActive, - "wallet_address": agent.WalletAddress, - "total_intents_won": agent.TotalIntentsWon, - "total_capital_usdc": agent.TotalCapitalUsdc, - "total_executions": totalExecutions, - "win_rate": rep.WinRate, - "avg_slippage": rep.AvgSlippageDelivered, - "composite_score": rep.CompositeScore, - "on_chain_score": rep.OnChainReputationScore, - "consecutive_success": rep.ConsecutiveSuccesses, - "created_at": agent.CreatedAt, - }) -} +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/Cookit-labs/Backend/internal/models" +) + +// CreateAgent registers an agent so it can submit proposals. +func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { + var req CreateAgentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + // A wallet identifies an agent on chain, so two agents sharing one would + // make settlement ambiguous. + wallet := strings.TrimSpace(req.WalletAddress) + var existing models.Agent + err := h.db.First(&existing, "wallet_address = ?", wallet).Error + if err == nil { + respondError(w, http.StatusConflict, "an agent with that wallet address already exists") + return + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + respondError(w, http.StatusInternalServerError, "failed to check existing agents") + return + } + + agent := &models.Agent{ + ID: uuid.New().String(), + Name: strings.TrimSpace(req.Name), + StrategyType: strings.ToLower(strings.TrimSpace(req.StrategyType)), + Description: req.Description, + WalletAddress: wallet, + IsActive: true, + } + + if err := h.db.Create(agent).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create agent") + return + } + + // Reputation is created alongside the agent rather than lazily, so the + // leaderboard — which skips agents with no reputation row — shows a new + // agent immediately instead of hiding it until its first settlement. + reputation := &models.AgentReputation{ + AgentID: agent.ID, + } + if err := h.db.Create(reputation).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to initialise agent reputation") + return + } + agent.Reputation = reputation + + respondJSON(w, http.StatusCreated, agent) +} + +// ListAgents returns every registered agent. +func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { + query := h.db.Preload("Reputation") + + // ?active=true is how the orchestrator asks for agents it may actually + // invite into a competition. + if r.URL.Query().Get("active") == "true" { + query = query.Where("is_active = ?", true) + } + + var agents []models.Agent + if err := query.Find(&agents).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch agents") + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "agents": agents, + "total": len(agents), + }) +} + +// UpdateAgentStatus activates or deactivates an agent. +// +// Deactivation rather than deletion: proposals and executions reference an +// agent, and removing the row would orphan settled history. +func (h *Handler) UpdateAgentStatus(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + + var req UpdateAgentStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + var agent models.Agent + if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { + respondError(w, http.StatusNotFound, "agent not found") + return + } + + agent.IsActive = *req.IsActive + if err := h.db.Save(&agent).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to update agent") + return + } + + respondJSON(w, http.StatusOK, agent) +} + +func (h *Handler) GetLeaderboard(w http.ResponseWriter, r *http.Request) { + var agents []models.Agent + if err := h.db.Preload("Reputation").Find(&agents).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch agents") + return + } + + entries := make([]LeaderboardEntry, 0) + for _, agent := range agents { + if agent.Reputation == nil { + continue + } + + entry := LeaderboardEntry{ + AgentID: agent.ID, + Name: agent.Name, + StrategyType: agent.StrategyType, + TotalIntentsWon: agent.TotalIntentsWon, + WinRate: agent.Reputation.WinRate, + AvgSlippageDelivered: agent.Reputation.AvgSlippageDelivered, + CompositeScore: agent.Reputation.CompositeScore, + } + entries = append(entries, entry) + } + + respondJSON(w, http.StatusOK, LeaderboardResponse{ + Agents: entries, + Total: int64(len(entries)), + }) +} + +func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + + var agent models.Agent + if err := h.db.Preload("Reputation").Preload("Proposals").First(&agent, "id = ?", agentID).Error; err != nil { + respondError(w, http.StatusNotFound, "agent not found") + return + } + + // Count total executions for this agent + var totalExecutions int64 + h.db.Model(&models.Execution{}).Where("winning_agent_id = ?", agentID).Count(&totalExecutions) + + rep := agent.Reputation + if rep == nil { + rep = &models.AgentReputation{} + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "id": agent.ID, + "name": agent.Name, + "strategy_type": agent.StrategyType, + "description": agent.Description, + "is_active": agent.IsActive, + "wallet_address": agent.WalletAddress, + "total_intents_won": agent.TotalIntentsWon, + "total_capital_usdc": agent.TotalCapitalUsdc, + "total_executions": totalExecutions, + "win_rate": rep.WinRate, + "avg_slippage": rep.AvgSlippageDelivered, + "composite_score": rep.CompositeScore, + "on_chain_score": rep.OnChainReputationScore, + "consecutive_success": rep.ConsecutiveSuccesses, + "created_at": agent.CreatedAt, + }) +} diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go index 188fd7b..76aed18 100644 --- a/internal/handlers/handler.go +++ b/internal/handlers/handler.go @@ -1,63 +1,66 @@ -package handlers - -import ( - "encoding/json" - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/Cookit-labs/Backend/internal/circle" - "github.com/Cookit-labs/Backend/internal/db" - "github.com/Cookit-labs/Backend/internal/ws" -) - -type Handler struct { - db *db.DB - hub *ws.Hub - pubsub *ws.PubSub - circle *circle.Client -} - -func New(database *db.DB, wsHub *ws.Hub, redisPubSub *ws.PubSub, circleClient *circle.Client) *Handler { - return &Handler{ - db: database, - hub: wsHub, - pubsub: redisPubSub, - circle: circleClient, - } -} - -func (h *Handler) Mount(r chi.Router) { - r.Post("/intents", h.CreateIntent) - r.Get("/intents/{intentID}", h.GetIntent) - r.Post("/intents/{intentID}/proposals", h.CreateProposal) - r.Get("/intents/{intentID}/proposals", h.ListProposals) - r.Post("/intents/{intentID}/select", h.SelectWinner) - r.Post("/intents/{intentID}/validate", h.ValidateExecution) - r.Post("/intents/{intentID}/settle", h.SettleExecution) - r.Get("/leaderboard", h.GetLeaderboard) - r.Get("/agents/{agentID}", h.GetAgent) - - // Circle wallet + settlement - r.Post("/wallets", h.CreateWallet) - r.Get("/wallets/{walletID}/balance", h.GetWalletBalance) - r.Post("/wallets/transfer", h.TransferUSDC) - r.Post("/wallets/cctp", h.CCTPSimulate) -} - -// Utility - -func respondJSON(w http.ResponseWriter, status int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - if err := json.NewEncoder(w).Encode(data); err != nil { - http.Error(w, "failed to encode response", http.StatusInternalServerError) - } -} - -func respondError(w http.ResponseWriter, status int, message string) { - respondJSON(w, status, ErrorResponse{ - Error: message, - Status: status, - }) -} +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/Cookit-labs/Backend/internal/circle" + "github.com/Cookit-labs/Backend/internal/db" + "github.com/Cookit-labs/Backend/internal/ws" +) + +type Handler struct { + db *db.DB + hub *ws.Hub + pubsub *ws.PubSub + circle *circle.Client +} + +func New(database *db.DB, wsHub *ws.Hub, redisPubSub *ws.PubSub, circleClient *circle.Client) *Handler { + return &Handler{ + db: database, + hub: wsHub, + pubsub: redisPubSub, + circle: circleClient, + } +} + +func (h *Handler) Mount(r chi.Router) { + r.Post("/intents", h.CreateIntent) + r.Get("/intents/{intentID}", h.GetIntent) + r.Post("/intents/{intentID}/proposals", h.CreateProposal) + r.Get("/intents/{intentID}/proposals", h.ListProposals) + r.Post("/intents/{intentID}/select", h.SelectWinner) + r.Post("/intents/{intentID}/validate", h.ValidateExecution) + r.Post("/intents/{intentID}/settle", h.SettleExecution) + r.Get("/leaderboard", h.GetLeaderboard) + r.Post("/agents", h.CreateAgent) + r.Get("/agents", h.ListAgents) + r.Get("/agents/{agentID}", h.GetAgent) + r.Patch("/agents/{agentID}/status", h.UpdateAgentStatus) + + // Circle wallet + settlement + r.Post("/wallets", h.CreateWallet) + r.Get("/wallets/{walletID}/balance", h.GetWalletBalance) + r.Post("/wallets/transfer", h.TransferUSDC) + r.Post("/wallets/cctp", h.CCTPSimulate) +} + +// Utility + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } +} + +func respondError(w http.ResponseWriter, status int, message string) { + respondJSON(w, status, ErrorResponse{ + Error: message, + Status: status, + }) +} diff --git a/internal/handlers/types.go b/internal/handlers/types.go index e11da7e..3da96a3 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -92,3 +92,29 @@ type ErrorResponse struct { Error string `json:"error"` Status int `json:"status"` } + +// Agent requests + +// CreateAgentRequest registers an agent so it can submit proposals. +// +// Agents were previously read-only: proposals require an existing agent row, +// but nothing could create one, so a fresh database could not accept a single +// proposal. +type CreateAgentRequest struct { + Name string `json:"name" validate:"required"` + // Constrained to the strategies the scoring engine and the dApp both know + // about. A free-text value would be accepted here and then silently fail to + // match anything downstream. + StrategyType string `json:"strategy_type" validate:"required,oneof=twap momentum shadow arbitrage custom"` + Description string `json:"description"` + WalletAddress string `json:"wallet_address" validate:"required"` +} + +// UpdateAgentStatusRequest activates or deactivates an agent. +// +// Uses a pointer so that `false` is distinguishable from an omitted field — +// with a plain bool, "deactivate this agent" and "do not change the status" +// are the same JSON. +type UpdateAgentStatusRequest struct { + IsActive *bool `json:"is_active" validate:"required"` +} diff --git a/internal/handlers/validate_test.go b/internal/handlers/validate_test.go index a8f7417..25c94b0 100644 --- a/internal/handlers/validate_test.go +++ b/internal/handlers/validate_test.go @@ -97,3 +97,48 @@ func TestToSnakeKeepsAcronymsIntact(t *testing.T) { } } } + +func TestCreateAgentRequestRequiresIdentity(t *testing.T) { + msg := validateStruct(&CreateAgentRequest{}) + if msg == "" { + t.Fatal("expected an empty agent to be rejected") + } + for _, field := range []string{"name", "strategy_type", "wallet_address"} { + if !strings.Contains(msg, field) { + t.Errorf("expected %q to be reported, got: %s", field, msg) + } + } +} + +func TestCreateAgentRequestRejectsUnknownStrategy(t *testing.T) { + // A free-text strategy would be stored and then match nothing downstream. + req := CreateAgentRequest{ + Name: "Rogue", + StrategyType: "not_a_strategy", + WalletAddress: "0xabc", + } + if msg := validateStruct(&req); msg == "" { + t.Fatal("expected an unknown strategy to be rejected") + } +} + +func TestCreateAgentRequestAcceptsKnownStrategies(t *testing.T) { + for _, s := range []string{"twap", "momentum", "shadow", "arbitrage", "custom"} { + req := CreateAgentRequest{Name: "A", StrategyType: s, WalletAddress: "0xabc"} + if msg := validateStruct(&req); msg != "" { + t.Errorf("expected %q to be accepted, got: %s", s, msg) + } + } +} + +func TestUpdateAgentStatusDistinguishesFalseFromOmitted(t *testing.T) { + // The reason IsActive is a pointer: with a plain bool, "deactivate" and + // "leave unchanged" arrive as the same JSON. + deactivate := false + if msg := validateStruct(&UpdateAgentStatusRequest{IsActive: &deactivate}); msg != "" { + t.Fatalf("expected an explicit false to be accepted, got: %s", msg) + } + if msg := validateStruct(&UpdateAgentStatusRequest{}); msg == "" { + t.Fatal("expected an omitted is_active to be rejected") + } +} From f9ab261d90c6d86ea1cce06d8e4059d6791dd343 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:44:34 +0100 Subject: [PATCH 3/5] feat: record which chain an intent settles on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intents carried no chain. The dApp now routes per chain and connects wallets on both Arc and Stellar, so an intent submitted from the Stellar side was indistinguishable from an Arc one by the time it reached this service, and would settle against the wrong contracts. Adds a chain discriminator, defaulting to Arc when omitted so clients written before multi-chain keep working. Wallet addresses are validated against the chain at the API boundary. The two formats are unmistakable — 20 hex bytes behind 0x, or 56 base32 characters behind G — so accepting a mismatched pair only defers the failure to settlement, which is the most expensive place to find it. The family split (evm vs stellar) is deliberate: code that must branch between the two worlds branches on family rather than chain name, so adding another EVM chain does not mean revisiting every switch. Verified against a live Postgres: both chains persist, a mismatched address is rejected with a message naming the expected format, an unsupported chain is rejected, and an omitted chain still defaults. --- internal/chain/chain.go | 111 +++++++++++++++++++++++++++++++++++ internal/chain/chain_test.go | 76 ++++++++++++++++++++++++ internal/handlers/intents.go | 18 ++++++ internal/handlers/types.go | 5 ++ internal/models/models.go | 3 + 5 files changed, 213 insertions(+) create mode 100644 internal/chain/chain.go create mode 100644 internal/chain/chain_test.go diff --git a/internal/chain/chain.go b/internal/chain/chain.go new file mode 100644 index 0000000..f09a813 --- /dev/null +++ b/internal/chain/chain.go @@ -0,0 +1,111 @@ +// Package chain describes the networks an intent can settle on. +// +// Intents used to carry no chain at all. The dApp now routes per chain and +// connects wallets on both Arc and Stellar, so without a discriminator an +// intent submitted from the Stellar side is indistinguishable from an Arc one +// once it reaches this service — and would settle against the wrong contracts. +package chain + +import ( + "fmt" + "regexp" + "strings" +) + +// Chain is the network an intent settles on. +type Chain string + +const ( + Arc Chain = "arc" + Stellar Chain = "stellar" +) + +// Family is the execution environment. Code that must branch between the two +// worlds branches on this rather than on the chain name, so adding another EVM +// chain does not mean revisiting every switch. +type Family string + +const ( + FamilyEVM Family = "evm" + FamilyStellar Family = "stellar" +) + +var families = map[Chain]Family{ + Arc: FamilyEVM, + Stellar: FamilyStellar, +} + +// Supported lists every chain an intent may name, in display order. +var Supported = []Chain{Arc, Stellar} + +// Default is used when a request omits the chain, so existing clients that +// predate multi-chain keep working rather than failing validation. +const Default = Arc + +func (c Chain) Valid() bool { + _, ok := families[c] + return ok +} + +func (c Chain) Family() Family { + return families[c] +} + +func (c Chain) String() string { return string(c) } + +// Parse normalises and validates a chain name from a request. +func Parse(s string) (Chain, error) { + trimmed := strings.ToLower(strings.TrimSpace(s)) + if trimmed == "" { + return Default, nil + } + c := Chain(trimmed) + if !c.Valid() { + return "", fmt.Errorf("unsupported chain %q (want one of: %s)", s, JoinSupported()) + } + return c, nil +} + +func JoinSupported() string { + names := make([]string, 0, len(Supported)) + for _, c := range Supported { + names = append(names, string(c)) + } + return strings.Join(names, ", ") +} + +// EVM addresses are 20 hex bytes; Stellar public keys are 56 base32 characters +// beginning with G. The two are unmistakable, which is what makes it worth +// rejecting a mismatch at the API boundary rather than at settlement. +var ( + evmAddress = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`) + stellarAddress = regexp.MustCompile(`^G[A-Z2-7]{55}$`) +) + +// ValidateAddress reports whether an address belongs to the given chain. +// +// A single address column that accepts both formats without checking them +// against the chain will happily store a mismatched pair, and the failure then +// surfaces at settlement — the most expensive place to find it. +func ValidateAddress(c Chain, address string) error { + addr := strings.TrimSpace(address) + if addr == "" { + return fmt.Errorf("address is required") + } + + switch c.Family() { + case FamilyEVM: + if !evmAddress.MatchString(addr) { + return fmt.Errorf("%q is not a valid %s address (want 0x followed by 40 hex characters)", addr, c) + } + case FamilyStellar: + // Stellar encodes in uppercase base32; a lowercase key is malformed + // rather than merely differently spelled. + if !stellarAddress.MatchString(addr) { + return fmt.Errorf("%q is not a valid %s address (want G followed by 55 base32 characters)", addr, c) + } + default: + return fmt.Errorf("unsupported chain %q", c) + } + return nil +} diff --git a/internal/chain/chain_test.go b/internal/chain/chain_test.go new file mode 100644 index 0000000..eceed33 --- /dev/null +++ b/internal/chain/chain_test.go @@ -0,0 +1,76 @@ +package chain + +import "testing" + +func TestParseDefaultsToArcWhenOmitted(t *testing.T) { + // Clients written before multi-chain send no chain at all; they must keep + // working rather than start failing validation. + got, err := Parse("") + if err != nil || got != Arc { + t.Fatalf("Parse(\"\") = %q, %v; want arc, nil", got, err) + } +} + +func TestParseNormalises(t *testing.T) { + for _, in := range []string{"STELLAR", " stellar ", "Stellar"} { + got, err := Parse(in) + if err != nil || got != Stellar { + t.Errorf("Parse(%q) = %q, %v; want stellar, nil", in, got, err) + } + } +} + +func TestParseRejectsUnknownChain(t *testing.T) { + if _, err := Parse("ethereum"); err == nil { + t.Fatal("expected an unsupported chain to be rejected") + } +} + +func TestFamilySeparatesEVMFromStellar(t *testing.T) { + if Arc.Family() != FamilyEVM { + t.Errorf("arc should be an EVM chain") + } + if Stellar.Family() != FamilyStellar { + t.Errorf("stellar should not be an EVM chain") + } +} + +func TestValidateAddressAcceptsCorrectFormats(t *testing.T) { + if err := ValidateAddress(Arc, "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0"); err != nil { + t.Errorf("expected a valid EVM address to pass: %v", err) + } + // Circle's testnet USDC issuer — a real, well-formed Stellar key. + if err := ValidateAddress(Stellar, "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); err != nil { + t.Errorf("expected a valid Stellar address to pass: %v", err) + } +} + +// The mismatch this whole change exists to catch. +func TestValidateAddressRejectsWrongChainFormat(t *testing.T) { + if err := ValidateAddress(Stellar, "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0"); err == nil { + t.Error("expected an EVM address on a Stellar intent to be rejected") + } + if err := ValidateAddress(Arc, "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"); err == nil { + t.Error("expected a Stellar address on an Arc intent to be rejected") + } +} + +func TestValidateAddressRejectsMalformed(t *testing.T) { + cases := []struct { + chain Chain + address string + why string + }{ + {Arc, "", "empty"}, + {Arc, "0x123", "too short"}, + {Arc, "742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", "missing 0x"}, + {Arc, "0xZZZZ35Cc6634C0532925a3b844Bc9e7595f0bEb0", "non-hex"}, + {Stellar, "G123", "too short"}, + {Stellar, "gbbd47if6lwk7p7mdevscwr7dpuwv3ny3dtqevfl4nat4aqh3zllfla5", "lowercase"}, + } + for _, c := range cases { + if err := ValidateAddress(c.chain, c.address); err == nil { + t.Errorf("expected %s address to be rejected (%s)", c.chain, c.why) + } + } +} diff --git a/internal/handlers/intents.go b/internal/handlers/intents.go index 3b8b286..f8bdb3c 100644 --- a/internal/handlers/intents.go +++ b/internal/handlers/intents.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + "github.com/Cookit-labs/Backend/internal/chain" "github.com/Cookit-labs/Backend/internal/models" ) @@ -22,8 +23,23 @@ func (h *Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { return } + target, err := chain.Parse(req.Chain) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + + // Checked here rather than at settlement: an Arc address on a Stellar + // intent is unrecoverable once funds are in flight, and the two formats are + // unmistakable, so there is no reason to accept the mismatch. + if err := chain.ValidateAddress(target, req.UserWallet); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + intent := &models.Intent{ ID: uuid.New().String(), + Chain: target.String(), UserWallet: req.UserWallet, TokenIn: req.TokenIn, TokenOut: req.TokenOut, @@ -47,6 +63,7 @@ func (h *Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, IntentResponse{ ID: intent.ID, + Chain: intent.Chain, UserWallet: intent.UserWallet, TokenIn: intent.TokenIn, TokenOut: intent.TokenOut, @@ -70,6 +87,7 @@ func (h *Handler) GetIntent(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, IntentResponse{ ID: intent.ID, + Chain: intent.Chain, UserWallet: intent.UserWallet, TokenIn: intent.TokenIn, TokenOut: intent.TokenOut, diff --git a/internal/handlers/types.go b/internal/handlers/types.go index 3da96a3..8491bd4 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -5,6 +5,10 @@ import "time" // Intent requests/responses type CreateIntentRequest struct { + // Optional: omitting it defaults to Arc, so clients written before + // multi-chain keep working. The wallet is validated against whichever + // chain applies. + Chain string `json:"chain,omitempty" validate:"omitempty,oneof=arc stellar"` UserWallet string `json:"user_wallet" validate:"required"` TokenIn string `json:"token_in" validate:"required"` TokenOut string `json:"token_out" validate:"required"` @@ -16,6 +20,7 @@ type CreateIntentRequest struct { type IntentResponse struct { ID string `json:"id"` + Chain string `json:"chain"` UserWallet string `json:"user_wallet"` TokenIn string `json:"token_in"` TokenOut string `json:"token_out"` diff --git a/internal/models/models.go b/internal/models/models.go index 30a2219..cee4c15 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -9,6 +9,9 @@ import ( // Intent represents a user's trading intention type Intent struct { ID string `gorm:"primaryKey" json:"id"` + // Which network this intent settles on. Indexed because settlement and the + // orchestrator both filter by it. + Chain string `gorm:"index;default:arc" json:"chain"` UserWallet string `gorm:"index" json:"user_wallet"` TokenIn string `json:"token_in"` TokenOut string `json:"token_out"` From f9f7a113dfc24dc2492fd3526303aa924b2dbbee Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:48:59 +0100 Subject: [PATCH 4/5] feat: measure what agents deliver against what they promised MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents self-reported projected_slippage, won competitions on it, and no record was kept of whether the projection held. An agent that promises 0.1% and delivers 2% was indistinguishable from one that promises and delivers 0.1%. Executions now record the projection alongside the actual, plus the delta between them. The projection is copied onto the execution rather than read back through the proposal, so the comparison survives the proposal being edited or soft-deleted. Reputation gains a running mean of that delta. Separately, AvgSlippageDelivered was declared on the model but never written, so the leaderboard has been showing zero for every agent since it was added; both means are now maintained in SQL against the pre-increment count, so a concurrent settlement cannot read a stale average and overwrite it. Also fixes agent reputation rows being created without a primary key. The column has no database-side default, so the first agent inserted an empty string and the second collided with it — found by exercising the endpoint against a real Postgres rather than in the unit tests. Verified end to end: two settlements at 2% and 1% against a promised 0.1% produce an average delivered of 0.015 and an average delta of 0.014. --- internal/handlers/agents.go | 8 ++++++ internal/handlers/executions.go | 21 ++++++++++++++ internal/handlers/slippage_test.go | 45 ++++++++++++++++++++++++++++++ internal/handlers/types.go | 1 + internal/models/models.go | 13 +++++++++ 5 files changed, 88 insertions(+) create mode 100644 internal/handlers/slippage_test.go diff --git a/internal/handlers/agents.go b/internal/handlers/agents.go index 8520584..a3eabfe 100644 --- a/internal/handlers/agents.go +++ b/internal/handlers/agents.go @@ -58,6 +58,9 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { // leaderboard — which skips agents with no reputation row — shows a new // agent immediately instead of hiding it until its first settlement. reputation := &models.AgentReputation{ + // The primary key has no database-side default, so omitting it inserts + // an empty string and the second agent collides with the first. + ID: uuid.New().String(), AgentID: agent.ID, } if err := h.db.Create(reputation).Error; err != nil { @@ -144,6 +147,7 @@ func (h *Handler) GetLeaderboard(w http.ResponseWriter, r *http.Request) { TotalIntentsWon: agent.TotalIntentsWon, WinRate: agent.Reputation.WinRate, AvgSlippageDelivered: agent.Reputation.AvgSlippageDelivered, + AvgSlippageDelta: agent.Reputation.AvgSlippageDelta, CompositeScore: agent.Reputation.CompositeScore, } entries = append(entries, entry) @@ -185,6 +189,10 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { "total_executions": totalExecutions, "win_rate": rep.WinRate, "avg_slippage": rep.AvgSlippageDelivered, + // Positive means the agent habitually delivers worse than it promises. + // Exposed alongside win rate because an agent can win often on + // optimistic projections it never meets. + "avg_slippage_delta": rep.AvgSlippageDelta, "composite_score": rep.CompositeScore, "on_chain_score": rep.OnChainReputationScore, "consecutive_success": rep.ConsecutiveSuccesses, diff --git a/internal/handlers/executions.go b/internal/handlers/executions.go index d30559b..765fad9 100644 --- a/internal/handlers/executions.go +++ b/internal/handlers/executions.go @@ -193,7 +193,12 @@ func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { IntentID: intentID, WinningAgentID: proposal.AgentID, ProposalID: req.ProposalID, + // Recorded together so reputation can distinguish an agent that + // promises 0.1% and delivers it from one that promises 0.1% and + // delivers 2%. Without the projection, both look identical. + ProjectedSlippage: proposal.ProjectedSlippage, ActualSlippage: req.ActualSlippage, + SlippageDelta: req.ActualSlippage - proposal.ProjectedSlippage, TxHash: req.TxHash, SettlementStatus: "confirmed", ExecutionFeeUSDC: req.ExecutionFeeUSDC, @@ -230,7 +235,19 @@ func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { h.db.Model(&models.Agent{}).Where("id = ?", proposal.AgentID). UpdateColumn("total_intents_won", gorm.Expr("total_intents_won + 1")) + // Running means, computed in SQL against the pre-increment count so a + // concurrent settlement cannot read a stale average and overwrite it. + // AvgSlippageDelivered was declared but never written, so the leaderboard + // has been showing zero for every agent. h.db.Model(&models.AgentReputation{}).Where("agent_id = ?", proposal.AgentID).Updates(map[string]any{ + "avg_slippage_delivered": gorm.Expr( + "(avg_slippage_delivered * total_executions + ?) / (total_executions + 1)", + execution.ActualSlippage, + ), + "avg_slippage_delta": gorm.Expr( + "(avg_slippage_delta * total_executions + ?) / (total_executions + 1)", + execution.SlippageDelta, + ), "total_executions": gorm.Expr("total_executions + 1"), "consecutive_successes": gorm.Expr("consecutive_successes + 1"), }) @@ -240,6 +257,8 @@ func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { "execution_id": execution.ID, "agent_id": execution.WinningAgentID, "slippage": execution.ActualSlippage, + "projected": execution.ProjectedSlippage, + "delta": execution.SlippageDelta, "tx_hash": execution.TxHash, }) @@ -250,6 +269,8 @@ func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { "execution_id": execution.ID, "agent_id": execution.WinningAgentID, "slippage": execution.ActualSlippage, + "projected": execution.ProjectedSlippage, + "delta": execution.SlippageDelta, "tx_hash": execution.TxHash, }) diff --git a/internal/handlers/slippage_test.go b/internal/handlers/slippage_test.go new file mode 100644 index 0000000..9b7f44b --- /dev/null +++ b/internal/handlers/slippage_test.go @@ -0,0 +1,45 @@ +package handlers + +import "testing" + +// The delta is what makes reputation mean something: without it, an agent that +// promises 0.1% and delivers 0.1% is indistinguishable from one that promises +// 0.1% and delivers 2%. Both win, and both look identical afterwards. +func TestSlippageDeltaSign(t *testing.T) { + cases := []struct { + name string + projected float64 + actual float64 + wantSign int + }{ + {"delivered worse than promised", 0.001, 0.02, +1}, + {"delivered exactly as promised", 0.005, 0.005, 0}, + {"delivered better than promised", 0.01, 0.002, -1}, + } + + for _, c := range cases { + delta := c.actual - c.projected + got := 0 + if delta > 0 { + got = 1 + } else if delta < 0 { + got = -1 + } + if got != c.wantSign { + t.Errorf("%s: delta %.4f has sign %d, want %d", c.name, delta, got, c.wantSign) + } + } +} + +// Mirrors the SQL running mean, which is computed against the pre-increment +// count so a concurrent settlement cannot read a stale average. +func TestRunningMean(t *testing.T) { + mean, n := 0.0, int64(0) + for _, v := range []float64{0.02, 0.01} { + mean = (mean*float64(n) + v) / float64(n+1) + n++ + } + if want := 0.015; mean != want { + t.Fatalf("running mean = %v, want %v", mean, want) + } +} diff --git a/internal/handlers/types.go b/internal/handlers/types.go index 8491bd4..182fe8b 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -83,6 +83,7 @@ type LeaderboardEntry struct { TotalIntentsWon int64 `json:"total_intents_won"` WinRate float64 `json:"win_rate"` AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` + AvgSlippageDelta float64 `json:"avg_slippage_delta"` CompositeScore float64 `json:"composite_score"` } diff --git a/internal/models/models.go b/internal/models/models.go index cee4c15..c3f2797 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -62,7 +62,15 @@ type Execution struct { WinningAgentID string `gorm:"index" json:"winning_agent_id"` ProposalID string `json:"proposal_id"` ExecutedAmount string `json:"executed_amount"` + // What the winning agent promised, copied from the proposal at settlement. + // Kept on the execution rather than read back through the proposal so the + // comparison survives the proposal being edited or soft-deleted. + ProjectedSlippage float64 `json:"projected_slippage"` ActualSlippage float64 `json:"actual_slippage"` + // Actual minus projected. Positive means the agent delivered worse than it + // promised. Stored rather than computed on read so it can be aggregated and + // indexed without recomputing across every execution. + SlippageDelta float64 `gorm:"index" json:"slippage_delta"` TxHash string `json:"tx_hash"` SettlementStatus string `json:"settlement_status"` // pending, confirmed, failed ExecutionFeeUSDC string `json:"execution_fee_usdc"` // agent fee @@ -104,6 +112,11 @@ type AgentReputation struct { AgentID string `gorm:"uniqueIndex" json:"agent_id"` WinRate float64 `json:"win_rate"` // wins / total_proposals AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` + // Mean of (actual - projected) across settled executions. Positive means + // the agent habitually delivers worse than it promises, which win rate and + // average slippage alone cannot reveal — an agent can win often, on + // optimistic projections it never meets. + AvgSlippageDelta float64 `json:"avg_slippage_delta"` TotalExecutions int64 `json:"total_executions"` ConsecutiveSuccesses int64 `json:"consecutive_successes"` CompositeScore float64 `json:"composite_score"` // weighted avg of metrics From 3a68911e2f01abcf7d01f81d4b75f336ce2f22a8 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:49:55 +0100 Subject: [PATCH 5/5] style: gofmt the files touched in this branch --- internal/handlers/agents.go | 402 +++++++++++------------ internal/handlers/executions.go | 566 ++++++++++++++++---------------- internal/handlers/intents.go | 204 ++++++------ internal/handlers/proposals.go | 236 ++++++------- internal/handlers/types.go | 252 +++++++------- internal/handlers/validate.go | 216 ++++++------ internal/models/models.go | 262 +++++++-------- internal/validation/service.go | 288 ++++++++-------- 8 files changed, 1213 insertions(+), 1213 deletions(-) diff --git a/internal/handlers/agents.go b/internal/handlers/agents.go index a3eabfe..b7f331c 100644 --- a/internal/handlers/agents.go +++ b/internal/handlers/agents.go @@ -1,201 +1,201 @@ -package handlers - -import ( - "encoding/json" - "errors" - "net/http" - "strings" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - "gorm.io/gorm" - - "github.com/Cookit-labs/Backend/internal/models" -) - -// CreateAgent registers an agent so it can submit proposals. -func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { - var req CreateAgentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - if msg := validateStruct(&req); msg != "" { - respondError(w, http.StatusBadRequest, msg) - return - } - - // A wallet identifies an agent on chain, so two agents sharing one would - // make settlement ambiguous. - wallet := strings.TrimSpace(req.WalletAddress) - var existing models.Agent - err := h.db.First(&existing, "wallet_address = ?", wallet).Error - if err == nil { - respondError(w, http.StatusConflict, "an agent with that wallet address already exists") - return - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - respondError(w, http.StatusInternalServerError, "failed to check existing agents") - return - } - - agent := &models.Agent{ - ID: uuid.New().String(), - Name: strings.TrimSpace(req.Name), - StrategyType: strings.ToLower(strings.TrimSpace(req.StrategyType)), - Description: req.Description, - WalletAddress: wallet, - IsActive: true, - } - - if err := h.db.Create(agent).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to create agent") - return - } - - // Reputation is created alongside the agent rather than lazily, so the - // leaderboard — which skips agents with no reputation row — shows a new - // agent immediately instead of hiding it until its first settlement. - reputation := &models.AgentReputation{ - // The primary key has no database-side default, so omitting it inserts - // an empty string and the second agent collides with the first. - ID: uuid.New().String(), - AgentID: agent.ID, - } - if err := h.db.Create(reputation).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to initialise agent reputation") - return - } - agent.Reputation = reputation - - respondJSON(w, http.StatusCreated, agent) -} - -// ListAgents returns every registered agent. -func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { - query := h.db.Preload("Reputation") - - // ?active=true is how the orchestrator asks for agents it may actually - // invite into a competition. - if r.URL.Query().Get("active") == "true" { - query = query.Where("is_active = ?", true) - } - - var agents []models.Agent - if err := query.Find(&agents).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to fetch agents") - return - } - - respondJSON(w, http.StatusOK, map[string]any{ - "agents": agents, - "total": len(agents), - }) -} - -// UpdateAgentStatus activates or deactivates an agent. -// -// Deactivation rather than deletion: proposals and executions reference an -// agent, and removing the row would orphan settled history. -func (h *Handler) UpdateAgentStatus(w http.ResponseWriter, r *http.Request) { - agentID := chi.URLParam(r, "agentID") - - var req UpdateAgentStatusRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - if msg := validateStruct(&req); msg != "" { - respondError(w, http.StatusBadRequest, msg) - return - } - - var agent models.Agent - if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { - respondError(w, http.StatusNotFound, "agent not found") - return - } - - agent.IsActive = *req.IsActive - if err := h.db.Save(&agent).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to update agent") - return - } - - respondJSON(w, http.StatusOK, agent) -} - -func (h *Handler) GetLeaderboard(w http.ResponseWriter, r *http.Request) { - var agents []models.Agent - if err := h.db.Preload("Reputation").Find(&agents).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to fetch agents") - return - } - - entries := make([]LeaderboardEntry, 0) - for _, agent := range agents { - if agent.Reputation == nil { - continue - } - - entry := LeaderboardEntry{ - AgentID: agent.ID, - Name: agent.Name, - StrategyType: agent.StrategyType, - TotalIntentsWon: agent.TotalIntentsWon, - WinRate: agent.Reputation.WinRate, - AvgSlippageDelivered: agent.Reputation.AvgSlippageDelivered, - AvgSlippageDelta: agent.Reputation.AvgSlippageDelta, - CompositeScore: agent.Reputation.CompositeScore, - } - entries = append(entries, entry) - } - - respondJSON(w, http.StatusOK, LeaderboardResponse{ - Agents: entries, - Total: int64(len(entries)), - }) -} - -func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { - agentID := chi.URLParam(r, "agentID") - - var agent models.Agent - if err := h.db.Preload("Reputation").Preload("Proposals").First(&agent, "id = ?", agentID).Error; err != nil { - respondError(w, http.StatusNotFound, "agent not found") - return - } - - // Count total executions for this agent - var totalExecutions int64 - h.db.Model(&models.Execution{}).Where("winning_agent_id = ?", agentID).Count(&totalExecutions) - - rep := agent.Reputation - if rep == nil { - rep = &models.AgentReputation{} - } - - respondJSON(w, http.StatusOK, map[string]interface{}{ - "id": agent.ID, - "name": agent.Name, - "strategy_type": agent.StrategyType, - "description": agent.Description, - "is_active": agent.IsActive, - "wallet_address": agent.WalletAddress, - "total_intents_won": agent.TotalIntentsWon, - "total_capital_usdc": agent.TotalCapitalUsdc, - "total_executions": totalExecutions, - "win_rate": rep.WinRate, - "avg_slippage": rep.AvgSlippageDelivered, - // Positive means the agent habitually delivers worse than it promises. - // Exposed alongside win rate because an agent can win often on - // optimistic projections it never meets. - "avg_slippage_delta": rep.AvgSlippageDelta, - "composite_score": rep.CompositeScore, - "on_chain_score": rep.OnChainReputationScore, - "consecutive_success": rep.ConsecutiveSuccesses, - "created_at": agent.CreatedAt, - }) -} +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/Cookit-labs/Backend/internal/models" +) + +// CreateAgent registers an agent so it can submit proposals. +func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) { + var req CreateAgentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + // A wallet identifies an agent on chain, so two agents sharing one would + // make settlement ambiguous. + wallet := strings.TrimSpace(req.WalletAddress) + var existing models.Agent + err := h.db.First(&existing, "wallet_address = ?", wallet).Error + if err == nil { + respondError(w, http.StatusConflict, "an agent with that wallet address already exists") + return + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + respondError(w, http.StatusInternalServerError, "failed to check existing agents") + return + } + + agent := &models.Agent{ + ID: uuid.New().String(), + Name: strings.TrimSpace(req.Name), + StrategyType: strings.ToLower(strings.TrimSpace(req.StrategyType)), + Description: req.Description, + WalletAddress: wallet, + IsActive: true, + } + + if err := h.db.Create(agent).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create agent") + return + } + + // Reputation is created alongside the agent rather than lazily, so the + // leaderboard — which skips agents with no reputation row — shows a new + // agent immediately instead of hiding it until its first settlement. + reputation := &models.AgentReputation{ + // The primary key has no database-side default, so omitting it inserts + // an empty string and the second agent collides with the first. + ID: uuid.New().String(), + AgentID: agent.ID, + } + if err := h.db.Create(reputation).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to initialise agent reputation") + return + } + agent.Reputation = reputation + + respondJSON(w, http.StatusCreated, agent) +} + +// ListAgents returns every registered agent. +func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { + query := h.db.Preload("Reputation") + + // ?active=true is how the orchestrator asks for agents it may actually + // invite into a competition. + if r.URL.Query().Get("active") == "true" { + query = query.Where("is_active = ?", true) + } + + var agents []models.Agent + if err := query.Find(&agents).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch agents") + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "agents": agents, + "total": len(agents), + }) +} + +// UpdateAgentStatus activates or deactivates an agent. +// +// Deactivation rather than deletion: proposals and executions reference an +// agent, and removing the row would orphan settled history. +func (h *Handler) UpdateAgentStatus(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + + var req UpdateAgentStatusRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + var agent models.Agent + if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { + respondError(w, http.StatusNotFound, "agent not found") + return + } + + agent.IsActive = *req.IsActive + if err := h.db.Save(&agent).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to update agent") + return + } + + respondJSON(w, http.StatusOK, agent) +} + +func (h *Handler) GetLeaderboard(w http.ResponseWriter, r *http.Request) { + var agents []models.Agent + if err := h.db.Preload("Reputation").Find(&agents).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch agents") + return + } + + entries := make([]LeaderboardEntry, 0) + for _, agent := range agents { + if agent.Reputation == nil { + continue + } + + entry := LeaderboardEntry{ + AgentID: agent.ID, + Name: agent.Name, + StrategyType: agent.StrategyType, + TotalIntentsWon: agent.TotalIntentsWon, + WinRate: agent.Reputation.WinRate, + AvgSlippageDelivered: agent.Reputation.AvgSlippageDelivered, + AvgSlippageDelta: agent.Reputation.AvgSlippageDelta, + CompositeScore: agent.Reputation.CompositeScore, + } + entries = append(entries, entry) + } + + respondJSON(w, http.StatusOK, LeaderboardResponse{ + Agents: entries, + Total: int64(len(entries)), + }) +} + +func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + + var agent models.Agent + if err := h.db.Preload("Reputation").Preload("Proposals").First(&agent, "id = ?", agentID).Error; err != nil { + respondError(w, http.StatusNotFound, "agent not found") + return + } + + // Count total executions for this agent + var totalExecutions int64 + h.db.Model(&models.Execution{}).Where("winning_agent_id = ?", agentID).Count(&totalExecutions) + + rep := agent.Reputation + if rep == nil { + rep = &models.AgentReputation{} + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "id": agent.ID, + "name": agent.Name, + "strategy_type": agent.StrategyType, + "description": agent.Description, + "is_active": agent.IsActive, + "wallet_address": agent.WalletAddress, + "total_intents_won": agent.TotalIntentsWon, + "total_capital_usdc": agent.TotalCapitalUsdc, + "total_executions": totalExecutions, + "win_rate": rep.WinRate, + "avg_slippage": rep.AvgSlippageDelivered, + // Positive means the agent habitually delivers worse than it promises. + // Exposed alongside win rate because an agent can win often on + // optimistic projections it never meets. + "avg_slippage_delta": rep.AvgSlippageDelta, + "composite_score": rep.CompositeScore, + "on_chain_score": rep.OnChainReputationScore, + "consecutive_success": rep.ConsecutiveSuccesses, + "created_at": agent.CreatedAt, + }) +} diff --git a/internal/handlers/executions.go b/internal/handlers/executions.go index 765fad9..4234cda 100644 --- a/internal/handlers/executions.go +++ b/internal/handlers/executions.go @@ -1,283 +1,283 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - "time" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "gorm.io/gorm" - - "github.com/Cookit-labs/Backend/internal/models" - "github.com/Cookit-labs/Backend/internal/scoring" - "github.com/Cookit-labs/Backend/internal/validation" -) - -func (h *Handler) SelectWinner(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - // Fetch all proposals for this intent - var proposals []models.Proposal - if err := h.db.Where("intent_id = ?", intentID).Find(&proposals).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to fetch proposals") - return - } - - if len(proposals) == 0 { - respondError(w, http.StatusBadRequest, "no proposals to select from") - return - } - - // Fetch reputation records for all agents in these proposals - agentIDs := make([]string, len(proposals)) - for i, p := range proposals { - agentIDs[i] = p.AgentID - } - - var reputations []models.AgentReputation - h.db.Where("agent_id IN ?", agentIDs).Find(&reputations) - - repMap := make(map[string]*models.AgentReputation, len(reputations)) - for i := range reputations { - repMap[reputations[i].AgentID] = &reputations[i] - } - - // Run deterministic scoring engine - engine := scoring.New() - result := engine.Score(proposals, repMap) - - if result.Winner == nil { - respondError(w, http.StatusInternalServerError, "scoring engine returned no winner") - return - } - - // Persist each proposal's computed score - for _, s := range result.AllScores { - h.db.Model(&models.Proposal{}).Where("id = ?", s.ProposalID).Update("score", s.Total) - } - - // Update intent with selected agent - if err := h.db.Model(&models.Intent{}).Where("id = ?", intentID).Updates(map[string]any{ - "status": "winner_selected", - "selected_agent_id": result.Winner.AgentID, - }).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to update intent") - return - } - - // Broadcast winner selection - h.hub.Broadcast(intentID, "winner_selected", map[string]any{ - "proposal_id": result.Winner.ProposalID, - "agent_id": result.Winner.AgentID, - "score": result.Winner.Total, - "execution_efficiency": result.Winner.ExecutionEfficiencyScore, - "win_rate_score": result.Winner.WinRateScore, - "slippage_score": result.Winner.SlippageScore, - "reputation_score": result.Winner.ReputationScore, - }) - - // Publish to Redis pub/sub - ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) - defer cancel() - _ = h.pubsub.Publish(ctx, intentID, "winner_selected", map[string]any{ - "proposal_id": result.Winner.ProposalID, - "agent_id": result.Winner.AgentID, - "score": result.Winner.Total, - }) - - respondJSON(w, http.StatusOK, map[string]any{ - "winner_proposal_id": result.Winner.ProposalID, - "agent_id": result.Winner.AgentID, - "total_score": result.Winner.Total, - "execution_efficiency": result.Winner.ExecutionEfficiencyScore, - "win_rate_score": result.Winner.WinRateScore, - "slippage_score": result.Winner.SlippageScore, - "reputation_score": result.Winner.ReputationScore, - "all_scores": result.AllScores, - }) -} - -func (h *Handler) ValidateExecution(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - var req ValidateExecutionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - if msg := validateStruct(&req); msg != "" { - respondError(w, http.StatusBadRequest, msg) - return - } - - // Fetch intent and proposal - var intent models.Intent - if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { - respondError(w, http.StatusNotFound, "intent not found") - return - } - - var proposal models.Proposal - if err := h.db.First(&proposal, "id = ?", req.ProposalID).Error; err != nil { - respondError(w, http.StatusNotFound, "proposal not found") - return - } - - // Run full validation service - svc := validation.New() - result := svc.Validate(&intent, &proposal) - - if !result.Valid { - // Record failed validation attempt against the agent - h.db.Model(&models.Proposal{}).Where("id = ?", req.ProposalID). - Update("score", 0) - - respondJSON(w, http.StatusUnprocessableEntity, map[string]any{ - "valid": false, - "errors": result.Errors, - }) - return - } - - respondJSON(w, http.StatusOK, map[string]any{ - "valid": true, - "message": "execution validation passed", - }) -} - -func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - var req SettleExecutionRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - if msg := validateStruct(&req); msg != "" { - respondError(w, http.StatusBadRequest, msg) - return - } - - // Fetch intent and proposal - var intent models.Intent - if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { - respondError(w, http.StatusNotFound, "intent not found") - return - } - - var proposal models.Proposal - if err := h.db.First(&proposal, "id = ?", req.ProposalID).Error; err != nil { - respondError(w, http.StatusNotFound, "proposal not found") - return - } - - // Re-run validation gate before any settlement - svc := validation.New() - if vr := svc.Validate(&intent, &proposal); !vr.Valid { - respondJSON(w, http.StatusUnprocessableEntity, map[string]any{ - "error": "settlement blocked: validation failed", - "errors": vr.Errors, - }) - return - } - - // Create execution record - now := time.Now() - execution := &models.Execution{ - ID: uuid.New().String(), - IntentID: intentID, - WinningAgentID: proposal.AgentID, - ProposalID: req.ProposalID, - // Recorded together so reputation can distinguish an agent that - // promises 0.1% and delivers it from one that promises 0.1% and - // delivers 2%. Without the projection, both look identical. - ProjectedSlippage: proposal.ProjectedSlippage, - ActualSlippage: req.ActualSlippage, - SlippageDelta: req.ActualSlippage - proposal.ProjectedSlippage, - TxHash: req.TxHash, - SettlementStatus: "confirmed", - ExecutionFeeUSDC: req.ExecutionFeeUSDC, - ExecutedAt: now, - SettledAt: &now, - } - - if err := h.db.Create(execution).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to create execution") - return - } - - // Update intent status - if err := h.db.Model(&models.Intent{}).Where("id = ?", intentID).Update("status", "settled").Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to update intent") - return - } - - // Pay agent execution fee via Circle if wallets are configured - if proposal.AgentID != "" && req.ExecutionFeeUSDC != "" { - // Fetch agent's Circle wallet address - var agent models.Agent - if err := h.db.First(&agent, "id = ?", proposal.AgentID).Error; err == nil && agent.WalletAddress != "" { - idempotencyKey := "settle-" + execution.ID - feeCtx, feeCancel := context.WithTimeout(context.Background(), 15*1e9) - defer feeCancel() - // Transfer fee from escrow (intent's wallet) to agent wallet - // In V0 we use the agent's WalletAddress as the Circle wallet ID - _, _ = h.circle.SendUSDC(feeCtx, idempotencyKey, intent.UserWallet, agent.WalletAddress, req.ExecutionFeeUSDC) - } - } - - // Update agent win stats and reputation - h.db.Model(&models.Agent{}).Where("id = ?", proposal.AgentID). - UpdateColumn("total_intents_won", gorm.Expr("total_intents_won + 1")) - - // Running means, computed in SQL against the pre-increment count so a - // concurrent settlement cannot read a stale average and overwrite it. - // AvgSlippageDelivered was declared but never written, so the leaderboard - // has been showing zero for every agent. - h.db.Model(&models.AgentReputation{}).Where("agent_id = ?", proposal.AgentID).Updates(map[string]any{ - "avg_slippage_delivered": gorm.Expr( - "(avg_slippage_delivered * total_executions + ?) / (total_executions + 1)", - execution.ActualSlippage, - ), - "avg_slippage_delta": gorm.Expr( - "(avg_slippage_delta * total_executions + ?) / (total_executions + 1)", - execution.SlippageDelta, - ), - "total_executions": gorm.Expr("total_executions + 1"), - "consecutive_successes": gorm.Expr("consecutive_successes + 1"), - }) - - // Broadcast settlement - h.hub.Broadcast(intentID, "execution_settled", map[string]any{ - "execution_id": execution.ID, - "agent_id": execution.WinningAgentID, - "slippage": execution.ActualSlippage, - "projected": execution.ProjectedSlippage, - "delta": execution.SlippageDelta, - "tx_hash": execution.TxHash, - }) - - // Publish to Redis pub/sub - ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) - defer cancel() - _ = h.pubsub.Publish(ctx, intentID, "execution_settled", map[string]any{ - "execution_id": execution.ID, - "agent_id": execution.WinningAgentID, - "slippage": execution.ActualSlippage, - "projected": execution.ProjectedSlippage, - "delta": execution.SlippageDelta, - "tx_hash": execution.TxHash, - }) - - respondJSON(w, http.StatusOK, map[string]any{ - "execution_id": execution.ID, - "status": execution.SettlementStatus, - "actual_slippage": execution.ActualSlippage, - "tx_hash": execution.TxHash, - }) -} +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "gorm.io/gorm" + + "github.com/Cookit-labs/Backend/internal/models" + "github.com/Cookit-labs/Backend/internal/scoring" + "github.com/Cookit-labs/Backend/internal/validation" +) + +func (h *Handler) SelectWinner(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + // Fetch all proposals for this intent + var proposals []models.Proposal + if err := h.db.Where("intent_id = ?", intentID).Find(&proposals).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch proposals") + return + } + + if len(proposals) == 0 { + respondError(w, http.StatusBadRequest, "no proposals to select from") + return + } + + // Fetch reputation records for all agents in these proposals + agentIDs := make([]string, len(proposals)) + for i, p := range proposals { + agentIDs[i] = p.AgentID + } + + var reputations []models.AgentReputation + h.db.Where("agent_id IN ?", agentIDs).Find(&reputations) + + repMap := make(map[string]*models.AgentReputation, len(reputations)) + for i := range reputations { + repMap[reputations[i].AgentID] = &reputations[i] + } + + // Run deterministic scoring engine + engine := scoring.New() + result := engine.Score(proposals, repMap) + + if result.Winner == nil { + respondError(w, http.StatusInternalServerError, "scoring engine returned no winner") + return + } + + // Persist each proposal's computed score + for _, s := range result.AllScores { + h.db.Model(&models.Proposal{}).Where("id = ?", s.ProposalID).Update("score", s.Total) + } + + // Update intent with selected agent + if err := h.db.Model(&models.Intent{}).Where("id = ?", intentID).Updates(map[string]any{ + "status": "winner_selected", + "selected_agent_id": result.Winner.AgentID, + }).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to update intent") + return + } + + // Broadcast winner selection + h.hub.Broadcast(intentID, "winner_selected", map[string]any{ + "proposal_id": result.Winner.ProposalID, + "agent_id": result.Winner.AgentID, + "score": result.Winner.Total, + "execution_efficiency": result.Winner.ExecutionEfficiencyScore, + "win_rate_score": result.Winner.WinRateScore, + "slippage_score": result.Winner.SlippageScore, + "reputation_score": result.Winner.ReputationScore, + }) + + // Publish to Redis pub/sub + ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) + defer cancel() + _ = h.pubsub.Publish(ctx, intentID, "winner_selected", map[string]any{ + "proposal_id": result.Winner.ProposalID, + "agent_id": result.Winner.AgentID, + "score": result.Winner.Total, + }) + + respondJSON(w, http.StatusOK, map[string]any{ + "winner_proposal_id": result.Winner.ProposalID, + "agent_id": result.Winner.AgentID, + "total_score": result.Winner.Total, + "execution_efficiency": result.Winner.ExecutionEfficiencyScore, + "win_rate_score": result.Winner.WinRateScore, + "slippage_score": result.Winner.SlippageScore, + "reputation_score": result.Winner.ReputationScore, + "all_scores": result.AllScores, + }) +} + +func (h *Handler) ValidateExecution(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var req ValidateExecutionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + // Fetch intent and proposal + var intent models.Intent + if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + var proposal models.Proposal + if err := h.db.First(&proposal, "id = ?", req.ProposalID).Error; err != nil { + respondError(w, http.StatusNotFound, "proposal not found") + return + } + + // Run full validation service + svc := validation.New() + result := svc.Validate(&intent, &proposal) + + if !result.Valid { + // Record failed validation attempt against the agent + h.db.Model(&models.Proposal{}).Where("id = ?", req.ProposalID). + Update("score", 0) + + respondJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "valid": false, + "errors": result.Errors, + }) + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "valid": true, + "message": "execution validation passed", + }) +} + +func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var req SettleExecutionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + // Fetch intent and proposal + var intent models.Intent + if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + var proposal models.Proposal + if err := h.db.First(&proposal, "id = ?", req.ProposalID).Error; err != nil { + respondError(w, http.StatusNotFound, "proposal not found") + return + } + + // Re-run validation gate before any settlement + svc := validation.New() + if vr := svc.Validate(&intent, &proposal); !vr.Valid { + respondJSON(w, http.StatusUnprocessableEntity, map[string]any{ + "error": "settlement blocked: validation failed", + "errors": vr.Errors, + }) + return + } + + // Create execution record + now := time.Now() + execution := &models.Execution{ + ID: uuid.New().String(), + IntentID: intentID, + WinningAgentID: proposal.AgentID, + ProposalID: req.ProposalID, + // Recorded together so reputation can distinguish an agent that + // promises 0.1% and delivers it from one that promises 0.1% and + // delivers 2%. Without the projection, both look identical. + ProjectedSlippage: proposal.ProjectedSlippage, + ActualSlippage: req.ActualSlippage, + SlippageDelta: req.ActualSlippage - proposal.ProjectedSlippage, + TxHash: req.TxHash, + SettlementStatus: "confirmed", + ExecutionFeeUSDC: req.ExecutionFeeUSDC, + ExecutedAt: now, + SettledAt: &now, + } + + if err := h.db.Create(execution).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create execution") + return + } + + // Update intent status + if err := h.db.Model(&models.Intent{}).Where("id = ?", intentID).Update("status", "settled").Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to update intent") + return + } + + // Pay agent execution fee via Circle if wallets are configured + if proposal.AgentID != "" && req.ExecutionFeeUSDC != "" { + // Fetch agent's Circle wallet address + var agent models.Agent + if err := h.db.First(&agent, "id = ?", proposal.AgentID).Error; err == nil && agent.WalletAddress != "" { + idempotencyKey := "settle-" + execution.ID + feeCtx, feeCancel := context.WithTimeout(context.Background(), 15*1e9) + defer feeCancel() + // Transfer fee from escrow (intent's wallet) to agent wallet + // In V0 we use the agent's WalletAddress as the Circle wallet ID + _, _ = h.circle.SendUSDC(feeCtx, idempotencyKey, intent.UserWallet, agent.WalletAddress, req.ExecutionFeeUSDC) + } + } + + // Update agent win stats and reputation + h.db.Model(&models.Agent{}).Where("id = ?", proposal.AgentID). + UpdateColumn("total_intents_won", gorm.Expr("total_intents_won + 1")) + + // Running means, computed in SQL against the pre-increment count so a + // concurrent settlement cannot read a stale average and overwrite it. + // AvgSlippageDelivered was declared but never written, so the leaderboard + // has been showing zero for every agent. + h.db.Model(&models.AgentReputation{}).Where("agent_id = ?", proposal.AgentID).Updates(map[string]any{ + "avg_slippage_delivered": gorm.Expr( + "(avg_slippage_delivered * total_executions + ?) / (total_executions + 1)", + execution.ActualSlippage, + ), + "avg_slippage_delta": gorm.Expr( + "(avg_slippage_delta * total_executions + ?) / (total_executions + 1)", + execution.SlippageDelta, + ), + "total_executions": gorm.Expr("total_executions + 1"), + "consecutive_successes": gorm.Expr("consecutive_successes + 1"), + }) + + // Broadcast settlement + h.hub.Broadcast(intentID, "execution_settled", map[string]any{ + "execution_id": execution.ID, + "agent_id": execution.WinningAgentID, + "slippage": execution.ActualSlippage, + "projected": execution.ProjectedSlippage, + "delta": execution.SlippageDelta, + "tx_hash": execution.TxHash, + }) + + // Publish to Redis pub/sub + ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) + defer cancel() + _ = h.pubsub.Publish(ctx, intentID, "execution_settled", map[string]any{ + "execution_id": execution.ID, + "agent_id": execution.WinningAgentID, + "slippage": execution.ActualSlippage, + "projected": execution.ProjectedSlippage, + "delta": execution.SlippageDelta, + "tx_hash": execution.TxHash, + }) + + respondJSON(w, http.StatusOK, map[string]any{ + "execution_id": execution.ID, + "status": execution.SettlementStatus, + "actual_slippage": execution.ActualSlippage, + "tx_hash": execution.TxHash, + }) +} diff --git a/internal/handlers/intents.go b/internal/handlers/intents.go index f8bdb3c..0bb7f8a 100644 --- a/internal/handlers/intents.go +++ b/internal/handlers/intents.go @@ -1,102 +1,102 @@ -package handlers - -import ( - "encoding/json" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "github.com/Cookit-labs/Backend/internal/chain" - "github.com/Cookit-labs/Backend/internal/models" -) - -func (h *Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { - var req CreateIntentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - if msg := validateStruct(&req); msg != "" { - respondError(w, http.StatusBadRequest, msg) - return - } - - target, err := chain.Parse(req.Chain) - if err != nil { - respondError(w, http.StatusBadRequest, err.Error()) - return - } - - // Checked here rather than at settlement: an Arc address on a Stellar - // intent is unrecoverable once funds are in flight, and the two formats are - // unmistakable, so there is no reason to accept the mismatch. - if err := chain.ValidateAddress(target, req.UserWallet); err != nil { - respondError(w, http.StatusBadRequest, err.Error()) - return - } - - intent := &models.Intent{ - ID: uuid.New().String(), - Chain: target.String(), - UserWallet: req.UserWallet, - TokenIn: req.TokenIn, - TokenOut: req.TokenOut, - AmountIn: req.AmountIn, - MaxSlippage: req.MaxSlippage, - Deadline: req.Deadline, - Status: "pending", - Metadata: req.Metadata, - } - - if err := h.db.Create(intent).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to create intent") - return - } - - // Broadcast to WebSocket subscribers - h.hub.Broadcast(intent.ID, "intent_created", map[string]string{ - "intent_id": intent.ID, - "status": intent.Status, - }) - - respondJSON(w, http.StatusCreated, IntentResponse{ - ID: intent.ID, - Chain: intent.Chain, - UserWallet: intent.UserWallet, - TokenIn: intent.TokenIn, - TokenOut: intent.TokenOut, - AmountIn: intent.AmountIn, - MaxSlippage: intent.MaxSlippage, - Deadline: intent.Deadline, - Status: intent.Status, - CreatedAt: intent.CreatedAt, - UpdatedAt: intent.UpdatedAt, - }) -} - -func (h *Handler) GetIntent(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - var intent models.Intent - if err := h.db.Preload("Proposals").Preload("Execution").First(&intent, "id = ?", intentID).Error; err != nil { - respondError(w, http.StatusNotFound, "intent not found") - return - } - - respondJSON(w, http.StatusOK, IntentResponse{ - ID: intent.ID, - Chain: intent.Chain, - UserWallet: intent.UserWallet, - TokenIn: intent.TokenIn, - TokenOut: intent.TokenOut, - AmountIn: intent.AmountIn, - MaxSlippage: intent.MaxSlippage, - Deadline: intent.Deadline, - Status: intent.Status, - SelectedAgentID: intent.SelectedAgentID, - CreatedAt: intent.CreatedAt, - UpdatedAt: intent.UpdatedAt, - }) -} +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/Cookit-labs/Backend/internal/chain" + "github.com/Cookit-labs/Backend/internal/models" +) + +func (h *Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { + var req CreateIntentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + target, err := chain.Parse(req.Chain) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + + // Checked here rather than at settlement: an Arc address on a Stellar + // intent is unrecoverable once funds are in flight, and the two formats are + // unmistakable, so there is no reason to accept the mismatch. + if err := chain.ValidateAddress(target, req.UserWallet); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + + intent := &models.Intent{ + ID: uuid.New().String(), + Chain: target.String(), + UserWallet: req.UserWallet, + TokenIn: req.TokenIn, + TokenOut: req.TokenOut, + AmountIn: req.AmountIn, + MaxSlippage: req.MaxSlippage, + Deadline: req.Deadline, + Status: "pending", + Metadata: req.Metadata, + } + + if err := h.db.Create(intent).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create intent") + return + } + + // Broadcast to WebSocket subscribers + h.hub.Broadcast(intent.ID, "intent_created", map[string]string{ + "intent_id": intent.ID, + "status": intent.Status, + }) + + respondJSON(w, http.StatusCreated, IntentResponse{ + ID: intent.ID, + Chain: intent.Chain, + UserWallet: intent.UserWallet, + TokenIn: intent.TokenIn, + TokenOut: intent.TokenOut, + AmountIn: intent.AmountIn, + MaxSlippage: intent.MaxSlippage, + Deadline: intent.Deadline, + Status: intent.Status, + CreatedAt: intent.CreatedAt, + UpdatedAt: intent.UpdatedAt, + }) +} + +func (h *Handler) GetIntent(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var intent models.Intent + if err := h.db.Preload("Proposals").Preload("Execution").First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + respondJSON(w, http.StatusOK, IntentResponse{ + ID: intent.ID, + Chain: intent.Chain, + UserWallet: intent.UserWallet, + TokenIn: intent.TokenIn, + TokenOut: intent.TokenOut, + AmountIn: intent.AmountIn, + MaxSlippage: intent.MaxSlippage, + Deadline: intent.Deadline, + Status: intent.Status, + SelectedAgentID: intent.SelectedAgentID, + CreatedAt: intent.CreatedAt, + UpdatedAt: intent.UpdatedAt, + }) +} diff --git a/internal/handlers/proposals.go b/internal/handlers/proposals.go index 2edf281..893ae56 100644 --- a/internal/handlers/proposals.go +++ b/internal/handlers/proposals.go @@ -1,118 +1,118 @@ -package handlers - -import ( - "context" - "encoding/json" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - - "github.com/Cookit-labs/Backend/internal/models" -) - -func (h *Handler) CreateProposal(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - // Verify intent exists - var intent models.Intent - if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { - respondError(w, http.StatusNotFound, "intent not found") - return - } - - var req CreateProposalRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - respondError(w, http.StatusBadRequest, "invalid request body") - return - } - - if msg := validateStruct(&req); msg != "" { - respondError(w, http.StatusBadRequest, msg) - return - } - - // Verify agent exists - var agent models.Agent - if err := h.db.First(&agent, "id = ?", req.AgentID).Error; err != nil { - respondError(w, http.StatusNotFound, "agent not found") - return - } - - proposal := &models.Proposal{ - ID: uuid.New().String(), - IntentID: intentID, - AgentID: req.AgentID, - StrategyType: req.StrategyType, - TokenIn: req.TokenIn, - TokenOut: req.TokenOut, - ProjectedSlippage: req.ProjectedSlippage, - ProjectedExecutionQuality: req.ProjectedExecutionQuality, - ProposedAmount: req.ProposedAmount, - ExecutionPath: req.ExecutionPath, - } - - if err := h.db.Create(proposal).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to create proposal") - return - } - - // Broadcast to WebSocket subscribers - h.hub.Broadcast(intentID, "proposal_received", map[string]any{ - "proposal_id": proposal.ID, - "agent_id": proposal.AgentID, - "strategy": proposal.StrategyType, - "slippage": proposal.ProjectedSlippage, - "quality": proposal.ProjectedExecutionQuality, - }) - - // Publish to Redis pub/sub (multi-instance broadcast) - ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) - defer cancel() - _ = h.pubsub.Publish(ctx, intentID, "proposal_received", map[string]any{ - "proposal_id": proposal.ID, - "agent_id": proposal.AgentID, - "strategy": proposal.StrategyType, - "slippage": proposal.ProjectedSlippage, - "quality": proposal.ProjectedExecutionQuality, - }) - - respondJSON(w, http.StatusCreated, ProposalResponse{ - ID: proposal.ID, - IntentID: proposal.IntentID, - AgentID: proposal.AgentID, - StrategyType: proposal.StrategyType, - ProjectedSlippage: proposal.ProjectedSlippage, - ProjectedExecutionQuality: proposal.ProjectedExecutionQuality, - CreatedAt: proposal.CreatedAt, - }) -} - -func (h *Handler) ListProposals(w http.ResponseWriter, r *http.Request) { - intentID := chi.URLParam(r, "intentID") - - var proposals []models.Proposal - if err := h.db.Where("intent_id = ?", intentID).Find(&proposals).Error; err != nil { - respondError(w, http.StatusInternalServerError, "failed to fetch proposals") - return - } - - responses := make([]ProposalResponse, len(proposals)) - for i, p := range proposals { - responses[i] = ProposalResponse{ - ID: p.ID, - IntentID: p.IntentID, - AgentID: p.AgentID, - StrategyType: p.StrategyType, - ProjectedSlippage: p.ProjectedSlippage, - ProjectedExecutionQuality: p.ProjectedExecutionQuality, - Score: p.Score, - CreatedAt: p.CreatedAt, - } - } - - respondJSON(w, http.StatusOK, map[string]any{ - "proposals": responses, - "total": len(proposals), - }) -} +package handlers + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/Cookit-labs/Backend/internal/models" +) + +func (h *Handler) CreateProposal(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + // Verify intent exists + var intent models.Intent + if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + var req CreateProposalRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + if msg := validateStruct(&req); msg != "" { + respondError(w, http.StatusBadRequest, msg) + return + } + + // Verify agent exists + var agent models.Agent + if err := h.db.First(&agent, "id = ?", req.AgentID).Error; err != nil { + respondError(w, http.StatusNotFound, "agent not found") + return + } + + proposal := &models.Proposal{ + ID: uuid.New().String(), + IntentID: intentID, + AgentID: req.AgentID, + StrategyType: req.StrategyType, + TokenIn: req.TokenIn, + TokenOut: req.TokenOut, + ProjectedSlippage: req.ProjectedSlippage, + ProjectedExecutionQuality: req.ProjectedExecutionQuality, + ProposedAmount: req.ProposedAmount, + ExecutionPath: req.ExecutionPath, + } + + if err := h.db.Create(proposal).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create proposal") + return + } + + // Broadcast to WebSocket subscribers + h.hub.Broadcast(intentID, "proposal_received", map[string]any{ + "proposal_id": proposal.ID, + "agent_id": proposal.AgentID, + "strategy": proposal.StrategyType, + "slippage": proposal.ProjectedSlippage, + "quality": proposal.ProjectedExecutionQuality, + }) + + // Publish to Redis pub/sub (multi-instance broadcast) + ctx, cancel := context.WithTimeout(context.Background(), 5*1e9) + defer cancel() + _ = h.pubsub.Publish(ctx, intentID, "proposal_received", map[string]any{ + "proposal_id": proposal.ID, + "agent_id": proposal.AgentID, + "strategy": proposal.StrategyType, + "slippage": proposal.ProjectedSlippage, + "quality": proposal.ProjectedExecutionQuality, + }) + + respondJSON(w, http.StatusCreated, ProposalResponse{ + ID: proposal.ID, + IntentID: proposal.IntentID, + AgentID: proposal.AgentID, + StrategyType: proposal.StrategyType, + ProjectedSlippage: proposal.ProjectedSlippage, + ProjectedExecutionQuality: proposal.ProjectedExecutionQuality, + CreatedAt: proposal.CreatedAt, + }) +} + +func (h *Handler) ListProposals(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var proposals []models.Proposal + if err := h.db.Where("intent_id = ?", intentID).Find(&proposals).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch proposals") + return + } + + responses := make([]ProposalResponse, len(proposals)) + for i, p := range proposals { + responses[i] = ProposalResponse{ + ID: p.ID, + IntentID: p.IntentID, + AgentID: p.AgentID, + StrategyType: p.StrategyType, + ProjectedSlippage: p.ProjectedSlippage, + ProjectedExecutionQuality: p.ProjectedExecutionQuality, + Score: p.Score, + CreatedAt: p.CreatedAt, + } + } + + respondJSON(w, http.StatusOK, map[string]any{ + "proposals": responses, + "total": len(proposals), + }) +} diff --git a/internal/handlers/types.go b/internal/handlers/types.go index 182fe8b..833b9a8 100644 --- a/internal/handlers/types.go +++ b/internal/handlers/types.go @@ -1,126 +1,126 @@ -package handlers - -import "time" - -// Intent requests/responses - -type CreateIntentRequest struct { - // Optional: omitting it defaults to Arc, so clients written before - // multi-chain keep working. The wallet is validated against whichever - // chain applies. - Chain string `json:"chain,omitempty" validate:"omitempty,oneof=arc stellar"` - UserWallet string `json:"user_wallet" validate:"required"` - TokenIn string `json:"token_in" validate:"required"` - TokenOut string `json:"token_out" validate:"required"` - AmountIn string `json:"amount_in" validate:"required"` - MaxSlippage float64 `json:"max_slippage" validate:"required,gt=0"` - Deadline time.Time `json:"deadline" validate:"required"` - Metadata []byte `json:"metadata,omitempty"` -} - -type IntentResponse struct { - ID string `json:"id"` - Chain string `json:"chain"` - UserWallet string `json:"user_wallet"` - TokenIn string `json:"token_in"` - TokenOut string `json:"token_out"` - AmountIn string `json:"amount_in"` - MaxSlippage float64 `json:"max_slippage"` - Deadline time.Time `json:"deadline"` - Status string `json:"status"` - SelectedAgentID string `json:"selected_agent_id,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Proposal requests/responses - -type CreateProposalRequest struct { - AgentID string `json:"agent_id" validate:"required"` - StrategyType string `json:"strategy_type" validate:"required"` - // The pair the agent will execute. Validated against the intent before - // settlement; omitting it is a validation failure, not a default. - TokenIn string `json:"token_in" validate:"required"` - TokenOut string `json:"token_out" validate:"required"` - // gte=0 rather than required: required rejects the zero value, and zero - // projected slippage is a legitimate claim. - ProjectedSlippage float64 `json:"projected_slippage" validate:"gte=0,lte=1"` - ProjectedExecutionQuality float64 `json:"projected_execution_quality" validate:"gte=0,lte=1"` - ProposedAmount string `json:"proposed_amount" validate:"required"` - ExecutionPath string `json:"execution_path" validate:"required"` -} - -type ProposalResponse struct { - ID string `json:"id"` - IntentID string `json:"intent_id"` - AgentID string `json:"agent_id"` - StrategyType string `json:"strategy_type"` - ProjectedSlippage float64 `json:"projected_slippage"` - ProjectedExecutionQuality float64 `json:"projected_execution_quality"` - Score float64 `json:"score"` - CreatedAt time.Time `json:"created_at"` -} - -// Execution/Settlement - -type ValidateExecutionRequest struct { - ProposalID string `json:"proposal_id" validate:"required"` -} - -type SettleExecutionRequest struct { - ProposalID string `json:"proposal_id" validate:"required"` - ActualSlippage float64 `json:"actual_slippage" validate:"required,gte=0"` - TxHash string `json:"tx_hash" validate:"required"` - ExecutionFeeUSDC string `json:"execution_fee_usdc" validate:"required"` -} - -// Leaderboard - -type LeaderboardEntry struct { - AgentID string `json:"agent_id"` - Name string `json:"name"` - StrategyType string `json:"strategy_type"` - TotalIntentsWon int64 `json:"total_intents_won"` - WinRate float64 `json:"win_rate"` - AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` - AvgSlippageDelta float64 `json:"avg_slippage_delta"` - CompositeScore float64 `json:"composite_score"` -} - -type LeaderboardResponse struct { - Agents []LeaderboardEntry `json:"agents"` - Total int64 `json:"total"` -} - -// Error response - -type ErrorResponse struct { - Error string `json:"error"` - Status int `json:"status"` -} - -// Agent requests - -// CreateAgentRequest registers an agent so it can submit proposals. -// -// Agents were previously read-only: proposals require an existing agent row, -// but nothing could create one, so a fresh database could not accept a single -// proposal. -type CreateAgentRequest struct { - Name string `json:"name" validate:"required"` - // Constrained to the strategies the scoring engine and the dApp both know - // about. A free-text value would be accepted here and then silently fail to - // match anything downstream. - StrategyType string `json:"strategy_type" validate:"required,oneof=twap momentum shadow arbitrage custom"` - Description string `json:"description"` - WalletAddress string `json:"wallet_address" validate:"required"` -} - -// UpdateAgentStatusRequest activates or deactivates an agent. -// -// Uses a pointer so that `false` is distinguishable from an omitted field — -// with a plain bool, "deactivate this agent" and "do not change the status" -// are the same JSON. -type UpdateAgentStatusRequest struct { - IsActive *bool `json:"is_active" validate:"required"` -} +package handlers + +import "time" + +// Intent requests/responses + +type CreateIntentRequest struct { + // Optional: omitting it defaults to Arc, so clients written before + // multi-chain keep working. The wallet is validated against whichever + // chain applies. + Chain string `json:"chain,omitempty" validate:"omitempty,oneof=arc stellar"` + UserWallet string `json:"user_wallet" validate:"required"` + TokenIn string `json:"token_in" validate:"required"` + TokenOut string `json:"token_out" validate:"required"` + AmountIn string `json:"amount_in" validate:"required"` + MaxSlippage float64 `json:"max_slippage" validate:"required,gt=0"` + Deadline time.Time `json:"deadline" validate:"required"` + Metadata []byte `json:"metadata,omitempty"` +} + +type IntentResponse struct { + ID string `json:"id"` + Chain string `json:"chain"` + UserWallet string `json:"user_wallet"` + TokenIn string `json:"token_in"` + TokenOut string `json:"token_out"` + AmountIn string `json:"amount_in"` + MaxSlippage float64 `json:"max_slippage"` + Deadline time.Time `json:"deadline"` + Status string `json:"status"` + SelectedAgentID string `json:"selected_agent_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Proposal requests/responses + +type CreateProposalRequest struct { + AgentID string `json:"agent_id" validate:"required"` + StrategyType string `json:"strategy_type" validate:"required"` + // The pair the agent will execute. Validated against the intent before + // settlement; omitting it is a validation failure, not a default. + TokenIn string `json:"token_in" validate:"required"` + TokenOut string `json:"token_out" validate:"required"` + // gte=0 rather than required: required rejects the zero value, and zero + // projected slippage is a legitimate claim. + ProjectedSlippage float64 `json:"projected_slippage" validate:"gte=0,lte=1"` + ProjectedExecutionQuality float64 `json:"projected_execution_quality" validate:"gte=0,lte=1"` + ProposedAmount string `json:"proposed_amount" validate:"required"` + ExecutionPath string `json:"execution_path" validate:"required"` +} + +type ProposalResponse struct { + ID string `json:"id"` + IntentID string `json:"intent_id"` + AgentID string `json:"agent_id"` + StrategyType string `json:"strategy_type"` + ProjectedSlippage float64 `json:"projected_slippage"` + ProjectedExecutionQuality float64 `json:"projected_execution_quality"` + Score float64 `json:"score"` + CreatedAt time.Time `json:"created_at"` +} + +// Execution/Settlement + +type ValidateExecutionRequest struct { + ProposalID string `json:"proposal_id" validate:"required"` +} + +type SettleExecutionRequest struct { + ProposalID string `json:"proposal_id" validate:"required"` + ActualSlippage float64 `json:"actual_slippage" validate:"required,gte=0"` + TxHash string `json:"tx_hash" validate:"required"` + ExecutionFeeUSDC string `json:"execution_fee_usdc" validate:"required"` +} + +// Leaderboard + +type LeaderboardEntry struct { + AgentID string `json:"agent_id"` + Name string `json:"name"` + StrategyType string `json:"strategy_type"` + TotalIntentsWon int64 `json:"total_intents_won"` + WinRate float64 `json:"win_rate"` + AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` + AvgSlippageDelta float64 `json:"avg_slippage_delta"` + CompositeScore float64 `json:"composite_score"` +} + +type LeaderboardResponse struct { + Agents []LeaderboardEntry `json:"agents"` + Total int64 `json:"total"` +} + +// Error response + +type ErrorResponse struct { + Error string `json:"error"` + Status int `json:"status"` +} + +// Agent requests + +// CreateAgentRequest registers an agent so it can submit proposals. +// +// Agents were previously read-only: proposals require an existing agent row, +// but nothing could create one, so a fresh database could not accept a single +// proposal. +type CreateAgentRequest struct { + Name string `json:"name" validate:"required"` + // Constrained to the strategies the scoring engine and the dApp both know + // about. A free-text value would be accepted here and then silently fail to + // match anything downstream. + StrategyType string `json:"strategy_type" validate:"required,oneof=twap momentum shadow arbitrage custom"` + Description string `json:"description"` + WalletAddress string `json:"wallet_address" validate:"required"` +} + +// UpdateAgentStatusRequest activates or deactivates an agent. +// +// Uses a pointer so that `false` is distinguishable from an omitted field — +// with a plain bool, "deactivate this agent" and "do not change the status" +// are the same JSON. +type UpdateAgentStatusRequest struct { + IsActive *bool `json:"is_active" validate:"required"` +} diff --git a/internal/handlers/validate.go b/internal/handlers/validate.go index 4971b7a..8371b14 100644 --- a/internal/handlers/validate.go +++ b/internal/handlers/validate.go @@ -1,108 +1,108 @@ -package handlers - -import ( - "fmt" - "strings" - - "github.com/go-playground/validator/v10" -) - -// The `validate` tags on the request DTOs described the intended contract but -// nothing read them: there was no validator in the module at all. Every tag was -// documentation. A proposal could arrive with no agent, no venue and a slippage -// of -5 and be persisted unchallenged. -// -// One shared instance: constructing a validator per request re-parses struct -// tags via reflection each time, and the library is designed to be reused. -var validate = validator.New(validator.WithRequiredStructEnabled()) - -// validateStruct returns a human-readable message naming every field that -// failed, or an empty string when the payload is acceptable. -// -// All failures are reported together rather than stopping at the first, so a -// caller fixing a malformed request does not have to discover the problems one -// round-trip at a time. -func validateStruct(payload any) string { - err := validate.Struct(payload) - if err == nil { - return "" - } - - var invalid *validator.InvalidValidationError - if ok := asInvalid(err, &invalid); ok { - // Programmer error — a non-struct was passed in. Surface it rather than - // reporting the request as invalid. - return "internal validation error" - } - - var msgs []string - for _, fe := range err.(validator.ValidationErrors) { - msgs = append(msgs, describe(fe)) - } - return strings.Join(msgs, "; ") -} - -func asInvalid(err error, target **validator.InvalidValidationError) bool { - v, ok := err.(*validator.InvalidValidationError) - if ok { - *target = v - } - return ok -} - -// describe turns a tag failure into something an API consumer can act on. -// The library's default message is the tag name alone, which does not say what -// the caller should have sent. -func describe(fe validator.FieldError) string { - field := jsonName(fe) - switch fe.Tag() { - case "required": - return fmt.Sprintf("%s is required", field) - case "gte": - return fmt.Sprintf("%s must be at least %s", field, fe.Param()) - case "lte": - return fmt.Sprintf("%s must be at most %s", field, fe.Param()) - case "gt": - return fmt.Sprintf("%s must be greater than %s", field, fe.Param()) - case "oneof": - return fmt.Sprintf("%s must be one of: %s", field, strings.ReplaceAll(fe.Param(), " ", ", ")) - default: - return fmt.Sprintf("%s is invalid (%s)", field, fe.Tag()) - } -} - -// jsonName reports the field as the caller sent it, not as Go spells it. -// Telling an API consumer that "ProjectedSlippage" is invalid is unhelpful when -// they sent "projected_slippage". -func jsonName(fe validator.FieldError) string { - name := fe.Field() - return toSnake(name) -} - -// toSnake converts a Go field name to its JSON spelling. -// -// Runs of capitals are kept together so that acronyms survive: AgentID becomes -// agent_id, not agent_i_d. An underscore is inserted before a capital only when -// it starts a new word — that is, when the previous character was lowercase, or -// when the capital is followed by a lowercase one (as the D in IDToken). -func toSnake(s string) string { - runes := []rune(s) - var b strings.Builder - - for i, r := range runes { - isUpper := r >= 'A' && r <= 'Z' - if isUpper && i > 0 { - prevLower := runes[i-1] >= 'a' && runes[i-1] <= 'z' - nextLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z' - if prevLower || nextLower { - b.WriteByte('_') - } - } - if isUpper { - b.WriteRune(r + 32) - } else { - b.WriteRune(r) - } - } - return b.String() -} +package handlers + +import ( + "fmt" + "strings" + + "github.com/go-playground/validator/v10" +) + +// The `validate` tags on the request DTOs described the intended contract but +// nothing read them: there was no validator in the module at all. Every tag was +// documentation. A proposal could arrive with no agent, no venue and a slippage +// of -5 and be persisted unchallenged. +// +// One shared instance: constructing a validator per request re-parses struct +// tags via reflection each time, and the library is designed to be reused. +var validate = validator.New(validator.WithRequiredStructEnabled()) + +// validateStruct returns a human-readable message naming every field that +// failed, or an empty string when the payload is acceptable. +// +// All failures are reported together rather than stopping at the first, so a +// caller fixing a malformed request does not have to discover the problems one +// round-trip at a time. +func validateStruct(payload any) string { + err := validate.Struct(payload) + if err == nil { + return "" + } + + var invalid *validator.InvalidValidationError + if ok := asInvalid(err, &invalid); ok { + // Programmer error — a non-struct was passed in. Surface it rather than + // reporting the request as invalid. + return "internal validation error" + } + + var msgs []string + for _, fe := range err.(validator.ValidationErrors) { + msgs = append(msgs, describe(fe)) + } + return strings.Join(msgs, "; ") +} + +func asInvalid(err error, target **validator.InvalidValidationError) bool { + v, ok := err.(*validator.InvalidValidationError) + if ok { + *target = v + } + return ok +} + +// describe turns a tag failure into something an API consumer can act on. +// The library's default message is the tag name alone, which does not say what +// the caller should have sent. +func describe(fe validator.FieldError) string { + field := jsonName(fe) + switch fe.Tag() { + case "required": + return fmt.Sprintf("%s is required", field) + case "gte": + return fmt.Sprintf("%s must be at least %s", field, fe.Param()) + case "lte": + return fmt.Sprintf("%s must be at most %s", field, fe.Param()) + case "gt": + return fmt.Sprintf("%s must be greater than %s", field, fe.Param()) + case "oneof": + return fmt.Sprintf("%s must be one of: %s", field, strings.ReplaceAll(fe.Param(), " ", ", ")) + default: + return fmt.Sprintf("%s is invalid (%s)", field, fe.Tag()) + } +} + +// jsonName reports the field as the caller sent it, not as Go spells it. +// Telling an API consumer that "ProjectedSlippage" is invalid is unhelpful when +// they sent "projected_slippage". +func jsonName(fe validator.FieldError) string { + name := fe.Field() + return toSnake(name) +} + +// toSnake converts a Go field name to its JSON spelling. +// +// Runs of capitals are kept together so that acronyms survive: AgentID becomes +// agent_id, not agent_i_d. An underscore is inserted before a capital only when +// it starts a new word — that is, when the previous character was lowercase, or +// when the capital is followed by a lowercase one (as the D in IDToken). +func toSnake(s string) string { + runes := []rune(s) + var b strings.Builder + + for i, r := range runes { + isUpper := r >= 'A' && r <= 'Z' + if isUpper && i > 0 { + prevLower := runes[i-1] >= 'a' && runes[i-1] <= 'z' + nextLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z' + if prevLower || nextLower { + b.WriteByte('_') + } + } + if isUpper { + b.WriteRune(r + 32) + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/models/models.go b/internal/models/models.go index c3f2797..35df3de 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,131 +1,131 @@ -package models - -import ( - "time" - - "gorm.io/gorm" -) - -// Intent represents a user's trading intention -type Intent struct { - ID string `gorm:"primaryKey" json:"id"` - // Which network this intent settles on. Indexed because settlement and the - // orchestrator both filter by it. - Chain string `gorm:"index;default:arc" json:"chain"` - UserWallet string `gorm:"index" json:"user_wallet"` - TokenIn string `json:"token_in"` - TokenOut string `json:"token_out"` - AmountIn string `json:"amount_in"` // stored as string to preserve precision - MaxSlippage float64 `json:"max_slippage"` // e.g., 0.01 = 1% - Deadline time.Time `json:"deadline"` - Status string `json:"status"` // pending, competition_open, winner_selected, executing, settled, failed - SelectedAgentID string `json:"selected_agent_id"` - Metadata []byte `gorm:"type:jsonb" json:"metadata"` // arbitrary user data - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - // Relationships - Proposals []Proposal `gorm:"foreignKey:IntentID;references:ID" json:"proposals,omitempty"` - Execution *Execution `gorm:"foreignKey:IntentID;references:ID" json:"execution,omitempty"` -} - -// Proposal represents an agent's bid to execute an intent -type Proposal struct { - ID string `gorm:"primaryKey" json:"id"` - IntentID string `gorm:"index" json:"intent_id"` - AgentID string `gorm:"index" json:"agent_id"` - StrategyType string `json:"strategy_type"` // TWAP, Momentum, Shadow - // The pair the agent intends to execute. Recorded on the proposal rather - // than inferred from the intent, so validation can catch an agent that - // proposes a different pair than the one the user asked for. - TokenIn string `json:"token_in"` - TokenOut string `json:"token_out"` - ProjectedSlippage float64 `json:"projected_slippage"` // e.g., 0.005 = 0.5% - ProjectedExecutionQuality float64 `json:"projected_execution_quality"` // 0-1 - ProposedAmount string `json:"proposed_amount"` - ExecutionPath string `json:"execution_path"` // e.g., "uniswap_v3" or "curve" - Score float64 `json:"score"` // computed by scoring engine - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - // Relationships - Intent *Intent `gorm:"foreignKey:IntentID;references:ID" json:"-"` - Agent *Agent `gorm:"foreignKey:AgentID;references:ID" json:"-"` -} - -// Execution represents the settled outcome of an intent -type Execution struct { - ID string `gorm:"primaryKey" json:"id"` - IntentID string `gorm:"uniqueIndex" json:"intent_id"` - WinningAgentID string `gorm:"index" json:"winning_agent_id"` - ProposalID string `json:"proposal_id"` - ExecutedAmount string `json:"executed_amount"` - // What the winning agent promised, copied from the proposal at settlement. - // Kept on the execution rather than read back through the proposal so the - // comparison survives the proposal being edited or soft-deleted. - ProjectedSlippage float64 `json:"projected_slippage"` - ActualSlippage float64 `json:"actual_slippage"` - // Actual minus projected. Positive means the agent delivered worse than it - // promised. Stored rather than computed on read so it can be aggregated and - // indexed without recomputing across every execution. - SlippageDelta float64 `gorm:"index" json:"slippage_delta"` - TxHash string `json:"tx_hash"` - SettlementStatus string `json:"settlement_status"` // pending, confirmed, failed - ExecutionFeeUSDC string `json:"execution_fee_usdc"` // agent fee - ArcContractAddr string `json:"arc_contract_addr"` - ExecutedAt time.Time `json:"executed_at"` - SettledAt *time.Time `json:"settled_at"` - FailureReason string `json:"failure_reason"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - // Relationships - Intent *Intent `gorm:"foreignKey:IntentID;references:ID" json:"-"` - Agent *Agent `gorm:"foreignKey:WinningAgentID;references:ID" json:"-"` -} - -// Agent represents an autonomous execution agent -type Agent struct { - ID string `gorm:"primaryKey" json:"id"` - Name string `json:"name"` - StrategyType string `json:"strategy_type"` // TWAP, Momentum, Shadow - Description string `json:"description"` - WalletAddress string `json:"wallet_address"` - IsActive bool `json:"is_active"` - TotalIntentsWon int64 `json:"total_intents_won"` - TotalCapitalUsdc string `json:"total_capital_usdc"` // lifetime volume - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - // Relationships - Reputation *AgentReputation `gorm:"foreignKey:AgentID;references:ID" json:"reputation,omitempty"` - Proposals []Proposal `gorm:"foreignKey:AgentID;references:ID" json:"proposals,omitempty"` -} - -// AgentReputation tracks agent performance metrics on-chain seed values -type AgentReputation struct { - ID string `gorm:"primaryKey" json:"id"` - AgentID string `gorm:"uniqueIndex" json:"agent_id"` - WinRate float64 `json:"win_rate"` // wins / total_proposals - AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` - // Mean of (actual - projected) across settled executions. Positive means - // the agent habitually delivers worse than it promises, which win rate and - // average slippage alone cannot reveal — an agent can win often, on - // optimistic projections it never meets. - AvgSlippageDelta float64 `json:"avg_slippage_delta"` - TotalExecutions int64 `json:"total_executions"` - ConsecutiveSuccesses int64 `json:"consecutive_successes"` - CompositeScore float64 `json:"composite_score"` // weighted avg of metrics - OnChainReputationScore string `json:"on_chain_reputation_score"` // synced to Arc L1 - LastSyncedAt *time.Time `json:"last_synced_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - - // Relationships - Agent *Agent `gorm:"foreignKey:AgentID;references:ID" json:"-"` -} +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// Intent represents a user's trading intention +type Intent struct { + ID string `gorm:"primaryKey" json:"id"` + // Which network this intent settles on. Indexed because settlement and the + // orchestrator both filter by it. + Chain string `gorm:"index;default:arc" json:"chain"` + UserWallet string `gorm:"index" json:"user_wallet"` + TokenIn string `json:"token_in"` + TokenOut string `json:"token_out"` + AmountIn string `json:"amount_in"` // stored as string to preserve precision + MaxSlippage float64 `json:"max_slippage"` // e.g., 0.01 = 1% + Deadline time.Time `json:"deadline"` + Status string `json:"status"` // pending, competition_open, winner_selected, executing, settled, failed + SelectedAgentID string `json:"selected_agent_id"` + Metadata []byte `gorm:"type:jsonb" json:"metadata"` // arbitrary user data + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Proposals []Proposal `gorm:"foreignKey:IntentID;references:ID" json:"proposals,omitempty"` + Execution *Execution `gorm:"foreignKey:IntentID;references:ID" json:"execution,omitempty"` +} + +// Proposal represents an agent's bid to execute an intent +type Proposal struct { + ID string `gorm:"primaryKey" json:"id"` + IntentID string `gorm:"index" json:"intent_id"` + AgentID string `gorm:"index" json:"agent_id"` + StrategyType string `json:"strategy_type"` // TWAP, Momentum, Shadow + // The pair the agent intends to execute. Recorded on the proposal rather + // than inferred from the intent, so validation can catch an agent that + // proposes a different pair than the one the user asked for. + TokenIn string `json:"token_in"` + TokenOut string `json:"token_out"` + ProjectedSlippage float64 `json:"projected_slippage"` // e.g., 0.005 = 0.5% + ProjectedExecutionQuality float64 `json:"projected_execution_quality"` // 0-1 + ProposedAmount string `json:"proposed_amount"` + ExecutionPath string `json:"execution_path"` // e.g., "uniswap_v3" or "curve" + Score float64 `json:"score"` // computed by scoring engine + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Intent *Intent `gorm:"foreignKey:IntentID;references:ID" json:"-"` + Agent *Agent `gorm:"foreignKey:AgentID;references:ID" json:"-"` +} + +// Execution represents the settled outcome of an intent +type Execution struct { + ID string `gorm:"primaryKey" json:"id"` + IntentID string `gorm:"uniqueIndex" json:"intent_id"` + WinningAgentID string `gorm:"index" json:"winning_agent_id"` + ProposalID string `json:"proposal_id"` + ExecutedAmount string `json:"executed_amount"` + // What the winning agent promised, copied from the proposal at settlement. + // Kept on the execution rather than read back through the proposal so the + // comparison survives the proposal being edited or soft-deleted. + ProjectedSlippage float64 `json:"projected_slippage"` + ActualSlippage float64 `json:"actual_slippage"` + // Actual minus projected. Positive means the agent delivered worse than it + // promised. Stored rather than computed on read so it can be aggregated and + // indexed without recomputing across every execution. + SlippageDelta float64 `gorm:"index" json:"slippage_delta"` + TxHash string `json:"tx_hash"` + SettlementStatus string `json:"settlement_status"` // pending, confirmed, failed + ExecutionFeeUSDC string `json:"execution_fee_usdc"` // agent fee + ArcContractAddr string `json:"arc_contract_addr"` + ExecutedAt time.Time `json:"executed_at"` + SettledAt *time.Time `json:"settled_at"` + FailureReason string `json:"failure_reason"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Intent *Intent `gorm:"foreignKey:IntentID;references:ID" json:"-"` + Agent *Agent `gorm:"foreignKey:WinningAgentID;references:ID" json:"-"` +} + +// Agent represents an autonomous execution agent +type Agent struct { + ID string `gorm:"primaryKey" json:"id"` + Name string `json:"name"` + StrategyType string `json:"strategy_type"` // TWAP, Momentum, Shadow + Description string `json:"description"` + WalletAddress string `json:"wallet_address"` + IsActive bool `json:"is_active"` + TotalIntentsWon int64 `json:"total_intents_won"` + TotalCapitalUsdc string `json:"total_capital_usdc"` // lifetime volume + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Reputation *AgentReputation `gorm:"foreignKey:AgentID;references:ID" json:"reputation,omitempty"` + Proposals []Proposal `gorm:"foreignKey:AgentID;references:ID" json:"proposals,omitempty"` +} + +// AgentReputation tracks agent performance metrics on-chain seed values +type AgentReputation struct { + ID string `gorm:"primaryKey" json:"id"` + AgentID string `gorm:"uniqueIndex" json:"agent_id"` + WinRate float64 `json:"win_rate"` // wins / total_proposals + AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` + // Mean of (actual - projected) across settled executions. Positive means + // the agent habitually delivers worse than it promises, which win rate and + // average slippage alone cannot reveal — an agent can win often, on + // optimistic projections it never meets. + AvgSlippageDelta float64 `json:"avg_slippage_delta"` + TotalExecutions int64 `json:"total_executions"` + ConsecutiveSuccesses int64 `json:"consecutive_successes"` + CompositeScore float64 `json:"composite_score"` // weighted avg of metrics + OnChainReputationScore string `json:"on_chain_reputation_score"` // synced to Arc L1 + LastSyncedAt *time.Time `json:"last_synced_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Relationships + Agent *Agent `gorm:"foreignKey:AgentID;references:ID" json:"-"` +} diff --git a/internal/validation/service.go b/internal/validation/service.go index aa97679..7fd5d68 100644 --- a/internal/validation/service.go +++ b/internal/validation/service.go @@ -1,144 +1,144 @@ -package validation - -import ( - "slices" - "fmt" - "strings" - "time" - - "github.com/Cookit-labs/Backend/internal/models" -) - -// ApprovedVenues is the list of permitted execution venues. -// The AI agent must have set a venue in this list on their proposal. -var ApprovedVenues = []string{ - "uniswap_v3", - "uniswap_v2", - "curve", - "balancer", - "1inch", - "arc_dex", -} - -// ValidationError represents a single failed constraint. -type ValidationError struct { - Field string `json:"field"` - Message string `json:"message"` -} - -func (e ValidationError) Error() string { - return fmt.Sprintf("%s: %s", e.Field, e.Message) -} - -// Result is the outcome of a full validation run. -type Result struct { - Valid bool `json:"valid"` - Errors []ValidationError `json:"errors,omitempty"` -} - -// Service runs all pre-settlement constraint checks. -type Service struct{} - -func New() *Service { - return &Service{} -} - -// Validate checks a proposal against an intent before any USDC moves. -// All checks run regardless — caller receives the full list of failures. -func (s *Service) Validate(intent *models.Intent, proposal *models.Proposal) Result { - var errs []ValidationError - - // 1. Token pair matches - if !tokenPairMatches(intent, proposal) { - errs = append(errs, ValidationError{ - Field: "token_pair", - Message: fmt.Sprintf( - "proposal token pair %s→%s does not match intent %s→%s", - proposal.TokenIn, proposal.TokenOut, intent.TokenIn, intent.TokenOut, - ), - }) - } - - // 2. Slippage within tolerance - if proposal.ProjectedSlippage > intent.MaxSlippage { - errs = append(errs, ValidationError{ - Field: "slippage", - Message: fmt.Sprintf( - "projected slippage %.4f%% exceeds max tolerance %.4f%%", - proposal.ProjectedSlippage*100, - intent.MaxSlippage*100, - ), - }) - } - - // 3. Deadline not passed - if time.Now().After(intent.Deadline) { - errs = append(errs, ValidationError{ - Field: "deadline", - Message: fmt.Sprintf("intent deadline passed at %s", intent.Deadline.UTC().Format(time.RFC3339)), - }) - } - - // 4. Execution venue is approved - if !venueApproved(proposal.ExecutionPath) { - errs = append(errs, ValidationError{ - Field: "venue", - Message: fmt.Sprintf("venue %q is not on the approved list: %s", proposal.ExecutionPath, strings.Join(ApprovedVenues, ", ")), - }) - } - - // 5. Proposal belongs to this intent - if proposal.IntentID != intent.ID { - errs = append(errs, ValidationError{ - Field: "intent_id", - Message: "proposal does not belong to this intent", - }) - } - - // 6. Agent on the proposal is active (non-empty agent ID) - if proposal.AgentID == "" { - errs = append(errs, ValidationError{ - Field: "agent_id", - Message: "proposal has no assigned agent", - }) - } - - return Result{ - Valid: len(errs) == 0, - Errors: errs, - } -} - -// tokenPairMatches reports whether the proposal executes the pair the user -// actually asked for. -// -// This previously ignored the proposal entirely and checked only that the -// intent's own fields were non-empty and different from each other, which no -// proposal could ever fail. A proposal swapping an unrelated pair validated -// clean and went to settlement. -// -// Comparison is case-insensitive and trims surrounding space: agents submit -// symbols as free text, and "usdc" naming the same asset as "USDC" is a -// formatting difference, not a mismatched pair. -func tokenPairMatches(intent *models.Intent, proposal *models.Proposal) bool { - if intent.TokenIn == "" || intent.TokenOut == "" { - return false - } - // An empty pair on the proposal is a mismatch, not a pass. Treating it as - // acceptable would restore the original bug for any agent that simply - // omits the fields. - if proposal.TokenIn == "" || proposal.TokenOut == "" { - return false - } - return symbolsEqual(intent.TokenIn, proposal.TokenIn) && - symbolsEqual(intent.TokenOut, proposal.TokenOut) -} - -func symbolsEqual(a, b string) bool { - return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) -} - -func venueApproved(venue string) bool { - v := strings.ToLower(strings.TrimSpace(venue)) - return slices.Contains(ApprovedVenues, v) -} +package validation + +import ( + "fmt" + "slices" + "strings" + "time" + + "github.com/Cookit-labs/Backend/internal/models" +) + +// ApprovedVenues is the list of permitted execution venues. +// The AI agent must have set a venue in this list on their proposal. +var ApprovedVenues = []string{ + "uniswap_v3", + "uniswap_v2", + "curve", + "balancer", + "1inch", + "arc_dex", +} + +// ValidationError represents a single failed constraint. +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +func (e ValidationError) Error() string { + return fmt.Sprintf("%s: %s", e.Field, e.Message) +} + +// Result is the outcome of a full validation run. +type Result struct { + Valid bool `json:"valid"` + Errors []ValidationError `json:"errors,omitempty"` +} + +// Service runs all pre-settlement constraint checks. +type Service struct{} + +func New() *Service { + return &Service{} +} + +// Validate checks a proposal against an intent before any USDC moves. +// All checks run regardless — caller receives the full list of failures. +func (s *Service) Validate(intent *models.Intent, proposal *models.Proposal) Result { + var errs []ValidationError + + // 1. Token pair matches + if !tokenPairMatches(intent, proposal) { + errs = append(errs, ValidationError{ + Field: "token_pair", + Message: fmt.Sprintf( + "proposal token pair %s→%s does not match intent %s→%s", + proposal.TokenIn, proposal.TokenOut, intent.TokenIn, intent.TokenOut, + ), + }) + } + + // 2. Slippage within tolerance + if proposal.ProjectedSlippage > intent.MaxSlippage { + errs = append(errs, ValidationError{ + Field: "slippage", + Message: fmt.Sprintf( + "projected slippage %.4f%% exceeds max tolerance %.4f%%", + proposal.ProjectedSlippage*100, + intent.MaxSlippage*100, + ), + }) + } + + // 3. Deadline not passed + if time.Now().After(intent.Deadline) { + errs = append(errs, ValidationError{ + Field: "deadline", + Message: fmt.Sprintf("intent deadline passed at %s", intent.Deadline.UTC().Format(time.RFC3339)), + }) + } + + // 4. Execution venue is approved + if !venueApproved(proposal.ExecutionPath) { + errs = append(errs, ValidationError{ + Field: "venue", + Message: fmt.Sprintf("venue %q is not on the approved list: %s", proposal.ExecutionPath, strings.Join(ApprovedVenues, ", ")), + }) + } + + // 5. Proposal belongs to this intent + if proposal.IntentID != intent.ID { + errs = append(errs, ValidationError{ + Field: "intent_id", + Message: "proposal does not belong to this intent", + }) + } + + // 6. Agent on the proposal is active (non-empty agent ID) + if proposal.AgentID == "" { + errs = append(errs, ValidationError{ + Field: "agent_id", + Message: "proposal has no assigned agent", + }) + } + + return Result{ + Valid: len(errs) == 0, + Errors: errs, + } +} + +// tokenPairMatches reports whether the proposal executes the pair the user +// actually asked for. +// +// This previously ignored the proposal entirely and checked only that the +// intent's own fields were non-empty and different from each other, which no +// proposal could ever fail. A proposal swapping an unrelated pair validated +// clean and went to settlement. +// +// Comparison is case-insensitive and trims surrounding space: agents submit +// symbols as free text, and "usdc" naming the same asset as "USDC" is a +// formatting difference, not a mismatched pair. +func tokenPairMatches(intent *models.Intent, proposal *models.Proposal) bool { + if intent.TokenIn == "" || intent.TokenOut == "" { + return false + } + // An empty pair on the proposal is a mismatch, not a pass. Treating it as + // acceptable would restore the original bug for any agent that simply + // omits the fields. + if proposal.TokenIn == "" || proposal.TokenOut == "" { + return false + } + return symbolsEqual(intent.TokenIn, proposal.TokenIn) && + symbolsEqual(intent.TokenOut, proposal.TokenOut) +} + +func symbolsEqual(a, b string) bool { + return strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) +} + +func venueApproved(venue string) bool { + v := strings.ToLower(strings.TrimSpace(venue)) + return slices.Contains(ApprovedVenues, v) +}