diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..a591c7a --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,15 @@ +# Production Environment Variables +# Copy to .env.prod and set actual values before deployment + +# Server +PORT=8080 +ENV=production + +# Database (use strong passwords in production!) +DATABASE_URL=postgres://intent:YOUR_PROD_PASSWORD@postgres:5432/intent?sslmode=require + +# Redis +REDIS_URL=redis://redis:6379 + +# Circle API (required for wallet + settlement) +CIRCLE_API_KEY=your_circle_production_api_key diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c759fc1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,107 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: intent + POSTGRES_PASSWORD: intent_test + POSTGRES_DB: intent_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:7-alpine + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Download dependencies + run: go mod download + + - name: Run tests + run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./... + env: + DATABASE_URL: postgres://intent:intent_test@localhost:5432/intent_test?sslmode=disable + REDIS_URL: redis://localhost:6379 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage.txt + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + + - name: Run golangci-lint + run: golangci-lint run --timeout=5m + + build: + runs-on: ubuntu-latest + needs: [test, lint] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.26' + + - name: Build + run: go build -v -o bin/server ./cmd/server + env: + CGO_ENABLED: 0 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: server-${{ runner.os }} + path: bin/server + retention-days: 5 diff --git a/.gitignore b/.gitignore index c85033b..4bc8ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,8 @@ bin/ *.sqlite *.sqlite3 *.bak -*.backup \ No newline at end of file +*.backup +tmp/ +vendor/ +coverage.txt +.air.toml diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..f97b4bb --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,19 @@ +run: + timeout: 5m + tests: true + +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - typecheck + - unused + +issues: + exclude-rules: + - path: _test\.go + linters: + - errcheck diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0699cd1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.26-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server + +# Final image +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +COPY --from=builder /app/server . +COPY .env.example .env + +EXPOSE 8080 + +CMD ["./server"] diff --git a/README.md b/README.md index 106a447..6ef106d 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,81 @@ make clean # Remove build artifacts --- +## CI/CD + +GitHub Actions workflow (`.github/workflows/ci.yml`) runs on every push and PR: + +**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 + +**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 +``` + +**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) +```bash +make db-up # Start postgres + redis only +make dev # Run Go server locally with live reload +``` + +### Production (containerized) +```bash +# Build image +docker build -t intent-backend:v1 . + +# Run orchestrated stack +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 +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) + +--- + ## Development Workflow ### Adding new API endpoints @@ -282,14 +357,41 @@ func (h *Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { } ``` -### Broadcasting real-time updates +### 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) -// Frontend receives via WebSocket: -// { "type": "proposal_received", "payload": {...proposal...} } +// When a winner is selected +h.hub.Broadcast(intentID, "winner_selected", winnerData) + +// When execution settles +h.hub.Broadcast(intentID, "execution_settled", executionData) + +// Frontend receives: +// { "type": "proposal_received", "payload": {...} } ``` --- @@ -302,7 +404,7 @@ Issue tracking for planned backend work: |-------|-------------|--------| | #4 | Server setup (HTTP + WebSocket) | ✅ Done | | #2 | Database tables (models + migrations) | ✅ Done | -| #3 | API layer (all REST endpoints) | 🔄 In progress | +| #3 | API layer (all REST endpoints) | ✅ Done | | #5 | Redis pub/sub integration | 🔄 Planned | | #6 | Scoring engine | 🔄 Planned | | #7 | Execution validation service | 🔄 Planned | diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2645db5 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,50 @@ +services: + backend: + build: . + container_name: intent-backend + ports: + - "8080:8080" + environment: + DATABASE_URL: postgres://intent:intent_prod_password@postgres:5432/intent?sslmode=disable + REDIS_URL: redis://redis:6379 + PORT: 8080 + ENV: production + CIRCLE_API_KEY: ${CIRCLE_API_KEY:-} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + restart: unless-stopped + + postgres: + image: postgres:16-alpine + container_name: intent-postgres-prod + environment: + POSTGRES_USER: intent + POSTGRES_PASSWORD: intent_prod_password + POSTGRES_DB: intent + volumes: + - postgres_prod_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U intent"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: intent-redis-prod + volumes: + - redis_prod_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + postgres_prod_data: + redis_prod_data: diff --git a/internal/handlers/agents.go b/internal/handlers/agents.go new file mode 100644 index 0000000..ed7a364 --- /dev/null +++ b/internal/handlers/agents.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/Cookit-labs/Backend/internal/models" +) + +func (h *Handler) GetLeaderboard(w http.ResponseWriter, r *http.Request) { + var agents []models.Agent + if err := h.db.Preload("Reputation").Find(&agents).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch agents") + return + } + + entries := make([]LeaderboardEntry, 0) + for _, agent := range agents { + if agent.Reputation == nil { + continue + } + + entry := LeaderboardEntry{ + AgentID: agent.ID, + Name: agent.Name, + StrategyType: agent.StrategyType, + TotalIntentsWon: agent.TotalIntentsWon, + WinRate: agent.Reputation.WinRate, + AvgSlippageDelivered: agent.Reputation.AvgSlippageDelivered, + CompositeScore: agent.Reputation.CompositeScore, + } + entries = append(entries, entry) + } + + respondJSON(w, http.StatusOK, LeaderboardResponse{ + Agents: entries, + Total: int64(len(entries)), + }) +} + +func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + + var agent models.Agent + if err := h.db.Preload("Reputation").Preload("Proposals").First(&agent, "id = ?", agentID).Error; err != nil { + respondError(w, http.StatusNotFound, "agent not found") + return + } + + // Count total executions for this agent + var totalExecutions int64 + h.db.Model(&models.Execution{}).Where("winning_agent_id = ?", agentID).Count(&totalExecutions) + + rep := agent.Reputation + if rep == nil { + rep = &models.AgentReputation{} + } + + respondJSON(w, http.StatusOK, map[string]interface{}{ + "id": agent.ID, + "name": agent.Name, + "strategy_type": agent.StrategyType, + "description": agent.Description, + "is_active": agent.IsActive, + "wallet_address": agent.WalletAddress, + "total_intents_won": agent.TotalIntentsWon, + "total_capital_usdc": agent.TotalCapitalUsdc, + "total_executions": totalExecutions, + "win_rate": rep.WinRate, + "avg_slippage": rep.AvgSlippageDelivered, + "composite_score": rep.CompositeScore, + "on_chain_score": rep.OnChainReputationScore, + "consecutive_success": rep.ConsecutiveSuccesses, + "created_at": agent.CreatedAt, + }) +} diff --git a/internal/handlers/executions.go b/internal/handlers/executions.go new file mode 100644 index 0000000..b2aa140 --- /dev/null +++ b/internal/handlers/executions.go @@ -0,0 +1,174 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/Cookit-labs/Backend/internal/models" +) + +func (h *Handler) SelectWinner(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + // Fetch all proposals for this intent + var proposals []models.Proposal + if err := h.db.Where("intent_id = ?", intentID).Find(&proposals).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch proposals") + return + } + + if len(proposals) == 0 { + respondError(w, http.StatusBadRequest, "no proposals to select from") + return + } + + // 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 + } + } + + // 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, + }).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to update intent") + return + } + + // Broadcast winner selection + h.hub.Broadcast(intentID, "winner_selected", map[string]any{ + "proposal_id": winner.ID, + "agent_id": winner.AgentID, + "score": winner.Score, + }) + + respondJSON(w, http.StatusOK, map[string]any{ + "winner_id": winner.ID, + "agent_id": winner.AgentID, + "score": winner.Score, + }) +} + +func (h *Handler) ValidateExecution(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var req ValidateExecutionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + // Fetch intent and proposal + var intent models.Intent + if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + var proposal models.Proposal + if err := h.db.First(&proposal, "id = ?", req.ProposalID).Error; err != nil { + respondError(w, http.StatusNotFound, "proposal not found") + return + } + + // Validation checks (simplified for now) + // TODO: Full validation service (Issue #7) + validationErrors := []string{} + + if time.Now().After(intent.Deadline) { + validationErrors = append(validationErrors, "intent deadline has passed") + } + + if proposal.ProjectedSlippage > intent.MaxSlippage { + validationErrors = append(validationErrors, "projected slippage exceeds max tolerance") + } + + if len(validationErrors) > 0 { + respondJSON(w, http.StatusBadRequest, map[string]any{ + "valid": false, + "errors": validationErrors, + }) + return + } + + respondJSON(w, http.StatusOK, map[string]any{ + "valid": true, + "message": "execution validation passed", + }) +} + +func (h *Handler) SettleExecution(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var req SettleExecutionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + // Fetch intent and proposal + var intent models.Intent + if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + var proposal models.Proposal + if err := h.db.First(&proposal, "id = ?", req.ProposalID).Error; err != nil { + respondError(w, http.StatusNotFound, "proposal not found") + return + } + + // Create execution record + now := time.Now() + execution := &models.Execution{ + ID: uuid.New().String(), + IntentID: intentID, + WinningAgentID: proposal.AgentID, + ProposalID: req.ProposalID, + ActualSlippage: req.ActualSlippage, + TxHash: req.TxHash, + SettlementStatus: "confirmed", + ExecutionFeeUSDC: req.ExecutionFeeUSDC, + ExecutedAt: now, + SettledAt: &now, + } + + if err := h.db.Create(execution).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create execution") + return + } + + // Update intent status + if err := h.db.Model(&models.Intent{}).Where("id = ?", intentID).Update("status", "settled").Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to update intent") + return + } + + // Update agent stats + // TODO: Update reputation (Issue #6, #8) + + // Broadcast settlement + h.hub.Broadcast(intentID, "execution_settled", map[string]any{ + "execution_id": execution.ID, + "agent_id": execution.WinningAgentID, + "slippage": execution.ActualSlippage, + "tx_hash": execution.TxHash, + }) + + respondJSON(w, http.StatusOK, map[string]any{ + "execution_id": execution.ID, + "status": execution.SettlementStatus, + "actual_slippage": execution.ActualSlippage, + "tx_hash": execution.TxHash, + }) +} diff --git a/internal/handlers/handler.go b/internal/handlers/handler.go new file mode 100644 index 0000000..f0e25c3 --- /dev/null +++ b/internal/handlers/handler.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/Cookit-labs/Backend/internal/db" + "github.com/Cookit-labs/Backend/internal/ws" +) + +type Handler struct { + db *db.DB + hub *ws.Hub + // pubsub *ws.PubSub +} + +// redisPubSub *ws.PubSub +func New(database *db.DB, wsHub *ws.Hub) *Handler { + return &Handler{ + db: database, + hub: wsHub, + // pubsub: redisPubSub, + } +} + +func (h *Handler) Mount(r chi.Router) { + r.Post("/intents", h.CreateIntent) + r.Get("/intents/{intentID}", h.GetIntent) + r.Post("/intents/{intentID}/proposals", h.CreateProposal) + r.Get("/intents/{intentID}/proposals", h.ListProposals) + r.Post("/intents/{intentID}/select", h.SelectWinner) + r.Post("/intents/{intentID}/validate", h.ValidateExecution) + r.Post("/intents/{intentID}/settle", h.SettleExecution) + r.Get("/leaderboard", h.GetLeaderboard) + r.Get("/agents/{agentID}", h.GetAgent) +} + +// Utility + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(data); err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + } +} + +func respondError(w http.ResponseWriter, status int, message string) { + respondJSON(w, status, ErrorResponse{ + Error: message, + Status: status, + }) +} diff --git a/internal/handlers/intents.go b/internal/handlers/intents.go new file mode 100644 index 0000000..4c22f48 --- /dev/null +++ b/internal/handlers/intents.go @@ -0,0 +1,79 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/Cookit-labs/Backend/internal/models" +) + +func (h *Handler) CreateIntent(w http.ResponseWriter, r *http.Request) { + var req CreateIntentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + intent := &models.Intent{ + ID: uuid.New().String(), + UserWallet: req.UserWallet, + TokenIn: req.TokenIn, + TokenOut: req.TokenOut, + AmountIn: req.AmountIn, + MaxSlippage: req.MaxSlippage, + Deadline: req.Deadline, + Status: "pending", + Metadata: req.Metadata, + } + + if err := h.db.Create(intent).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create intent") + return + } + + // Broadcast to WebSocket subscribers + h.hub.Broadcast(intent.ID, "intent_created", map[string]string{ + "intent_id": intent.ID, + "status": intent.Status, + }) + + respondJSON(w, http.StatusCreated, IntentResponse{ + ID: intent.ID, + UserWallet: intent.UserWallet, + TokenIn: intent.TokenIn, + TokenOut: intent.TokenOut, + AmountIn: intent.AmountIn, + MaxSlippage: intent.MaxSlippage, + Deadline: intent.Deadline, + Status: intent.Status, + CreatedAt: intent.CreatedAt, + UpdatedAt: intent.UpdatedAt, + }) +} + +func (h *Handler) GetIntent(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var intent models.Intent + if err := h.db.Preload("Proposals").Preload("Execution").First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + respondJSON(w, http.StatusOK, IntentResponse{ + ID: intent.ID, + UserWallet: intent.UserWallet, + TokenIn: intent.TokenIn, + TokenOut: intent.TokenOut, + AmountIn: intent.AmountIn, + MaxSlippage: intent.MaxSlippage, + Deadline: intent.Deadline, + Status: intent.Status, + SelectedAgentID: intent.SelectedAgentID, + CreatedAt: intent.CreatedAt, + UpdatedAt: intent.UpdatedAt, + }) +} diff --git a/internal/handlers/proposals.go b/internal/handlers/proposals.go new file mode 100644 index 0000000..19399a7 --- /dev/null +++ b/internal/handlers/proposals.go @@ -0,0 +1,99 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/Cookit-labs/Backend/internal/models" +) + +func (h *Handler) CreateProposal(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + // Verify intent exists + var intent models.Intent + if err := h.db.First(&intent, "id = ?", intentID).Error; err != nil { + respondError(w, http.StatusNotFound, "intent not found") + return + } + + var req CreateProposalRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "invalid request body") + return + } + + // Verify agent exists + var agent models.Agent + if err := h.db.First(&agent, "id = ?", req.AgentID).Error; err != nil { + respondError(w, http.StatusNotFound, "agent not found") + return + } + + proposal := &models.Proposal{ + ID: uuid.New().String(), + IntentID: intentID, + AgentID: req.AgentID, + StrategyType: req.StrategyType, + ProjectedSlippage: req.ProjectedSlippage, + ProjectedExecutionQuality: req.ProjectedExecutionQuality, + ProposedAmount: req.ProposedAmount, + ExecutionPath: req.ExecutionPath, + } + + if err := h.db.Create(proposal).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to create proposal") + return + } + + // Broadcast to WebSocket subscribers + h.hub.Broadcast(intentID, "proposal_received", map[string]any{ + "proposal_id": proposal.ID, + "agent_id": proposal.AgentID, + "strategy": proposal.StrategyType, + "slippage": proposal.ProjectedSlippage, + "quality": proposal.ProjectedExecutionQuality, + }) + + respondJSON(w, http.StatusCreated, ProposalResponse{ + ID: proposal.ID, + IntentID: proposal.IntentID, + AgentID: proposal.AgentID, + StrategyType: proposal.StrategyType, + ProjectedSlippage: proposal.ProjectedSlippage, + ProjectedExecutionQuality: proposal.ProjectedExecutionQuality, + CreatedAt: proposal.CreatedAt, + }) +} + +func (h *Handler) ListProposals(w http.ResponseWriter, r *http.Request) { + intentID := chi.URLParam(r, "intentID") + + var proposals []models.Proposal + if err := h.db.Where("intent_id = ?", intentID).Find(&proposals).Error; err != nil { + respondError(w, http.StatusInternalServerError, "failed to fetch proposals") + return + } + + responses := make([]ProposalResponse, len(proposals)) + for i, p := range proposals { + responses[i] = ProposalResponse{ + ID: p.ID, + IntentID: p.IntentID, + AgentID: p.AgentID, + StrategyType: p.StrategyType, + ProjectedSlippage: p.ProjectedSlippage, + ProjectedExecutionQuality: p.ProjectedExecutionQuality, + Score: p.Score, + CreatedAt: p.CreatedAt, + } + } + + respondJSON(w, http.StatusOK, map[string]any{ + "proposals": responses, + "total": len(proposals), + }) +} diff --git a/internal/handlers/types.go b/internal/handlers/types.go new file mode 100644 index 0000000..e7b13b4 --- /dev/null +++ b/internal/handlers/types.go @@ -0,0 +1,88 @@ +package handlers + +import "time" + +// Intent requests/responses + +type CreateIntentRequest struct { + UserWallet string `json:"user_wallet" validate:"required"` + TokenIn string `json:"token_in" validate:"required"` + TokenOut string `json:"token_out" validate:"required"` + AmountIn string `json:"amount_in" validate:"required"` + MaxSlippage float64 `json:"max_slippage" validate:"required,gt=0"` + Deadline time.Time `json:"deadline" validate:"required"` + Metadata []byte `json:"metadata,omitempty"` +} + +type IntentResponse struct { + ID string `json:"id"` + UserWallet string `json:"user_wallet"` + TokenIn string `json:"token_in"` + TokenOut string `json:"token_out"` + AmountIn string `json:"amount_in"` + MaxSlippage float64 `json:"max_slippage"` + Deadline time.Time `json:"deadline"` + Status string `json:"status"` + SelectedAgentID string `json:"selected_agent_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Proposal requests/responses + +type CreateProposalRequest struct { + AgentID string `json:"agent_id" validate:"required"` + StrategyType string `json:"strategy_type" validate:"required"` + ProjectedSlippage float64 `json:"projected_slippage" validate:"required,gte=0"` + ProjectedExecutionQuality float64 `json:"projected_execution_quality" validate:"required,gte=0,lte=1"` + ProposedAmount string `json:"proposed_amount" validate:"required"` + ExecutionPath string `json:"execution_path" validate:"required"` +} + +type ProposalResponse struct { + ID string `json:"id"` + IntentID string `json:"intent_id"` + AgentID string `json:"agent_id"` + StrategyType string `json:"strategy_type"` + ProjectedSlippage float64 `json:"projected_slippage"` + ProjectedExecutionQuality float64 `json:"projected_execution_quality"` + Score float64 `json:"score"` + CreatedAt time.Time `json:"created_at"` +} + +// Execution/Settlement + +type ValidateExecutionRequest struct { + ProposalID string `json:"proposal_id" validate:"required"` +} + +type SettleExecutionRequest struct { + ProposalID string `json:"proposal_id" validate:"required"` + ActualSlippage float64 `json:"actual_slippage" validate:"required,gte=0"` + TxHash string `json:"tx_hash" validate:"required"` + ExecutionFeeUSDC string `json:"execution_fee_usdc" validate:"required"` +} + +// Leaderboard + +type LeaderboardEntry struct { + AgentID string `json:"agent_id"` + Name string `json:"name"` + StrategyType string `json:"strategy_type"` + TotalIntentsWon int64 `json:"total_intents_won"` + WinRate float64 `json:"win_rate"` + AvgSlippageDelivered float64 `json:"avg_slippage_delivered"` + CompositeScore float64 `json:"composite_score"` +} + +type LeaderboardResponse struct { + Agents []LeaderboardEntry `json:"agents"` + Total int64 `json:"total"` +} + +// Error response + +type ErrorResponse struct { + Error string `json:"error"` + Status int `json:"status"` +} diff --git a/internal/server/server.go b/internal/server/server.go index 4da1915..85b20b9 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "github.com/Cookit-labs/Backend/internal/config" "github.com/Cookit-labs/Backend/internal/db" + "github.com/Cookit-labs/Backend/internal/handlers" "github.com/Cookit-labs/Backend/internal/ws" ) @@ -57,9 +58,10 @@ func (s *Server) buildRouter() *chi.Mux { r.Get("/health", s.handleHealth) r.Get("/ws/intents/{intentID}", s.handleWS) - // API routes registered here by handler packages + // API routes + h := handlers.New(s.db, s.hub) r.Route("/api/v1", func(r chi.Router) { - // handlers will be mounted here in subsequent issues + h.Mount(r) }) return r diff --git a/tmp/server b/tmp/server index 2e25044..01f6c03 100755 Binary files a/tmp/server and b/tmp/server differ