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._
[](https://github.com/go-chi/chi)
[](https://www.postgresql.org)
[](https://redis.io)
+[](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) {