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/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/agents.go b/internal/handlers/agents.go index ed7a364..b7f331c 100644 --- a/internal/handlers/agents.go +++ b/internal/handlers/agents.go @@ -1,13 +1,132 @@ 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 { @@ -28,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) @@ -58,17 +178,21 @@ func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { } 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, + "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, 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..4234cda 100644 --- a/internal/handlers/executions.go +++ b/internal/handlers/executions.go @@ -70,13 +70,13 @@ func (h *Handler) SelectWinner(w http.ResponseWriter, r *http.Request) { // 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, + "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 @@ -89,14 +89,14 @@ func (h *Handler) SelectWinner(w http.ResponseWriter, r *http.Request) { }) 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, + "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, }) } @@ -109,6 +109,11 @@ func (h *Handler) ValidateExecution(w http.ResponseWriter, r *http.Request) { 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 { @@ -153,6 +158,11 @@ func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { 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 { @@ -179,16 +189,21 @@ func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { // 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, + 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 { @@ -220,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"), }) @@ -230,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, }) @@ -240,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/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/intents.go b/internal/handlers/intents.go index 4c22f48..0bb7f8a 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" ) @@ -17,8 +18,28 @@ func (h *Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { 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, @@ -42,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, @@ -65,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/proposals.go b/internal/handlers/proposals.go index 14d6675..893ae56 100644 --- a/internal/handlers/proposals.go +++ b/internal/handlers/proposals.go @@ -27,6 +27,11 @@ func (h *Handler) CreateProposal(w http.ResponseWriter, r *http.Request) { 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 { @@ -39,6 +44,8 @@ func (h *Handler) CreateProposal(w http.ResponseWriter, r *http.Request) { IntentID: intentID, AgentID: req.AgentID, StrategyType: req.StrategyType, + TokenIn: req.TokenIn, + TokenOut: req.TokenOut, ProjectedSlippage: req.ProjectedSlippage, ProjectedExecutionQuality: req.ProjectedExecutionQuality, ProposedAmount: req.ProposedAmount, 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 e7b13b4..833b9a8 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"` @@ -31,10 +36,16 @@ type IntentResponse struct { // 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"` + 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"` } @@ -72,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"` } @@ -86,3 +98,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.go b/internal/handlers/validate.go new file mode 100644 index 0000000..8371b14 --- /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..25c94b0 --- /dev/null +++ b/internal/handlers/validate_test.go @@ -0,0 +1,144 @@ +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) + } + } +} + +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") + } +} diff --git a/internal/models/models.go b/internal/models/models.go index 9a0c96f..35df3de 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -8,7 +8,10 @@ import ( // Intent represents a user's trading intention type Intent struct { - ID string `gorm:"primaryKey" json:"id"` + 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"` @@ -29,10 +32,15 @@ type Intent struct { // 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 + 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"` @@ -49,12 +57,20 @@ type Proposal struct { // 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"` + 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 @@ -92,10 +108,15 @@ type Agent struct { // 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"` + 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 diff --git a/internal/validation/service.go b/internal/validation/service.go index fe79e48..7fd5d68 100644 --- a/internal/validation/service.go +++ b/internal/validation/service.go @@ -1,8 +1,8 @@ package validation import ( - "slices" "fmt" + "slices" "strings" "time" @@ -51,8 +51,11 @@ func (s *Service) Validate(intent *models.Intent, proposal *models.Proposal) Res // 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), + 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, + ), }) } @@ -106,8 +109,33 @@ func (s *Service) Validate(intent *models.Intent, proposal *models.Proposal) Res } } -func tokenPairMatches(intent *models.Intent, _ *models.Proposal) bool { - return intent.TokenIn != "" && intent.TokenOut != "" && intent.TokenIn != intent.TokenOut +// 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 { 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) + } +}