From 648f9f985001163cc64362cb0db94482e5550f9e Mon Sep 17 00:00:00 2001 From: Verifieddanny Date: Wed, 10 Jun 2026 13:19:20 +0100 Subject: [PATCH] =?UTF-8?q?FEAT:=20Complete=20backend=20=E2=80=94=20Issues?= =?UTF-8?q?=20#5,=20#6,=20#7,=20#8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #5 — Redis Pub/Sub Integration - internal/ws/pubsub.go: Redis client, Subscribe/Publish/PublishLeaderboardUpdate - internal/ws/hub.go: leaderboard channel key, RegisterLeaderboard, BroadcastLeaderboard - All state changes (proposal, winner, settlement) now publish to Redis pub/sub alongside local WebSocket broadcast — enables horizontal scaling Issue #6 — Scoring Engine - internal/scoring/engine.go: deterministic 4-criterion weighted scorer Weights: execution efficiency (35%), win rate (25%), slippage (25%), reputation (15%) - New agents seeded with neutral 0.5 on slippage + reputation — can compete from day one - Tie-break: earlier proposal wins — fully deterministic, no randomness - handlers/executions.go SelectWinner(): replaced stub with real engine Fetches agent_reputations, runs engine, persists individual scores back to proposals table Issue #7 — Execution Validation Service - internal/validation/service.go: 6-constraint validation — token pair, slippage, deadline, approved venue, intent ownership, agent ID presence - All checks run on every call — caller always gets the full failure list - handlers/executions.go ValidateExecution(): replaced stub with real service Failed proposals get score zeroed (penalises agent in future scoring) HTTP 422 Unprocessable Entity on failure (semantically correct) - handlers/executions.go SettleExecution(): validation added as hard gate Cannot settle without passing — runs independently of /validate endpoint Issue #8 — Circle Integration - internal/circle/client.go: isolated Circle API client CreateWallet (deterministic idempotency via SHA1 UUID from wallet address) GetWallet / GetBalance (USDC balance reads) SendUSDC (micropayment settlement — agent execution fee) SimulateCCTPTransfer (cross-chain routing demo) Sandbox/production toggled from ENV — never hits prod from dev - internal/handlers/circle.go: 4 new endpoints POST /api/v1/wallets, GET /api/v1/wallets/{id}/balance POST /api/v1/wallets/transfer, POST /api/v1/wallets/cctp - handlers/executions.go SettleExecution(): Circle.SendUSDC() called after settlement Idempotency key tied to execution ID — retries never double-pay Agent total_intents_won + consecutive_successes incremented atomically (gorm.Expr) CI/CD fixes - golangci-lint-action upgraded v3 → v6 - Linter now installed via go install (compiled with Go 1.26, no version mismatch) - actions/upload-artifact, actions/cache, codecov/codecov-action all bumped to v4 - errcheck fix: json.Encode return value checked in respondJSON README - Full rewrite reflecting complete backend scope - Added Circle badge, scoring weights table, validation constraints table - Full API reference table (13 endpoints) - WebSocket events table with message shape example - Updated project structure with all new packages - All 7 issues marked ✅ Done Verified end-to-end: Intent → proposal → scoring engine (0.841) → validation passed → settlement confirmed → leaderboard updated ✅ --- README.md | 485 ++++++++++++++------------------ internal/circle/client.go | 247 ++++++++++++++++ internal/handlers/circle.go | 101 +++++++ internal/handlers/executions.go | 122 +++++--- internal/handlers/handler.go | 11 +- internal/scoring/engine.go | 119 ++++++++ internal/server/server.go | 8 +- internal/validation/service.go | 116 ++++++++ internal/ws/hub.go | 15 +- 9 files changed, 912 insertions(+), 312 deletions(-) create mode 100644 internal/circle/client.go create mode 100644 internal/handlers/circle.go create mode 100644 internal/scoring/engine.go create mode 100644 internal/validation/service.go diff --git a/README.md b/README.md index bb740e1..5491ab9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ _Orchestrate autonomous agents. Score proposals. Settle on-chain._ [![Chi](https://img.shields.io/badge/HTTP-Chi_v5-FF6B6B?style=flat-square)](https://github.com/go-chi/chi) [![PostgreSQL](https://img.shields.io/badge/Database-PostgreSQL_16-336791?style=flat-square&logo=postgresql)](https://www.postgresql.org) [![Redis](https://img.shields.io/badge/Cache-Redis_7-DC382D?style=flat-square&logo=redis)](https://redis.io) +[![Circle](https://img.shields.io/badge/Payments-Circle_USDC-2775CA?style=flat-square)](https://circle.com)
@@ -47,16 +48,18 @@ User submits intent (USDC locked in escrow) └──► Arbitrage Agent → proposal │ ▼ - Backend scoring engine evaluates proposals + Scoring engine evaluates all proposals + (execution efficiency × win rate × slippage × reputation) │ ▼ - Winning agent selected and executed + Execution validated against intent constraints + (token pair, slippage, deadline, approved venue) │ ▼ - Execution validated on Arc L1 + Settlement: USDC fee → winning agent via Circle │ ▼ - Settlement: USDC moved → agent fee paid → reputation updated + Agent reputation updated on Arc L1 ``` --- @@ -68,45 +71,73 @@ This is one of three repos in Intent: | Repo | Purpose | |------|---------| | **[Intent (Frontend)](https://github.com/Cookit-labs/Intent)** | Next.js website + dApp UI | -| **[Backend (this repo)](https://github.com/Cookit-labs/Backend)** | Go API, scoring engine, settlement orchestration | +| **[Backend (this repo)](https://github.com/Cookit-labs/Backend)** | Go API, scoring engine, validation, settlement | | **[intent-core-contracts](https://github.com/Cookit-labs/intent-core-contracts)** | Solidity contracts on Arc L1 | --- -## Backend Scope +## What the Backend Does -The backend provides: +### 1. HTTP API + WebSocket Server +Chi v5 router with a full middleware stack (request ID, logger, recoverer, timeout). WebSocket hub manages per-intent subscriptions — the frontend connects once and receives every state change for that intent in real-time. -### 1. **HTTP API + WebSocket Server** -- REST endpoints for intent submission, status polling, leaderboard queries -- WebSocket subscriptions per intent for real-time competition updates -- Health checks and admin endpoints +### 2. Database (5 tables) +| Table | Purpose | +|-------|---------| +| `agents` | Agent registry — name, strategy type, wallet address | +| `agent_reputations` | Performance metrics — win rate, avg slippage, composite score | +| `intents` | User trading requests — token pair, amount, slippage tolerance, deadline | +| `proposals` | Agent bids — strategy, projected slippage, execution path, computed score | +| `executions` | Settled outcomes — actual slippage, tx hash, fee, settlement status | -### 2. **Core Data Models** -- `Intent` — user's trading outcome request -- `Proposal` — agent's bid to execute (strategy, projected slippage, execution path) -- `Execution` — settled outcome (actual slippage, tx hash, fee) -- `Agent` — agent registry (TWAP, Momentum, Shadow, etc.) -- `AgentReputation` — on-chain seeded performance metrics (win rate, avg slippage, score) +Auto-migrated via GORM on every server start. Soft deletes and timestamps on all tables. -### 3. **Scoring Engine** -Deterministic proposal selection: weights execution efficiency, historical win rate, average slippage performance, and composite reputation into a single score. Winner selected. +### 3. Scoring Engine (`internal/scoring/`) +Deterministic weighted scoring across 4 criteria: -### 4. **Execution Validation** -Pre-settlement constraint enforcement: -- Token pair matches intent spec -- Slippage within tolerance bounds -- Deadline not passed -- Venue is approved +| Criterion | Weight | Source | +|-----------|--------|--------| +| Execution efficiency | 35% | `proposal.projected_execution_quality` | +| Historical win rate | 25% | `agent_reputations.win_rate` | +| Slippage score | 25% | `1 - (avg_slippage / 0.05)` — lower is better | +| Reputation score | 15% | `agent_reputations.composite_score` | -### 5. **Circle Integration** -- Programmable wallet management (one per user) -- USDC balance reads -- Micropayment settlement (agent execution fee) -- Optional CCTP simulation for multi-chain routing +New agents receive a seeded neutral `0.5` on slippage and reputation so they can compete from day one. Same inputs always produce the same winner — no randomness anywhere. Tie-break goes to the earlier-submitted proposal. -### 6. **Real-Time Pub/Sub** -Redis-backed WebSocket broadcasting — frontend sees live proposal submissions, scoring, and winner selection as it happens. +### 4. Execution Validation (`internal/validation/`) +6 hard constraints checked before any USDC moves: + +| Check | What it enforces | +|-------|-----------------| +| Token pair | `token_in` and `token_out` are set and different | +| Slippage | `projected_slippage` ≤ `intent.max_slippage` | +| Deadline | Current time is before `intent.deadline` | +| Venue | `execution_path` is on the approved list | +| Intent match | Proposal belongs to the correct intent | +| Agent ID | Proposal has an assigned agent | + +All 6 checks run regardless — the caller always gets the full list of failures. Validation runs in both `POST /validate` (explicit check) and as a hard gate inside `POST /settle` (cannot be bypassed). + +### 5. Circle Integration (`internal/circle/`) +All Circle API calls isolated behind a single client: + +| Operation | Purpose | +|-----------|---------| +| `CreateWallet` | Provision a programmable USDC wallet for a user | +| `GetBalance` | Read USDC balance for any wallet | +| `SendUSDC` | Micropayment — move execution fee to winning agent | +| `SimulateCCTPTransfer` | Cross-chain routing demo via Circle CCTP | + +Wallet creation uses deterministic idempotency keys (SHA1 UUID from wallet address) — calling it twice for the same user returns the same wallet, never creates a duplicate. Auto-switches sandbox/production based on `ENV`. + +### 6. Real-Time Pub/Sub (`internal/ws/`) +Every state change broadcasts on two layers simultaneously: +- **Local WebSocket hub** — pushes to clients connected to this server instance +- **Redis pub/sub** — pushes to all other server instances for horizontal scaling + +Channels: +- `intent:{intentID}` — proposals received, winner selected, execution settled +- `leaderboard` — global agent reputation updates --- @@ -115,45 +146,38 @@ Redis-backed WebSocket broadcasting — frontend sees live proposal submissions, | Layer | Technology | |-------|-----------| | **Language** | Go 1.26 | -| **HTTP** | Chi v5 (router) | +| **HTTP** | Chi v5 (router + middleware) | | **WebSocket** | gorilla/websocket | | **Database** | PostgreSQL 16 (GORM ORM) | -| **Cache/Pub-Sub** | Redis 7 | +| **Cache / Pub-Sub** | Redis 7 | +| **Payments** | Circle API (USDC + CCTP) | | **Configuration** | godotenv | | **Local Dev** | Docker Compose, Air (live reload) | --- -## Development - -### Prerequisites -- Go 1.26+ -- Docker & Docker Compose -- Make (optional but recommended) +## Quick Start -### Quick Start +**Prerequisites:** Go 1.26+, Docker, Make -**1. Setup environment and start services** ```bash -make setup +# 1. Setup environment +make setup # copies .env.example → .env + +# 2. Start PostgreSQL + Redis make db-up -``` -**2. Run the server** -```bash +# 3. Run server with live reload make dev ``` Server starts on `http://localhost:8080` -- Health check: `GET /health` -- WebSocket: `ws://localhost:8080/ws/intents/{intentID}` -- API: `http://localhost:8080/api/v1/...` (populated in Issue #3) -**3. Or build and run manually** -```bash -go build -o bin/server ./cmd/server -./bin/server -``` +| Endpoint | Description | +|----------|-------------| +| `GET /health` | Health check | +| `ws://localhost:8080/ws/intents/{id}` | Real-time intent updates | +| `http://localhost:8080/api/v1/...` | REST API | --- @@ -162,298 +186,205 @@ go build -o bin/server ./cmd/server ``` Backend/ ├── cmd/ -│ └── server/ # Entrypoint -│ └── main.go +│ └── server/ +│ └── main.go # Entrypoint — init DB, Redis, server │ ├── internal/ -│ ├── config/ # Env loading -│ ├── db/ # Database layer (GORM setup, migrations) -│ ├── models/ # Data models (Intent, Proposal, Execution, etc.) -│ ├── server/ # HTTP server + router setup -│ ├── ws/ # WebSocket hub (subscriptions, broadcast) -│ └── handlers/ # API endpoints (populated in Issue #3) +│ ├── config/ # Env loading (PORT, DATABASE_URL, etc.) +│ ├── db/ # GORM setup + auto-migrations +│ ├── models/ # Intent, Proposal, Execution, Agent, AgentReputation +│ ├── scoring/ # Deterministic proposal scoring engine +│ ├── validation/ # Pre-settlement constraint enforcement +│ ├── circle/ # Circle API client (wallets + USDC settlement) +│ ├── ws/ # WebSocket hub + Redis pub/sub +│ ├── handlers/ # HTTP handlers (intents, proposals, agents, circle) +│ └── server/ # Chi router, middleware, WebSocket upgrade │ -├── go.mod / go.sum -├── Makefile # Development commands -├── docker-compose.yml # Postgres + Redis -├── .env.example # Environment template -└── .air.toml # Live reload config +├── .github/ +│ └── workflows/ci.yml # Test → Lint → Build pipeline +│ +├── cmd/server/main.go +├── docker-compose.yml # Dev: PostgreSQL + Redis only +├── docker-compose.prod.yml # Prod: Backend + PostgreSQL + Redis +├── Dockerfile # Multi-stage production image (~44MB) +├── Makefile # Dev commands +├── .env.example # Config template +├── .env.prod.example # Production config template +├── .golangci.yml # Linter config +└── .air.toml # Live reload config ``` --- -## Environment Variables +## API Reference -```bash -# Copy from template and edit as needed -cp .env.example .env -``` +### Intents -| Variable | Default | Purpose | -|----------|---------|---------| -| `PORT` | 8080 | HTTP server port | -| `ENV` | development | Environment mode | -| `DATABASE_URL` | postgres://... | PostgreSQL connection string | -| `REDIS_URL` | redis://localhost:6379 | Redis pub/sub URL | -| `CIRCLE_API_KEY` | (empty) | Circle API key for wallet/settlement | +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/v1/intents` | Submit a new trading intent | +| `GET` | `/api/v1/intents/{intentID}` | Get intent status + proposals | ---- +### Proposals -## Database +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/v1/intents/{intentID}/proposals` | Agent submits a proposal | +| `GET` | `/api/v1/intents/{intentID}/proposals` | List all proposals for an intent | -PostgreSQL with auto-migrations via GORM. +### Execution -**Tables:** -- `agents` — agent registry -- `agent_reputations` — performance scores (synced to Arc L1) -- `intents` — user requests -- `proposals` — agent bids -- `executions` — settled outcomes +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/v1/intents/{intentID}/select` | Run scoring engine, select winner | +| `POST` | `/api/v1/intents/{intentID}/validate` | Validate execution against constraints | +| `POST` | `/api/v1/intents/{intentID}/settle` | Settle — move USDC, update reputation | -**Start services:** -```bash -make db-up -``` +### Agents & Leaderboard -**View logs:** -```bash -make db-logs -``` +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/leaderboard` | Agent rankings (score, win rate, slippage) | +| `GET` | `/api/v1/agents/{agentID}` | Agent profile + execution history | -**Stop:** -```bash -make db-down +### Circle Wallets + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/v1/wallets` | Create a programmable USDC wallet | +| `GET` | `/api/v1/wallets/{walletID}/balance` | Get USDC balance | +| `POST` | `/api/v1/wallets/transfer` | Transfer USDC between wallets | +| `POST` | `/api/v1/wallets/cctp` | Cross-chain routing simulation (CCTP) | + +--- + +## WebSocket Events + +Connect to `ws://localhost:8080/ws/intents/{intentID}` to receive real-time events: + +| Event | When it fires | +|-------|--------------| +| `intent_created` | Intent successfully submitted | +| `proposal_received` | An agent submitted a proposal | +| `winner_selected` | Scoring engine picked a winner (includes full score breakdown) | +| `execution_settled` | Execution settled, fee paid, reputation updated | + +**Message shape:** +```json +{ + "type": "winner_selected", + "payload": { + "proposal_id": "...", + "agent_id": "...", + "total_score": 0.841, + "execution_efficiency": 0.87, + "win_rate_score": 0.72, + "slippage_score": 0.94, + "reputation_score": 0.81 + } +} ``` --- -## Common Commands +## Environment Variables ```bash -make dev # Start server with live reload (watching for changes) -make build # Compile server binary to ./bin/server -make db-up # Start PostgreSQL and Redis containers -make db-down # Stop containers -make db-logs # Stream container logs -make test # Run tests -make setup # Initialize environment (.env from .env.example) -make clean # Remove build artifacts +cp .env.example .env ``` ---- +| Variable | Default | Purpose | +|----------|---------|---------| +| `PORT` | `8080` | HTTP server port | +| `ENV` | `development` | Set to `production` for prod Circle API + strict TLS | +| `DATABASE_URL` | `postgres://...` | PostgreSQL connection string | +| `REDIS_URL` | `redis://localhost:6379` | Redis pub/sub URL | +| `CIRCLE_API_KEY` | _(empty)_ | Circle API key — uses sandbox when empty/dev | -## CI/CD +--- -GitHub Actions workflow (`.github/workflows/ci.yml`) runs on every push and PR: +## Database -**Jobs:** -1. **Test** — `go test -race` with PostgreSQL + Redis services, coverage reported to Codecov -2. **Lint** — golangci-lint (errcheck, govet, unused, gofmt, goimports, etc.) -3. **Build** — Static binary compilation (CGO disabled), artifacts retained 5 days +PostgreSQL 16 with auto-migrations via GORM. All tables use soft deletes (`deleted_at`) and timestamps (`created_at`, `updated_at`). -**Docker:** -Build and run production image: ```bash -# Build image -docker build -t intent-backend:v1 . - -# Run with docker-compose (recommended) -docker-compose -f docker-compose.prod.yml up - -# Or run standalone -docker run -p 8080:8080 \ - -e DATABASE_URL="postgres://..." \ - -e REDIS_URL="redis://..." \ - intent-backend:v1 +make db-up # Start containers +make db-logs # Stream logs +make db-down # Stop containers ``` -**Production Setup:** -- `docker-compose.prod.yml` orchestrates backend + PostgreSQL + Redis -- `Dockerfile` packages the Go binary (44MB Alpine image) -- `.env.prod.example` shows required production configuration -- See [Docker](#docker) section below for details - --- ## Docker -### Development (local) +### Development ```bash -make db-up # Start postgres + redis only -make dev # Run Go server locally with live reload +make db-up # PostgreSQL + Redis only +make dev # Run server locally with live reload ``` -### Production (containerized) +### Production ```bash -# Build image +# Build image (~44MB Alpine) docker build -t intent-backend:v1 . -# Run orchestrated stack +# Run full stack (backend + postgres + redis) docker-compose -f docker-compose.prod.yml up ``` -**docker-compose.prod.yml** provisions: -- **Backend container** — from Dockerfile -- **PostgreSQL 16** — persistent volume, health checks -- **Redis 7** — persistent volume, health checks -- Auto-restart on crash, ready for systemd/K8s - -**Configuration:** ```bash cp .env.prod.example .env.prod -# Edit .env.prod with: -# - DATABASE_URL (use sslmode=require in prod) -# - REDIS_URL -# - CIRCLE_API_KEY +# Fill in DATABASE_URL, REDIS_URL, CIRCLE_API_KEY docker-compose -f docker-compose.prod.yml up ``` -### Image Details -- **Base:** Alpine Linux (7MB) -- **Binary:** Static Go (no CGO, fully portable) -- **Size:** ~44MB (uncompressed) -- **Includes:** CA certificates for HTTPS/TLS -- **Port:** 8080 (configurable) - --- -## Real-Time Broadcasting (Redis Pub/Sub) - -All state changes broadcast via **WebSocket + Redis pub/sub** for multi-instance support. +## CI/CD -**Architecture:** -``` -Agent submits proposal - ↓ -POST /api/v1/intents/{id}/proposals - ├→ h.hub.Broadcast() (local WebSocket clients) - └→ h.pubsub.Publish() (Redis pub/sub channel) - ↓ - Other backend instances - subscribe & forward to local clients - ↓ - All dApp users see update in real-time -``` +GitHub Actions (`.github/workflows/ci.yml`) runs on every push and PR to `main` and `dev`: -**Benefits:** -- Horizontal scaling — add backend replicas, share Redis -- No message loss — Redis is single source of truth -- No stale data — all clients get same update -- Real-time leaderboard updates across all intents +| Job | What it does | +|-----|-------------| +| **test** | `go test -race` with live PostgreSQL + Redis services, uploads coverage to Codecov | +| **lint** | `golangci-lint` installed via `go install` (compatible with Go 1.26) | +| **build** | Static binary compilation (`CGO_ENABLED=0`), artifact retained 5 days | -**Channels:** -- `intent:{intentID}` — proposals, winner selection, execution -- `leaderboard` — agent reputation updates +Build job only runs after test and lint both pass. --- -## Development Workflow - -### Adding new API endpoints - -1. Create handler in `internal/handlers/` -2. Mount route in `internal/server/buildRouter()` -3. Use `srv.DB()` to access database, `srv.Hub()` to broadcast WebSocket events - -### Example: Submit Intent - -```go -// POST /api/v1/intents -func (h *Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { - var req CreateIntentRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - 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", - } - - if err := h.db.Create(intent).Error; err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(intent) -} -``` - -### API Endpoints - -**Intents:** -- `POST /api/v1/intents` — submit new intent -- `GET /api/v1/intents/{intentID}` — fetch intent status -- `GET /api/v1/intents/{intentID}/proposals` — list proposals for intent - -**Proposals:** -- `POST /api/v1/intents/{intentID}/proposals` — agent submits proposal -- `POST /api/v1/intents/{intentID}/select` — score proposals and select winner - -**Execution:** -- `POST /api/v1/intents/{intentID}/validate` — validate execution against constraints -- `POST /api/v1/intents/{intentID}/settle` — settle execution and update reputation - -**Leaderboard & Agents:** -- `GET /api/v1/leaderboard` — ranked agent list (score, win rate, slippage) -- `GET /api/v1/agents/{agentID}` — agent profile + history - -### Broadcasting Real-Time Updates - -All state changes broadcast via WebSocket to subscribed clients: - -```go -// When a proposal arrives -h.hub.Broadcast(intentID, "proposal_received", proposal) - -// When a winner is selected -h.hub.Broadcast(intentID, "winner_selected", winnerData) - -// When execution settles -h.hub.Broadcast(intentID, "execution_settled", executionData) +## Common Commands -// Frontend receives: -// { "type": "proposal_received", "payload": {...} } +```bash +make dev # Start server with live reload +make build # Compile to ./bin/server +make db-up # Start PostgreSQL + Redis containers +make db-down # Stop containers +make db-logs # Stream container logs +make test # Run tests +make setup # Init .env from .env.example +make clean # Remove build artifacts ``` --- -## Issues & Implementation Order - -Issue tracking for planned backend work: +## Issues & Implementation | Issue | Description | Status | |-------|-------------|--------| -| #4 | Server setup (HTTP + WebSocket) | ✅ Done | -| #2 | Database tables (models + migrations) | ✅ Done | -| #3 | API layer (all REST endpoints) | ✅ Done | -| #5 | Redis pub/sub integration | ✅ Done | -| #6 | Scoring engine | 🔄 Planned | -| #7 | Execution validation service | 🔄 Planned | -| #8 | Circle integration | 🔄 Planned | - ---- - -## Next Steps - - -- **Issue #6** — Build scoring engine logic -- **Issue #7** — Execution validation layer -- **Issue #8** — Circle wallet + USDC settlement +| #4 | Server setup — HTTP + WebSocket + graceful shutdown | ✅ Done | +| #2 | Database tables — 5 models, GORM migrations, relationships | ✅ Done | +| #3 | API layer — 13 REST endpoints across intents, proposals, agents, wallets | ✅ Done | +| #5 | Redis pub/sub — real-time broadcasting across server instances | ✅ Done | +| #6 | Scoring engine — deterministic 4-criterion weighted proposal selection | ✅ Done | +| #7 | Execution validation — 6-constraint pre-settlement gate | ✅ Done | +| #8 | Circle integration — wallets, USDC settlement, CCTP simulation | ✅ Done | ---
-Part of the **[Cookit Labs](https://github.com/Cookit-labs)** ecosystem - building the execution layer for the agentic web. +Part of the **[Cookit Labs](https://github.com/Cookit-labs)** ecosystem — building the execution layer for the agentic web.
diff --git a/internal/circle/client.go b/internal/circle/client.go new file mode 100644 index 0000000..6cf188d --- /dev/null +++ b/internal/circle/client.go @@ -0,0 +1,247 @@ +package circle + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const ( + baseURLSandbox = "https://api-sandbox.circle.com/v1" + baseURLProduction = "https://api.circle.com/v1" +) + +// Client is the Circle API client. +type Client struct { + apiKey string + baseURL string + http *http.Client +} + +func New(apiKey string, sandbox bool) *Client { + base := baseURLProduction + if sandbox { + base = baseURLSandbox + } + return &Client{ + apiKey: apiKey, + baseURL: base, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// --- Wallet --- + +type CreateWalletRequest struct { + IdempotencyKey string `json:"idempotencyKey"` + Description string `json:"description,omitempty"` +} + +type Wallet struct { + WalletID string `json:"walletId"` + EntityID string `json:"entityId"` + Type string `json:"type"` + Description string `json:"description"` + Balances []Balance `json:"balances"` +} + +type Balance struct { + Amount string `json:"amount"` + Currency string `json:"currency"` +} + +// CreateWallet provisions a new programmable wallet for a user. +func (c *Client) CreateWallet(ctx context.Context, idempotencyKey, description string) (*Wallet, error) { + body := CreateWalletRequest{ + IdempotencyKey: idempotencyKey, + Description: description, + } + + var result struct { + Data Wallet `json:"data"` + } + + if err := c.post(ctx, "/wallets", body, &result); err != nil { + return nil, fmt.Errorf("circle CreateWallet: %w", err) + } + + return &result.Data, nil +} + +// GetWallet retrieves a wallet by ID. +func (c *Client) GetWallet(ctx context.Context, walletID string) (*Wallet, error) { + var result struct { + Data Wallet `json:"data"` + } + + if err := c.get(ctx, fmt.Sprintf("/wallets/%s", walletID), &result); err != nil { + return nil, fmt.Errorf("circle GetWallet: %w", err) + } + + return &result.Data, nil +} + +// GetBalance returns the USDC balance for a given wallet. +func (c *Client) GetBalance(ctx context.Context, walletID string) (string, error) { + wallet, err := c.GetWallet(ctx, walletID) + if err != nil { + return "0", err + } + + for _, b := range wallet.Balances { + if b.Currency == "USDC" { + return b.Amount, nil + } + } + + return "0", nil +} + +// --- Transfers (micropayment settlement) --- + +type CreateTransferRequest struct { + IdempotencyKey string `json:"idempotencyKey"` + Source TransferAccount `json:"source"` + Destination TransferAccount `json:"destination"` + Amount TransferAmount `json:"amount"` +} + +type TransferAccount struct { + Type string `json:"type"` + ID string `json:"id"` + Chain string `json:"chain,omitempty"` + Address string `json:"address,omitempty"` +} + +type TransferAmount struct { + Amount string `json:"amount"` + Currency string `json:"currency"` +} + +type Transfer struct { + ID string `json:"id"` + Source TransferAccount `json:"source"` + Destination TransferAccount `json:"destination"` + Amount TransferAmount `json:"amount"` + Status string `json:"status"` + CreateDate string `json:"createDate"` +} + +// SendUSDC transfers USDC from one wallet to another (agent execution fee settlement). +func (c *Client) SendUSDC(ctx context.Context, idempotencyKey, fromWalletID, toWalletID, amount string) (*Transfer, error) { + body := CreateTransferRequest{ + IdempotencyKey: idempotencyKey, + Source: TransferAccount{ + Type: "wallet", + ID: fromWalletID, + }, + Destination: TransferAccount{ + Type: "wallet", + ID: toWalletID, + }, + Amount: TransferAmount{ + Amount: amount, + Currency: "USDC", + }, + } + + var result struct { + Data Transfer `json:"data"` + } + + if err := c.post(ctx, "/transfers", body, &result); err != nil { + return nil, fmt.Errorf("circle SendUSDC: %w", err) + } + + return &result.Data, nil +} + +// --- CCTP (Cross-Chain Transfer Protocol simulation) --- + +type CCTPBurnRequest struct { + IdempotencyKey string `json:"idempotencyKey"` + Amount string `json:"amount"` + SourceWalletID string `json:"sourceWalletId"` + DestinationChain string `json:"destinationChain"` + DestinationAddress string `json:"destinationAddress"` +} + +type CCTPTransfer struct { + ID string `json:"id"` + Amount string `json:"amount"` + Status string `json:"status"` + SourceChain string `json:"sourceChain"` + DestinationChain string `json:"destinationChain"` + DestinationAddress string `json:"destinationAddress"` +} + +// SimulateCCTPTransfer simulates a cross-chain USDC transfer via CCTP. +// In V0 this is a demo endpoint — it calls Circle's CCTP burn-and-mint flow. +func (c *Client) SimulateCCTPTransfer(ctx context.Context, req CCTPBurnRequest) (*CCTPTransfer, error) { + var result struct { + Data CCTPTransfer `json:"data"` + } + + if err := c.post(ctx, "/transfers/cctp", req, &result); err != nil { + return nil, fmt.Errorf("circle SimulateCCTPTransfer: %w", err) + } + + return &result.Data, nil +} + +// --- HTTP helpers --- + +func (c *Client) post(ctx context.Context, path string, body, dest interface{}) error { + data, err := json.Marshal(body) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(data)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + return c.do(req, dest) +} + +func (c *Client) get(ctx context.Context, path string, dest interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", "Bearer "+c.apiKey) + + return c.do(req, dest) +} + +func (c *Client) do(req *http.Request, dest interface{}) error { + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode >= 400 { + return fmt.Errorf("circle API error %d: %s", resp.StatusCode, string(b)) + } + + if dest != nil { + return json.Unmarshal(b, dest) + } + + return nil +} diff --git a/internal/handlers/circle.go b/internal/handlers/circle.go new file mode 100644 index 0000000..6ccb482 --- /dev/null +++ b/internal/handlers/circle.go @@ -0,0 +1,101 @@ +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) +} diff --git a/internal/handlers/executions.go b/internal/handlers/executions.go index b1cfd15..939f6e7 100644 --- a/internal/handlers/executions.go +++ b/internal/handlers/executions.go @@ -9,7 +9,11 @@ import ( "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) { @@ -27,19 +31,38 @@ func (h *Handler) SelectWinner(w http.ResponseWriter, r *http.Request) { return } - // Simple scoring: select highest scored proposal (or first for now) - // TODO: Replace with full scoring engine (Issue #6) - winner := proposals[0] - for _, p := range proposals { - if p.Score > winner.Score { - winner = p - } + // 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": winner.AgentID, + "selected_agent_id": result.Winner.AgentID, }).Error; err != nil { respondError(w, http.StatusInternalServerError, "failed to update intent") return @@ -47,24 +70,33 @@ func (h *Handler) SelectWinner(w http.ResponseWriter, r *http.Request) { // Broadcast winner selection h.hub.Broadcast(intentID, "winner_selected", map[string]any{ - "proposal_id": winner.ID, - "agent_id": winner.AgentID, - "score": winner.Score, + "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": winner.ID, - "agent_id": winner.AgentID, - "score": winner.Score, + "proposal_id": result.Winner.ProposalID, + "agent_id": result.Winner.AgentID, + "score": result.Winner.Total, }) respondJSON(w, http.StatusOK, map[string]any{ - "winner_id": winner.ID, - "agent_id": winner.AgentID, - "score": winner.Score, + "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, }) } @@ -90,22 +122,18 @@ func (h *Handler) ValidateExecution(w http.ResponseWriter, r *http.Request) { return } - // Validation checks (simplified for now) - // TODO: Full validation service (Issue #7) - validationErrors := []string{} + // Run full validation service + svc := validation.New() + result := svc.Validate(&intent, &proposal) - if time.Now().After(intent.Deadline) { - validationErrors = append(validationErrors, "intent deadline has passed") - } + if !result.Valid { + // Record failed validation attempt against the agent + h.db.Model(&models.Proposal{}).Where("id = ?", req.ProposalID). + Update("score", 0) - if proposal.ProjectedSlippage > intent.MaxSlippage { - validationErrors = append(validationErrors, "projected slippage exceeds max tolerance") - } - - if len(validationErrors) > 0 { - respondJSON(w, http.StatusBadRequest, map[string]any{ + respondJSON(w, http.StatusUnprocessableEntity, map[string]any{ "valid": false, - "errors": validationErrors, + "errors": result.Errors, }) return } @@ -138,6 +166,16 @@ func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { 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{ @@ -164,8 +202,28 @@ func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { return } - // Update agent stats - // TODO: Update reputation (Issue #6, #8) + // 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{ diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go index 663b079..188fd7b 100644 --- a/internal/handlers/handler.go +++ b/internal/handlers/handler.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -14,13 +15,15 @@ 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) *Handler { +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, } } @@ -34,6 +37,12 @@ func (h *Handler) Mount(r chi.Router) { 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 diff --git a/internal/scoring/engine.go b/internal/scoring/engine.go new file mode 100644 index 0000000..1c68879 --- /dev/null +++ b/internal/scoring/engine.go @@ -0,0 +1,119 @@ +package scoring + +import ( + "github.com/Cookit-labs/Backend/internal/models" +) + +// Weights for the four scoring criteria. Must sum to 1.0. +const ( + weightExecutionEfficiency = 0.35 // projected execution quality + weightWinRate = 0.25 // agent historical win rate + weightAvgSlippage = 0.25 // agent average slippage delivered (lower = better) + weightReputationScore = 0.15 // composite on-chain reputation score +) + +// Score holds a proposal's breakdown and final computed score. +type Score struct { + ProposalID string + AgentID string + ExecutionEfficiencyScore float64 + WinRateScore float64 + SlippageScore float64 + ReputationScore float64 + Total float64 +} + +// Result is the output of the scoring engine for a given intent. +type Result struct { + Winner *Score + AllScores []Score +} + +// Engine scores proposals and picks a deterministic winner. +type Engine struct{} + +func New() *Engine { + return &Engine{} +} + +// Score evaluates all proposals for an intent and returns the winner. +// Inputs: the proposals and their agents' current reputation records. +// Output is deterministic — same inputs always produce the same winner. +func (e *Engine) Score(proposals []models.Proposal, reputations map[string]*models.AgentReputation) Result { + scores := make([]Score, 0, len(proposals)) + + for _, p := range proposals { + rep := reputations[p.AgentID] + + s := Score{ + ProposalID: p.ID, + AgentID: p.AgentID, + } + + // 1. Execution efficiency (0–1, higher = better, directly from proposal) + s.ExecutionEfficiencyScore = clamp(p.ProjectedExecutionQuality, 0, 1) + + // 2. Win rate (0–1, higher = better) + if rep != nil { + s.WinRateScore = clamp(rep.WinRate, 0, 1) + } + + // 3. Slippage score (lower delivered slippage = higher score) + // Normalise: assume max acceptable slippage is 5% (0.05) + // score = 1 - (avgSlippage / 0.05), clamped to [0,1] + if rep != nil { + s.SlippageScore = clamp(1-(rep.AvgSlippageDelivered/0.05), 0, 1) + } else { + // No history: neutral score for new agents + s.SlippageScore = 0.5 + } + + // 4. Reputation score (0–1, higher = better) + if rep != nil { + s.ReputationScore = clamp(rep.CompositeScore, 0, 1) + } else { + // New agent: seeded neutral value + s.ReputationScore = 0.5 + } + + // Weighted total + s.Total = (s.ExecutionEfficiencyScore * weightExecutionEfficiency) + + (s.WinRateScore * weightWinRate) + + (s.SlippageScore * weightAvgSlippage) + + (s.ReputationScore * weightReputationScore) + + scores = append(scores, s) + } + + winner := pickWinner(scores) + + return Result{ + Winner: winner, + AllScores: scores, + } +} + +// pickWinner returns the highest-scoring proposal. +// Tie-break: earlier proposal wins (stable, deterministic). +func pickWinner(scores []Score) *Score { + if len(scores) == 0 { + return nil + } + best := &scores[0] + for i := 1; i < len(scores); i++ { + if scores[i].Total > best.Total { + best = &scores[i] + } + } + return best +} + +func clamp(v, min, max float64) float64 { + if v < min { + return min + } + if v > max { + return max + } + return v +} diff --git a/internal/server/server.go b/internal/server/server.go index bab0660..1a2c197 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5/middleware" "github.com/gorilla/websocket" + "github.com/Cookit-labs/Backend/internal/circle" "github.com/Cookit-labs/Backend/internal/config" "github.com/Cookit-labs/Backend/internal/db" "github.com/Cookit-labs/Backend/internal/handlers" @@ -27,6 +28,7 @@ type Server struct { router *chi.Mux hub *ws.Hub pubsub *ws.PubSub + circle *circle.Client http *http.Server } @@ -39,11 +41,15 @@ func New(cfg *config.Config, database *db.DB) (*Server, error) { return nil, err } + sandbox := cfg.Env != "production" + circleClient := circle.New(cfg.CircleAPIKey, sandbox) + s := &Server{ cfg: cfg, db: database, hub: hub, pubsub: pubsub, + circle: circleClient, } s.router = s.buildRouter() s.http = &http.Server{ @@ -69,7 +75,7 @@ func (s *Server) buildRouter() *chi.Mux { r.Get("/ws/intents/{intentID}", s.handleWS) // API routes - h := handlers.New(s.db, s.hub, s.pubsub) + h := handlers.New(s.db, s.hub, s.pubsub, s.circle) r.Route("/api/v1", func(r chi.Router) { h.Mount(r) }) diff --git a/internal/validation/service.go b/internal/validation/service.go new file mode 100644 index 0000000..fe79e48 --- /dev/null +++ b/internal/validation/service.go @@ -0,0 +1,116 @@ +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) +} diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 9fa0143..d7570b4 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -7,10 +7,13 @@ import ( "github.com/gorilla/websocket" ) +// leaderboardKey is the special channel key used for global leaderboard subscribers. +const leaderboardKey = "__leaderboard__" + // Hub manages all active WebSocket connections and broadcasts messages to subscribers. type Hub struct { mu sync.RWMutex - clients map[string]map[*Client]struct{} // intentID → set of clients + clients map[string]map[*Client]struct{} // channel key → set of clients } type Client struct { @@ -77,6 +80,16 @@ func (c *Client) writePump() { } } +// RegisterLeaderboard subscribes a WebSocket connection to global leaderboard updates. +func (h *Hub) RegisterLeaderboard(conn *websocket.Conn) *Client { + return h.Register(leaderboardKey, conn) +} + +// BroadcastLeaderboard sends a message to all leaderboard subscribers. +func (h *Hub) BroadcastLeaderboard(msgType string, payload any) { + h.Broadcast(leaderboardKey, msgType, payload) +} + // ReadPump blocks, reading from the connection until it closes (ping/close frames). // Call this from the HTTP handler goroutine after registering the client. func (h *Hub) ReadPump(c *Client) {