From 4b5abb0d449bb43071158f19dd5006a778f27bc3 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Thu, 16 Jul 2026 22:07:09 +0530 Subject: [PATCH 01/16] feat: enforce request-bound payment authorization v2 Co-authored-by: codex --- .env.example | 7 +- .env.production.example | 4 +- .github/workflows/e2e.yml | 21 +--- DEPLOY.md | 3 +- README.md | 8 +- deploy/fly/gateway.fly.toml | 1 + deploy/fly/verifier.fly.toml | 1 + docker-compose.yml | 2 + gateway/README.md | 9 +- gateway/cache.go | 2 +- gateway/cache_integration_test.go | 3 +- gateway/config.go | 28 +++++ gateway/config_test.go | 40 +++++- gateway/errors.go | 11 +- gateway/errors_test.go | 40 +++++- gateway/main.go | 96 +++++++++------ gateway/metrics.go | 7 ++ gateway/metrics_test.go | 13 ++ gateway/openapi.yaml | 68 ++++++++++- gateway/payment_flow.go | 52 +++++++- gateway/payment_flow_test.go | 115 ++++++++++++++++-- gateway/receipt.go | 24 +++- gateway/receipt_store_integration_test.go | 3 +- gateway/receipt_test.go | 39 ++++++ gateway/request_binding.go | 50 ++++++++ gateway/timeout_test.go | 3 +- run_e2e.sh | 11 +- sdk/typescript/README.md | 4 +- .../src/__tests__/client-flow.test.ts | 59 ++++++++- sdk/typescript/src/__tests__/receipts.test.ts | 47 +++++++ sdk/typescript/src/protocol/microai.ts | 12 +- sdk/typescript/src/protocol/types.ts | 6 + sdk/typescript/src/receipts.ts | 23 +++- tests/README.md | 12 +- tests/e2e.test.ts | 76 +++++++++--- tests/mock-openrouter.ts | 21 ++++ verifier/README.md | 15 ++- verifier/src/main.rs | 113 +++++++++++++++-- web/src/app/docs/protocol/page.mdx | 24 +++- web/src/lib/verify-receipt.ts | 12 +- web/src/lib/x402-client.test.ts | 8 ++ web/src/lib/x402-client.ts | 5 - 42 files changed, 929 insertions(+), 169 deletions(-) create mode 100644 gateway/request_binding.go create mode 100644 tests/mock-openrouter.ts diff --git a/.env.example b/.env.example index 13f14d09..d6707671 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,8 @@ RECEIPT_TTL=86400 # Signature expiry configuration # Chain ID enforced by the verifier (must match CHAIN_ID) EXPECTED_CHAIN_ID=84532 +# Reject legacy v1 authorization by default. Set to 1 only for an explicit rollback window. +MIN_AUTHORIZATION_VERSION=2 # Signature expiry window in seconds (default: 300 = 5 minutes) SIGNATURE_EXPIRY_SECONDS=300 @@ -54,10 +56,9 @@ VERIFIER_REDIS_TIMEOUT_MS=2000 # Service URLs (for Docker/production) VERIFIER_URL=http://127.0.0.1:3002 -# Public gateway origin reserved for the request-bound authorization v2 cutover. +# Required trusted public gateway origin used in request-bound authorization. # Set only the origin (scheme + host + optional port), with no path/query/fragment. -# The current v1 gateway does not read this value yet. -# PAYGATE_AUDIENCE=http://localhost:3000 +PAYGATE_AUDIENCE=http://localhost:3000 # Maximum allowed request body size in bytes for verifier # Default: 1048576 (1MB) diff --git a/.env.production.example b/.env.production.example index ef477dc2..90c74f6b 100644 --- a/.env.production.example +++ b/.env.production.example @@ -12,8 +12,7 @@ SERVER_WALLET_PRIVATE_KEY= # The gateway normalizes this on startup, but storing it canonically up # front avoids the warning log on every boot. RECIPIENT_ADDRESS= -# Public gateway origin for request-bound authorization v2. Configure this now; -# it becomes enforced when the gateway v2 cutover is deployed. +# Required trusted public gateway origin for request-bound authorization v2. PAYGATE_AUDIENCE=https://.onrender.com PAYMENT_AMOUNT=0.001 @@ -39,6 +38,7 @@ CHAIN_ID=84532 EXPECTED_CHAIN_ID=84532 SIGNATURE_EXPIRY_SECONDS=300 SIGNATURE_CLOCK_SKEW_SECONDS=60 +MIN_AUTHORIZATION_VERSION=2 RECEIPT_STORE=redis CACHE_ENABLED=false REQUEST_TIMEOUT_SECONDS=60 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ceadfbde..bc181c6f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -39,49 +39,38 @@ jobs: e2e: runs-on: ubuntu-latest env: - OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OPENROUTER_API_KEY: "" OPENROUTER_MODEL: ${{ vars.OPENROUTER_MODEL || 'z-ai/glm-4.5-air:free' }} RECEIPT_STORE: memory CACHE_ENABLED: "false" + PAYGATE_AUDIENCE: http://localhost:3000 + MIN_AUTHORIZATION_VERSION: "2" steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - name: Check OpenRouter key - run: | - if [ -z "$OPENROUTER_API_KEY" ]; then - echo "OPENROUTER_API_KEY not set; skipping E2E." - exit 0 - fi - - name: Set up Go - if: env.OPENROUTER_API_KEY != '' uses: actions/setup-go@v6 with: go-version: '1.24.x' - name: Set up Rust - if: env.OPENROUTER_API_KEY != '' uses: dtolnay/rust-toolchain@stable - name: Set up Bun - if: env.OPENROUTER_API_KEY != '' uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.13' - name: Install JS deps - if: env.OPENROUTER_API_KEY != '' - run: bun install + run: bun install --frozen-lockfile - name: Prep Go/Rust deps - if: env.OPENROUTER_API_KEY != '' run: | go mod download cargo fetch - - name: Run E2E (requires API key) - if: env.OPENROUTER_API_KEY != '' + - name: Run deterministic E2E run: | chmod +x ./run_e2e.sh timeout 150 ./run_e2e.sh diff --git a/DEPLOY.md b/DEPLOY.md index 2133c524..26e2e9b2 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -76,6 +76,7 @@ The verifier is stateless EIP-712 signature recovery. Public on Render's free ti - `VERIFIER_NONCE_STORE=redis` - `VERIFIER_NONCE_KEY_PREFIX=microai:verifier:nonce:` - `VERIFIER_REDIS_TIMEOUT_MS=2000` + - `MIN_AUTHORIZATION_VERSION=2` 4. Click **Deploy Web Service**. First Rust build takes ~3–5 min. 5. Copy the assigned public URL — e.g. `https://microai-verifier.onrender.com`. The gateway needs this URL in the next step. @@ -114,8 +115,8 @@ RECIPIENT_ADDRESS= REDIS_URL= RECEIPT_STORE=redis VERIFIER_URL=https://.onrender.com +PAYGATE_AUDIENCE=https://.onrender.com CHAIN_ID=84532 -EXPECTED_CHAIN_ID=84532 PAYMENT_AMOUNT=0.001 ALLOWED_ORIGINS=* TRUSTED_PROXIES=0.0.0.0/0 diff --git a/README.md b/README.md index 7da7c9a4..279c3a53 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,7 @@ sequenceDiagram G-->>C: 200 result + X-402-Receipt ``` -The signed context binds the recipient, token, amount, nonce, timestamp, and chain ID. The verifier rejects malformed signatures, wrong chains, stale or future timestamps, and replayed nonces before the gateway calls the AI provider. - -Request-bound authorization v2 is being rolled out in stages. The verifier, browser, and TypeScript SDK can already validate the v2 audience, method, encoded resource, content type, exact serialized body hash, and claimed payer; the public gateway continues issuing v1 contexts until the cutover is deployed. +Authorization v2 binds the payer and payment fields to the configured gateway audience, HTTP method, encoded path and raw query, content type, and SHA-256 hash of the exact request bytes. The verifier rejects malformed signatures, wrong chains, stale or future timestamps, replayed nonces, mismatched request bindings, and legacy authorization before the gateway calls the AI provider. ## Architecture @@ -181,6 +179,7 @@ Signed retries include: X-402-Signature: X-402-Nonce: X-402-Timestamp: +X-402-Payer: ``` Successful responses return the signed receipt as base64-encoded JSON in `X-402-Receipt`. See [`gateway/openapi.yaml`](gateway/openapi.yaml) for the complete contract. @@ -231,7 +230,8 @@ The full local template is [`.env.example`](.env.example); production placeholde | `RECIPIENT_ADDRESS`, `PAYMENT_AMOUNT` | Values embedded in payment contexts. | | `CHAIN_ID`, `EXPECTED_CHAIN_ID` | Gateway and verifier chain IDs; these must match. | | `VERIFIER_URL` | Verifier base URL used by the gateway; required at startup. | -| `PAYGATE_AUDIENCE` | Public gateway origin reserved for request-bound authorization v2; configure it before the gateway cutover. | +| `PAYGATE_AUDIENCE` | Required trusted public gateway origin used by request-bound authorization. Never derived from forwarded host headers. | +| `MIN_AUTHORIZATION_VERSION` | Verifier minimum accepted authorization version; defaults to `2`. Set `1` only for an explicit rollback window. | | `VERIFIER_NONCE_STORE` | `memory` locally or `redis` for shared replay protection. | | `RECEIPT_STORE` | `memory` locally or `redis` for restart-safe receipts. | | `REDIS_URL` | Required by Redis nonce, receipt, or response-cache modes. | diff --git a/deploy/fly/gateway.fly.toml b/deploy/fly/gateway.fly.toml index e62e71bd..bf32ce98 100644 --- a/deploy/fly/gateway.fly.toml +++ b/deploy/fly/gateway.fly.toml @@ -9,6 +9,7 @@ primary_region = "bom" [env] PORT = "3000" VERIFIER_URL = "http://.internal:3002" + PAYGATE_AUDIENCE = "https://.fly.dev" OPENROUTER_MODEL = "z-ai/glm-4.5-air:free" PAYMENT_AMOUNT = "0.001" CHAIN_ID = "84532" diff --git a/deploy/fly/verifier.fly.toml b/deploy/fly/verifier.fly.toml index 6a534bfd..cabebc04 100644 --- a/deploy/fly/verifier.fly.toml +++ b/deploy/fly/verifier.fly.toml @@ -8,6 +8,7 @@ primary_region = "bom" [env] EXPECTED_CHAIN_ID = "84532" + MIN_AUTHORIZATION_VERSION = "2" SIGNATURE_EXPIRY_SECONDS = "300" SIGNATURE_CLOCK_SKEW_SECONDS = "60" VERIFIER_NONCE_STORE = "redis" diff --git a/docker-compose.yml b/docker-compose.yml index f48cbeb7..67adef1a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: environment: - PORT=3000 - VERIFIER_URL=http://verifier:3002 + - PAYGATE_AUDIENCE=${PAYGATE_AUDIENCE:-http://localhost:3000} - REDIS_URL=redis:6379 - CACHE_ENABLED=${CACHE_ENABLED:-false} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-http://localhost:3001} @@ -50,6 +51,7 @@ services: - VERIFIER_NONCE_STORE=${VERIFIER_NONCE_STORE:-redis} - VERIFIER_NONCE_KEY_PREFIX=${VERIFIER_NONCE_KEY_PREFIX:-microai:verifier:nonce:} - VERIFIER_REDIS_TIMEOUT_MS=${VERIFIER_REDIS_TIMEOUT_MS:-2000} + - MIN_AUTHORIZATION_VERSION=${MIN_AUTHORIZATION_VERSION:-2} - REDIS_URL=${REDIS_URL:-redis:6379} - REDIS_PASSWORD - REDIS_DB diff --git a/gateway/README.md b/gateway/README.md index 9a07faa8..729a2845 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -5,7 +5,7 @@ The gateway is the public API entry point for MicroAI Paygate. It is a Go/Gin se ## Responsibilities - Return `402 Payment Required` with a complete payment context when summarize requests are unsigned. -- Require signed retries to include `X-402-Signature`, `X-402-Nonce`, and `X-402-Timestamp`. +- Require signed retries to include `X-402-Signature`, `X-402-Nonce`, `X-402-Timestamp`, and `X-402-Payer`. - Reconstruct the payment context and call the Rust verifier at `POST /verify`. - Map verifier business failures to sanitized public gateway errors. - Call the configured AI provider only after payment verification succeeds. @@ -25,7 +25,7 @@ sequenceDiagram C->>G: POST /api/ai/summarize G-->>C: 402 + paymentContext(recipient, token, amount, chainId, nonce, timestamp) - C->>G: Retry with X-402-Signature, X-402-Nonce, X-402-Timestamp + C->>G: Retry with X-402-Signature, nonce, timestamp, payer G->>V: POST /verify with reconstructed context V-->>G: valid + recovered wallet or structured error_code alt Optional Redis response cache hit @@ -94,7 +94,7 @@ Common optional variables: | `OLLAMA_URL` | `http://localhost:11434` | Used when `AI_PROVIDER=ollama`. | | `OLLAMA_MODEL` | `llama2` provider default | Used when `AI_PROVIDER=ollama`. | | `VERIFIER_URL` | **required** (no fallback) | Where the gateway calls `/verify`. Use `http://127.0.0.1:3002` for `bun run stack`, `http://verifier:3002` in Compose, the platform's HTTPS URL in production. The gateway refuses to start if unset. | -| `PAYGATE_AUDIENCE` | unset | Public gateway origin reserved for request-bound authorization v2. Configure the production origin before the gateway cutover; the current v1 gateway does not read it. | +| `PAYGATE_AUDIENCE` | **required** | Trusted public gateway origin included in every v2 authorization. Origins only; paths, queries, fragments, credentials, and non-HTTP schemes are rejected at startup. | | `RECIPIENT_ADDRESS` | Development fallback address | Recipient embedded in payment contexts. | | `PAYMENT_AMOUNT` | `0.001` | Amount string embedded in payment contexts. | | `CHAIN_ID` | `84532` | Base Sepolia by default. Must match verifier `EXPECTED_CHAIN_ID`. | @@ -165,6 +165,9 @@ The gateway exposes sanitized public errors and logs internal details with a `co - `invalid_timestamp` - `chain_id_mismatch` - `nonce_already_used` +- `invalid_authorization_context` +- `authorization_version_too_old` +- `signer_mismatch` - `verification_unavailable` - `verifier_timeout` - `upstream_unavailable` diff --git a/gateway/cache.go b/gateway/cache.go index aeb44ed3..cd62b307 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -122,7 +122,7 @@ func CacheMiddleware() gin.HandlerFunc { } cacheHits.WithLabelValues(routePath).Inc() - payment, ok := verifyPaidRequest(c) + payment, ok := verifyPaidRequest(c, requestBody) if !ok { c.Abort() return diff --git a/gateway/cache_integration_test.go b/gateway/cache_integration_test.go index a0bf11a4..087ace86 100644 --- a/gateway/cache_integration_test.go +++ b/gateway/cache_integration_test.go @@ -39,7 +39,7 @@ func TestCacheIntegration_FullFlow(t *testing.T) { isValid := req.Signature == "0xValidSig" resp := VerifyResponse{ IsValid: isValid, - RecoveredAddress: "0xTestUser", + RecoveredAddress: "0x14791697260e4c9a71f18484c9f997b308e59325", Error: "", } if !isValid { @@ -116,6 +116,7 @@ func TestCacheIntegration_FullFlow(t *testing.T) { } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-402-Signature", sig) + req.Header.Set("X-402-Payer", "0x14791697260E4c9A71f18484C9f997B308e59325") req.Header.Set("X-402-Nonce", "nonce-123") req.Header.Set("X-402-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) diff --git a/gateway/config.go b/gateway/config.go index 9ff0ebee..56a45ee7 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -55,6 +55,34 @@ func isValidAllowedOrigin(origin string) bool { return parsed.Path == "" && parsed.RawQuery == "" && parsed.Fragment == "" } +func normalizePaygateAudience() error { + raw := strings.TrimSpace(os.Getenv("PAYGATE_AUDIENCE")) + if raw == "" { + return fmt.Errorf("PAYGATE_AUDIENCE is required") + } + + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return fmt.Errorf("PAYGATE_AUDIENCE must be an absolute origin") + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("PAYGATE_AUDIENCE scheme must be http or https") + } + if parsed.User != nil { + return fmt.Errorf("PAYGATE_AUDIENCE must not contain credentials") + } + if (parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("PAYGATE_AUDIENCE must contain an origin only") + } + + canonical := parsed.Scheme + "://" + strings.ToLower(parsed.Host) + if err := os.Setenv("PAYGATE_AUDIENCE", canonical); err != nil { + return fmt.Errorf("failed to normalize PAYGATE_AUDIENCE: %w", err) + } + return nil +} + func getReceiptStoreMode() string { mode := strings.ToLower(strings.TrimSpace(os.Getenv("RECEIPT_STORE"))) if mode == "" { diff --git a/gateway/config_test.go b/gateway/config_test.go index 0023a357..f4cf1c95 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -5,12 +5,15 @@ import ( "strings" "testing" "time" + + "github.com/stretchr/testify/require" ) func TestValidateConfig_MissingRequiredEnv(t *testing.T) { t.Setenv("OPENROUTER_API_KEY", "") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "") t.Setenv("VERIFIER_URL", "") + t.Setenv("PAYGATE_AUDIENCE", "") t.Setenv("CACHE_ENABLED", "false") t.Setenv("RECEIPT_STORE", "memory") @@ -19,7 +22,7 @@ func TestValidateConfig_MissingRequiredEnv(t *testing.T) { t.Fatalf("expected error when required env vars are missing, got nil") } - expectedVars := []string{"OPENROUTER_API_KEY", "SERVER_WALLET_PRIVATE_KEY", "VERIFIER_URL"} + expectedVars := []string{"OPENROUTER_API_KEY", "SERVER_WALLET_PRIVATE_KEY", "VERIFIER_URL", "PAYGATE_AUDIENCE"} errStr := err.Error() for _, v := range expectedVars { if !strings.Contains(errStr, v) { @@ -28,6 +31,35 @@ func TestValidateConfig_MissingRequiredEnv(t *testing.T) { } } +func TestNormalizePaygateAudience(t *testing.T) { + tests := []struct { + name string + value string + want string + wantErr string + }{ + {name: "canonical origin", value: "https://gateway.example.com", want: "https://gateway.example.com"}, + {name: "normalizes case and trailing slash", value: " HTTPS://Gateway.Example.COM/ ", want: "https://gateway.example.com"}, + {name: "rejects path", value: "https://gateway.example.com/api", wantErr: "origin only"}, + {name: "rejects query", value: "https://gateway.example.com?tenant=a", wantErr: "origin only"}, + {name: "rejects credentials", value: "https://user@gateway.example.com", wantErr: "credentials"}, + {name: "rejects unsupported scheme", value: "javascript://gateway.example.com", wantErr: "http or https"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Setenv("PAYGATE_AUDIENCE", test.value) + err := normalizePaygateAudience() + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, test.want, os.Getenv("PAYGATE_AUDIENCE")) + }) + } +} + // TestNormalizeRecipientAddress covers the three branches of the new // startup-time address normalization: empty (no-op), valid hex with // non-canonical case (rewrites to EIP-55), and invalid hex (returns error). @@ -68,6 +100,7 @@ func TestValidateConfig_WithRequiredEnv(t *testing.T) { t.Setenv("OPENROUTER_API_KEY", "test-key") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("PAYGATE_AUDIENCE", "https://gateway.example.com") t.Setenv("CACHE_ENABLED", "false") t.Setenv("RECEIPT_STORE", "memory") t.Setenv("RECIPIENT_ADDRESS", "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219") @@ -82,6 +115,7 @@ func TestValidateConfig_CacheEnabledRequiresRedis(t *testing.T) { t.Setenv("OPENROUTER_API_KEY", "test-key") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("PAYGATE_AUDIENCE", "https://gateway.example.com") t.Setenv("CACHE_ENABLED", "true") t.Setenv("RECEIPT_STORE", "memory") t.Setenv("REDIS_URL", "") @@ -101,6 +135,7 @@ func TestValidateConfig_CacheEnabledWithValidRedis(t *testing.T) { t.Setenv("OPENROUTER_API_KEY", "test-key") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("PAYGATE_AUDIENCE", "https://gateway.example.com") t.Setenv("CACHE_ENABLED", "true") t.Setenv("RECEIPT_STORE", "memory") t.Setenv("REDIS_URL", "localhost:6379") @@ -116,6 +151,7 @@ func TestValidateConfig_DefaultReceiptStoreRequiresRedis(t *testing.T) { t.Setenv("OPENROUTER_API_KEY", "test-key") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("PAYGATE_AUDIENCE", "https://gateway.example.com") t.Setenv("CACHE_ENABLED", "false") t.Setenv("RECEIPT_STORE", "") t.Setenv("REDIS_URL", "") @@ -134,6 +170,7 @@ func TestValidateConfig_MemoryReceiptStoreDoesNotRequireRedis(t *testing.T) { t.Setenv("OPENROUTER_API_KEY", "test-key") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("PAYGATE_AUDIENCE", "https://gateway.example.com") t.Setenv("CACHE_ENABLED", "false") t.Setenv("RECEIPT_STORE", "memory") t.Setenv("REDIS_URL", "") @@ -149,6 +186,7 @@ func TestValidateConfig_InvalidReceiptStoreMode(t *testing.T) { t.Setenv("OPENROUTER_API_KEY", "test-key") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("PAYGATE_AUDIENCE", "https://gateway.example.com") t.Setenv("CACHE_ENABLED", "false") t.Setenv("RECEIPT_STORE", "postgres") t.Setenv("REDIS_URL", "localhost:6379") diff --git a/gateway/errors.go b/gateway/errors.go index 95044a88..cc0627d0 100644 --- a/gateway/errors.go +++ b/gateway/errors.go @@ -48,6 +48,12 @@ func verifierFailureResponse(verifyResp *VerifyResponse) (int, string) { return http.StatusBadRequest, "invalid_timestamp" case "invalid_signature": return http.StatusForbidden, "invalid_signature" + case "signer_mismatch": + return http.StatusForbidden, "signer_mismatch" + case "invalid_authorization_context": + return http.StatusBadRequest, "invalid_authorization_context" + case "authorization_version_too_old": + return http.StatusBadRequest, "authorization_version_too_old" } // Backward compatibility for older verifier responses without error_code. @@ -71,7 +77,10 @@ func isVerifierBusinessRejection(verifyResp *VerifyResponse) bool { "timestamp_expired", "timestamp_future", "timestamp_missing", - "invalid_signature": + "invalid_signature", + "signer_mismatch", + "invalid_authorization_context", + "authorization_version_too_old": return true default: return false diff --git a/gateway/errors_test.go b/gateway/errors_test.go index 6641ffc5..a9ecf8f5 100644 --- a/gateway/errors_test.go +++ b/gateway/errors_test.go @@ -46,6 +46,7 @@ func signedSummarizeRequest(body string) *http.Request { req.Header.Set("X-402-Signature", "0xsigned") req.Header.Set("X-402-Nonce", "nonce-1") req.Header.Set("X-402-Timestamp", "1700000000") + req.Header.Set("X-402-Payer", "0x14791697260E4c9A71f18484C9f997B308e59325") req.Header.Set("X-Correlation-ID", "test-correlation-id") return req } @@ -90,7 +91,7 @@ func TestHandleSummarizeSanitizesOpenRouterProviderError(t *testing.T) { aiProvider = failingProvider{ err: errors.New("openrouter returned status 429: SENSITIVE_PROVIDER_DETAIL"), } - withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"0xabc","error":""}`) + withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"0x14791697260e4c9a71f18484c9f997b308e59325","error":""}`) router := newSummarizeTestRouter() recorder := httptest.NewRecorder() @@ -194,6 +195,41 @@ func TestHandleSummarizeMapsVerifierChainMismatch(t *testing.T) { require.Equal(t, "test-correlation-id", response["correlation_id"]) } +func TestHandleSummarizeMapsAuthorizationRejections(t *testing.T) { + for _, errorCode := range []string{ + "invalid_authorization_context", + "authorization_version_too_old", + } { + t.Run(errorCode, func(t *testing.T) { + withVerifierResponse(t, http.StatusBadRequest, `{"is_valid":false,"recovered_address":null,"error":"SENSITIVE_AUTH_DETAIL","error_code":"`+errorCode+`"}`) + + router := newSummarizeTestRouter() + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, signedSummarizeRequest(`{"text":"hello"}`)) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + require.NotContains(t, recorder.Body.String(), "SENSITIVE_AUTH_DETAIL") + var response map[string]string + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, errorCode, response["error"]) + }) + } +} + +func TestHandleSummarizeMapsSignerMismatch(t *testing.T) { + withVerifierResponse(t, http.StatusOK, `{"is_valid":false,"recovered_address":"0x0000000000000000000000000000000000000001","error":"SENSITIVE_SIGNER_DETAIL","error_code":"signer_mismatch"}`) + + router := newSummarizeTestRouter() + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, signedSummarizeRequest(`{"text":"hello"}`)) + + require.Equal(t, http.StatusForbidden, recorder.Code) + require.NotContains(t, recorder.Body.String(), "SENSITIVE_SIGNER_DETAIL") + var response map[string]string + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "signer_mismatch", response["error"]) +} + func TestHandleSummarizeMapsVerifierInvalidSignatureParse(t *testing.T) { withVerifierResponse(t, http.StatusBadRequest, `{"is_valid":false,"recovered_address":null,"error":"bad signature: SENSITIVE_PARSE_DETAIL","error_code":"invalid_signature"}`) @@ -232,7 +268,7 @@ func TestHandleSummarizePreservesAIProviderTimeoutStatus(t *testing.T) { origProvider := aiProvider t.Cleanup(func() { aiProvider = origProvider }) aiProvider = failingProvider{err: context.DeadlineExceeded} - withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"0xabc","error":""}`) + withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"0x14791697260e4c9a71f18484c9f997b308e59325","error":""}`) router := newSummarizeTestRouter() recorder := httptest.NewRecorder() diff --git a/gateway/main.go b/gateway/main.go index 8be9cc33..4482f911 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -38,17 +38,24 @@ import ( ) type PaymentContext struct { - Recipient string `json:"recipient"` - Token string `json:"token"` - Amount string `json:"amount"` - Nonce string `json:"nonce"` - ChainID int `json:"chainId"` - Timestamp uint64 `json:"timestamp"` + AuthorizationVersion int `json:"authorizationVersion"` + Recipient string `json:"recipient"` + Token string `json:"token"` + Amount string `json:"amount"` + Nonce string `json:"nonce"` + ChainID int `json:"chainId"` + Timestamp uint64 `json:"timestamp"` + Audience string `json:"audience"` + Method string `json:"method"` + Resource string `json:"resource"` + ContentType string `json:"contentType"` + RequestHash string `json:"requestHash"` } type VerifyRequest struct { Context PaymentContext `json:"context"` Signature string `json:"signature"` + Payer string `json:"payer"` } type VerifyResponse struct { @@ -71,6 +78,7 @@ func validateConfig() error { required := []string{ "SERVER_WALLET_PRIVATE_KEY", // Critical for signing receipts "VERIFIER_URL", // Where the gateway calls /verify; loopback fallback would hide misconfig in prod + "PAYGATE_AUDIENCE", // Trusted public origin used in request-bound payment authorization } // Add provider-specific requirements @@ -117,6 +125,9 @@ func validateConfig() error { if err := normalizeRecipientAddress(); err != nil { return fmt.Errorf("RECIPIENT_ADDRESS validation failed: %w", err) } + if err := normalizePaygateAudience(); err != nil { + return fmt.Errorf("PAYGATE_AUDIENCE validation failed: %w", err) + } return nil } @@ -431,11 +442,6 @@ func handleSummarize(c *gin.Context) { var requestBody []byte var err error - if !hasPaymentHeaders(c) { - writePaymentChallenge(c) - return - } - // Check if body already read by middleware if body, exists := c.Get("request_body"); exists { // Cache middleware always sets this as []byte, safe to assert @@ -446,8 +452,12 @@ func handleSummarize(c *gin.Context) { if requestBody == nil { // Read body with limit (only if middleware didn't process it) const maxBodySize = 10 * 1024 * 1024 - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(maxBodySize)) - requestBody, err = io.ReadAll(c.Request.Body) + if c.Request.Body != nil { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, int64(maxBodySize)) + requestBody, err = io.ReadAll(c.Request.Body) + } else { + requestBody = []byte{} + } if err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { @@ -459,7 +469,7 @@ func handleSummarize(c *gin.Context) { } } - payment, ok := verifyPaidRequest(c) + payment, ok := verifyPaidRequest(c, requestBody) if !ok { return } @@ -499,24 +509,16 @@ func handleSummarize(c *gin.Context) { // verifyPayment calls the verification service. -func verifyPayment(ctx context.Context, signature, nonce string, timestamp uint64) (*VerifyResponse, *PaymentContext, error) { - paymentCtx := PaymentContext{ - Recipient: getRecipientAddress(), - Token: "USDC", - Amount: getPaymentAmount(), - Nonce: nonce, - ChainID: getChainID(), - Timestamp: timestamp, - } - +func verifyPayment(ctx context.Context, signature, payer string, paymentCtx PaymentContext) (*VerifyResponse, error) { verifyReq := VerifyRequest{ Context: paymentCtx, Signature: signature, + Payer: payer, } verifyBody, err := json.Marshal(verifyReq) if err != nil { - return nil, nil, fmt.Errorf("marshal verification request: %w", err) + return nil, fmt.Errorf("marshal verification request: %w", err) } // VERIFIER_URL is guaranteed non-empty here: validateConfig() exits at @@ -529,7 +531,7 @@ func verifyPayment(ctx context.Context, signature, nonce string, timestamp uint6 vreq, err := http.NewRequestWithContext(verifierCtx, "POST", verifierURL+"/verify", bytes.NewBuffer(verifyBody)) if err != nil { - return nil, nil, fmt.Errorf("create verifier request: %w", err) + return nil, fmt.Errorf("create verifier request: %w", err) } vreq.Header.Set("Content-Type", "application/json") @@ -540,7 +542,7 @@ func verifyPayment(ctx context.Context, signature, nonce string, timestamp uint6 // Use http.DefaultClient and rely on verifierCtx for timeouts/cancellation. resp, err := http.DefaultClient.Do(vreq) if err != nil { - return nil, nil, fmt.Errorf("verifier request failed: %w", err) + return nil, fmt.Errorf("verifier request failed: %w", err) } defer resp.Body.Close() @@ -549,20 +551,20 @@ func verifyPayment(ctx context.Context, signature, nonce string, timestamp uint6 bodyText := strings.TrimSpace(string(body)) var verifyResp VerifyResponse if bodyText != "" && json.Unmarshal(body, &verifyResp) == nil && isVerifierBusinessRejection(&verifyResp) { - return &verifyResp, &paymentCtx, nil + return &verifyResp, nil } if bodyText == "" { - return nil, nil, fmt.Errorf("verifier returned status %d", resp.StatusCode) + return nil, fmt.Errorf("verifier returned status %d", resp.StatusCode) } - return nil, nil, fmt.Errorf("verifier returned status %d: %s", resp.StatusCode, bodyText) + return nil, fmt.Errorf("verifier returned status %d: %s", resp.StatusCode, bodyText) } var verifyResp VerifyResponse if err := json.NewDecoder(resp.Body).Decode(&verifyResp); err != nil { - return nil, nil, fmt.Errorf("decode verification response: %w", err) + return nil, fmt.Errorf("decode verification response: %w", err) } - return &verifyResp, &paymentCtx, nil + return &verifyResp, nil } // generateAndSendReceipt handles receipt generation, storage, and sending the final JSON response. @@ -604,18 +606,32 @@ func generateAndSendReceipt(c *gin.Context, paymentCtx PaymentContext, recovered return nil } -// createPaymentContext constructs a PaymentContext prefilled with the recipient address (from RECIPIENT_ADDRESS or a fallback), the USDC token, amount "0.001", a newly generated UUID nonce, and the configured chain ID. -func createPaymentContext() PaymentContext { +// createPaymentContext constructs the exact request-bound context the wallet signs. +func createPaymentContext(binding paymentRequestBinding, nonce string, timestamp uint64) PaymentContext { return PaymentContext{ - Recipient: getRecipientAddress(), - Token: "USDC", - Amount: getPaymentAmount(), - Nonce: uuid.New().String(), - ChainID: getChainID(), - Timestamp: uint64(time.Now().Unix()), + AuthorizationVersion: paymentAuthorizationVersion, + Recipient: getRecipientAddress(), + Token: "USDC", + Amount: getPaymentAmount(), + Nonce: nonce, + ChainID: getChainID(), + Timestamp: timestamp, + Audience: binding.Audience, + Method: binding.Method, + Resource: binding.Resource, + ContentType: binding.ContentType, + RequestHash: binding.RequestHash, } } +func createPaymentChallengeContext(request *http.Request, body []byte) PaymentContext { + return createPaymentContext( + buildPaymentRequestBinding(request, body), + uuid.New().String(), + uint64(time.Now().Unix()), + ) +} + // getRecipientAddress retrieves the recipient address from the RECIPIENT_ADDRESS environment variable. // If RECIPIENT_ADDRESS is unset, it logs a warning and returns the default address "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219". func getRecipientAddress() string { diff --git a/gateway/metrics.go b/gateway/metrics.go index cf77209d..0c771a9a 100644 --- a/gateway/metrics.go +++ b/gateway/metrics.go @@ -82,6 +82,13 @@ var ( }, []string{"result"}, ) + paymentAuthorizationTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "gateway_payment_authorization_total", + Help: "Request-bound payment authorization outcomes", + }, + []string{"version", "outcome"}, + ) activeRequests = promauto.NewGauge( prometheus.GaugeOpts{ Name: "gateway_active_requests", diff --git a/gateway/metrics_test.go b/gateway/metrics_test.go index 07984a3e..68afd30e 100644 --- a/gateway/metrics_test.go +++ b/gateway/metrics_test.go @@ -163,6 +163,19 @@ func TestGatewayVerificationMetricRecordsInvalidResponses(t *testing.T) { require.Equal(t, float64(1), after-before) } +func TestGatewayAuthorizationMetricRecordsDowngradeRejection(t *testing.T) { + withVerifierResponse(t, http.StatusBadRequest, `{"is_valid":false,"recovered_address":null,"error":"legacy authorization rejected","error_code":"authorization_version_too_old"}`) + router := newSummarizeTestRouter() + + before := testutil.ToFloat64(paymentAuthorizationTotal.WithLabelValues("2", "downgrade")) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, signedSummarizeRequest(`{"text":"hello"}`)) + + require.Equal(t, http.StatusBadRequest, recorder.Code) + after := testutil.ToFloat64(paymentAuthorizationTotal.WithLabelValues("2", "downgrade")) + require.Equal(t, float64(1), after-before) +} + func TestGatewayVerificationMetricRecordsMalformedSuccessResponses(t *testing.T) { withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"","error":""}`) router := newSummarizeTestRouter() diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index cfddf757..32517d3f 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -60,7 +60,7 @@ paths: post: summary: Summarize text with x402 payment enforcement description: | - Unsigned requests return HTTP 402 with a payment context. Clients sign that context with EIP-712 typed data and retry with X-402-Signature, X-402-Nonce, and X-402-Timestamp. + Unsigned requests return HTTP 402 with a request-bound v2 payment context. Clients sign that context and their payer address with EIP-712 typed data, then retry with X-402-Signature, X-402-Nonce, X-402-Timestamp, and X-402-Payer. The context binds the configured gateway audience, method, encoded resource, content type, and SHA-256 hash of the exact request bytes. parameters: - name: X-402-Signature in: header @@ -83,6 +83,13 @@ paths: schema: type: string example: "1700000000" + - name: X-402-Payer + in: header + required: false + description: Wallet address claimed by and covered by the v2 EIP-712 signature. Required on signed retries. + schema: + type: string + example: "0x14791697260E4c9A71f18484C9f997B308e59325" - name: X-Correlation-ID in: header required: false @@ -112,7 +119,7 @@ paths: schema: $ref: "#/components/schemas/SummarizeResponse" "400": - description: Invalid timestamp, chain mismatch, or invalid request body. + description: Invalid timestamp, chain, payer, authorization version, request binding, or request body. content: application/json: schema: @@ -127,6 +134,10 @@ paths: invalidBody: value: error: Invalid request body + invalidAuthorization: + value: + error: invalid_authorization_context + correlation_id: "550e8400-e29b-41d4-a716-446655440000" "402": description: Payment required. Sign the returned paymentContext and retry. content: @@ -134,13 +145,13 @@ paths: schema: $ref: "#/components/schemas/PaymentRequiredResponse" "403": - description: Invalid signature. + description: Invalid signature or a signature that does not match the claimed payer/request binding. content: application/json: schema: $ref: "#/components/schemas/PublicError" example: - error: invalid_signature + error: signer_mismatch correlation_id: "550e8400-e29b-41d4-a716-446655440000" "409": description: Nonce already used. @@ -352,8 +363,12 @@ components: PaymentContext: type: object - required: [recipient, token, amount, nonce, chainId, timestamp] + required: [authorizationVersion, recipient, token, amount, nonce, chainId, timestamp, audience, method, resource, contentType, requestHash] properties: + authorizationVersion: + type: integer + const: 2 + description: Request-bound payment authorization contract version. recipient: type: string description: Ethereum address of payment recipient. @@ -376,6 +391,28 @@ components: format: uint64 description: Unix timestamp in seconds used in the EIP-712 message. example: 1700000000 + audience: + type: string + format: uri + description: Trusted public gateway origin configured by the server. + example: https://gateway.example.com + method: + type: string + description: Uppercase HTTP method covered by the signature. + example: POST + resource: + type: string + description: Encoded path plus the unmodified raw query covered by the signature. + example: /api/ai/summarize?mode=brief&tag=a&tag=b + contentType: + type: string + description: Request Content-Type covered by the signature. + example: application/json + requestHash: + type: string + pattern: '^0x[0-9a-fA-F]{64}$' + description: SHA-256 hash of the exact request-body bytes covered by the signature. + example: "0x8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9" PublicError: type: object @@ -385,6 +422,9 @@ components: type: string enum: - invalid_signature + - signer_mismatch + - invalid_authorization_context + - authorization_version_too_old - invalid_timestamp - chain_id_mismatch - nonce_already_used @@ -483,6 +523,24 @@ components: endpoint: type: string example: /api/ai/summarize + authorization_version: + type: integer + example: 2 + audience: + type: string + example: https://gateway.example.com + method: + type: string + example: POST + resource: + type: string + example: /api/ai/summarize?mode=brief + content_type: + type: string + example: application/json + authorization_request_hash: + type: string + example: "0x8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9" request_hash: type: string example: sha256:... diff --git a/gateway/payment_flow.go b/gateway/payment_flow.go index 0f5493db..4126d53d 100644 --- a/gateway/payment_flow.go +++ b/gateway/payment_flow.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" + "github.com/ethereum/go-ethereum/common" "github.com/gin-gonic/gin" ) @@ -19,20 +20,21 @@ func hasPaymentHeaders(c *gin.Context) bool { return c.GetHeader("X-402-Signature") != "" && c.GetHeader("X-402-Nonce") != "" } -func writePaymentChallenge(c *gin.Context) { +func writePaymentChallenge(c *gin.Context, requestBody []byte) { + paymentAuthorizationTotal.WithLabelValues("2", "challenge").Inc() c.JSON(http.StatusPaymentRequired, gin.H{ "error": "Payment Required", "message": "Please sign the payment context", - "paymentContext": createPaymentContext(), + "paymentContext": createPaymentChallengeContext(c.Request, requestBody), }) } -func verifyPaidRequest(c *gin.Context) (*verifiedPayment, bool) { +func verifyPaidRequest(c *gin.Context, requestBody []byte) (*verifiedPayment, bool) { signature := c.GetHeader("X-402-Signature") nonce := c.GetHeader("X-402-Nonce") if !hasPaymentHeaders(c) { - writePaymentChallenge(c) + writePaymentChallenge(c, requestBody) return nil, false } @@ -41,9 +43,22 @@ func verifyPaidRequest(c *gin.Context) (*verifiedPayment, bool) { return nil, false } - verifyResp, paymentCtx, err := verifyPayment(c.Request.Context(), signature, nonce, timestampValue) + payer := c.GetHeader("X-402-Payer") + if !common.IsHexAddress(payer) { + paymentAuthorizationTotal.WithLabelValues("2", "invalid_context").Inc() + respondError(c, http.StatusBadRequest, "invalid_authorization_context", fmt.Errorf("missing or invalid X-402-Payer header")) + return nil, false + } + + paymentCtx := createPaymentContext( + buildPaymentRequestBinding(c.Request, requestBody), + nonce, + timestampValue, + ) + verifyResp, err := verifyPayment(c.Request.Context(), signature, payer, paymentCtx) if err != nil { verificationTotal.WithLabelValues("error").Inc() + paymentAuthorizationTotal.WithLabelValues("2", "verification_error").Inc() ctxErr := c.Request.Context().Err() if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || @@ -57,23 +72,48 @@ func verifyPaidRequest(c *gin.Context) (*verifiedPayment, bool) { if !verifyResp.IsValid { verificationTotal.WithLabelValues("invalid").Inc() + paymentAuthorizationTotal.WithLabelValues("2", paymentAuthorizationOutcome(verifyResp.ErrorCode)).Inc() respondVerificationFailure(c, verifyResp) return nil, false } if verifyResp.RecoveredAddress == "" { verificationTotal.WithLabelValues("error").Inc() + paymentAuthorizationTotal.WithLabelValues("2", "verification_error").Inc() respondError(c, http.StatusBadGateway, "verification_unavailable", fmt.Errorf("verifier success missing recovered_address")) return nil, false } + if !common.IsHexAddress(verifyResp.RecoveredAddress) || + common.HexToAddress(verifyResp.RecoveredAddress) != common.HexToAddress(payer) { + verificationTotal.WithLabelValues("error").Inc() + paymentAuthorizationTotal.WithLabelValues("2", "verification_error").Inc() + respondError(c, http.StatusBadGateway, "verification_unavailable", fmt.Errorf("verifier success recovered address does not match claimed payer")) + return nil, false + } verificationTotal.WithLabelValues("success").Inc() + paymentAuthorizationTotal.WithLabelValues("2", "success").Inc() return &verifiedPayment{ - PaymentContext: *paymentCtx, + PaymentContext: paymentCtx, RecoveredAddress: verifyResp.RecoveredAddress, }, true } +func paymentAuthorizationOutcome(errorCode string) string { + switch errorCode { + case "invalid_authorization_context": + return "invalid_context" + case "authorization_version_too_old": + return "downgrade" + case "signer_mismatch": + return "signer_mismatch" + case "nonce_already_used": + return "replay" + default: + return "rejected" + } +} + func paymentTimestamp(c *gin.Context) (uint64, bool) { timestampHeader := c.GetHeader("X-402-Timestamp") if timestampHeader == "" { diff --git a/gateway/payment_flow_test.go b/gateway/payment_flow_test.go index 004ea604..991083e6 100644 --- a/gateway/payment_flow_test.go +++ b/gateway/payment_flow_test.go @@ -2,7 +2,9 @@ package main import ( "context" + "crypto/sha256" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -27,10 +29,13 @@ func newPaymentFlowTestContext(req *http.Request) (*gin.Context, *httptest.Respo } func TestVerifyPaidRequestWritesPaymentChallengeForMissingHeaders(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/ai/summarize", strings.NewReader(`{"text":"hello"}`)) + t.Setenv("PAYGATE_AUDIENCE", "https://gateway.example.com") + body := []byte(`{"text":"hello"}`) + req := httptest.NewRequest(http.MethodPost, "/api/ai/summarize?mode=brief&tag=a&tag=b", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") c, recorder := newPaymentFlowTestContext(req) - verified, ok := verifyPaidRequest(c) + verified, ok := verifyPaidRequest(c, body) require.False(t, ok) require.Nil(t, verified) @@ -50,6 +55,32 @@ func TestVerifyPaidRequestWritesPaymentChallengeForMissingHeaders(t *testing.T) require.NotEmpty(t, response.PaymentContext.Nonce) require.Positive(t, response.PaymentContext.ChainID) require.Positive(t, response.PaymentContext.Timestamp) + require.Equal(t, 2, response.PaymentContext.AuthorizationVersion) + require.Equal(t, "https://gateway.example.com", response.PaymentContext.Audience) + require.Equal(t, http.MethodPost, response.PaymentContext.Method) + require.Equal(t, "/api/ai/summarize?mode=brief&tag=a&tag=b", response.PaymentContext.Resource) + require.Equal(t, "application/json", response.PaymentContext.ContentType) + require.Equal(t, fmt.Sprintf("0x%x", sha256.Sum256(body)), response.PaymentContext.RequestHash) +} + +func TestPaymentChallengeUsesConfiguredAudienceNotForwardedHost(t *testing.T) { + t.Setenv("PAYGATE_AUDIENCE", "https://gateway.example.com") + body := []byte(`{"text":"hello"}`) + req := httptest.NewRequest(http.MethodPost, "http://internal:3000/api/ai/summarize", strings.NewReader(string(body))) + req.Host = "attacker.example" + req.Header.Set("Forwarded", "host=attacker.example;proto=https") + req.Header.Set("X-Forwarded-Host", "attacker.example") + req.Header.Set("Content-Type", "application/json") + c, recorder := newPaymentFlowTestContext(req) + + _, ok := verifyPaidRequest(c, body) + + require.False(t, ok) + var response struct { + PaymentContext PaymentContext `json:"paymentContext"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "https://gateway.example.com", response.PaymentContext.Audience) } func TestHandleSummarizeRejectsOversizedBodyBeforeVerification(t *testing.T) { @@ -71,19 +102,74 @@ func TestHandleSummarizeRejectsOversizedBodyBeforeVerification(t *testing.T) { } func TestVerifyPaidRequestReturnsVerifiedPayment(t *testing.T) { - withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"0xabc","error":""}`) + withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"0x14791697260e4c9a71f18484c9f997b308e59325","error":""}`) req := signedSummarizeRequest(`{"text":"hello"}`) c, recorder := newPaymentFlowTestContext(req) - verified, ok := verifyPaidRequest(c) + verified, ok := verifyPaidRequest(c, []byte(`{"text":"hello"}`)) require.True(t, ok) require.Equal(t, http.StatusOK, recorder.Code) - require.Equal(t, "0xabc", verified.RecoveredAddress) + require.Equal(t, "0x14791697260e4c9a71f18484c9f997b308e59325", verified.RecoveredAddress) require.Equal(t, "nonce-1", verified.PaymentContext.Nonce) require.Equal(t, uint64(1700000000), verified.PaymentContext.Timestamp) } +func TestVerifyPaidRequestSendsServerReconstructedV2Context(t *testing.T) { + t.Setenv("PAYGATE_AUDIENCE", "https://gateway.example.com") + requests := make(chan VerifyRequest, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request VerifyRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + requests <- request + _, _ = w.Write([]byte(`{"is_valid":true,"recovered_address":"0x14791697260e4c9a71f18484c9f997b308e59325","error":""}`)) + })) + t.Cleanup(server.Close) + t.Setenv("VERIFIER_URL", server.URL) + + body := []byte(`{"text":"hello"}`) + req := signedSummarizeRequest(string(body)) + req.URL.RawQuery = "mode=brief&tag=a&tag=b" + req.Host = "attacker.example" + c, _ := newPaymentFlowTestContext(req) + + verified, ok := verifyPaidRequest(c, body) + + require.True(t, ok) + require.Equal(t, "0x14791697260e4c9a71f18484c9f997b308e59325", verified.RecoveredAddress) + verifierRequest := <-requests + require.Equal(t, "0x14791697260E4c9A71f18484C9f997B308e59325", verifierRequest.Payer) + require.Equal(t, 2, verifierRequest.Context.AuthorizationVersion) + require.Equal(t, "https://gateway.example.com", verifierRequest.Context.Audience) + require.Equal(t, http.MethodPost, verifierRequest.Context.Method) + require.Equal(t, "/api/ai/summarize?mode=brief&tag=a&tag=b", verifierRequest.Context.Resource) + require.Equal(t, "application/json", verifierRequest.Context.ContentType) + require.Equal(t, fmt.Sprintf("0x%x", sha256.Sum256(body)), verifierRequest.Context.RequestHash) +} + +func TestVerifyPaidRequestRejectsMissingOrInvalidPayerBeforeCallingVerifier(t *testing.T) { + var verifierCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + verifierCalls.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + t.Setenv("VERIFIER_URL", server.URL) + + for _, payer := range []string{"", "not-an-address"} { + req := signedSummarizeRequest(`{"text":"hello"}`) + req.Header.Set("X-402-Payer", payer) + c, recorder := newPaymentFlowTestContext(req) + + verified, ok := verifyPaidRequest(c, []byte(`{"text":"hello"}`)) + + require.False(t, ok) + require.Nil(t, verified) + require.Equal(t, http.StatusBadRequest, recorder.Code) + } + require.Zero(t, verifierCalls.Load()) +} + func TestVerifyPaidRequestMapsVerifierTimeout(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(1500 * time.Millisecond) @@ -95,7 +181,7 @@ func TestVerifyPaidRequestMapsVerifierTimeout(t *testing.T) { req := signedSummarizeRequest(`{"text":"hello"}`) c, recorder := newPaymentFlowTestContext(req) - verified, ok := verifyPaidRequest(c) + verified, ok := verifyPaidRequest(c, []byte(`{"text":"hello"}`)) require.False(t, ok) require.Nil(t, verified) @@ -112,7 +198,7 @@ func TestVerifyPaidRequestRequiresRecoveredAddress(t *testing.T) { req := signedSummarizeRequest(`{"text":"hello"}`) c, recorder := newPaymentFlowTestContext(req) - verified, ok := verifyPaidRequest(c) + verified, ok := verifyPaidRequest(c, []byte(`{"text":"hello"}`)) require.False(t, ok) require.Nil(t, verified) @@ -123,3 +209,18 @@ func TestVerifyPaidRequestRequiresRecoveredAddress(t *testing.T) { require.Equal(t, "verification_unavailable", response["error"]) require.Equal(t, "test-correlation-id", response["correlation_id"]) } + +func TestVerifyPaidRequestRequiresRecoveredAddressToMatchClaimedPayer(t *testing.T) { + withVerifierResponse(t, http.StatusOK, `{"is_valid":true,"recovered_address":"0x0000000000000000000000000000000000000001","error":""}`) + req := signedSummarizeRequest(`{"text":"hello"}`) + c, recorder := newPaymentFlowTestContext(req) + + verified, ok := verifyPaidRequest(c, []byte(`{"text":"hello"}`)) + + require.False(t, ok) + require.Nil(t, verified) + require.Equal(t, http.StatusBadGateway, recorder.Code) + var response map[string]string + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "verification_unavailable", response["error"]) +} diff --git a/gateway/receipt.go b/gateway/receipt.go index 7b6be7a8..f03c5069 100644 --- a/gateway/receipt.go +++ b/gateway/receipt.go @@ -34,9 +34,15 @@ type PaymentDetails struct { // ServiceDetails contains service-related information type ServiceDetails struct { - Endpoint string `json:"endpoint"` - RequestHash string `json:"request_hash"` - ResponseHash string `json:"response_hash"` + Endpoint string `json:"endpoint"` + AuthorizationVersion int `json:"authorization_version,omitempty"` + Audience string `json:"audience,omitempty"` + Method string `json:"method,omitempty"` + Resource string `json:"resource,omitempty"` + ContentType string `json:"content_type,omitempty"` + AuthorizationHash string `json:"authorization_request_hash,omitempty"` + RequestHash string `json:"request_hash"` + ResponseHash string `json:"response_hash"` } // SignedReceipt contains the receipt and its cryptographic signature @@ -73,9 +79,15 @@ func GenerateReceipt(payment PaymentContext, payer string, endpoint string, reqB Nonce: payment.Nonce, }, Service: ServiceDetails{ - Endpoint: endpoint, - RequestHash: hashData(reqBody), - ResponseHash: hashData(respBody), + Endpoint: endpoint, + AuthorizationVersion: payment.AuthorizationVersion, + Audience: payment.Audience, + Method: payment.Method, + Resource: payment.Resource, + ContentType: payment.ContentType, + AuthorizationHash: payment.RequestHash, + RequestHash: hashData(reqBody), + ResponseHash: hashData(respBody), }, } diff --git a/gateway/receipt_store_integration_test.go b/gateway/receipt_store_integration_test.go index c7a2e400..b3cc8fb6 100644 --- a/gateway/receipt_store_integration_test.go +++ b/gateway/receipt_store_integration_test.go @@ -31,7 +31,7 @@ func TestRedisReceiptStore_PersistsAcrossGatewayRestart(t *testing.T) { verifier := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(VerifyResponse{ IsValid: true, - RecoveredAddress: "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21", + RecoveredAddress: "0x14791697260e4c9a71f18484c9f997b308e59325", }) })) defer verifier.Close() @@ -74,6 +74,7 @@ func TestRedisReceiptStore_PersistsAcrossGatewayRestart(t *testing.T) { createReq := httptest.NewRequest(http.MethodPost, "/api/ai/summarize", bytes.NewBufferString(`{"text":"persist this receipt"}`)) createReq.Header.Set("Content-Type", "application/json") createReq.Header.Set("X-402-Signature", "0xValidSig") + createReq.Header.Set("X-402-Payer", "0x14791697260E4c9A71f18484C9f997B308e59325") createReq.Header.Set("X-402-Nonce", "restart-test-nonce") createReq.Header.Set("X-402-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) createResp := httptest.NewRecorder() diff --git a/gateway/receipt_test.go b/gateway/receipt_test.go index 28ec8162..1430460d 100644 --- a/gateway/receipt_test.go +++ b/gateway/receipt_test.go @@ -73,6 +73,45 @@ func TestHashData(t *testing.T) { } } +func TestGenerateReceiptPreservesRequestBoundAuthorization(t *testing.T) { + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + payment := PaymentContext{ + AuthorizationVersion: 2, + Recipient: "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219", + Token: "USDC", + Amount: "0.001", + Nonce: "bound-receipt", + ChainID: 84532, + Timestamp: 1700000000, + Audience: "https://gateway.example.com", + Method: "POST", + Resource: "/api/ai/summarize?mode=brief", + ContentType: "application/json", + RequestHash: "0x8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9", + } + + receipt, err := GenerateReceipt( + payment, + "0x14791697260e4c9a71f18484c9f997b308e59325", + payment.Resource, + []byte(`{"text":"hello"}`), + []byte(`{"result":"hi"}`), + ) + if err != nil { + t.Fatalf("GenerateReceipt() error = %v", err) + } + + service := receipt.Receipt.Service + if service.AuthorizationVersion != payment.AuthorizationVersion || + service.Audience != payment.Audience || + service.Method != payment.Method || + service.Resource != payment.Resource || + service.ContentType != payment.ContentType || + service.AuthorizationHash != payment.RequestHash { + t.Fatalf("receipt lost request-bound authorization: %#v", service) + } +} + func TestSignReceipt(t *testing.T) { // Create a test receipt receipt := Receipt{ diff --git a/gateway/request_binding.go b/gateway/request_binding.go new file mode 100644 index 00000000..b748b6fd --- /dev/null +++ b/gateway/request_binding.go @@ -0,0 +1,50 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "net/http" + "os" + "strings" +) + +const paymentAuthorizationVersion = 2 + +type paymentRequestBinding struct { + Audience string + Method string + Resource string + ContentType string + RequestHash string +} + +func buildPaymentRequestBinding(request *http.Request, body []byte) paymentRequestBinding { + resource := request.URL.EscapedPath() + if resource == "" { + resource = "/" + } + if request.URL.RawQuery != "" { + resource += "?" + request.URL.RawQuery + } + + contentType := strings.TrimSpace(request.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + } + + return paymentRequestBinding{ + Audience: getPaygateAudience(), + Method: strings.ToUpper(request.Method), + Resource: resource, + ContentType: contentType, + RequestHash: fmt.Sprintf("0x%x", sha256.Sum256(body)), + } +} + +func getPaygateAudience() string { + audience := strings.TrimSpace(os.Getenv("PAYGATE_AUDIENCE")) + if audience == "" { + return "http://localhost:3000" + } + return audience +} diff --git a/gateway/timeout_test.go b/gateway/timeout_test.go index a35e3872..970a3a13 100644 --- a/gateway/timeout_test.go +++ b/gateway/timeout_test.go @@ -123,7 +123,7 @@ func TestHandleSummarize_AIRequestTimeoutReturns504(t *testing.T) { // Set up a verifier that returns valid immediately verifier := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) - w.Write([]byte(`{"is_valid":true, "recovered_address":"0xabc","error":""}`)) + w.Write([]byte(`{"is_valid":true, "recovered_address":"0x14791697260e4c9a71f18484c9f997b308e59325","error":""}`)) })) defer verifier.Close() @@ -158,6 +158,7 @@ func TestHandleSummarize_AIRequestTimeoutReturns504(t *testing.T) { reqBody := strings.NewReader(`{"text":"hello"}`) req, _ := http.NewRequest("POST", "/api/ai/summarize", reqBody) req.Header.Set("X-402-Signature", "sig") + req.Header.Set("X-402-Payer", "0x14791697260E4c9A71f18484C9f997B308e59325") req.Header.Set("X-402-Nonce", "nonce") req.Header.Set("X-402-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) req.Header.Set("Content-Type", "application/json") diff --git a/run_e2e.sh b/run_e2e.sh index 05aaf4cb..cfea7bdd 100755 --- a/run_e2e.sh +++ b/run_e2e.sh @@ -22,7 +22,7 @@ if [ $? -ne 0 ]; then exit 1 fi echo "Starting Verifier..." -PORT=3002 cargo run --quiet & +PORT=3002 MIN_AUTHORIZATION_VERSION=2 cargo run --quiet & VERIFIER_PID=$! echo "Starting Gateway..." @@ -32,6 +32,15 @@ export CACHE_ENABLED="${CACHE_ENABLED:-false}" # The gateway now requires VERIFIER_URL at startup; point it at the verifier # we just spawned on localhost above. Honors any caller-supplied override. export VERIFIER_URL="${VERIFIER_URL:-http://127.0.0.1:3002}" +export PAYGATE_AUDIENCE="${PAYGATE_AUDIENCE:-http://localhost:3000}" +export SERVER_WALLET_PRIVATE_KEY="${SERVER_WALLET_PRIVATE_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}" +export RECIPIENT_ADDRESS="${RECIPIENT_ADDRESS:-0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219}" +if [ -z "$OPENROUTER_API_KEY" ]; then + echo "Starting deterministic OpenRouter mock..." + export OPENROUTER_API_KEY="e2e-test-key" + export OPENROUTER_URL="http://127.0.0.1:3100/api/v1/chat/completions" + (cd "$SCRIPT_DIR" && bun tests/mock-openrouter.ts) & +fi go run . & GATEWAY_PID=$! diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index a4b34fe0..3d11d9cc 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -7,7 +7,7 @@ The SDK handles the existing gateway flow: 1. Send an unsigned request. 2. Read the `402 Payment Required` `paymentContext`. 3. Sign the payment context with EIP-712. -4. Retry with the signed `X-402-*` headers (v2 also includes the recovered `X-402-Payer`). +4. Retry with the signed `X-402-*` headers, including the signer-derived `X-402-Payer`. 5. Decode the `X-402-Receipt` response header. 6. Verify the signed receipt locally. @@ -19,7 +19,7 @@ MicroAI Paygate currently uses a custom x402-style wire contract. It is not offi A valid EIP-712 signature proves wallet authorization for the payment context. It does not prove that USDC moved on-chain. -The SDK accepts the current legacy context and the staged request-bound v2 context. For v2 it verifies the audience, method, encoded path and raw query, content type, and SHA-256 hash of the exact serialized body before opening the wallet signature prompt. The public gateway remains on v1 until the separate cutover change is deployed. +The gateway now issues request-bound v2 contexts. Before opening the wallet signature prompt, the SDK requires v2 and verifies the audience, method, encoded path and raw query, content type, and SHA-256 hash of the exact serialized body. Production verifiers also default to rejecting v1, preventing downgrade at both client and server boundaries. ## Install And Test diff --git a/sdk/typescript/src/__tests__/client-flow.test.ts b/sdk/typescript/src/__tests__/client-flow.test.ts index a57ee483..8150090a 100644 --- a/sdk/typescript/src/__tests__/client-flow.test.ts +++ b/sdk/typescript/src/__tests__/client-flow.test.ts @@ -17,12 +17,21 @@ const wallet = new ethers.Wallet( ); const paymentContext: PaymentContext = { - recipient: "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219", - token: "USDC", - amount: "0.001", + ...(authorizationV2Fixture.context as PaymentContextV2), nonce: "client-flow-nonce", - chainId: 84532, timestamp: 1766611200, + audience: "http://gateway.test", + resource: "/api/ai/summarize", + requestHash: ethers.sha256(ethers.toUtf8Bytes(JSON.stringify({ text: "hello" }))), +}; + +const legacyPaymentContext: PaymentContext = { + recipient: paymentContext.recipient, + token: paymentContext.token, + amount: paymentContext.amount, + nonce: "legacy-client-flow-nonce", + chainId: paymentContext.chainId, + timestamp: paymentContext.timestamp, }; const receiptSigningKey = new ethers.SigningKey( @@ -214,6 +223,41 @@ describe("PaygateClient request flow", () => { expect(calls).toHaveLength(1); }); + it("rejects a custom adapter downgrade to a legacy unbound context", async () => { + class DowngradeProtocol extends MicroAIPaygateProtocol { + signCalls = 0; + + override async readPaymentContext(): Promise { + return legacyPaymentContext; + } + + override signPaymentContext( + signer: Parameters[0], + ctx: PaymentContext, + payer?: string, + ): Promise { + this.signCalls += 1; + return super.signPaymentContext(signer, ctx, payer); + } + } + const protocol = new DowngradeProtocol(); + const { calls, fetcher } = scriptedFetch([ + jsonResponse({ paymentContext: authorizationV2Fixture.context }, { status: 402 }), + ]); + const client = new PaygateClient({ + gatewayUrl: "http://gateway.test", + signer: wallet, + fetch: fetcher, + protocol, + }); + + await expect( + client.request({ method: "POST", path: "/api/ai/summarize", body: { text: "hello" } }), + ).rejects.toMatchObject({ code: "payment_binding_mismatch" }); + expect(protocol.signCalls).toBe(0); + expect(calls).toHaveLength(1); + }); + it("handles unsigned request, 402 challenge, signed retry, and verified receipt", async () => { const requestBody = JSON.stringify({ text: "hello" }); const responseBody = JSON.stringify({ result: "summarized text" }); @@ -327,7 +371,6 @@ describe("PaygateClient request flow", () => { { ...paymentContext, timestamp: 1766611200.5 }, { ...paymentContext, timestamp: Number.MAX_SAFE_INTEGER + 1 }, { ...paymentContext, authorizationVersion: 3 }, - { ...paymentContext, audience: "http://gateway.test" }, { ...authorizationV2Fixture.context, requestHash: undefined }, ]; @@ -447,8 +490,12 @@ describe("PaygateClient request flow", () => { const requestBody = JSON.stringify({ text: "hello" }); const responseBody = JSON.stringify({ result: "summarized text" }); const receipt = signedReceiptForPayloads({ requestBody, responseBody }); + const prefixedContext = { + ...paymentContext, + resource: "/paygate/api/ai/summarize", + }; const { fetcher } = scriptedFetch([ - jsonResponse({ paymentContext }, { status: 402 }), + jsonResponse({ paymentContext: prefixedContext }, { status: 402 }), jsonResponse( { result: "summarized text" }, { status: 200, headers: { "X-402-Receipt": receiptHeader(receipt) } }, diff --git a/sdk/typescript/src/__tests__/receipts.test.ts b/sdk/typescript/src/__tests__/receipts.test.ts index 65a170bd..3f0e822c 100644 --- a/sdk/typescript/src/__tests__/receipts.test.ts +++ b/sdk/typescript/src/__tests__/receipts.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { ethers } from "ethers"; import fixture from "../__fixtures__/gateway-receipt.json"; import { PaygateSdkError, @@ -62,6 +63,52 @@ describe("receipt helpers", () => { ).toBe(true); }); + it("verifyReceipt preserves request-bound v2 receipt fields in the signed payload", async () => { + const signingKey = new ethers.SigningKey( + "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ); + const receipt: SignedReceipt["receipt"] = { + id: "rcpt_boundv2test", + version: "1.0", + timestamp: "2026-07-16T00:00:00Z", + payment: { + payer: "0x14791697260E4c9A71f18484C9f997B308e59325", + recipient: "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219", + amount: "0.001", + token: "USDC", + chainId: 84532, + nonce: "bound-v2-receipt", + }, + service: { + endpoint: "/api/ai/summarize", + authorization_version: 2, + audience: "https://gateway.example.com", + method: "POST", + resource: "/api/ai/summarize?mode=brief", + content_type: "application/json", + authorization_request_hash: + "0x8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9", + request_hash: + "sha256:8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9", + response_hash: + "sha256:8a90fd4352d6e287b3e908e62f802c99c4f5680c9644cb27fb64d638e3fbb9d4", + }, + }; + const digest = ethers.keccak256(ethers.toUtf8Bytes(JSON.stringify(receipt))); + const signature = signingKey.sign(digest); + const signedReceipt: SignedReceipt = { + receipt, + signature: ethers.hexlify( + ethers.concat([signature.r, signature.s, ethers.toBeHex(signature.yParity, 1)]), + ), + server_public_key: signingKey.publicKey, + }; + + expect( + await verifyReceipt(signedReceipt, { expectedServerPublicKey: signingKey.publicKey }), + ).toBe(true); + }); + it("verifyReceipt requires the expected gateway receipt signing key as a trust anchor", async () => { expect(await verifyReceipt(cloneFixture())).toBe(false); expect( diff --git a/sdk/typescript/src/protocol/microai.ts b/sdk/typescript/src/protocol/microai.ts index 93fa3d1b..70defae4 100644 --- a/sdk/typescript/src/protocol/microai.ts +++ b/sdk/typescript/src/protocol/microai.ts @@ -43,11 +43,6 @@ function hasBasePaymentFields(value: Record): boolean { function isPaymentContext(value: unknown): value is PaymentContext { if (!isRecord(value)) return false; if (!hasBasePaymentFields(value)) return false; - if (value.authorizationVersion === undefined) { - return !["audience", "method", "resource", "contentType", "requestHash"].some( - (field) => field in value, - ); - } return ( value.authorizationVersion === 2 && isNonEmptyString(value.audience) && @@ -63,7 +58,12 @@ export function validatePaymentContextForRequest( ctx: PaymentContext, request: PaymentRequestBinding, ): void { - if (ctx.authorizationVersion !== 2) return; + if (ctx.authorizationVersion !== 2) { + throw new PaygateSdkError( + "payment_binding_mismatch", + "Gateway requires request-bound payment authorization v2", + ); + } const url = new URL(request.url); const expected: Pick< diff --git a/sdk/typescript/src/protocol/types.ts b/sdk/typescript/src/protocol/types.ts index 19f3d5e2..f243e10c 100644 --- a/sdk/typescript/src/protocol/types.ts +++ b/sdk/typescript/src/protocol/types.ts @@ -54,6 +54,12 @@ export type PaymentDetails = { export type ServiceDetails = { endpoint: string; + authorization_version?: number; + audience?: string; + method?: string; + resource?: string; + content_type?: string; + authorization_request_hash?: string; request_hash: string; response_hash: string; }; diff --git a/sdk/typescript/src/receipts.ts b/sdk/typescript/src/receipts.ts index 2787b011..f00e9b37 100644 --- a/sdk/typescript/src/receipts.ts +++ b/sdk/typescript/src/receipts.ts @@ -66,6 +66,23 @@ export function decodeReceiptHeader(headerValue: string): SignedReceipt { } function serializeReceiptForGateway(receipt: Receipt): string { + const service = { + endpoint: receipt.service.endpoint, + ...(receipt.service.authorization_version !== undefined && { + authorization_version: receipt.service.authorization_version, + }), + ...(receipt.service.audience !== undefined && { audience: receipt.service.audience }), + ...(receipt.service.method !== undefined && { method: receipt.service.method }), + ...(receipt.service.resource !== undefined && { resource: receipt.service.resource }), + ...(receipt.service.content_type !== undefined && { + content_type: receipt.service.content_type, + }), + ...(receipt.service.authorization_request_hash !== undefined && { + authorization_request_hash: receipt.service.authorization_request_hash, + }), + request_hash: receipt.service.request_hash, + response_hash: receipt.service.response_hash, + }; return JSON.stringify({ id: receipt.id, version: receipt.version, @@ -78,11 +95,7 @@ function serializeReceiptForGateway(receipt: Receipt): string { chainId: receipt.payment.chainId, nonce: receipt.payment.nonce, }, - service: { - endpoint: receipt.service.endpoint, - request_hash: receipt.service.request_hash, - response_hash: receipt.service.response_hash, - }, + service, }); } diff --git a/tests/README.md b/tests/README.md index fc2ee1a3..5f5cd5aa 100644 --- a/tests/README.md +++ b/tests/README.md @@ -5,10 +5,11 @@ The `tests/` directory contains Bun end-to-end coverage for the gateway and veri ## What The E2E Flow Covers - Unsigned `POST /api/ai/summarize` returns `402 Payment Required`. -- The 402 response includes a payment context with nonce, chain ID, and timestamp. -- A test wallet signs the payment context with EIP-712 typed data. -- The signed retry includes `X-402-Signature`, `X-402-Nonce`, and `X-402-Timestamp`. +- The 402 response includes a v2 context bound to the audience, method, resource, content type, and exact body hash. +- A test wallet signs the request-bound context and claimed payer with EIP-712 typed data. +- The signed retry includes `X-402-Signature`, `X-402-Nonce`, `X-402-Timestamp`, and `X-402-Payer`. - The signed request is accepted by the verifier and proceeds to the AI provider. +- Changing one request byte after signing returns `403 signer_mismatch`. - Reusing the same signed context returns `409 nonce_already_used`. ## Prerequisites @@ -17,7 +18,7 @@ The `tests/` directory contains Bun end-to-end coverage for the gateway and veri - Go toolchain - Rust toolchain - Ports `3000` and `3002` free -- `OPENROUTER_API_KEY` for the default OpenRouter gateway startup path +- No external AI key is required; the helper starts a deterministic OpenRouter-compatible mock when `OPENROUTER_API_KEY` is unset. When these variables are unset, the helper defaults the gateway to: @@ -85,7 +86,8 @@ The signed request may return `502 upstream_unavailable` or `504 upstream_timeou Failures that usually indicate payment-flow regressions: - Initial request is not `402`. -- Payment context lacks `nonce`, `chainId`, or `timestamp`. +- Payment context lacks v2 request-binding fields, `nonce`, `chainId`, or `timestamp`. - Signed retry returns `400 invalid_timestamp`. - Signed retry returns `403 invalid_signature`. +- A changed request does not return `403 signer_mismatch`. - Replay does not return `409 nonce_already_used`. diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 3f5ce3b7..c73e652b 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -1,8 +1,7 @@ -import { describe, it, expect, beforeAll } from "bun:test"; +import { describe, it, expect } from "bun:test"; import { ethers } from "ethers"; const GATEWAY_URL = "http://localhost:3000"; -const VERIFIER_URL = "http://localhost:3002"; // Mock wallet for testing const PRIVATE_KEY = "0x0123456789012345678901234567890123456789012345678901234567890123"; @@ -11,30 +10,52 @@ const wallet = new ethers.Wallet(PRIVATE_KEY); async function signPaymentContext(paymentContext: any) { const domain = { name: "MicroAI Paygate", - version: "1", + version: "2", chainId: paymentContext.chainId, verifyingContract: ethers.ZeroAddress, }; const types = { - Payment: [ + PaymentAuthorization: [ + { name: "payer", type: "address" }, { name: "recipient", type: "address" }, { name: "token", type: "string" }, { name: "amount", type: "string" }, { name: "nonce", type: "string" }, { name: "timestamp", type: "uint256" }, + { name: "audience", type: "string" }, + { name: "method", type: "string" }, + { name: "resource", type: "string" }, + { name: "contentType", type: "string" }, + { name: "requestHash", type: "bytes32" }, ], }; return wallet.signTypedData(domain, types, { + payer: wallet.address, recipient: paymentContext.recipient, token: paymentContext.token, amount: paymentContext.amount, nonce: paymentContext.nonce, timestamp: paymentContext.timestamp, + audience: paymentContext.audience, + method: paymentContext.method, + resource: paymentContext.resource, + contentType: paymentContext.contentType, + requestHash: paymentContext.requestHash, }); } +function signedHeaders(paymentContext: any, signature: string) { + return { + "Content-Type": "application/json", + "X-402-Signature": signature, + "X-402-Nonce": paymentContext.nonce, + "X-402-Timestamp": paymentContext.timestamp.toString(), + "X-402-Payer": wallet.address, + }; +} + describe("MicroAI Paygate E2E Flow", () => { it("should return 402 Payment Required initially", async () => { const res = await fetch(`${GATEWAY_URL}/api/ai/summarize`, { @@ -48,14 +69,19 @@ describe("MicroAI Paygate E2E Flow", () => { expect(data.error).toBe("Payment Required"); expect(data.paymentContext).toBeDefined(); expect(data.paymentContext.nonce).toBeDefined(); + expect(data.paymentContext.authorizationVersion).toBe(2); + expect(data.paymentContext.audience).toBe(GATEWAY_URL); + expect(data.paymentContext.method).toBe("POST"); + expect(data.paymentContext.resource).toBe("/api/ai/summarize"); }); it("should accept a valid signature and return result", async () => { // 1. Get Nonce + const body = JSON.stringify({ text: "This is a test text to summarize." }); const initRes = await fetch(`${GATEWAY_URL}/api/ai/summarize`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "Hello world" }), + body, }); const initData = await initRes.json() as any; const { paymentContext } = initData; @@ -66,13 +92,8 @@ describe("MicroAI Paygate E2E Flow", () => { // 3. Send Signed Request const res = await fetch(`${GATEWAY_URL}/api/ai/summarize`, { method: "POST", - headers: { - "Content-Type": "application/json", - "X-402-Signature": signature, - "X-402-Nonce": paymentContext.nonce, - "X-402-Timestamp": paymentContext.timestamp.toString(), - }, - body: JSON.stringify({ text: "This is a test text to summarize." }), + headers: signedHeaders(paymentContext, signature), + body, }); // Note: It might fail if OpenRouter credentials are missing/invalid, but we expect at least not 402/403. @@ -91,21 +112,16 @@ describe("MicroAI Paygate E2E Flow", () => { }, 30000); it("should reject replayed signed payment context", async () => { + const body = JSON.stringify({ text: "Replay protection test text." }); const initRes = await fetch(`${GATEWAY_URL}/api/ai/summarize`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: "Replay setup" }), + body, }); const initData = await initRes.json() as any; const { paymentContext } = initData; const signature = await signPaymentContext(paymentContext); - const body = JSON.stringify({ text: "Replay protection test text." }); - const headers = { - "Content-Type": "application/json", - "X-402-Signature": signature, - "X-402-Nonce": paymentContext.nonce, - "X-402-Timestamp": paymentContext.timestamp.toString(), - }; + const headers = signedHeaders(paymentContext, signature); const first = await fetch(`${GATEWAY_URL}/api/ai/summarize`, { method: "POST", @@ -132,4 +148,24 @@ describe("MicroAI Paygate E2E Flow", () => { const data = await second.json() as any; expect(data.error).toBe("nonce_already_used"); }, 30000); + + it("should reject a signed retry when one request byte changes", async () => { + const originalBody = JSON.stringify({ text: "Bound request" }); + const initRes = await fetch(`${GATEWAY_URL}/api/ai/summarize`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: originalBody, + }); + const { paymentContext } = await initRes.json() as any; + const signature = await signPaymentContext(paymentContext); + + const response = await fetch(`${GATEWAY_URL}/api/ai/summarize`, { + method: "POST", + headers: signedHeaders(paymentContext, signature), + body: originalBody + " ", + }); + + expect(response.status).toBe(403); + expect((await response.json() as any).error).toBe("signer_mismatch"); + }); }); diff --git a/tests/mock-openrouter.ts b/tests/mock-openrouter.ts new file mode 100644 index 00000000..ab253fc7 --- /dev/null +++ b/tests/mock-openrouter.ts @@ -0,0 +1,21 @@ +const port = Number(Bun.env.MOCK_OPENROUTER_PORT ?? "3100"); + +Bun.serve({ + hostname: "127.0.0.1", + port, + async fetch(request) { + if (request.method !== "POST") { + return new Response("Not found", { status: 404 }); + } + + const body = await request.json() as { + messages?: Array<{ content?: string }>; + }; + const text = body.messages?.at(-1)?.content ?? ""; + return Response.json({ + choices: [{ message: { content: `Mock summary: ${text.slice(0, 80)}` } }], + }); + }, +}); + +console.log(`Mock OpenRouter listening on http://127.0.0.1:${port}`); diff --git a/verifier/README.md b/verifier/README.md index 7de0f2cb..04c315e1 100644 --- a/verifier/README.md +++ b/verifier/README.md @@ -84,18 +84,25 @@ Request shape: ```json { "context": { + "authorizationVersion": 2, "recipient": "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219", "token": "USDC", "amount": "0.001", "nonce": "550e8400-e29b-41d4-a716-446655440000", "chainId": 84532, - "timestamp": 1700000000 + "timestamp": 1700000000, + "audience": "https://gateway.example.com", + "method": "POST", + "resource": "/api/ai/summarize", + "contentType": "application/json", + "requestHash": "0x8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9" }, - "signature": "0x..." + "signature": "0x...", + "payer": "0x14791697260E4c9A71f18484C9f997B308e59325" } ``` -The verifier also accepts the staged `authorizationVersion: 2` context, which adds `audience`, `method`, `resource`, `contentType`, and `requestHash`. V2 requests must include a top-level `payer` address that is also covered by the typed-data signature; the verifier rejects the request unless the recovered signer matches it. Legacy requests omit both fields until the gateway cutover. +V2 requests must include every request-binding field and a top-level `payer` covered by the EIP-712 signature. The verifier rejects the request unless the recovered signer matches that payer. `MIN_AUTHORIZATION_VERSION` defaults to `2`, so legacy v1 authorization fails closed; set it to `1` only for an explicit rollback window. Successful response: @@ -124,6 +131,7 @@ Important error codes: | --- | --- | | `invalid_signature` | Signature recovery failed or signer did not match the context. | | `invalid_authorization_context` | The v2 version, binding fields, or payer are missing or malformed. | +| `authorization_version_too_old` | Authorization is below `MIN_AUTHORIZATION_VERSION`. | | `signer_mismatch` | The v2 signature does not recover to the claimed payer. | | `chain_id_mismatch` | Payment context chain does not match verifier expectation. | | `timestamp_expired` | Timestamp is older than `SIGNATURE_EXPIRY_SECONDS`. | @@ -146,6 +154,7 @@ Returns Prometheus text-format metrics for verifier request volume, verification | `PORT` | `3002` | Listen port for the verifier service. Invalid values fall back to `3002` with a warning. | | `BIND_ADDRESS` | `0.0.0.0` | Network interface/address the verifier binds to. Invalid values fall back to `0.0.0.0` with a warning. | | `SIGNATURE_CLOCK_SKEW_SECONDS` | `60` | Allowed future timestamp skew. | +| `MIN_AUTHORIZATION_VERSION` | `2` | Minimum accepted authorization version. Set `1` only for an explicit rollback window. | | `VERIFIER_NONCE_STORE` | `memory` | Use `memory` locally/tests or `redis` for shared multi-replica replay protection. | | `REDIS_URL` | unset | Required when `VERIFIER_NONCE_STORE=redis`; accepts `host:port`, `redis://...`, or `rediss://...`. | | `VERIFIER_NONCE_KEY_PREFIX` | `microai:verifier:nonce:` | Redis key prefix for accepted nonce hashes. | diff --git a/verifier/src/main.rs b/verifier/src/main.rs index 97b51017..217f28ab 100644 --- a/verifier/src/main.rs +++ b/verifier/src/main.rs @@ -37,6 +37,7 @@ struct AppState { nonce_store: Arc, signature_expiry_seconds: u64, clock_skew_seconds: u64, + minimum_authorization_version: u8, } struct MemoryNonceStore { @@ -151,6 +152,14 @@ fn get_expected_chain_id() -> u64 { parse_chain_id_env("CHAIN_ID").unwrap_or(DEFAULT_EXPECTED_CHAIN_ID) } +fn get_minimum_authorization_version() -> Result { + let raw = env::var("MIN_AUTHORIZATION_VERSION").unwrap_or_else(|_| "2".to_string()); + match raw.trim().parse::() { + Ok(version @ 1..=2) => Ok(version), + _ => Err("MIN_AUTHORIZATION_VERSION must be 1 or 2".to_string()), + } +} + fn get_port() -> u16 { match std::env::var("PORT") { Ok(v) => match v.parse::() { @@ -304,6 +313,8 @@ async fn main() { nonce_store, signature_expiry_seconds: get_env_u64("SIGNATURE_EXPIRY_SECONDS", 300), clock_skew_seconds: get_env_u64("SIGNATURE_CLOCK_SKEW_SECONDS", 60), + minimum_authorization_version: get_minimum_authorization_version() + .expect("failed to configure minimum authorization version"), }; let recorder = PrometheusBuilder::new() .install_recorder() @@ -767,7 +778,7 @@ async fn verify_signature( }; // 3. Now that we have a safe payload, proceed with your existing logic - println!("[CID: {}] Verify nonce={}", cid, payload.context.nonce); + println!("[CID: {}] Verify payment authorization", cid); if payload.context.chain_id != state.expected_chain_id { record_verification_failure(&request_start, "chain_id_mismatch"); @@ -820,6 +831,27 @@ async fn verify_signature( ); } + let authorization_version = match payload.context.authorization.version { + AuthorizationVersion::Legacy => 1, + AuthorizationVersion::V2 => 2, + AuthorizationVersion::Unsupported(version) => version, + }; + if authorization_version < state.minimum_authorization_version { + record_verification_failure(&request_start, "authorization_downgrade"); + return ( + StatusCode::BAD_REQUEST, + res_headers, + Json(VerifyResponse { + is_valid: false, + recovered_address: None, + error: Some(format!( + "authorization version {authorization_version} is below the configured minimum" + )), + error_code: Some("authorization_version_too_old".to_string()), + }), + ); + } + let typed_data = match build_payment_typed_data(&payload.context, payload.payer.as_deref()) { Ok(td) => td, Err(e) => { @@ -1011,6 +1043,7 @@ mod tests { nonce_store, signature_expiry_seconds, clock_skew_seconds, + minimum_authorization_version: 1, } } @@ -1185,6 +1218,24 @@ mod tests { }); } + #[test] + fn test_minimum_authorization_version_defaults_to_v2_and_rejects_invalid_values() { + let _guard = ENV_LOCK.lock().unwrap(); + let old = env::var("MIN_AUTHORIZATION_VERSION").ok(); + + env::remove_var("MIN_AUTHORIZATION_VERSION"); + assert_eq!(get_minimum_authorization_version().unwrap(), 2); + env::set_var("MIN_AUTHORIZATION_VERSION", "1"); + assert_eq!(get_minimum_authorization_version().unwrap(), 1); + env::set_var("MIN_AUTHORIZATION_VERSION", "3"); + assert!(get_minimum_authorization_version().is_err()); + + match old { + Some(value) => env::set_var("MIN_AUTHORIZATION_VERSION", value), + None => env::remove_var("MIN_AUTHORIZATION_VERSION"), + } + } + #[test] fn test_normalize_redis_url_accepts_bare_host_port() { assert_eq!(normalize_redis_url("redis:6379"), "redis://redis:6379"); @@ -1415,16 +1466,63 @@ mod tests { } #[tokio::test] - async fn test_verify_signature_rejects_tampered_v2_binding_for_claimed_payer() { - let mut request = signed_v2_request(&unique_test_nonce()).await; - request.context.authorization.request_hash = Some(format!("0x{}", "00".repeat(32))); + async fn test_verify_signature_rejects_legacy_authorization_when_v2_is_required() { + let request = signed_request(&unique_test_nonce(), BASE_SEPOLIA_CHAIN_ID, now()).await; + let mut state = app_state(); + state.minimum_authorization_version = 2; let (status, _, Json(response)) = - verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(request))).await; + verify_signature(State(state), HeaderMap::new(), Ok(Json(request))).await; - assert_eq!(status, StatusCode::OK); + assert_eq!(status, StatusCode::BAD_REQUEST); assert!(!response.is_valid); - assert_eq!(response.error_code.as_deref(), Some("signer_mismatch")); + assert_eq!( + response.error_code.as_deref(), + Some("authorization_version_too_old") + ); + } + + #[tokio::test] + async fn test_verify_signature_rejects_tampered_v2_binding_for_claimed_payer() { + let request = signed_v2_request(&unique_test_nonce()).await; + let mut tampered_requests = Vec::new(); + + let mut tampered = request.clone(); + tampered.context.authorization.audience = Some("https://attacker.example".to_string()); + tampered_requests.push(("audience", tampered)); + + let mut tampered = request.clone(); + tampered.context.authorization.method = Some("GET".to_string()); + tampered_requests.push(("method", tampered)); + + let mut tampered = request.clone(); + tampered.context.authorization.resource = Some("/api/ai/other".to_string()); + tampered_requests.push(("resource", tampered)); + + let mut tampered = request.clone(); + tampered.context.authorization.content_type = Some("text/plain".to_string()); + tampered_requests.push(("content type", tampered)); + + let mut tampered = request.clone(); + tampered.context.authorization.request_hash = Some(format!("0x{}", "00".repeat(32))); + tampered_requests.push(("request hash", tampered)); + + let mut tampered = request; + tampered.payer = Some("0x0000000000000000000000000000000000000001".to_string()); + tampered_requests.push(("payer", tampered)); + + for (field, tampered) in tampered_requests { + let (status, _, Json(response)) = + verify_signature(State(app_state()), HeaderMap::new(), Ok(Json(tampered))).await; + + assert_eq!(status, StatusCode::OK, "{field}"); + assert!(!response.is_valid, "{field}"); + assert_eq!( + response.error_code.as_deref(), + Some("signer_mismatch"), + "{field}" + ); + } } #[tokio::test] @@ -2117,6 +2215,7 @@ mod tests { nonce_store: memory_nonce_store(), signature_expiry_seconds: 300, clock_skew_seconds: 60, + minimum_authorization_version: 1, }; let app = Router::new() .route("/verify", post(verify_signature)) diff --git a/web/src/app/docs/protocol/page.mdx b/web/src/app/docs/protocol/page.mdx index 6f84c290..b6edfeb8 100644 --- a/web/src/app/docs/protocol/page.mdx +++ b/web/src/app/docs/protocol/page.mdx @@ -9,9 +9,9 @@ It is not official x402-compatible yet because it does not use the official head 1. Client sends an unsigned request to the gateway. 2. Gateway returns `402 Payment Required` with a `paymentContext`. 3. Client signs the payment context with EIP-712. -4. Client retries with `X-402-Signature`, `X-402-Nonce`, and `X-402-Timestamp`. +4. Client retries with `X-402-Signature`, `X-402-Nonce`, `X-402-Timestamp`, and `X-402-Payer`. 5. Gateway sends the signed payload to the Rust verifier. -6. Verifier recovers the wallet, checks chain ID, timestamp, and nonce. +6. Verifier recovers the wallet and checks the claimed payer, request binding, chain ID, timestamp, and nonce. 7. Gateway calls the configured AI provider. 8. Gateway signs a receipt over the request and response hashes. 9. Client decodes and verifies the receipt. @@ -21,7 +21,7 @@ It is not official x402-compatible yet because it does not use the official head ```ts const domain = { name: "MicroAI Paygate", - version: "1", + version: "2", chainId: paymentContext.chainId, verifyingContract: ethers.ZeroAddress, }; @@ -31,24 +31,37 @@ const domain = { ```ts const types = { - Payment: [ + PaymentAuthorization: [ + { name: "payer", type: "address" }, { name: "recipient", type: "address" }, { name: "token", type: "string" }, { name: "amount", type: "string" }, { name: "nonce", type: "string" }, { name: "timestamp", type: "uint256" }, + { name: "audience", type: "string" }, + { name: "method", type: "string" }, + { name: "resource", type: "string" }, + { name: "contentType", type: "string" }, + { name: "requestHash", type: "bytes32" }, ], }; ``` -## Payment Context Fields +## Authorization Fields +- `authorizationVersion`: current request-bound contract version, `2`. +- `payer`: signer-derived wallet address sent separately as `X-402-Payer` and covered by the signature. - `recipient`: recipient wallet address. - `token`: display token symbol, currently `USDC`. - `amount`: requested payment amount as a string. - `nonce`: one-time replay protection value. - `chainId`: EIP-712 chain ID. - `timestamp`: signature freshness timestamp. +- `audience`: trusted public gateway origin. +- `method`: uppercase HTTP method. +- `resource`: encoded path plus raw query string. +- `contentType`: exact request content type. +- `requestHash`: SHA-256 hash of the exact serialized request bytes. ## Receipt Fields @@ -56,6 +69,7 @@ Receipts include: - payment payer, recipient, amount, token, chain ID, and nonce. - service endpoint. +- authorization version, audience, method, resource, content type, and authorization request hash. - `request_hash`. - `response_hash`. - gateway signature. diff --git a/web/src/lib/verify-receipt.ts b/web/src/lib/verify-receipt.ts index 36627db2..0d7043e2 100644 --- a/web/src/lib/verify-receipt.ts +++ b/web/src/lib/verify-receipt.ts @@ -20,9 +20,15 @@ export interface PaymentDetails { nonce: string; } -export interface ServiceDetails { - endpoint: string; - request_hash: string; +export interface ServiceDetails { + endpoint: string; + authorization_version?: number; + audience?: string; + method?: string; + resource?: string; + content_type?: string; + authorization_request_hash?: string; + request_hash: string; response_hash: string; } diff --git a/web/src/lib/x402-client.test.ts b/web/src/lib/x402-client.test.ts index 570ca1ed..431ea3d0 100644 --- a/web/src/lib/x402-client.test.ts +++ b/web/src/lib/x402-client.test.ts @@ -65,6 +65,14 @@ describe("request-bound payment authorization", () => { it("rejects unknown, partial, and malformed authorization versions", async () => { const invalidContexts = [ + { + recipient: authorizationV2Fixture.context.recipient, + token: "USDC", + amount: "0.001", + nonce: "legacy-context", + chainId: 84532, + timestamp: 1766611200, + }, { ...authorizationV2Fixture.context, authorizationVersion: 3 }, { ...authorizationV2Fixture.context, requestHash: undefined }, { ...authorizationV2Fixture.context, authorizationVersion: undefined }, diff --git a/web/src/lib/x402-client.ts b/web/src/lib/x402-client.ts index 4bb0daca..b9d2ea58 100644 --- a/web/src/lib/x402-client.ts +++ b/web/src/lib/x402-client.ts @@ -33,11 +33,6 @@ function isPaymentContext(value: unknown): value is PaymentContext { ) { return false; } - if (value.authorizationVersion === undefined) { - return !["audience", "method", "resource", "contentType", "requestHash"].some( - (field) => field in value, - ); - } return ( value.authorizationVersion === 2 && isNonEmptyString(value.audience) && From c869dacb949593d956ea2b6724722332172f70ca Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Thu, 16 Jul 2026 22:12:13 +0530 Subject: [PATCH 02/16] fix: run E2E dependency setup in service modules Co-authored-by: codex --- .github/workflows/e2e.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bc181c6f..7dca3db2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -67,8 +67,8 @@ jobs: - name: Prep Go/Rust deps run: | - go mod download - cargo fetch + go mod download -C gateway + cargo fetch --manifest-path verifier/Cargo.toml - name: Run deterministic E2E run: | From 16977c9d3cfacfb62c455b6ab14b1fdc7a4b7eec Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Thu, 16 Jul 2026 22:16:17 +0530 Subject: [PATCH 03/16] fix: wait for E2E services to become healthy Co-authored-by: codex --- .github/workflows/e2e.yml | 2 +- run_e2e.sh | 29 +++++++++++++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7dca3db2..5851d5e8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -73,4 +73,4 @@ jobs: - name: Run deterministic E2E run: | chmod +x ./run_e2e.sh - timeout 150 ./run_e2e.sh + timeout 300 ./run_e2e.sh diff --git a/run_e2e.sh b/run_e2e.sh index cfea7bdd..7fbb0f9c 100755 --- a/run_e2e.sh +++ b/run_e2e.sh @@ -2,6 +2,7 @@ # Get the directory where the script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +E2E_TMP_DIR="$(mktemp -d)" # Function to cleanup background processes on exit cleanup() { @@ -10,6 +11,7 @@ cleanup() { if [ -n "$(jobs -p)" ]; then jobs -p | xargs kill 2>/dev/null fi + rm -rf "$E2E_TMP_DIR" exit } @@ -41,12 +43,31 @@ if [ -z "$OPENROUTER_API_KEY" ]; then export OPENROUTER_URL="http://127.0.0.1:3100/api/v1/chat/completions" (cd "$SCRIPT_DIR" && bun tests/mock-openrouter.ts) & fi -go run . & +echo "Building Gateway..." +go build -o "$E2E_TMP_DIR/gateway" . || { echo "Gateway build failed"; exit 1; } +"$E2E_TMP_DIR/gateway" & GATEWAY_PID=$! -# Wait for services to be ready -echo "Waiting for services to initialize (10s)..." -sleep 10 +# Wait for both services to be ready instead of assuming a fixed build/start time. +echo "Waiting for services to initialize..." +SERVICES_READY=false +for _ in $(seq 1 60); do + if curl --fail --silent "http://127.0.0.1:3002/health" >/dev/null 2>&1 && \ + curl --fail --silent "http://localhost:3000/healthz" >/dev/null 2>&1; then + SERVICES_READY=true + break + fi + if ! kill -0 "$VERIFIER_PID" 2>/dev/null || ! kill -0 "$GATEWAY_PID" 2>/dev/null; then + echo "A service exited before becoming ready" + exit 1 + fi + sleep 1 +done + +if [ "$SERVICES_READY" != true ]; then + echo "Services did not become ready within 60 seconds" + exit 1 +fi echo "Running E2E Tests..." cd "$SCRIPT_DIR" || { echo "Error: Failed to change directory to $SCRIPT_DIR"; exit 1; } From 2d31b85111f7609c286c6a00ca420f8ad60b6bfb Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Thu, 16 Jul 2026 22:20:19 +0530 Subject: [PATCH 04/16] fix: harden authorization config and E2E cleanup Co-authored-by: codex --- gateway/config.go | 3 +++ gateway/config_test.go | 1 + run_e2e.sh | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/gateway/config.go b/gateway/config.go index 56a45ee7..3896b5c1 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -69,6 +69,9 @@ func normalizePaygateAudience() error { if parsed.Scheme != "http" && parsed.Scheme != "https" { return fmt.Errorf("PAYGATE_AUDIENCE scheme must be http or https") } + if parsed.Hostname() == "" { + return fmt.Errorf("PAYGATE_AUDIENCE must have a non-empty host") + } if parsed.User != nil { return fmt.Errorf("PAYGATE_AUDIENCE must not contain credentials") } diff --git a/gateway/config_test.go b/gateway/config_test.go index f4cf1c95..30541b52 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -44,6 +44,7 @@ func TestNormalizePaygateAudience(t *testing.T) { {name: "rejects query", value: "https://gateway.example.com?tenant=a", wantErr: "origin only"}, {name: "rejects credentials", value: "https://user@gateway.example.com", wantErr: "credentials"}, {name: "rejects unsupported scheme", value: "javascript://gateway.example.com", wantErr: "http or https"}, + {name: "rejects empty hostname", value: "https://:443", wantErr: "non-empty host"}, } for _, test := range tests { diff --git a/run_e2e.sh b/run_e2e.sh index 7fbb0f9c..f2e26fa1 100755 --- a/run_e2e.sh +++ b/run_e2e.sh @@ -6,13 +6,14 @@ E2E_TMP_DIR="$(mktemp -d)" # Function to cleanup background processes on exit cleanup() { + local exit_code=$? echo "Stopping services..." # Use a portable check for jobs since xargs -r is not available on all macOS versions if [ -n "$(jobs -p)" ]; then jobs -p | xargs kill 2>/dev/null fi rm -rf "$E2E_TMP_DIR" - exit + exit "$exit_code" } trap cleanup EXIT From 65d3b33858ea4c24f08bd3b7ea72b2e3b062104a Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Thu, 16 Jul 2026 22:32:39 +0530 Subject: [PATCH 05/16] fix: canonicalize payment audience default ports Co-authored-by: codex --- gateway/config.go | 14 +++++++++++++- gateway/config_test.go | 5 +++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/gateway/config.go b/gateway/config.go index 3896b5c1..a9c06af7 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -3,6 +3,7 @@ package main import ( "fmt" "log" + "net" "net/url" "os" "strings" @@ -79,7 +80,18 @@ func normalizePaygateAudience() error { return fmt.Errorf("PAYGATE_AUDIENCE must contain an origin only") } - canonical := parsed.Scheme + "://" + strings.ToLower(parsed.Host) + hostname := strings.ToLower(parsed.Hostname()) + port := parsed.Port() + if (parsed.Scheme == "https" && port == "443") || (parsed.Scheme == "http" && port == "80") { + port = "" + } + canonicalHost := hostname + if port != "" { + canonicalHost = net.JoinHostPort(hostname, port) + } else if strings.Contains(hostname, ":") { + canonicalHost = "[" + hostname + "]" + } + canonical := parsed.Scheme + "://" + canonicalHost if err := os.Setenv("PAYGATE_AUDIENCE", canonical); err != nil { return fmt.Errorf("failed to normalize PAYGATE_AUDIENCE: %w", err) } diff --git a/gateway/config_test.go b/gateway/config_test.go index 30541b52..6c74ca22 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -40,6 +40,11 @@ func TestNormalizePaygateAudience(t *testing.T) { }{ {name: "canonical origin", value: "https://gateway.example.com", want: "https://gateway.example.com"}, {name: "normalizes case and trailing slash", value: " HTTPS://Gateway.Example.COM/ ", want: "https://gateway.example.com"}, + {name: "strips default HTTPS port", value: "https://gateway.example.com:443", want: "https://gateway.example.com"}, + {name: "strips default HTTP port", value: "http://localhost:80", want: "http://localhost"}, + {name: "preserves non-default port", value: "https://gateway.example.com:8443", want: "https://gateway.example.com:8443"}, + {name: "normalizes IPv6 default port", value: "https://[::1]:443", want: "https://[::1]"}, + {name: "preserves IPv6 non-default port", value: "https://[::1]:8443", want: "https://[::1]:8443"}, {name: "rejects path", value: "https://gateway.example.com/api", wantErr: "origin only"}, {name: "rejects query", value: "https://gateway.example.com?tenant=a", wantErr: "origin only"}, {name: "rejects credentials", value: "https://user@gateway.example.com", wantErr: "credentials"}, From 2a6b4096f22a5bc5f144d78a9f919d1a57e9beb4 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Fri, 17 Jul 2026 10:53:54 +0530 Subject: [PATCH 06/16] fix: close payment v2 validation gaps Co-authored-by: codex --- README.md | 2 +- gateway/config.go | 7 +++++ gateway/config_test.go | 2 ++ tests/e2e.test.ts | 10 ------- web/scripts/deployed-smoke-test.ts | 45 ++++++++++++++++++++++-------- 5 files changed, 44 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 2489746c..875b28e9 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ Run the checks for every component you change: | Web | `cd web && bun run lint && bun run typecheck && bun run test:unit && bun run build` | | SDK | `cd sdk/typescript && bun run typecheck && bun run test` | | Unit suite | `bun run test:unit` | -| E2E | `RECEIPT_STORE=memory VERIFIER_NONCE_STORE=memory CACHE_ENABLED=false bun run test:e2e` — also requires `OPENROUTER_API_KEY` for the default provider | +| E2E | `RECEIPT_STORE=memory VERIFIER_NONCE_STORE=memory CACHE_ENABLED=false bun run test:e2e` — starts a deterministic OpenRouter-compatible mock when `OPENROUTER_API_KEY` is unset; set the key to exercise the live provider | > [!TIP] > Do not replace `bun run test:e2e` with plain `bun test`; the E2E script builds and starts the gateway and verifier first. diff --git a/gateway/config.go b/gateway/config.go index a9c06af7..7c816533 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -6,6 +6,7 @@ import ( "net" "net/url" "os" + "strconv" "strings" "time" ) @@ -82,6 +83,12 @@ func normalizePaygateAudience() error { hostname := strings.ToLower(parsed.Hostname()) port := parsed.Port() + if port != "" { + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + return fmt.Errorf("PAYGATE_AUDIENCE must contain a valid port") + } + } if (parsed.Scheme == "https" && port == "443") || (parsed.Scheme == "http" && port == "80") { port = "" } diff --git a/gateway/config_test.go b/gateway/config_test.go index 6c74ca22..5a507b23 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -45,6 +45,8 @@ func TestNormalizePaygateAudience(t *testing.T) { {name: "preserves non-default port", value: "https://gateway.example.com:8443", want: "https://gateway.example.com:8443"}, {name: "normalizes IPv6 default port", value: "https://[::1]:443", want: "https://[::1]"}, {name: "preserves IPv6 non-default port", value: "https://[::1]:8443", want: "https://[::1]:8443"}, + {name: "rejects zero port", value: "https://gateway.example.com:0", wantErr: "valid port"}, + {name: "rejects out-of-range port", value: "https://gateway.example.com:99999", wantErr: "valid port"}, {name: "rejects path", value: "https://gateway.example.com/api", wantErr: "origin only"}, {name: "rejects query", value: "https://gateway.example.com?tenant=a", wantErr: "origin only"}, {name: "rejects credentials", value: "https://user@gateway.example.com", wantErr: "credentials"}, diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index c73e652b..af690662 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -96,16 +96,6 @@ describe("MicroAI Paygate E2E Flow", () => { body, }); - // Note: It might fail if OpenRouter credentials are missing/invalid, but we expect at least not 402/403. - // If 502 with upstream_unavailable, it means verification passed and only the AI provider failed. - if (res.status === 502) { - const text = await res.text(); - if (text.includes("upstream_unavailable")) { - expect(true).toBe(true); - return; - } - } - expect(res.status).toBe(200); const data = await res.json() as any; expect(data.result).toBeDefined(); diff --git a/web/scripts/deployed-smoke-test.ts b/web/scripts/deployed-smoke-test.ts index 627907be..45e7652b 100644 --- a/web/scripts/deployed-smoke-test.ts +++ b/web/scripts/deployed-smoke-test.ts @@ -24,24 +24,36 @@ const VERIFIER = process.env.VERIFIER_URL ?? "https://microai-paygate.onrender.c const ORIGIN = process.env.ORIGIN ?? "https://microai-paygate.vercel.app"; const DOMAIN_NAME = "MicroAI Paygate"; -const DOMAIN_VERSION = "1"; +const DOMAIN_VERSION = "2"; type PaymentContext = { + authorizationVersion: 2; recipient: string; token: string; amount: string; nonce: string; chainId: number; timestamp: number; + audience: string; + method: string; + resource: string; + contentType: string; + requestHash: string; }; const types = { - Payment: [ + PaymentAuthorization: [ + { name: "payer", type: "address" }, { name: "recipient", type: "address" }, { name: "token", type: "string" }, { name: "amount", type: "string" }, { name: "nonce", type: "string" }, { name: "timestamp", type: "uint256" }, + { name: "audience", type: "string" }, + { name: "method", type: "string" }, + { name: "resource", type: "string" }, + { name: "contentType", type: "string" }, + { name: "requestHash", type: "bytes32" }, ], }; @@ -62,16 +74,17 @@ function timedFetch(url: string, init: RequestInit = {}): Promise { return fetch(url, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); } -async function getChallenge(text = "smoke"): Promise { +async function getChallenge(body: string): Promise { const r = await timedFetch(`${GATEWAY}/api/ai/summarize`, { method: "POST", headers: { "Content-Type": "application/json", Origin: ORIGIN }, - body: JSON.stringify({ text }), + body, }); return (await r.json()).paymentContext as PaymentContext; } async function signCtx(wallet: ethers.Signer, ctx: PaymentContext, chainIdOverride?: number) { + const payer = await wallet.getAddress(); return wallet.signTypedData( { name: DOMAIN_NAME, @@ -81,16 +94,22 @@ async function signCtx(wallet: ethers.Signer, ctx: PaymentContext, chainIdOverri }, types, { + payer, recipient: ctx.recipient, token: ctx.token, amount: ctx.amount, nonce: ctx.nonce, timestamp: ctx.timestamp, + audience: ctx.audience, + method: ctx.method, + resource: ctx.resource, + contentType: ctx.contentType, + requestHash: ctx.requestHash, }, ); } -async function postSigned(ctx: PaymentContext, sig: string) { +async function postSigned(ctx: PaymentContext, sig: string, payer: string, body: string) { const r = await timedFetch(`${GATEWAY}/api/ai/summarize`, { method: "POST", headers: { @@ -99,8 +118,9 @@ async function postSigned(ctx: PaymentContext, sig: string) { "X-402-Signature": sig, "X-402-Nonce": ctx.nonce, "X-402-Timestamp": String(ctx.timestamp), + "X-402-Payer": payer, }, - body: JSON.stringify({ text: "smoke test summarize" }), + body, }); return { status: r.status, body: await r.text(), headers: r.headers }; } @@ -111,10 +131,12 @@ async function main() { console.log(`Ephemeral wallet: ${wallet.address}`); bar("Happy path"); - const ctx = await getChallenge(); + const body = JSON.stringify({ text: "smoke test summarize" }); + const ctx = await getChallenge(body); + rec("challenge uses authorization v2", ctx.authorizationVersion === 2, String(ctx.authorizationVersion)); rec("recipient is EIP-55 canonical", (() => { try { return ethers.getAddress(ctx.recipient) === ctx.recipient; } catch { return false; } })(), ctx.recipient); const sig = await signCtx(wallet, ctx); - const ok = await postSigned(ctx, sig); + const ok = await postSigned(ctx, sig, wallet.address, body); rec("signed flow returns 200", ok.status === 200, `HTTP ${ok.status}`); const receiptHeader = ok.headers.get("x-402-receipt"); rec("X-402-Receipt header present", !!receiptHeader, receiptHeader ? "yes" : "missing"); @@ -129,13 +151,14 @@ async function main() { } bar("Negative cases"); - const replay = await postSigned(ctx, sig); + const replay = await postSigned(ctx, sig, wallet.address, body); rec("replay rejected with 409", replay.status === 409, `HTTP ${replay.status}`); - const expiredCtx = await getChallenge(); + const expiredBody = JSON.stringify({ text: "expired smoke test" }); + const expiredCtx = await getChallenge(expiredBody); expiredCtx.timestamp = Math.floor(Date.now() / 1000) - 3600; const expiredSig = await signCtx(wallet, expiredCtx); - const expired = await postSigned(expiredCtx, expiredSig); + const expired = await postSigned(expiredCtx, expiredSig, wallet.address, expiredBody); rec("expired timestamp rejected with 400", expired.status === 400, `HTTP ${expired.status}`); // Asserts on the CORS header rather than the HTTP status: Bun's From e9c6ea3a5f5f9ba5c4ac4032d4dd2dea7f22683c Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Fri, 17 Jul 2026 11:18:38 +0530 Subject: [PATCH 07/16] fix: harden payment smoke validation Co-authored-by: codex --- gateway/config.go | 11 ++++++++++- gateway/config_test.go | 1 + gateway/go.mod | 2 +- run_e2e.sh | 1 + web/scripts/deployed-smoke-test.ts | 23 ++++++++++++++++++++++- 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/gateway/config.go b/gateway/config.go index 7c816533..23053be8 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -9,6 +9,8 @@ import ( "strconv" "strings" "time" + + "golang.org/x/net/idna" ) var defaultAllowedOrigins = []string{"http://localhost:3001"} @@ -81,7 +83,14 @@ func normalizePaygateAudience() error { return fmt.Errorf("PAYGATE_AUDIENCE must contain an origin only") } - hostname := strings.ToLower(parsed.Hostname()) + hostname := parsed.Hostname() + if !strings.Contains(hostname, ":") { + hostname, err = idna.Lookup.ToASCII(hostname) + if err != nil { + return fmt.Errorf("PAYGATE_AUDIENCE must contain a valid hostname: %w", err) + } + } + hostname = strings.ToLower(hostname) port := parsed.Port() if port != "" { portNumber, err := strconv.Atoi(port) diff --git a/gateway/config_test.go b/gateway/config_test.go index 5a507b23..b8a2622f 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -40,6 +40,7 @@ func TestNormalizePaygateAudience(t *testing.T) { }{ {name: "canonical origin", value: "https://gateway.example.com", want: "https://gateway.example.com"}, {name: "normalizes case and trailing slash", value: " HTTPS://Gateway.Example.COM/ ", want: "https://gateway.example.com"}, + {name: "normalizes international hostname", value: "https://測試", want: "https://xn--g6w251d"}, {name: "strips default HTTPS port", value: "https://gateway.example.com:443", want: "https://gateway.example.com"}, {name: "strips default HTTP port", value: "http://localhost:80", want: "http://localhost"}, {name: "preserves non-default port", value: "https://gateway.example.com:8443", want: "https://gateway.example.com:8443"}, diff --git a/gateway/go.mod b/gateway/go.mod index 2675f756..c9aca6be 100644 --- a/gateway/go.mod +++ b/gateway/go.mod @@ -15,6 +15,7 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.21.0 github.com/stretchr/testify v1.11.1 + golang.org/x/net v0.47.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -58,7 +59,6 @@ require ( go.yaml.in/yaml/v2 v2.4.2 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/crypto v0.45.0 // indirect - golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.31.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/run_e2e.sh b/run_e2e.sh index f2e26fa1..16e11ed7 100755 --- a/run_e2e.sh +++ b/run_e2e.sh @@ -40,6 +40,7 @@ export SERVER_WALLET_PRIVATE_KEY="${SERVER_WALLET_PRIVATE_KEY:-0123456789abcdef0 export RECIPIENT_ADDRESS="${RECIPIENT_ADDRESS:-0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219}" if [ -z "$OPENROUTER_API_KEY" ]; then echo "Starting deterministic OpenRouter mock..." + export AI_PROVIDER="openrouter" export OPENROUTER_API_KEY="e2e-test-key" export OPENROUTER_URL="http://127.0.0.1:3100/api/v1/chat/completions" (cd "$SCRIPT_DIR" && bun tests/mock-openrouter.ts) & diff --git a/web/scripts/deployed-smoke-test.ts b/web/scripts/deployed-smoke-test.ts index 45e7652b..73a74fa6 100644 --- a/web/scripts/deployed-smoke-test.ts +++ b/web/scripts/deployed-smoke-test.ts @@ -21,7 +21,7 @@ import { ethers } from "ethers"; const GATEWAY = process.env.GATEWAY_URL ?? "https://microai-gateway.onrender.com"; const VERIFIER = process.env.VERIFIER_URL ?? "https://microai-paygate.onrender.com"; -const ORIGIN = process.env.ORIGIN ?? "https://microai-paygate.vercel.app"; +const ORIGIN = new URL(process.env.ORIGIN ?? "https://microai-paygate.vercel.app").origin; const DOMAIN_NAME = "MicroAI Paygate"; const DOMAIN_VERSION = "2"; @@ -109,6 +109,21 @@ async function signCtx(wallet: ethers.Signer, ctx: PaymentContext, chainIdOverri ); } +function validateChallengeBinding(ctx: PaymentContext, body: string): boolean { + const expectedAudience = new URL(GATEWAY).origin; + const expectedRequestHash = ethers.sha256(ethers.toUtf8Bytes(body)); + const checks = [ + ["challenge audience matches gateway", ctx.audience === expectedAudience, ctx.audience], + ["challenge method is POST", ctx.method === "POST", ctx.method], + ["challenge resource matches request", ctx.resource === "/api/ai/summarize", ctx.resource], + ["challenge content type matches request", ctx.contentType === "application/json", ctx.contentType], + ["challenge request hash matches body", ctx.requestHash === expectedRequestHash, ctx.requestHash], + ] as const; + + for (const [label, ok, detail] of checks) rec(label, ok, detail); + return checks.every(([, ok]) => ok); +} + async function postSigned(ctx: PaymentContext, sig: string, payer: string, body: string) { const r = await timedFetch(`${GATEWAY}/api/ai/summarize`, { method: "POST", @@ -135,9 +150,15 @@ async function main() { const ctx = await getChallenge(body); rec("challenge uses authorization v2", ctx.authorizationVersion === 2, String(ctx.authorizationVersion)); rec("recipient is EIP-55 canonical", (() => { try { return ethers.getAddress(ctx.recipient) === ctx.recipient; } catch { return false; } })(), ctx.recipient); + if (ctx.authorizationVersion !== 2 || !validateChallengeBinding(ctx, body)) { + bar(`Summary: ${failures} failure(s)`); + process.exit(1); + } const sig = await signCtx(wallet, ctx); const ok = await postSigned(ctx, sig, wallet.address, body); rec("signed flow returns 200", ok.status === 200, `HTTP ${ok.status}`); + const allowOrigin = ok.headers.get("access-control-allow-origin"); + rec("signed response allows deployed web origin", allowOrigin === ORIGIN, allowOrigin ?? "missing"); const receiptHeader = ok.headers.get("x-402-receipt"); rec("X-402-Receipt header present", !!receiptHeader, receiptHeader ? "yes" : "missing"); From 21363a4b7ff0ca2ca79718965a73da0199f1ae86 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Fri, 17 Jul 2026 11:27:43 +0530 Subject: [PATCH 08/16] fix: wait for e2e mock readiness Co-authored-by: codex --- run_e2e.sh | 24 +++++++++++++++++++----- tests/mock-openrouter.ts | 3 +++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/run_e2e.sh b/run_e2e.sh index 16e11ed7..f0a00b78 100755 --- a/run_e2e.sh +++ b/run_e2e.sh @@ -38,12 +38,14 @@ export VERIFIER_URL="${VERIFIER_URL:-http://127.0.0.1:3002}" export PAYGATE_AUDIENCE="${PAYGATE_AUDIENCE:-http://localhost:3000}" export SERVER_WALLET_PRIVATE_KEY="${SERVER_WALLET_PRIVATE_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}" export RECIPIENT_ADDRESS="${RECIPIENT_ADDRESS:-0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219}" +MOCK_PID="" if [ -z "$OPENROUTER_API_KEY" ]; then echo "Starting deterministic OpenRouter mock..." export AI_PROVIDER="openrouter" export OPENROUTER_API_KEY="e2e-test-key" export OPENROUTER_URL="http://127.0.0.1:3100/api/v1/chat/completions" (cd "$SCRIPT_DIR" && bun tests/mock-openrouter.ts) & + MOCK_PID=$! fi echo "Building Gateway..." go build -o "$E2E_TMP_DIR/gateway" . || { echo "Gateway build failed"; exit 1; } @@ -54,15 +56,27 @@ GATEWAY_PID=$! echo "Waiting for services to initialize..." SERVICES_READY=false for _ in $(seq 1 60); do - if curl --fail --silent "http://127.0.0.1:3002/health" >/dev/null 2>&1 && \ - curl --fail --silent "http://localhost:3000/healthz" >/dev/null 2>&1; then - SERVICES_READY=true - break - fi if ! kill -0 "$VERIFIER_PID" 2>/dev/null || ! kill -0 "$GATEWAY_PID" 2>/dev/null; then echo "A service exited before becoming ready" exit 1 fi + if [ -n "$MOCK_PID" ] && ! kill -0 "$MOCK_PID" 2>/dev/null; then + echo "The OpenRouter mock exited before becoming ready" + exit 1 + fi + MOCK_READY=true + if [ -n "$MOCK_PID" ]; then + MOCK_READY=false + if curl --fail --silent "http://127.0.0.1:3100/health" >/dev/null 2>&1; then + MOCK_READY=true + fi + fi + if curl --fail --silent "http://127.0.0.1:3002/health" >/dev/null 2>&1 && \ + curl --fail --silent "http://localhost:3000/healthz" >/dev/null 2>&1 && \ + [ "$MOCK_READY" = true ]; then + SERVICES_READY=true + break + fi sleep 1 done diff --git a/tests/mock-openrouter.ts b/tests/mock-openrouter.ts index ab253fc7..a847806f 100644 --- a/tests/mock-openrouter.ts +++ b/tests/mock-openrouter.ts @@ -4,6 +4,9 @@ Bun.serve({ hostname: "127.0.0.1", port, async fetch(request) { + if (request.method === "GET" && new URL(request.url).pathname === "/health") { + return Response.json({ status: "ok" }); + } if (request.method !== "POST") { return new Response("Not found", { status: 404 }); } From 58190f93b5d098873bdf1a1ebf0a07933953ee94 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Fri, 17 Jul 2026 11:51:15 +0530 Subject: [PATCH 09/16] fix: validate signed retry preflight Co-authored-by: codex --- web/scripts/deployed-smoke-test.ts | 43 ++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/web/scripts/deployed-smoke-test.ts b/web/scripts/deployed-smoke-test.ts index 73a74fa6..be09d8f2 100644 --- a/web/scripts/deployed-smoke-test.ts +++ b/web/scripts/deployed-smoke-test.ts @@ -25,6 +25,13 @@ const ORIGIN = new URL(process.env.ORIGIN ?? "https://microai-paygate.vercel.app const DOMAIN_NAME = "MicroAI Paygate"; const DOMAIN_VERSION = "2"; +const SIGNED_RETRY_HEADERS = [ + "content-type", + "x-402-signature", + "x-402-nonce", + "x-402-timestamp", + "x-402-payer", +] as const; type PaymentContext = { authorizationVersion: 2; @@ -140,12 +147,48 @@ async function postSigned(ctx: PaymentContext, sig: string, payer: string, body: return { status: r.status, body: await r.text(), headers: r.headers }; } +async function validateSignedRetryPreflight(): Promise { + const response = await timedFetch(`${GATEWAY}/api/ai/summarize`, { + method: "OPTIONS", + headers: { + Origin: ORIGIN, + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": SIGNED_RETRY_HEADERS.join(","), + }, + }); + const allowOrigin = response.headers.get("access-control-allow-origin"); + const allowMethods = new Set( + (response.headers.get("access-control-allow-methods") ?? "") + .split(",") + .map((value) => value.trim().toLowerCase()), + ); + const allowHeaders = new Set( + (response.headers.get("access-control-allow-headers") ?? "") + .split(",") + .map((value) => value.trim().toLowerCase()), + ); + const missingHeaders = SIGNED_RETRY_HEADERS.filter((header) => !allowHeaders.has(header)); + + const checks = [ + ["signed retry preflight succeeds", response.ok, `HTTP ${response.status}`], + ["preflight allows deployed web origin", allowOrigin === ORIGIN, allowOrigin ?? "missing"], + ["preflight allows POST", allowMethods.has("post"), [...allowMethods].join(",") || "missing"], + ["preflight allows signed retry headers", missingHeaders.length === 0, missingHeaders.join(",") || "all"], + ] as const; + for (const [label, ok, detail] of checks) rec(label, ok, detail); + return checks.every(([, ok]) => ok); +} + async function main() { console.log(`Targets:\n gateway ${GATEWAY}\n verifier ${VERIFIER}\n origin ${ORIGIN}`); const wallet = ethers.Wallet.createRandom(); console.log(`Ephemeral wallet: ${wallet.address}`); bar("Happy path"); + if (!(await validateSignedRetryPreflight())) { + bar(`Summary: ${failures} failure(s)`); + process.exit(1); + } const body = JSON.stringify({ text: "smoke test summarize" }); const ctx = await getChallenge(body); rec("challenge uses authorization v2", ctx.authorizationVersion === 2, String(ctx.authorizationVersion)); From 2e753435f2a256963260e8142713bdda04a9d5b5 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Mon, 20 Jul 2026 17:43:58 +0530 Subject: [PATCH 10/16] fix: address authorization review gaps Co-authored-by: codex --- .github/workflows/e2e.yml | 2 ++ DEPLOY.md | 6 ++-- gateway/README.md | 6 ++-- gateway/cache_integration_test.go | 49 ++++++++++++++++++++---------- gateway/config.go | 3 ++ gateway/config_test.go | 1 + gateway/errors_test.go | 2 +- run_e2e.sh | 4 +-- tests/README.md | 4 +-- tests/mock-openrouter.ts | 6 +++- web/scripts/deployed-smoke-test.ts | 3 ++ web/src/app/docs/protocol/page.mdx | 2 +- 12 files changed, 59 insertions(+), 29 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5851d5e8..fc66db1d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -48,6 +48,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false - name: Set up Go uses: actions/setup-go@v6 diff --git a/DEPLOY.md b/DEPLOY.md index 26e2e9b2..d697383a 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -105,7 +105,7 @@ curl https://.onrender.com/health | Dockerfile Path | `gateway/Dockerfile` | | Health Check Path | `/healthz` (note the trailing `z` — differs from the verifier) | -3. Environment variables. Use Render's **"Add from .env"** button and paste the block below, then fill in the four `<...>` placeholders with real values: +3. Environment variables. Use Render's **"Add from .env"** button and paste the block below, then fill in all `<...>` placeholders with real values: ```env OPENROUTER_API_KEY= @@ -118,7 +118,7 @@ VERIFIER_URL=https://.onrender.com PAYGATE_AUDIENCE=https://.onrender.com CHAIN_ID=84532 PAYMENT_AMOUNT=0.001 -ALLOWED_ORIGINS=* +ALLOWED_ORIGINS=https://.vercel.app TRUSTED_PROXIES=0.0.0.0/0 VERIFIER_TIMEOUT_SECONDS=60 PORT=3000 @@ -126,7 +126,7 @@ PORT=3000 > **Important:** `RECIPIENT_ADDRESS` must be the canonical EIP-55-checksummed form, not lowercased or arbitrary-case. The browser wallet rejects malformed checksums with `bad address checksum` during signing. > -> **CORS:** `ALLOWED_ORIGINS=*` is permissive; tighten it after web deploy in step 5. +> **CORS:** `ALLOWED_ORIGINS` must be the exact public web origin. Wildcards are rejected, and paths, queries, and fragments are not allowed. > > **Verifier timeout:** the default `2s` is too short for Render free-tier cold-starts. `60s` lets the verifier wake up during the first signed request. diff --git a/gateway/README.md b/gateway/README.md index 729a2845..db866d65 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -25,7 +25,7 @@ sequenceDiagram C->>G: POST /api/ai/summarize G-->>C: 402 + paymentContext(recipient, token, amount, chainId, nonce, timestamp) - C->>G: Retry with X-402-Signature, nonce, timestamp, payer + C->>G: Retry with X-402-Signature, X-402-Nonce, X-402-Timestamp, X-402-Payer G->>V: POST /verify with reconstructed context V-->>G: valid + recovered wallet or structured error_code alt Optional Redis response cache hit @@ -81,6 +81,8 @@ Required for normal OpenRouter gateway startup: | --- | --- | | `OPENROUTER_API_KEY` | Required when `AI_PROVIDER` is unset or `openrouter`. | | `SERVER_WALLET_PRIVATE_KEY` | Required. Signs receipts. Use an unfunded development key locally. | +| `VERIFIER_URL` | Required. Where the gateway calls `/verify`. Use `http://127.0.0.1:3002` for `bun run stack`, `http://verifier:3002` in Compose, or the platform's HTTPS URL in production. | +| `PAYGATE_AUDIENCE` | Required. Trusted public gateway origin included in every v2 authorization. Paths, queries, fragments, credentials, and non-HTTP schemes are rejected at startup. | | `REDIS_URL` | Required when `RECEIPT_STORE=redis` or `CACHE_ENABLED=true`. | Common optional variables: @@ -93,8 +95,6 @@ Common optional variables: | `OPENROUTER_URL` | `https://openrouter.ai/api/v1/chat/completions` provider default | Used by tests and custom OpenRouter-compatible endpoints. | | `OLLAMA_URL` | `http://localhost:11434` | Used when `AI_PROVIDER=ollama`. | | `OLLAMA_MODEL` | `llama2` provider default | Used when `AI_PROVIDER=ollama`. | -| `VERIFIER_URL` | **required** (no fallback) | Where the gateway calls `/verify`. Use `http://127.0.0.1:3002` for `bun run stack`, `http://verifier:3002` in Compose, the platform's HTTPS URL in production. The gateway refuses to start if unset. | -| `PAYGATE_AUDIENCE` | **required** | Trusted public gateway origin included in every v2 authorization. Origins only; paths, queries, fragments, credentials, and non-HTTP schemes are rejected at startup. | | `RECIPIENT_ADDRESS` | Development fallback address | Recipient embedded in payment contexts. | | `PAYMENT_AMOUNT` | `0.001` | Amount string embedded in payment contexts. | | `CHAIN_ID` | `84532` | Base Sepolia by default. Must match verifier `EXPECTED_CHAIN_ID`. | diff --git a/gateway/cache_integration_test.go b/gateway/cache_integration_test.go index 087ace86..b8f8e1bf 100644 --- a/gateway/cache_integration_test.go +++ b/gateway/cache_integration_test.go @@ -3,11 +3,14 @@ package main import ( "bytes" "context" + "crypto/sha256" "encoding/json" + "fmt" "gateway/internal/ai" "net/http" "net/http/httptest" "strconv" + "sync" "sync/atomic" "testing" "time" @@ -28,6 +31,8 @@ func TestCacheIntegration_FullFlow(t *testing.T) { // 3. Setup Dependencies (Environment) // Mock Verifier + var verifierMu sync.Mutex + var verifierRequestHashes []string verifier := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Mock validation based on signature var req VerifyRequest @@ -35,6 +40,9 @@ func TestCacheIntegration_FullFlow(t *testing.T) { http.Error(w, "Invalid verification request", http.StatusBadRequest) return } + verifierMu.Lock() + verifierRequestHashes = append(verifierRequestHashes, req.Context.RequestHash) + verifierMu.Unlock() isValid := req.Signature == "0xValidSig" resp := VerifyResponse{ @@ -99,18 +107,21 @@ func TestCacheIntegration_FullFlow(t *testing.T) { // 5. Test execution textToSummarize := "This is a unique text for cache integration test " + time.Now().String() + compactBody, err := json.Marshal(map[string]string{"text": textToSummarize}) + if err != nil { + t.Fatalf("Failed to marshal compact request body: %v", err) + } + var spacedBody bytes.Buffer + if err := json.Indent(&spacedBody, compactBody, "", " "); err != nil { + t.Fatalf("Failed to format request body: %v", err) + } model := "z-ai/glm-4.5-air:free" // Default model cacheKey := getCacheKey(textToSummarize, model) // Helper to make request - makeRequest := func(sig string) *httptest.ResponseRecorder { + makeRequest := func(sig string, rawBody []byte) *httptest.ResponseRecorder { t.Helper() - reqBody := map[string]string{"text": textToSummarize} - jsonBody, err := json.Marshal(reqBody) - if err != nil { - t.Fatalf("Failed to marshal request body: %v", err) - } - req, err := http.NewRequest("POST", "/api/ai/summarize", bytes.NewBuffer(jsonBody)) + req, err := http.NewRequest("POST", "/api/ai/summarize", bytes.NewReader(rawBody)) if err != nil { t.Fatalf("Failed to create request: %v", err) } @@ -131,7 +142,7 @@ func TestCacheIntegration_FullFlow(t *testing.T) { // Request 1: Cache Miss (Valid Sig) start := time.Now() - w1 := makeRequest("0xValidSig") + w1 := makeRequest("0xValidSig", compactBody) duration1 := time.Since(start) if w1.Code != 200 { @@ -160,7 +171,7 @@ func TestCacheIntegration_FullFlow(t *testing.T) { // Request 2: Cache Hit (Valid Sig) start = time.Now() - w2 := makeRequest("0xValidSig") + w2 := makeRequest("0xValidSig", spacedBody.Bytes()) duration2 := time.Since(start) if w2.Code != 200 { @@ -169,24 +180,30 @@ func TestCacheIntegration_FullFlow(t *testing.T) { if aiCalls.Load() != 1 { t.Errorf("Expected AI calls to stay at 1, got %d (Cache Miss?)", aiCalls.Load()) } + verifierMu.Lock() + gotHashes := append([]string(nil), verifierRequestHashes...) + verifierMu.Unlock() + if len(gotHashes) < 2 { + t.Fatalf("Expected verifier requests for cache miss and hit, got %d", len(gotHashes)) + } + wantCompactHash := fmt.Sprintf("0x%x", sha256.Sum256(compactBody)) + wantSpacedHash := fmt.Sprintf("0x%x", sha256.Sum256(spacedBody.Bytes())) + if gotHashes[0] != wantCompactHash || gotHashes[1] != wantSpacedHash { + t.Fatalf("Verifier request hashes = %v, want [%s %s]", gotHashes[:2], wantCompactHash, wantSpacedHash) + } // Duration Check (should be significantly faster) if duration2 > 50*time.Millisecond { t.Logf("Warning: Cache hit was slow (%v), but logic verified.", duration2) } // Security Check: Cache HIT but INVALID Signature - w3 := makeRequest("0xInvalidSig") + w3 := makeRequest("0xInvalidSig", compactBody) if w3.Code != 403 { t.Errorf("Expected status 403 for invalid signature on cache hit, got %d", w3.Code) } // Security Check: Cache HIT but MISSING Signature - reqBody := map[string]string{"text": textToSummarize} - jsonBody, err := json.Marshal(reqBody) - if err != nil { - t.Fatalf("Failed to marshal request body: %v", err) - } - reqNoSig, err := http.NewRequest("POST", "/api/ai/summarize", bytes.NewBuffer(jsonBody)) + reqNoSig, err := http.NewRequest("POST", "/api/ai/summarize", bytes.NewReader(compactBody)) if err != nil { t.Fatalf("Failed to create request: %v", err) } diff --git a/gateway/config.go b/gateway/config.go index 23053be8..501b242c 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -84,6 +84,9 @@ func normalizePaygateAudience() error { } hostname := parsed.Hostname() + if strings.Contains(hostname, ":") && strings.Contains(hostname, "%") { + return fmt.Errorf("PAYGATE_AUDIENCE must contain a valid hostname") + } if !strings.Contains(hostname, ":") { hostname, err = idna.Lookup.ToASCII(hostname) if err != nil { diff --git a/gateway/config_test.go b/gateway/config_test.go index b8a2622f..95cb3c07 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -46,6 +46,7 @@ func TestNormalizePaygateAudience(t *testing.T) { {name: "preserves non-default port", value: "https://gateway.example.com:8443", want: "https://gateway.example.com:8443"}, {name: "normalizes IPv6 default port", value: "https://[::1]:443", want: "https://[::1]"}, {name: "preserves IPv6 non-default port", value: "https://[::1]:8443", want: "https://[::1]:8443"}, + {name: "rejects IPv6 zone identifier", value: "https://[fe80::1%25eth0]", wantErr: "valid hostname"}, {name: "rejects zero port", value: "https://gateway.example.com:0", wantErr: "valid port"}, {name: "rejects out-of-range port", value: "https://gateway.example.com:99999", wantErr: "valid port"}, {name: "rejects path", value: "https://gateway.example.com/api", wantErr: "origin only"}, diff --git a/gateway/errors_test.go b/gateway/errors_test.go index a9ecf8f5..2932fefa 100644 --- a/gateway/errors_test.go +++ b/gateway/errors_test.go @@ -217,7 +217,7 @@ func TestHandleSummarizeMapsAuthorizationRejections(t *testing.T) { } func TestHandleSummarizeMapsSignerMismatch(t *testing.T) { - withVerifierResponse(t, http.StatusOK, `{"is_valid":false,"recovered_address":"0x0000000000000000000000000000000000000001","error":"SENSITIVE_SIGNER_DETAIL","error_code":"signer_mismatch"}`) + withVerifierResponse(t, http.StatusForbidden, `{"is_valid":false,"recovered_address":"0x0000000000000000000000000000000000000001","error":"SENSITIVE_SIGNER_DETAIL","error_code":"signer_mismatch"}`) router := newSummarizeTestRouter() recorder := httptest.NewRecorder() diff --git a/run_e2e.sh b/run_e2e.sh index f0a00b78..440a169c 100755 --- a/run_e2e.sh +++ b/run_e2e.sh @@ -55,7 +55,7 @@ GATEWAY_PID=$! # Wait for both services to be ready instead of assuming a fixed build/start time. echo "Waiting for services to initialize..." SERVICES_READY=false -for _ in $(seq 1 60); do +for ((attempt = 1; attempt <= 60; attempt++)); do if ! kill -0 "$VERIFIER_PID" 2>/dev/null || ! kill -0 "$GATEWAY_PID" 2>/dev/null; then echo "A service exited before becoming ready" exit 1 @@ -72,7 +72,7 @@ for _ in $(seq 1 60); do fi fi if curl --fail --silent "http://127.0.0.1:3002/health" >/dev/null 2>&1 && \ - curl --fail --silent "http://localhost:3000/healthz" >/dev/null 2>&1 && \ + curl --fail --silent "http://localhost:3000/readyz" >/dev/null 2>&1 && \ [ "$MOCK_READY" = true ]; then SERVICES_READY=true break diff --git a/tests/README.md b/tests/README.md index 5f5cd5aa..6b67fb71 100644 --- a/tests/README.md +++ b/tests/README.md @@ -17,7 +17,7 @@ The `tests/` directory contains Bun end-to-end coverage for the gateway and veri - Bun - Go toolchain - Rust toolchain -- Ports `3000` and `3002` free +- Ports `3000` and `3002` free, plus `3100` when `OPENROUTER_API_KEY` is unset - No external AI key is required; the helper starts a deterministic OpenRouter-compatible mock when `OPENROUTER_API_KEY` is unset. When these variables are unset, the helper defaults the gateway to: @@ -81,7 +81,7 @@ Use only unfunded local or test wallet keys for live SDK tests. `PAYGATE_SERVER_ ## Reading Failures -The signed request may return `502 upstream_unavailable` or `504 upstream_timeout` after payment verification succeeds if OpenRouter is unavailable or slow. That usually means the x402 verification path passed and only the upstream AI call failed. +When a live `OPENROUTER_API_KEY` is supplied, the signed request may return `502 upstream_unavailable` or `504 upstream_timeout` if OpenRouter is unavailable or slow. The default deterministic mock path must return `200`. Failures that usually indicate payment-flow regressions: diff --git a/tests/mock-openrouter.ts b/tests/mock-openrouter.ts index a847806f..29f9fecd 100644 --- a/tests/mock-openrouter.ts +++ b/tests/mock-openrouter.ts @@ -4,9 +4,13 @@ Bun.serve({ hostname: "127.0.0.1", port, async fetch(request) { - if (request.method === "GET" && new URL(request.url).pathname === "/health") { + const path = new URL(request.url).pathname; + if (request.method === "GET" && path === "/health") { return Response.json({ status: "ok" }); } + if (request.method === "GET" && path === "/api/v1/models") { + return Response.json({ data: [] }); + } if (request.method !== "POST") { return new Response("Not found", { status: 404 }); } diff --git a/web/scripts/deployed-smoke-test.ts b/web/scripts/deployed-smoke-test.ts index be09d8f2..61267cb2 100644 --- a/web/scripts/deployed-smoke-test.ts +++ b/web/scripts/deployed-smoke-test.ts @@ -87,6 +87,9 @@ async function getChallenge(body: string): Promise { headers: { "Content-Type": "application/json", Origin: ORIGIN }, body, }); + const allowOrigin = r.headers.get("access-control-allow-origin"); + rec("challenge returns 402", r.status === 402, `HTTP ${r.status}`); + rec("challenge allows deployed web origin", allowOrigin === ORIGIN, allowOrigin ?? "missing"); return (await r.json()).paymentContext as PaymentContext; } diff --git a/web/src/app/docs/protocol/page.mdx b/web/src/app/docs/protocol/page.mdx index b6edfeb8..3878b015 100644 --- a/web/src/app/docs/protocol/page.mdx +++ b/web/src/app/docs/protocol/page.mdx @@ -61,7 +61,7 @@ const types = { - `method`: uppercase HTTP method. - `resource`: encoded path plus raw query string. - `contentType`: exact request content type. -- `requestHash`: SHA-256 hash of the exact serialized request bytes. +- `requestHash`: SHA-256 hash of the exact serialized request body bytes. ## Receipt Fields From 68239b759cf2df1634f306a2ac624f1bd41b8e95 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Mon, 20 Jul 2026 18:01:03 +0530 Subject: [PATCH 11/16] fix: cover authorization preflight contract Co-authored-by: codex --- CONTRIBUTING.md | 6 ++-- gateway/config.go | 1 + gateway/config_test.go | 3 ++ gateway/main.go | 50 +++++++++++++++------------- gateway/main_test.go | 35 +++++++++++++++++++ web/README.md | 12 +++++-- web/src/app/docs/api/page.mdx | 3 +- web/src/app/docs/operations/page.mdx | 12 ++++--- 8 files changed, 87 insertions(+), 35 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79fb56e3..66ef1535 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,7 +128,7 @@ Run the checks for the area you touched. When a change crosses services, run eve | `verifier/**` | `cd verifier && cargo fmt -- --check && cargo clippy -- -D warnings && cargo test` | | `web/**` | `cd web && bun run lint && bun run typecheck && bun run test:unit && bun run build` | | `sdk/typescript/**` | `cd sdk/typescript && bun run typecheck && bun run test` | -| `tests/**` or x402 flow | `RECEIPT_STORE=memory VERIFIER_NONCE_STORE=memory CACHE_ENABLED=false bun run test:e2e` when `OPENROUTER_API_KEY` is available | +| `tests/**` or x402 flow | `RECEIPT_STORE=memory VERIFIER_NONCE_STORE=memory CACHE_ENABLED=false bun run test:e2e` | | `gateway/openapi.yaml` | YAML parse plus compare against gateway routes | | `.github/workflows/**` | YAML parse and explain which paths trigger checks | | Docker/Compose/deploy docs | Validate YAML/TOML where practical and do not run real deploy commands unless maintainers explicitly approve | @@ -171,7 +171,7 @@ Common files to check: Treat these changes as security-sensitive even if they look small: - `PaymentContext` fields in Go, Rust, TypeScript, tests, or docs. -- `X-402-Signature`, `X-402-Nonce`, or `X-402-Timestamp` handling. +- `X-402-Signature`, `X-402-Nonce`, `X-402-Timestamp`, or `X-402-Payer` handling. - Signature expiry, client clock skew, chain ID, or recipient/token/amount validation. - Verifier nonce replay protection. - Receipt signing, receipt TTL, receipt lookup, and Redis persistence. @@ -199,7 +199,7 @@ If you change a public gateway endpoint, update `gateway/openapi.yaml` in the sa If you copied `.env.example`, explicitly override `RECEIPT_STORE` and `VERIFIER_NONCE_STORE` to `memory` when testing without Redis. -The default OpenRouter path still requires `OPENROUTER_API_KEY` for gateway startup. If a signed request returns `502 upstream_unavailable` or `504 upstream_timeout`, payment verification may have succeeded and only the upstream model call failed. Read the test output before assuming the x402 flow broke. +When `OPENROUTER_API_KEY` is unset, the helper starts a deterministic local OpenRouter-compatible mock, so the default E2E path needs no external secret and must return `200` for a valid signed request. Set a real key only when intentionally testing the live OpenRouter path; live-provider failures may return `502 upstream_unavailable` or `504 upstream_timeout` after payment verification succeeds. ## Security Reports diff --git a/gateway/config.go b/gateway/config.go index 501b242c..c7d3144a 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -100,6 +100,7 @@ func normalizePaygateAudience() error { if err != nil || portNumber < 1 || portNumber > 65535 { return fmt.Errorf("PAYGATE_AUDIENCE must contain a valid port") } + port = strconv.Itoa(portNumber) } if (parsed.Scheme == "https" && port == "443") || (parsed.Scheme == "http" && port == "80") { port = "" diff --git a/gateway/config_test.go b/gateway/config_test.go index 95cb3c07..7b1ce8c5 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -42,8 +42,11 @@ func TestNormalizePaygateAudience(t *testing.T) { {name: "normalizes case and trailing slash", value: " HTTPS://Gateway.Example.COM/ ", want: "https://gateway.example.com"}, {name: "normalizes international hostname", value: "https://測試", want: "https://xn--g6w251d"}, {name: "strips default HTTPS port", value: "https://gateway.example.com:443", want: "https://gateway.example.com"}, + {name: "strips zero-padded default HTTPS port", value: "https://gateway.example.com:0443", want: "https://gateway.example.com"}, {name: "strips default HTTP port", value: "http://localhost:80", want: "http://localhost"}, + {name: "strips zero-padded default HTTP port", value: "http://localhost:0080", want: "http://localhost"}, {name: "preserves non-default port", value: "https://gateway.example.com:8443", want: "https://gateway.example.com:8443"}, + {name: "canonicalizes zero-padded non-default port", value: "https://gateway.example.com:08443", want: "https://gateway.example.com:8443"}, {name: "normalizes IPv6 default port", value: "https://[::1]:443", want: "https://[::1]"}, {name: "preserves IPv6 non-default port", value: "https://[::1]:8443", want: "https://[::1]:8443"}, {name: "rejects IPv6 zone identifier", value: "https://[fe80::1%25eth0]", wantErr: "valid hostname"}, diff --git a/gateway/main.go b/gateway/main.go index 4482f911..3cec9693 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -330,29 +330,7 @@ func main() { // constraints. registerDocRoutes(r) - r.Use(cors.New(cors.Config{ - AllowOrigins: getAllowedOrigins(), - AllowMethods: []string{"GET", "POST", "OPTIONS"}, - AllowHeaders: []string{ - "Origin", - "Content-Type", - "X-402-Signature", - "X-402-Nonce", - "X-402-Timestamp", - "X-402-Payer", - "X-Correlation-ID", - }, - ExposeHeaders: []string{ - "Content-Length", - "X-RateLimit-Limit", - "X-RateLimit-Remaining", - "X-RateLimit-Reset", - "Retry-After", - "X-402-Receipt", - "X-Correlation-ID", - }, - AllowCredentials: true, - })) + configureCORSMiddleware(r) // Initialize rate limiters if enabled if getRateLimitEnabled() { @@ -433,6 +411,32 @@ func main() { } } +func configureCORSMiddleware(r *gin.Engine) { + r.Use(cors.New(cors.Config{ + AllowOrigins: getAllowedOrigins(), + AllowMethods: []string{"GET", "POST", "OPTIONS"}, + AllowHeaders: []string{ + "Origin", + "Content-Type", + "X-402-Signature", + "X-402-Nonce", + "X-402-Timestamp", + "X-402-Payer", + "X-Correlation-ID", + }, + ExposeHeaders: []string{ + "Content-Length", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + "Retry-After", + "X-402-Receipt", + "X-Correlation-ID", + }, + AllowCredentials: true, + })) +} + // handleSummarize handles POST /api/ai/summarize requests. It validates // payment headers, calls the verifier service to validate the signature, and // forwards the text to the AI service. The handler respects context timeouts diff --git a/gateway/main_test.go b/gateway/main_test.go index ed02ce13..eb3298f0 100644 --- a/gateway/main_test.go +++ b/gateway/main_test.go @@ -73,6 +73,41 @@ func TestHandleSummarize_NoHeaders(t *testing.T) { } } +func TestCORSMiddlewareAllowsSignedRetryPreflight(t *testing.T) { + const origin = "https://web.example.com" + t.Setenv("ALLOWED_ORIGINS", origin) + + gin.SetMode(gin.TestMode) + router := gin.New() + configureCORSMiddleware(router) + router.POST("/api/ai/summarize", func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + request := httptest.NewRequest(http.MethodOptions, "/api/ai/summarize", nil) + request.Header.Set("Origin", origin) + request.Header.Set("Access-Control-Request-Method", http.MethodPost) + request.Header.Set("Access-Control-Request-Headers", "content-type,x-402-signature,x-402-nonce,x-402-timestamp,x-402-payer") + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusNoContent, recorder.Code) + require.Equal(t, origin, recorder.Header().Get("Access-Control-Allow-Origin")) + + headerTokens := func(name string) map[string]bool { + tokens := make(map[string]bool) + for _, token := range strings.Split(recorder.Header().Get(name), ",") { + tokens[strings.ToLower(strings.TrimSpace(token))] = true + } + return tokens + } + require.True(t, headerTokens("Access-Control-Allow-Methods")["post"]) + allowedHeaders := headerTokens("Access-Control-Allow-Headers") + for _, header := range []string{"content-type", "x-402-signature", "x-402-nonce", "x-402-timestamp", "x-402-payer"} { + require.Truef(t, allowedHeaders[header], "preflight response missing %s", header) + } +} + func TestGetChainIDDefaultBaseSepolia(t *testing.T) { t.Setenv("CHAIN_ID", "") diff --git a/web/README.md b/web/README.md index 3af510ae..971dfa4f 100644 --- a/web/README.md +++ b/web/README.md @@ -9,7 +9,7 @@ The web app is a Next.js/Bun frontend on port `3001`. It lets users submit text - Detect an injected EVM provider such as MetaMask, Rabby, or Coinbase Wallet. - Switch or add the requested chain when the wallet is on the wrong network. - Sign the gateway-provided EIP-712 payment context. -- Retry with `X-402-Signature`, `X-402-Nonce`, and `X-402-Timestamp`. +- Retry with `X-402-Signature`, `X-402-Nonce`, `X-402-Timestamp`, and `X-402-Payer`. - Display summary results or user-facing errors. - Serve the in-app MDX documentation experience at `/docs`. @@ -61,16 +61,22 @@ The frontend signs the same EIP-712 domain and type enforced by the verifier: ```text Domain: name: MicroAI Paygate - version: 1 + version: 2 chainId: paymentContext.chainId verifyingContract: 0x0000000000000000000000000000000000000000 -Payment: +PaymentAuthorization: + payer address recipient address token string amount string nonce string timestamp uint256 + audience string + method string + resource string + contentType string + requestHash bytes32 ``` If this shape changes, update gateway, verifier, web, E2E tests, OpenAPI, and docs together. diff --git a/web/src/app/docs/api/page.mdx b/web/src/app/docs/api/page.mdx index 63797c50..32eabc8f 100644 --- a/web/src/app/docs/api/page.mdx +++ b/web/src/app/docs/api/page.mdx @@ -8,7 +8,7 @@ The gateway also serves OpenAPI at `GET /openapi.yaml` and Swagger UI at `GET /d ### `POST /api/ai/summarize` -Summarizes text after payment authorization. The first unsigned request receives a `402 Payment Required` response with a `paymentContext`. The signed retry includes the EIP-712 signature headers. +Summarizes text after payment authorization. The first unsigned request receives a `402 Payment Required` response with a request-bound authorization v2 `paymentContext`. The signed retry includes the EIP-712 signature and claimed payer headers. ### `GET /api/receipts/{id}` @@ -39,6 +39,7 @@ Content-Type: application/json X-402-Signature: 0x... X-402-Nonce: ... X-402-Timestamp: 1766611200 +X-402-Payer: 0x... ``` ## Response Headers diff --git a/web/src/app/docs/operations/page.mdx b/web/src/app/docs/operations/page.mdx index 834038f6..8d572455 100644 --- a/web/src/app/docs/operations/page.mdx +++ b/web/src/app/docs/operations/page.mdx @@ -5,13 +5,15 @@ This page collects the knobs and checks needed to run MicroAI Paygate locally or ## Core Environment Variables - `AI_PROVIDER`: `openrouter` by default, `ollama` for local Ollama experiments. -- `OPENROUTER_API_KEY`: required when using OpenRouter. +- `OPENROUTER_API_KEY`: required for live OpenRouter calls; not required by the deterministic E2E path. - `OPENROUTER_MODEL`: model name for OpenRouter. - `OLLAMA_URL` and `OLLAMA_MODEL`: used only when `AI_PROVIDER=ollama`. - `SERVER_WALLET_PRIVATE_KEY`: signs receipts. Use only unfunded local keys in development. - `RECIPIENT_ADDRESS`: embedded in payment contexts. - `CHAIN_ID`: gateway EIP-712 chain ID. - `EXPECTED_CHAIN_ID`: verifier chain ID. Falls back to `CHAIN_ID` if unset. +- `PAYGATE_AUDIENCE`: required public gateway origin bound into authorization v2 signatures. +- `MIN_AUTHORIZATION_VERSION`: verifier minimum, `2` by default. - `VERIFIER_URL`: where the gateway calls `/verify`. - `VERIFIER_NONCE_STORE`: `memory` for one verifier process or `redis` for shared replay protection. - `RECEIPT_STORE`: `redis` by default, `memory` for tests and quick local runs. @@ -20,11 +22,11 @@ This page collects the knobs and checks needed to run MicroAI Paygate locally or ## Provider Modes -OpenRouter is the default remote AI path. It needs `OPENROUTER_API_KEY`. +OpenRouter is the default remote AI path. Live calls need `OPENROUTER_API_KEY`. Ollama is the local AI experiment path. It needs a reachable Ollama server and configured model. -A deterministic mock provider is a planned follow-on for demos and CI. Do not document it as available until it is implemented. +When `OPENROUTER_API_KEY` is unset, `bun run test:e2e` starts a deterministic local OpenRouter-compatible mock on port `3100`. Supplying a real key opts into the live provider path instead. ## Test Commands @@ -40,7 +42,7 @@ RECEIPT_STORE=memory VERIFIER_NONCE_STORE=memory CACHE_ENABLED=false bun run tes bun run test:unit ``` -`bun run test:e2e` starts gateway and verifier. The command above overrides the Redis-backed `.env.example` store modes for a no-Redis run. With the default OpenRouter path, it also requires `OPENROUTER_API_KEY`. +`bun run test:e2e` starts gateway and verifier. The command above overrides the Redis-backed `.env.example` store modes for a no-Redis run. It also starts the deterministic provider mock when `OPENROUTER_API_KEY` is unset. ## Troubleshooting @@ -48,6 +50,6 @@ bun run test:unit - Wrong chain: switch the wallet to the configured chain ID. - Signature rejected: check account, nonce, timestamp, chain ID, and verifier logs. - Gateway cold start: wait for backend services to wake, then retry. -- OpenRouter key missing: set `OPENROUTER_API_KEY` or use a local provider mode. +- Live OpenRouter key missing: set `OPENROUTER_API_KEY`; deterministic E2E runs do not need one. - Verifier unavailable: check `VERIFIER_URL` and `http://localhost:3002/health`. - Receipt verification false: confirm `PAYGATE_SERVER_PUBLIC_KEY` is the trusted gateway receipt signing key. From 006520acd618d3227185d58b8eaefd4d16dd73c8 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Mon, 20 Jul 2026 18:07:28 +0530 Subject: [PATCH 12/16] test: validate cached payment challenge Co-authored-by: codex --- gateway/cache_integration_test.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/gateway/cache_integration_test.go b/gateway/cache_integration_test.go index b8f8e1bf..bdfc1d28 100644 --- a/gateway/cache_integration_test.go +++ b/gateway/cache_integration_test.go @@ -17,6 +17,7 @@ import ( "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" ) func TestCacheIntegration_FullFlow(t *testing.T) { @@ -212,8 +213,27 @@ func TestCacheIntegration_FullFlow(t *testing.T) { r.ServeHTTP(w4, reqNoSig) if w4.Code != 402 { - t.Errorf("Expected status 402 for missing signature, got %d", w4.Code) - } + t.Fatalf("Expected status 402 for missing signature, got %d", w4.Code) + } + var challenge struct { + PaymentContext PaymentContext `json:"paymentContext"` + } + if err := json.Unmarshal(w4.Body.Bytes(), &challenge); err != nil { + t.Fatalf("Failed to unmarshal payment challenge: %v", err) + } + wantRequestHash := fmt.Sprintf("0x%x", sha256.Sum256(compactBody)) + require.Equal(t, paymentAuthorizationVersion, challenge.PaymentContext.AuthorizationVersion) + require.Equal(t, "0xTestRecipient", challenge.PaymentContext.Recipient) + require.Equal(t, "USDC", challenge.PaymentContext.Token) + require.NotEmpty(t, challenge.PaymentContext.Amount) + require.NotEmpty(t, challenge.PaymentContext.Nonce) + require.Positive(t, challenge.PaymentContext.ChainID) + require.Positive(t, challenge.PaymentContext.Timestamp) + require.Equal(t, "http://localhost:3000", challenge.PaymentContext.Audience) + require.Equal(t, http.MethodPost, challenge.PaymentContext.Method) + require.Equal(t, "/api/ai/summarize", challenge.PaymentContext.Resource) + require.Equal(t, "application/json", challenge.PaymentContext.ContentType) + require.Equal(t, wantRequestHash, challenge.PaymentContext.RequestHash) // Verify Body var resp1, resp2 map[string]interface{} From 4ee9ebdd0acb1232ceda66279f9a90fcb1d40a50 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Mon, 20 Jul 2026 18:35:46 +0530 Subject: [PATCH 13/16] fix: close authorization compatibility gaps Co-authored-by: codex --- .github/workflows/e2e.yml | 3 +- DEPLOY.md | 9 +- gateway/cache_integration_test.go | 27 ++++-- gateway/config.go | 62 +++++++++++-- gateway/config_test.go | 6 ++ gateway/openapi.yaml | 4 +- gateway/receipt.go | 4 +- gateway/receipt_test.go | 3 + sdk/typescript/src/__tests__/receipts.test.ts | 13 ++- sdk/typescript/src/receipts.ts | 57 ++++++++---- tests/e2e.test.ts | 8 ++ web/src/app/docs/protocol/page.mdx | 1 + web/src/lib/verify-receipt.test.ts | 72 +++++++++++++++ web/src/lib/verify-receipt.ts | 91 +++++++++++++++---- 14 files changed, 300 insertions(+), 60 deletions(-) create mode 100644 web/src/lib/verify-receipt.test.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fc66db1d..9eb208a2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -38,6 +38,7 @@ concurrency: jobs: e2e: runs-on: ubuntu-latest + timeout-minutes: 20 env: OPENROUTER_API_KEY: "" OPENROUTER_MODEL: ${{ vars.OPENROUTER_MODEL || 'z-ai/glm-4.5-air:free' }} @@ -75,4 +76,4 @@ jobs: - name: Run deterministic E2E run: | chmod +x ./run_e2e.sh - timeout 300 ./run_e2e.sh + timeout 900 ./run_e2e.sh diff --git a/DEPLOY.md b/DEPLOY.md index d697383a..8ff7513c 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -76,11 +76,14 @@ The verifier is stateless EIP-712 signature recovery. Public on Render's free ti - `VERIFIER_NONCE_STORE=redis` - `VERIFIER_NONCE_KEY_PREFIX=microai:verifier:nonce:` - `VERIFIER_REDIS_TIMEOUT_MS=2000` - - `MIN_AUTHORIZATION_VERSION=2` + - `MIN_AUTHORIZATION_VERSION=1` during an upgrade from authorization v1. For a fresh deployment, use `2`. 4. Click **Deploy Web Service**. First Rust build takes ~3–5 min. 5. Copy the assigned public URL — e.g. `https://microai-verifier.onrender.com`. The gateway needs this URL in the next step. +> [!IMPORTANT] +> For an existing v1 deployment, keep the upgraded verifier at `MIN_AUTHORIZATION_VERSION=1` until the v2 gateway is live. After completing the gateway deployment below, raise the verifier setting to `2` and redeploy it. Setting the minimum to `2` before the gateway upgrade rejects every v1 authorization during the rollout window. + Verify: ```sh @@ -148,6 +151,8 @@ curl -i https://.onrender.com/api/ai/summarize \ # {"error":"Payment Required","paymentContext":{...}} ``` +7. For an upgrade from authorization v1, return to the verifier service, set `MIN_AUTHORIZATION_VERSION=2`, and redeploy it. This closes the temporary compatibility window after the v2 gateway is serving challenges. + ## 4. Deploy the Web on Vercel 1. Sign up at https://vercel.com using GitHub OAuth. @@ -176,7 +181,7 @@ Phase 1 analytics is privacy-scoped to funnel metadata only. The web app does ** ## 5. Tighten Gateway CORS -Go back to the gateway service on Render → **Environment** → update `ALLOWED_ORIGINS` from `*` to your exact Vercel domain: +Go back to the gateway service on Render → **Environment** → replace the placeholder `ALLOWED_ORIGINS` value with your exact Vercel domain: ``` ALLOWED_ORIGINS=https://.vercel.app diff --git a/gateway/cache_integration_test.go b/gateway/cache_integration_test.go index bdfc1d28..d78b392c 100644 --- a/gateway/cache_integration_test.go +++ b/gateway/cache_integration_test.go @@ -34,6 +34,7 @@ func TestCacheIntegration_FullFlow(t *testing.T) { // Mock Verifier var verifierMu sync.Mutex var verifierRequestHashes []string + seenNonces := make(map[string]struct{}) verifier := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Mock validation based on signature var req VerifyRequest @@ -41,11 +42,16 @@ func TestCacheIntegration_FullFlow(t *testing.T) { http.Error(w, "Invalid verification request", http.StatusBadRequest) return } + expectedSignature := fmt.Sprintf("0x%x", sha256.Sum256([]byte(req.Context.Nonce+":"+req.Context.RequestHash))) verifierMu.Lock() + _, replayed := seenNonces[req.Context.Nonce] + isValid := req.Signature == expectedSignature && !replayed + if isValid { + seenNonces[req.Context.Nonce] = struct{}{} + } verifierRequestHashes = append(verifierRequestHashes, req.Context.RequestHash) verifierMu.Unlock() - isValid := req.Signature == "0xValidSig" resp := VerifyResponse{ IsValid: isValid, RecoveredAddress: "0x14791697260e4c9a71f18484c9f997b308e59325", @@ -119,17 +125,22 @@ func TestCacheIntegration_FullFlow(t *testing.T) { model := "z-ai/glm-4.5-air:free" // Default model cacheKey := getCacheKey(textToSummarize, model) - // Helper to make request - makeRequest := func(sig string, rawBody []byte) *httptest.ResponseRecorder { + // Helper to make a request whose test signature is bound to its nonce and exact body hash. + makeRequest := func(nonce string, rawBody []byte, validSignature bool) *httptest.ResponseRecorder { t.Helper() req, err := http.NewRequest("POST", "/api/ai/summarize", bytes.NewReader(rawBody)) if err != nil { t.Fatalf("Failed to create request: %v", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-402-Signature", sig) + requestHash := fmt.Sprintf("0x%x", sha256.Sum256(rawBody)) + signature := fmt.Sprintf("0x%x", sha256.Sum256([]byte(nonce+":"+requestHash))) + if !validSignature { + signature = "0xInvalidSig" + } + req.Header.Set("X-402-Signature", signature) req.Header.Set("X-402-Payer", "0x14791697260E4c9A71f18484C9f997B308e59325") - req.Header.Set("X-402-Nonce", "nonce-123") + req.Header.Set("X-402-Nonce", nonce) req.Header.Set("X-402-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) w := httptest.NewRecorder() @@ -143,7 +154,7 @@ func TestCacheIntegration_FullFlow(t *testing.T) { // Request 1: Cache Miss (Valid Sig) start := time.Now() - w1 := makeRequest("0xValidSig", compactBody) + w1 := makeRequest("nonce-compact", compactBody, true) duration1 := time.Since(start) if w1.Code != 200 { @@ -172,7 +183,7 @@ func TestCacheIntegration_FullFlow(t *testing.T) { // Request 2: Cache Hit (Valid Sig) start = time.Now() - w2 := makeRequest("0xValidSig", spacedBody.Bytes()) + w2 := makeRequest("nonce-spaced", spacedBody.Bytes(), true) duration2 := time.Since(start) if w2.Code != 200 { @@ -198,7 +209,7 @@ func TestCacheIntegration_FullFlow(t *testing.T) { } // Security Check: Cache HIT but INVALID Signature - w3 := makeRequest("0xInvalidSig", compactBody) + w3 := makeRequest("nonce-invalid", compactBody, false) if w3.Code != 403 { t.Errorf("Expected status 403 for invalid signature on cache hit, got %d", w3.Code) } diff --git a/gateway/config.go b/gateway/config.go index c7d3144a..f93bdda7 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "net" + "net/netip" "net/url" "os" "strconv" @@ -83,15 +84,9 @@ func normalizePaygateAudience() error { return fmt.Errorf("PAYGATE_AUDIENCE must contain an origin only") } - hostname := parsed.Hostname() - if strings.Contains(hostname, ":") && strings.Contains(hostname, "%") { - return fmt.Errorf("PAYGATE_AUDIENCE must contain a valid hostname") - } - if !strings.Contains(hostname, ":") { - hostname, err = idna.Lookup.ToASCII(hostname) - if err != nil { - return fmt.Errorf("PAYGATE_AUDIENCE must contain a valid hostname: %w", err) - } + hostname, err := normalizeAudienceHostname(parsed.Hostname()) + if err != nil { + return err } hostname = strings.ToLower(hostname) port := parsed.Port() @@ -118,6 +113,55 @@ func normalizePaygateAudience() error { return nil } +func normalizeAudienceHostname(hostname string) (string, error) { + if strings.Contains(hostname, "%") { + return "", fmt.Errorf("PAYGATE_AUDIENCE must contain a valid hostname") + } + if address, err := netip.ParseAddr(hostname); err == nil { + if address.Is4In6() { + return "", fmt.Errorf("PAYGATE_AUDIENCE must contain a canonical IP address") + } + return address.String(), nil + } + if strings.Contains(hostname, ":") || endsInIPv4Number(hostname) { + return "", fmt.Errorf("PAYGATE_AUDIENCE must contain a canonical IP address") + } + + asciiHostname, err := idna.Lookup.ToASCII(hostname) + if err != nil { + return "", fmt.Errorf("PAYGATE_AUDIENCE must contain a valid hostname: %w", err) + } + return strings.ToLower(asciiHostname), nil +} + +func endsInIPv4Number(hostname string) bool { + lastLabel := hostname + if index := strings.LastIndexByte(hostname, '.'); index >= 0 { + lastLabel = hostname[index+1:] + } + if lastLabel == "" { + return false + } + if strings.HasPrefix(lastLabel, "0x") || strings.HasPrefix(lastLabel, "0X") { + lastLabel = lastLabel[2:] + if lastLabel == "" { + return false + } + for _, character := range lastLabel { + if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || (character >= 'A' && character <= 'F')) { + return false + } + } + return true + } + for _, character := range lastLabel { + if character < '0' || character > '9' { + return false + } + } + return true +} + func getReceiptStoreMode() string { mode := strings.ToLower(strings.TrimSpace(os.Getenv("RECEIPT_STORE"))) if mode == "" { diff --git a/gateway/config_test.go b/gateway/config_test.go index 7b1ce8c5..9ae49369 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -48,8 +48,14 @@ func TestNormalizePaygateAudience(t *testing.T) { {name: "preserves non-default port", value: "https://gateway.example.com:8443", want: "https://gateway.example.com:8443"}, {name: "canonicalizes zero-padded non-default port", value: "https://gateway.example.com:08443", want: "https://gateway.example.com:8443"}, {name: "normalizes IPv6 default port", value: "https://[::1]:443", want: "https://[::1]"}, + {name: "canonicalizes expanded IPv6", value: "https://[0:0:0:0:0:0:0:1]", want: "https://[::1]"}, + {name: "preserves canonical IPv4", value: "http://127.0.0.1", want: "http://127.0.0.1"}, {name: "preserves IPv6 non-default port", value: "https://[::1]:8443", want: "https://[::1]:8443"}, {name: "rejects IPv6 zone identifier", value: "https://[fe80::1%25eth0]", wantErr: "valid hostname"}, + {name: "rejects zero-padded IPv4", value: "http://127.000.000.001", wantErr: "canonical IP address"}, + {name: "rejects integer IPv4", value: "http://2130706433", wantErr: "canonical IP address"}, + {name: "rejects hexadecimal IPv4", value: "http://0x7f000001", wantErr: "canonical IP address"}, + {name: "rejects IPv4-mapped IPv6", value: "http://[::ffff:127.0.0.1]", wantErr: "canonical IP address"}, {name: "rejects zero port", value: "https://gateway.example.com:0", wantErr: "valid port"}, {name: "rejects out-of-range port", value: "https://gateway.example.com:99999", wantErr: "valid port"}, {name: "rejects path", value: "https://gateway.example.com/api", wantErr: "origin only"}, diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index 32517d3f..9546c670 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -490,7 +490,9 @@ components: example: rcpt_a1b2c3d4e5f6 version: type: string - example: "1.0" + enum: ["1.0", "2.0"] + description: Receipt serialization version. New request-bound receipts use 2.0; 1.0 remains readable for stored legacy receipts. + example: "2.0" timestamp: type: string format: date-time diff --git a/gateway/receipt.go b/gateway/receipt.go index f03c5069..627c3818 100644 --- a/gateway/receipt.go +++ b/gateway/receipt.go @@ -13,6 +13,8 @@ import ( "github.com/ethereum/go-ethereum/crypto" ) +const currentReceiptVersion = "2.0" + // Receipt represents a cryptographic payment receipt type Receipt struct { ID string `json:"id"` @@ -68,7 +70,7 @@ func GenerateReceipt(payment PaymentContext, payer string, endpoint string, reqB receipt := Receipt{ ID: receiptID, - Version: "1.0", + Version: currentReceiptVersion, Timestamp: time.Now().UTC(), Payment: PaymentDetails{ Payer: payer, diff --git a/gateway/receipt_test.go b/gateway/receipt_test.go index 1430460d..7872c6e6 100644 --- a/gateway/receipt_test.go +++ b/gateway/receipt_test.go @@ -100,6 +100,9 @@ func TestGenerateReceiptPreservesRequestBoundAuthorization(t *testing.T) { if err != nil { t.Fatalf("GenerateReceipt() error = %v", err) } + if receipt.Receipt.Version != currentReceiptVersion { + t.Fatalf("receipt version = %q, want %q", receipt.Receipt.Version, currentReceiptVersion) + } service := receipt.Receipt.Service if service.AuthorizationVersion != payment.AuthorizationVersion || diff --git a/sdk/typescript/src/__tests__/receipts.test.ts b/sdk/typescript/src/__tests__/receipts.test.ts index 3f0e822c..3124c419 100644 --- a/sdk/typescript/src/__tests__/receipts.test.ts +++ b/sdk/typescript/src/__tests__/receipts.test.ts @@ -69,7 +69,7 @@ describe("receipt helpers", () => { ); const receipt: SignedReceipt["receipt"] = { id: "rcpt_boundv2test", - version: "1.0", + version: "2.0", timestamp: "2026-07-16T00:00:00Z", payment: { payer: "0x14791697260E4c9A71f18484C9f997B308e59325", @@ -109,6 +109,17 @@ describe("receipt helpers", () => { ).toBe(true); }); + it("rejects unsupported versions and unsigned v2 metadata on legacy receipts", () => { + const unsupported = cloneFixture(); + unsupported.receipt.version = "3.0"; + expect(validateReceiptFormat(unsupported)).toBe(false); + + const legacyWithV2Metadata = cloneFixture(); + legacyWithV2Metadata.receipt.service.authorization_version = 2; + legacyWithV2Metadata.receipt.service.audience = "https://gateway.example.com"; + expect(validateReceiptFormat(legacyWithV2Metadata)).toBe(false); + }); + it("verifyReceipt requires the expected gateway receipt signing key as a trust anchor", async () => { expect(await verifyReceipt(cloneFixture())).toBe(false); expect( diff --git a/sdk/typescript/src/receipts.ts b/sdk/typescript/src/receipts.ts index f00e9b37..0eb51077 100644 --- a/sdk/typescript/src/receipts.ts +++ b/sdk/typescript/src/receipts.ts @@ -28,9 +28,9 @@ export function validateReceiptFormat(value: unknown): value is SignedReceipt { const service = receipt.service; if (!isRecord(payment) || !isRecord(service)) return false; - return ( + const validBase = isPrefixedString(receipt.id, "rcpt_") && - isNonEmptyString(receipt.version) && + (receipt.version === "1.0" || receipt.version === "2.0") && isNonEmptyString(receipt.timestamp) && isNonEmptyString(payment.payer) && isNonEmptyString(payment.recipient) && @@ -44,7 +44,23 @@ export function validateReceiptFormat(value: unknown): value is SignedReceipt { isPrefixedString(service.request_hash, "sha256:") && isPrefixedString(service.response_hash, "sha256:") && isPrefixedString(value.signature, "0x") && - isPrefixedString(value.server_public_key, "0x") + isPrefixedString(value.server_public_key, "0x"); + if (!validBase) return false; + + const v2Fields = [ + service.audience, + service.method, + service.resource, + service.content_type, + service.authorization_request_hash, + ]; + if (receipt.version === "1.0") { + return service.authorization_version === undefined && v2Fields.every((field) => field === undefined); + } + return ( + service.authorization_version === 2 && + v2Fields.every(isNonEmptyString) && + isPrefixedString(service.authorization_request_hash, "0x") ); } @@ -66,23 +82,24 @@ export function decodeReceiptHeader(headerValue: string): SignedReceipt { } function serializeReceiptForGateway(receipt: Receipt): string { - const service = { - endpoint: receipt.service.endpoint, - ...(receipt.service.authorization_version !== undefined && { - authorization_version: receipt.service.authorization_version, - }), - ...(receipt.service.audience !== undefined && { audience: receipt.service.audience }), - ...(receipt.service.method !== undefined && { method: receipt.service.method }), - ...(receipt.service.resource !== undefined && { resource: receipt.service.resource }), - ...(receipt.service.content_type !== undefined && { - content_type: receipt.service.content_type, - }), - ...(receipt.service.authorization_request_hash !== undefined && { - authorization_request_hash: receipt.service.authorization_request_hash, - }), - request_hash: receipt.service.request_hash, - response_hash: receipt.service.response_hash, - }; + const service = + receipt.version === "1.0" + ? { + endpoint: receipt.service.endpoint, + request_hash: receipt.service.request_hash, + response_hash: receipt.service.response_hash, + } + : { + endpoint: receipt.service.endpoint, + authorization_version: receipt.service.authorization_version, + audience: receipt.service.audience, + method: receipt.service.method, + resource: receipt.service.resource, + content_type: receipt.service.content_type, + authorization_request_hash: receipt.service.authorization_request_hash, + request_hash: receipt.service.request_hash, + response_hash: receipt.service.response_hash, + }; return JSON.stringify({ id: receipt.id, version: receipt.version, diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index af690662..786a6a56 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -97,6 +97,14 @@ describe("MicroAI Paygate E2E Flow", () => { }); expect(res.status).toBe(200); + const receiptHeader = res.headers.get("X-402-Receipt"); + expect(receiptHeader).toBeTruthy(); + const signedReceipt = JSON.parse(Buffer.from(receiptHeader!, "base64").toString("utf8")); + expect(signedReceipt.receipt.version).toBe("2.0"); + expect(signedReceipt.receipt.service.authorization_version).toBe(2); + expect(signedReceipt.receipt.service.authorization_request_hash).toBe( + paymentContext.requestHash, + ); const data = await res.json() as any; expect(data.result).toBeDefined(); }, 30000); diff --git a/web/src/app/docs/protocol/page.mdx b/web/src/app/docs/protocol/page.mdx index 3878b015..726634b2 100644 --- a/web/src/app/docs/protocol/page.mdx +++ b/web/src/app/docs/protocol/page.mdx @@ -67,6 +67,7 @@ const types = { Receipts include: +- `version`: `2.0` for request-bound authorization receipts. Clients may continue to verify stored legacy `1.0` receipts using the v1 serialization. - payment payer, recipient, amount, token, chain ID, and nonce. - service endpoint. - authorization version, audience, method, resource, content type, and authorization request hash. diff --git a/web/src/lib/verify-receipt.test.ts b/web/src/lib/verify-receipt.test.ts new file mode 100644 index 00000000..2ad4335b --- /dev/null +++ b/web/src/lib/verify-receipt.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'bun:test'; +import { ethers } from 'ethers'; +import { + validateReceiptFormat, + verifyReceipt, + type Receipt, + type SignedReceipt, +} from './verify-receipt'; + +const signingKey = new ethers.SigningKey( + '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', +); + +function signedReceipt(receipt: Receipt): SignedReceipt { + const digest = ethers.keccak256(ethers.toUtf8Bytes(JSON.stringify(receipt))); + const signature = signingKey.sign(digest); + return { + receipt, + signature: ethers.hexlify( + ethers.concat([signature.r, signature.s, ethers.toBeHex(signature.yParity, 1)]), + ), + server_public_key: signingKey.publicKey, + }; +} + +function receipt(version: '1.0' | '2.0'): Receipt { + const service = { + endpoint: '/api/ai/summarize', + ...(version === '2.0' && { + authorization_version: 2, + audience: 'https://gateway.example.com', + method: 'POST', + resource: '/api/ai/summarize', + content_type: 'application/json', + authorization_request_hash: + '0x8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9', + }), + request_hash: 'sha256:8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9', + response_hash: 'sha256:8a90fd4352d6e287b3e908e62f802c99c4f5680c9644cb27fb64d638e3fbb9d4', + }; + return { + id: 'rcpt_abcdef123456', + version, + timestamp: '2026-07-20T00:00:00Z', + payment: { + payer: '0x14791697260E4c9A71f18484C9f997B308e59325', + recipient: '0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219', + amount: '0.001', + token: 'USDC', + chainId: 84532, + nonce: 'receipt-version-test', + }, + service, + }; +} + +describe('versioned receipt verification', () => { + it('verifies stored v1 and request-bound v2 receipt serialization', async () => { + expect(await verifyReceipt(signedReceipt(receipt('1.0')))).toBe(true); + expect(await verifyReceipt(signedReceipt(receipt('2.0')))).toBe(true); + }); + + it('rejects unsupported versions and v2 metadata under version 1.0', () => { + const unsupported = signedReceipt(receipt('1.0')); + unsupported.receipt.version = '3.0'; + expect(validateReceiptFormat(unsupported)).toBe(false); + + const legacyWithV2Metadata = signedReceipt(receipt('1.0')); + legacyWithV2Metadata.receipt.service.authorization_version = 2; + expect(validateReceiptFormat(legacyWithV2Metadata)).toBe(false); + }); +}); diff --git a/web/src/lib/verify-receipt.ts b/web/src/lib/verify-receipt.ts index 0d7043e2..4398826d 100644 --- a/web/src/lib/verify-receipt.ts +++ b/web/src/lib/verify-receipt.ts @@ -40,11 +40,46 @@ export interface Receipt { service: ServiceDetails; } -export interface SignedReceipt { +export interface SignedReceipt { receipt: Receipt; signature: string; - server_public_key: string; -} + server_public_key: string; +} + +function serializeReceiptForGateway(receipt: Receipt): string { + const service = receipt.version === '1.0' + ? { + endpoint: receipt.service.endpoint, + request_hash: receipt.service.request_hash, + response_hash: receipt.service.response_hash, + } + : { + endpoint: receipt.service.endpoint, + authorization_version: receipt.service.authorization_version, + audience: receipt.service.audience, + method: receipt.service.method, + resource: receipt.service.resource, + content_type: receipt.service.content_type, + authorization_request_hash: receipt.service.authorization_request_hash, + request_hash: receipt.service.request_hash, + response_hash: receipt.service.response_hash, + }; + + return JSON.stringify({ + id: receipt.id, + version: receipt.version, + timestamp: receipt.timestamp, + payment: { + payer: receipt.payment.payer, + recipient: receipt.payment.recipient, + amount: receipt.payment.amount, + token: receipt.payment.token, + chainId: receipt.payment.chainId, + nonce: receipt.payment.nonce, + }, + service, + }); +} /** * Verifies a cryptographic receipt signature @@ -63,13 +98,13 @@ export interface SignedReceipt { export async function verifyReceipt(signedReceipt: SignedReceipt): Promise { try { // Validate structure - if (!signedReceipt?.receipt || !signedReceipt.signature || !signedReceipt.server_public_key) { - console.error('Invalid receipt structure'); - return false; - } - - // Serialize receipt deterministically (same as Go's json.Marshal) - const receiptJSON = JSON.stringify(signedReceipt.receipt); + if (!validateReceiptFormat(signedReceipt)) { + console.error('Invalid receipt structure'); + return false; + } + + // Serialize according to the versioned Go receipt contract. + const receiptJSON = serializeReceiptForGateway(signedReceipt.receipt); // Hash using Keccak256 (Ethereum-compatible) - same as Go's crypto.Keccak256Hash const messageHash = ethers.keccak256(ethers.toUtf8Bytes(receiptJSON)); @@ -109,14 +144,14 @@ export async function verifyReceipt(signedReceipt: SignedReceipt): Promise field === undefined); + } + return r.service.authorization_version === 2 && + [ + r.service.audience, + r.service.method, + r.service.resource, + r.service.content_type, + r.service.authorization_request_hash, + ].every((field) => typeof field === 'string' && field.length > 0) && + r.service.authorization_request_hash!.startsWith('0x'); +} /** * Fetches a receipt by ID from the gateway From c705e6dd949dd5f11d5fbe4f0c452ac860174666 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Mon, 20 Jul 2026 18:57:07 +0530 Subject: [PATCH 14/16] fix: bind receipts to authorization context Co-authored-by: codex --- gateway/config.go | 7 +- gateway/config_test.go | 1 + gateway/openapi.yaml | 160 ++++++++++++------ gateway/openapi_test.go | 37 ++++ .../src/__tests__/client-flow.test.ts | 67 ++++++-- sdk/typescript/src/client.ts | 51 +++++- 6 files changed, 250 insertions(+), 73 deletions(-) diff --git a/gateway/config.go b/gateway/config.go index f93bdda7..6e9151ac 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -135,9 +135,10 @@ func normalizeAudienceHostname(hostname string) (string, error) { } func endsInIPv4Number(hostname string) bool { - lastLabel := hostname - if index := strings.LastIndexByte(hostname, '.'); index >= 0 { - lastLabel = hostname[index+1:] + withoutTrailingDot := strings.TrimSuffix(hostname, ".") + lastLabel := withoutTrailingDot + if index := strings.LastIndexByte(withoutTrailingDot, '.'); index >= 0 { + lastLabel = withoutTrailingDot[index+1:] } if lastLabel == "" { return false diff --git a/gateway/config_test.go b/gateway/config_test.go index 9ae49369..388941cb 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -53,6 +53,7 @@ func TestNormalizePaygateAudience(t *testing.T) { {name: "preserves IPv6 non-default port", value: "https://[::1]:8443", want: "https://[::1]:8443"}, {name: "rejects IPv6 zone identifier", value: "https://[fe80::1%25eth0]", wantErr: "valid hostname"}, {name: "rejects zero-padded IPv4", value: "http://127.000.000.001", wantErr: "canonical IP address"}, + {name: "rejects trailing-dot IPv4", value: "http://127.0.0.1.", wantErr: "canonical IP address"}, {name: "rejects integer IPv4", value: "http://2130706433", wantErr: "canonical IP address"}, {name: "rejects hexadecimal IPv4", value: "http://0x7f000001", wantErr: "canonical IP address"}, {name: "rejects IPv4-mapped IPv6", value: "http://[::ffff:127.0.0.1]", wantErr: "canonical IP address"}, diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index 9546c670..e6dadbf1 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -482,7 +482,38 @@ components: example: valid Receipt: + oneOf: + - $ref: "#/components/schemas/ReceiptV1" + - $ref: "#/components/schemas/ReceiptV2" + discriminator: + propertyName: version + mapping: + "1.0": "#/components/schemas/ReceiptV1" + "2.0": "#/components/schemas/ReceiptV2" + + ReceiptV1: + type: object + additionalProperties: false + required: [id, version, timestamp, payment, service] + properties: + id: + type: string + example: rcpt_a1b2c3d4e5f6 + version: + type: string + enum: ["1.0"] + example: "1.0" + timestamp: + type: string + format: date-time + payment: + $ref: "#/components/schemas/ReceiptPayment" + service: + $ref: "#/components/schemas/ReceiptServiceV1" + + ReceiptV2: type: object + additionalProperties: false required: [id, version, timestamp, payment, service] properties: id: @@ -490,62 +521,85 @@ components: example: rcpt_a1b2c3d4e5f6 version: type: string - enum: ["1.0", "2.0"] - description: Receipt serialization version. New request-bound receipts use 2.0; 1.0 remains readable for stored legacy receipts. + enum: ["2.0"] example: "2.0" timestamp: type: string format: date-time payment: - type: object - required: [payer, recipient, amount, token, chainId, nonce] - properties: - payer: - type: string - example: "0x..." - recipient: - type: string - example: "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219" - amount: - type: string - example: "0.001" - token: - type: string - example: USDC - chainId: - type: integer - example: 84532 - nonce: - type: string - example: "550e8400-e29b-41d4-a716-446655440000" + $ref: "#/components/schemas/ReceiptPayment" service: - type: object - required: [endpoint, request_hash, response_hash] - properties: - endpoint: - type: string - example: /api/ai/summarize - authorization_version: - type: integer - example: 2 - audience: - type: string - example: https://gateway.example.com - method: - type: string - example: POST - resource: - type: string - example: /api/ai/summarize?mode=brief - content_type: - type: string - example: application/json - authorization_request_hash: - type: string - example: "0x8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9" - request_hash: - type: string - example: sha256:... - response_hash: - type: string - example: sha256:... + $ref: "#/components/schemas/ReceiptServiceV2" + + ReceiptPayment: + type: object + additionalProperties: false + required: [payer, recipient, amount, token, chainId, nonce] + properties: + payer: + type: string + example: "0x..." + recipient: + type: string + example: "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219" + amount: + type: string + example: "0.001" + token: + type: string + example: USDC + chainId: + type: integer + example: 84532 + nonce: + type: string + example: "550e8400-e29b-41d4-a716-446655440000" + + ReceiptServiceV1: + type: object + additionalProperties: false + required: [endpoint, request_hash, response_hash] + properties: + endpoint: + type: string + example: /api/ai/summarize + request_hash: + type: string + example: sha256:... + response_hash: + type: string + example: sha256:... + + ReceiptServiceV2: + type: object + additionalProperties: false + required: [endpoint, authorization_version, audience, method, resource, content_type, authorization_request_hash, request_hash, response_hash] + properties: + endpoint: + type: string + example: /api/ai/summarize + authorization_version: + type: integer + enum: [2] + audience: + type: string + example: https://gateway.example.com + method: + type: string + example: POST + resource: + type: string + example: /api/ai/summarize?mode=brief + content_type: + type: string + example: application/json + authorization_request_hash: + type: string + pattern: "^0x[0-9a-fA-F]{64}$" + example: "0x8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9" + request_hash: + type: string + example: sha256:... + response_hash: + type: string + example: sha256:... diff --git a/gateway/openapi_test.go b/gateway/openapi_test.go index e4792006..c7efba08 100644 --- a/gateway/openapi_test.go +++ b/gateway/openapi_test.go @@ -71,3 +71,40 @@ func TestOpenAPISpecMatchesRoutes(t *testing.T) { } } } + +func TestOpenAPIReceiptVersionsAreDiscriminated(t *testing.T) { + data, err := os.ReadFile(filepath.Join(".", "openapi.yaml")) + if err != nil { + t.Fatalf("read openapi.yaml: %v", err) + } + var spec struct { + Components struct { + Schemas map[string]struct { + Required []string `yaml:"required"` + AdditionalProperties *bool `yaml:"additionalProperties"` + OneOf []struct { + Ref string `yaml:"$ref"` + } `yaml:"oneOf"` + } `yaml:"schemas"` + } `yaml:"components"` + } + if err := yaml.Unmarshal(data, &spec); err != nil { + t.Fatalf("parse openapi.yaml: %v", err) + } + + receipt := spec.Components.Schemas["Receipt"] + if len(receipt.OneOf) != 2 || receipt.OneOf[0].Ref != "#/components/schemas/ReceiptV1" || receipt.OneOf[1].Ref != "#/components/schemas/ReceiptV2" { + t.Fatalf("Receipt oneOf does not discriminate v1 and v2: %#v", receipt.OneOf) + } + for _, schemaName := range []string{"ReceiptServiceV1", "ReceiptServiceV2"} { + schema := spec.Components.Schemas[schemaName] + if schema.AdditionalProperties == nil || *schema.AdditionalProperties { + t.Fatalf("%s must reject fields from other receipt versions", schemaName) + } + } + wantV2Fields := []string{"endpoint", "authorization_version", "audience", "method", "resource", "content_type", "authorization_request_hash", "request_hash", "response_hash"} + gotV2Fields := spec.Components.Schemas["ReceiptServiceV2"].Required + if strings.Join(gotV2Fields, ",") != strings.Join(wantV2Fields, ",") { + t.Fatalf("ReceiptServiceV2 required fields = %v, want %v", gotV2Fields, wantV2Fields) + } +} diff --git a/sdk/typescript/src/__tests__/client-flow.test.ts b/sdk/typescript/src/__tests__/client-flow.test.ts index 8150090a..4a1de8a0 100644 --- a/sdk/typescript/src/__tests__/client-flow.test.ts +++ b/sdk/typescript/src/__tests__/client-flow.test.ts @@ -44,6 +44,13 @@ function sha256Body(bodyText: string): string { } function serializeReceiptForGateway(receipt: Receipt): string { + const service = receipt.version === "1.0" + ? { + endpoint: receipt.service.endpoint, + request_hash: receipt.service.request_hash, + response_hash: receipt.service.response_hash, + } + : receipt.service; return JSON.stringify({ id: receipt.id, version: receipt.version, @@ -56,11 +63,7 @@ function serializeReceiptForGateway(receipt: Receipt): string { chainId: receipt.payment.chainId, nonce: receipt.payment.nonce, }, - service: { - endpoint: receipt.service.endpoint, - request_hash: receipt.service.request_hash, - response_hash: receipt.service.response_hash, - }, + service, }); } @@ -68,25 +71,33 @@ function signedReceiptForPayloads({ requestBody, responseBody, endpoint = "/api/ai/summarize", + authorization = paymentContext as PaymentContextV2, }: { requestBody: string; responseBody: string; endpoint?: string; + authorization?: PaymentContextV2; }): SignedReceipt { const receipt: Receipt = { id: "rcpt_clientflow1", - version: "1.0", + version: "2.0", timestamp: "2026-05-25T00:00:00Z", payment: { payer: wallet.address, - recipient: paymentContext.recipient, - amount: paymentContext.amount, - token: paymentContext.token, - chainId: paymentContext.chainId, - nonce: paymentContext.nonce, + recipient: authorization.recipient, + amount: authorization.amount, + token: authorization.token, + chainId: authorization.chainId, + nonce: authorization.nonce, }, service: { endpoint, + authorization_version: authorization.authorizationVersion, + audience: authorization.audience, + method: authorization.method, + resource: authorization.resource, + content_type: authorization.contentType, + authorization_request_hash: authorization.requestHash, request_hash: sha256Body(requestBody), response_hash: sha256Body(responseBody), }, @@ -486,14 +497,46 @@ describe("PaygateClient request flow", () => { } }); + it("rejects a valid receipt from a different v2 payment authorization", async () => { + const requestBody = JSON.stringify({ text: "hello" }); + const responseBody = JSON.stringify({ result: "summarized text" }); + const substitutedReceipt = signedReceiptForPayloads({ + requestBody, + responseBody, + authorization: { ...paymentContext, nonce: "different-payment-nonce" } as PaymentContextV2, + }); + const { fetcher } = scriptedFetch([ + jsonResponse({ paymentContext }, { status: 402 }), + jsonResponse( + { result: "summarized text" }, + { status: 200, headers: { "X-402-Receipt": receiptHeader(substitutedReceipt) } }, + ), + ]); + const client = new PaygateClient({ + gatewayUrl: "http://gateway.test", + signer: wallet, + fetch: fetcher, + trustedServerPublicKey, + }); + + await expect(client.summarize("hello")).rejects.toMatchObject({ + code: "receipt_verification_failed", + status: 200, + }); + }); + it("matches receipt endpoints against the request path, not gatewayUrl path prefixes", async () => { const requestBody = JSON.stringify({ text: "hello" }); const responseBody = JSON.stringify({ result: "summarized text" }); - const receipt = signedReceiptForPayloads({ requestBody, responseBody }); const prefixedContext = { ...paymentContext, resource: "/paygate/api/ai/summarize", }; + const receipt = signedReceiptForPayloads({ + requestBody, + responseBody, + authorization: prefixedContext as PaymentContextV2, + }); const { fetcher } = scriptedFetch([ jsonResponse({ paymentContext: prefixedContext }, { status: 402 }), jsonResponse( diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index daa1b630..4fde2b6f 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -9,10 +9,19 @@ import type { PaygateProtocolAdapter, PaygateRequest, PaygateResponse, + PaymentContext, PaymentSigner, SignedReceipt, } from "./protocol/types"; import { verifyReceipt } from "./receipts"; +import { isPaymentContextV2 } from "./payment"; + +type SuccessContext = { + endpoint: string; + requestBodyText?: string; + paymentContext?: PaymentContext; + payer?: string; +}; export type PaygateClientOptions = { gatewayUrl: string; @@ -98,7 +107,11 @@ export class PaygateClient { }); } - return this.readSuccess(retryResponse, successContext); + return this.readSuccess(retryResponse, { + ...successContext, + paymentContext, + payer, + }); } private buildUrl(path: string): string { @@ -170,19 +183,47 @@ export class PaygateClient { private receiptMatchesPayload( receipt: SignedReceipt, - context: { endpoint: string; requestBodyText?: string }, + context: SuccessContext, responseBodyText: string, ): boolean { - return ( + const payloadMatches = receipt.receipt.service.endpoint === context.endpoint && receipt.receipt.service.request_hash === this.hashBody(context.requestBodyText) && - receipt.receipt.service.response_hash === this.hashBody(responseBodyText) + receipt.receipt.service.response_hash === this.hashBody(responseBodyText); + if (!payloadMatches || context.paymentContext === undefined) return payloadMatches; + if (!isPaymentContextV2(context.paymentContext) || context.payer === undefined) return false; + + const payment = receipt.receipt.payment; + const service = receipt.receipt.service; + const authorization = context.paymentContext; + return ( + receipt.receipt.version === "2.0" && + this.sameAddress(payment.payer, context.payer) && + this.sameAddress(payment.recipient, authorization.recipient) && + payment.amount === authorization.amount && + payment.token === authorization.token && + payment.chainId === authorization.chainId && + payment.nonce === authorization.nonce && + service.authorization_version === authorization.authorizationVersion && + service.audience === authorization.audience && + service.method === authorization.method && + service.resource === authorization.resource && + service.content_type === authorization.contentType && + service.authorization_request_hash === authorization.requestHash ); } + private sameAddress(left: string, right: string): boolean { + try { + return ethers.getAddress(left) === ethers.getAddress(right); + } catch { + return false; + } + } + private async readSuccess( response: Response, - context: { endpoint: string; requestBodyText?: string }, + context: SuccessContext, ): Promise> { const bodyText = await response.text(); let data: TData; From e564accc6e233e6c0111ea759ebabafd8c480b70 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Mon, 20 Jul 2026 19:20:30 +0530 Subject: [PATCH 15/16] fix: complete receipt authorization binding Co-authored-by: codex --- gateway/config.go | 9 +++ gateway/config_test.go | 4 +- gateway/openapi.yaml | 35 ++++++++- gateway/openapi_test.go | 7 +- gateway/receipt.go | 2 + gateway/receipt_test.go | 3 + .../src/__tests__/client-flow.test.ts | 6 +- sdk/typescript/src/__tests__/receipts.test.ts | 18 ++++- sdk/typescript/src/client.ts | 1 + sdk/typescript/src/protocol/types.ts | 1 + sdk/typescript/src/receipts.ts | 28 ++++++- tests/e2e.test.ts | 1 + web/src/app/docs/protocol/page.mdx | 2 +- web/src/hooks/use-x402.ts | 8 +- web/src/lib/verify-receipt.test.ts | 49 +++++++++++- web/src/lib/verify-receipt.ts | 75 ++++++++++++++++--- 16 files changed, 225 insertions(+), 24 deletions(-) diff --git a/gateway/config.go b/gateway/config.go index 6e9151ac..be0e3830 100644 --- a/gateway/config.go +++ b/gateway/config.go @@ -117,6 +117,9 @@ func normalizeAudienceHostname(hostname string) (string, error) { if strings.Contains(hostname, "%") { return "", fmt.Errorf("PAYGATE_AUDIENCE must contain a valid hostname") } + if strings.HasSuffix(hostname, ".") { + return "", fmt.Errorf("PAYGATE_AUDIENCE hostname must not end with a dot") + } if address, err := netip.ParseAddr(hostname); err == nil { if address.Is4In6() { return "", fmt.Errorf("PAYGATE_AUDIENCE must contain a canonical IP address") @@ -131,6 +134,12 @@ func normalizeAudienceHostname(hostname string) (string, error) { if err != nil { return "", fmt.Errorf("PAYGATE_AUDIENCE must contain a valid hostname: %w", err) } + if address, err := netip.ParseAddr(asciiHostname); err == nil { + return address.String(), nil + } + if endsInIPv4Number(asciiHostname) { + return "", fmt.Errorf("PAYGATE_AUDIENCE must contain a canonical IP address") + } return strings.ToLower(asciiHostname), nil } diff --git a/gateway/config_test.go b/gateway/config_test.go index 388941cb..10940d43 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -53,9 +53,11 @@ func TestNormalizePaygateAudience(t *testing.T) { {name: "preserves IPv6 non-default port", value: "https://[::1]:8443", want: "https://[::1]:8443"}, {name: "rejects IPv6 zone identifier", value: "https://[fe80::1%25eth0]", wantErr: "valid hostname"}, {name: "rejects zero-padded IPv4", value: "http://127.000.000.001", wantErr: "canonical IP address"}, - {name: "rejects trailing-dot IPv4", value: "http://127.0.0.1.", wantErr: "canonical IP address"}, + {name: "rejects trailing-dot IPv4", value: "http://127.0.0.1.", wantErr: "must not end with a dot"}, + {name: "rejects trailing-dot DNS", value: "https://gateway.example.com.", wantErr: "must not end with a dot"}, {name: "rejects integer IPv4", value: "http://2130706433", wantErr: "canonical IP address"}, {name: "rejects hexadecimal IPv4", value: "http://0x7f000001", wantErr: "canonical IP address"}, + {name: "rejects IDNA-mapped hexadecimal IPv4", value: "http://0x7f000001", wantErr: "canonical IP address"}, {name: "rejects IPv4-mapped IPv6", value: "http://[::ffff:127.0.0.1]", wantErr: "canonical IP address"}, {name: "rejects zero port", value: "https://gateway.example.com:0", wantErr: "valid port"}, {name: "rejects out-of-range port", value: "https://gateway.example.com:99999", wantErr: "valid port"}, diff --git a/gateway/openapi.yaml b/gateway/openapi.yaml index e6dadbf1..061ac08c 100644 --- a/gateway/openapi.yaml +++ b/gateway/openapi.yaml @@ -507,7 +507,7 @@ components: type: string format: date-time payment: - $ref: "#/components/schemas/ReceiptPayment" + $ref: "#/components/schemas/ReceiptPaymentV1" service: $ref: "#/components/schemas/ReceiptServiceV1" @@ -527,11 +527,11 @@ components: type: string format: date-time payment: - $ref: "#/components/schemas/ReceiptPayment" + $ref: "#/components/schemas/ReceiptPaymentV2" service: $ref: "#/components/schemas/ReceiptServiceV2" - ReceiptPayment: + ReceiptPaymentV1: type: object additionalProperties: false required: [payer, recipient, amount, token, chainId, nonce] @@ -555,6 +555,35 @@ components: type: string example: "550e8400-e29b-41d4-a716-446655440000" + ReceiptPaymentV2: + type: object + additionalProperties: false + required: [payer, recipient, amount, token, chainId, nonce, timestamp] + properties: + payer: + type: string + example: "0x..." + recipient: + type: string + example: "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219" + amount: + type: string + example: "0.001" + token: + type: string + example: USDC + chainId: + type: integer + example: 84532 + nonce: + type: string + example: "550e8400-e29b-41d4-a716-446655440000" + timestamp: + type: integer + format: int64 + minimum: 1 + description: Unix timestamp from the verified payment authorization. + ReceiptServiceV1: type: object additionalProperties: false diff --git a/gateway/openapi_test.go b/gateway/openapi_test.go index c7efba08..d5c31909 100644 --- a/gateway/openapi_test.go +++ b/gateway/openapi_test.go @@ -96,12 +96,17 @@ func TestOpenAPIReceiptVersionsAreDiscriminated(t *testing.T) { if len(receipt.OneOf) != 2 || receipt.OneOf[0].Ref != "#/components/schemas/ReceiptV1" || receipt.OneOf[1].Ref != "#/components/schemas/ReceiptV2" { t.Fatalf("Receipt oneOf does not discriminate v1 and v2: %#v", receipt.OneOf) } - for _, schemaName := range []string{"ReceiptServiceV1", "ReceiptServiceV2"} { + for _, schemaName := range []string{"ReceiptPaymentV1", "ReceiptPaymentV2", "ReceiptServiceV1", "ReceiptServiceV2"} { schema := spec.Components.Schemas[schemaName] if schema.AdditionalProperties == nil || *schema.AdditionalProperties { t.Fatalf("%s must reject fields from other receipt versions", schemaName) } } + wantV2PaymentFields := []string{"payer", "recipient", "amount", "token", "chainId", "nonce", "timestamp"} + gotV2PaymentFields := spec.Components.Schemas["ReceiptPaymentV2"].Required + if strings.Join(gotV2PaymentFields, ",") != strings.Join(wantV2PaymentFields, ",") { + t.Fatalf("ReceiptPaymentV2 required fields = %v, want %v", gotV2PaymentFields, wantV2PaymentFields) + } wantV2Fields := []string{"endpoint", "authorization_version", "audience", "method", "resource", "content_type", "authorization_request_hash", "request_hash", "response_hash"} gotV2Fields := spec.Components.Schemas["ReceiptServiceV2"].Required if strings.Join(gotV2Fields, ",") != strings.Join(wantV2Fields, ",") { diff --git a/gateway/receipt.go b/gateway/receipt.go index 627c3818..5446b86e 100644 --- a/gateway/receipt.go +++ b/gateway/receipt.go @@ -32,6 +32,7 @@ type PaymentDetails struct { Token string `json:"token"` ChainID int `json:"chainId"` Nonce string `json:"nonce"` + Timestamp uint64 `json:"timestamp,omitempty"` } // ServiceDetails contains service-related information @@ -79,6 +80,7 @@ func GenerateReceipt(payment PaymentContext, payer string, endpoint string, reqB Token: payment.Token, ChainID: payment.ChainID, Nonce: payment.Nonce, + Timestamp: payment.Timestamp, }, Service: ServiceDetails{ Endpoint: endpoint, diff --git a/gateway/receipt_test.go b/gateway/receipt_test.go index 7872c6e6..fc7d666c 100644 --- a/gateway/receipt_test.go +++ b/gateway/receipt_test.go @@ -103,6 +103,9 @@ func TestGenerateReceiptPreservesRequestBoundAuthorization(t *testing.T) { if receipt.Receipt.Version != currentReceiptVersion { t.Fatalf("receipt version = %q, want %q", receipt.Receipt.Version, currentReceiptVersion) } + if receipt.Receipt.Payment.Timestamp != payment.Timestamp { + t.Fatalf("receipt authorization timestamp = %d, want %d", receipt.Receipt.Payment.Timestamp, payment.Timestamp) + } service := receipt.Receipt.Service if service.AuthorizationVersion != payment.AuthorizationVersion || diff --git a/sdk/typescript/src/__tests__/client-flow.test.ts b/sdk/typescript/src/__tests__/client-flow.test.ts index 4a1de8a0..d3f844ef 100644 --- a/sdk/typescript/src/__tests__/client-flow.test.ts +++ b/sdk/typescript/src/__tests__/client-flow.test.ts @@ -62,9 +62,12 @@ function serializeReceiptForGateway(receipt: Receipt): string { token: receipt.payment.token, chainId: receipt.payment.chainId, nonce: receipt.payment.nonce, + ...(receipt.version === "2.0" && { timestamp: receipt.payment.timestamp }), }, service, - }); + }).replace(/[<>&\u2028\u2029]/g, (character) => + `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); } function signedReceiptForPayloads({ @@ -89,6 +92,7 @@ function signedReceiptForPayloads({ token: authorization.token, chainId: authorization.chainId, nonce: authorization.nonce, + timestamp: authorization.timestamp, }, service: { endpoint, diff --git a/sdk/typescript/src/__tests__/receipts.test.ts b/sdk/typescript/src/__tests__/receipts.test.ts index 3124c419..a6026fee 100644 --- a/sdk/typescript/src/__tests__/receipts.test.ts +++ b/sdk/typescript/src/__tests__/receipts.test.ts @@ -78,13 +78,14 @@ describe("receipt helpers", () => { token: "USDC", chainId: 84532, nonce: "bound-v2-receipt", + timestamp: 1760572800, }, service: { endpoint: "/api/ai/summarize", authorization_version: 2, audience: "https://gateway.example.com", method: "POST", - resource: "/api/ai/summarize?mode=brief", + resource: "/api/ai/summarize?mode=brief&tag=\u2028", content_type: "application/json", authorization_request_hash: "0x8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9", @@ -94,7 +95,10 @@ describe("receipt helpers", () => { "sha256:8a90fd4352d6e287b3e908e62f802c99c4f5680c9644cb27fb64d638e3fbb9d4", }, }; - const digest = ethers.keccak256(ethers.toUtf8Bytes(JSON.stringify(receipt))); + const goJSON = JSON.stringify(receipt).replace(/[<>&\u2028\u2029]/g, (character) => + `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + const digest = ethers.keccak256(ethers.toUtf8Bytes(goJSON)); const signature = signingKey.sign(digest); const signedReceipt: SignedReceipt = { receipt, @@ -118,6 +122,16 @@ describe("receipt helpers", () => { legacyWithV2Metadata.receipt.service.authorization_version = 2; legacyWithV2Metadata.receipt.service.audience = "https://gateway.example.com"; expect(validateReceiptFormat(legacyWithV2Metadata)).toBe(false); + + const extraProperty = cloneFixture() as SignedReceipt & { receipt: { status?: string } }; + extraProperty.receipt.status = "refunded"; + expect(validateReceiptFormat(extraProperty)).toBe(false); + + const extraServiceProperty = cloneFixture() as SignedReceipt & { + receipt: { service: SignedReceipt["receipt"]["service"] & { status?: string } }; + }; + extraServiceProperty.receipt.service.status = "refunded"; + expect(validateReceiptFormat(extraServiceProperty)).toBe(false); }); it("verifyReceipt requires the expected gateway receipt signing key as a trust anchor", async () => { diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 4fde2b6f..042a75dc 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -204,6 +204,7 @@ export class PaygateClient { payment.token === authorization.token && payment.chainId === authorization.chainId && payment.nonce === authorization.nonce && + payment.timestamp === authorization.timestamp && service.authorization_version === authorization.authorizationVersion && service.audience === authorization.audience && service.method === authorization.method && diff --git a/sdk/typescript/src/protocol/types.ts b/sdk/typescript/src/protocol/types.ts index f243e10c..78888786 100644 --- a/sdk/typescript/src/protocol/types.ts +++ b/sdk/typescript/src/protocol/types.ts @@ -50,6 +50,7 @@ export type PaymentDetails = { token: string; chainId: number; nonce: string; + timestamp?: number; }; export type ServiceDetails = { diff --git a/sdk/typescript/src/receipts.ts b/sdk/typescript/src/receipts.ts index 0eb51077..1346b772 100644 --- a/sdk/typescript/src/receipts.ts +++ b/sdk/typescript/src/receipts.ts @@ -19,6 +19,12 @@ function isPrefixedString(value: unknown, prefix: string): value is string { return isNonEmptyString(value) && value.startsWith(prefix); } +function hasExactKeys(value: Record, keys: string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + export function validateReceiptFormat(value: unknown): value is SignedReceipt { if (!isRecord(value)) return false; const receipt = value.receipt; @@ -29,6 +35,7 @@ export function validateReceiptFormat(value: unknown): value is SignedReceipt { if (!isRecord(payment) || !isRecord(service)) return false; const validBase = + hasExactKeys(receipt, ["id", "version", "timestamp", "payment", "service"]) && isPrefixedString(receipt.id, "rcpt_") && (receipt.version === "1.0" || receipt.version === "2.0") && isNonEmptyString(receipt.timestamp) && @@ -55,9 +62,19 @@ export function validateReceiptFormat(value: unknown): value is SignedReceipt { service.authorization_request_hash, ]; if (receipt.version === "1.0") { - return service.authorization_version === undefined && v2Fields.every((field) => field === undefined); + return ( + hasExactKeys(payment, ["payer", "recipient", "amount", "token", "chainId", "nonce"]) && + hasExactKeys(service, ["endpoint", "request_hash", "response_hash"]) && + service.authorization_version === undefined && + v2Fields.every((field) => field === undefined) + ); } return ( + hasExactKeys(payment, ["payer", "recipient", "amount", "token", "chainId", "nonce", "timestamp"]) && + hasExactKeys(service, ["endpoint", "authorization_version", "audience", "method", "resource", "content_type", "authorization_request_hash", "request_hash", "response_hash"]) && + typeof payment.timestamp === "number" && + Number.isSafeInteger(payment.timestamp) && + payment.timestamp > 0 && service.authorization_version === 2 && v2Fields.every(isNonEmptyString) && isPrefixedString(service.authorization_request_hash, "0x") @@ -100,7 +117,7 @@ function serializeReceiptForGateway(receipt: Receipt): string { request_hash: receipt.service.request_hash, response_hash: receipt.service.response_hash, }; - return JSON.stringify({ + return stringifyLikeGo({ id: receipt.id, version: receipt.version, timestamp: receipt.timestamp, @@ -111,11 +128,18 @@ function serializeReceiptForGateway(receipt: Receipt): string { token: receipt.payment.token, chainId: receipt.payment.chainId, nonce: receipt.payment.nonce, + ...(receipt.version === "2.0" && { timestamp: receipt.payment.timestamp }), }, service, }); } +function stringifyLikeGo(value: unknown): string { + return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) => + `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); +} + function normalizePublicKey(value: string | undefined): string | null { if (!value) return null; try { diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 786a6a56..a7b88b15 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -102,6 +102,7 @@ describe("MicroAI Paygate E2E Flow", () => { const signedReceipt = JSON.parse(Buffer.from(receiptHeader!, "base64").toString("utf8")); expect(signedReceipt.receipt.version).toBe("2.0"); expect(signedReceipt.receipt.service.authorization_version).toBe(2); + expect(signedReceipt.receipt.payment.timestamp).toBe(paymentContext.timestamp); expect(signedReceipt.receipt.service.authorization_request_hash).toBe( paymentContext.requestHash, ); diff --git a/web/src/app/docs/protocol/page.mdx b/web/src/app/docs/protocol/page.mdx index 726634b2..977f2006 100644 --- a/web/src/app/docs/protocol/page.mdx +++ b/web/src/app/docs/protocol/page.mdx @@ -68,7 +68,7 @@ const types = { Receipts include: - `version`: `2.0` for request-bound authorization receipts. Clients may continue to verify stored legacy `1.0` receipts using the v1 serialization. -- payment payer, recipient, amount, token, chain ID, and nonce. +- payment payer, recipient, amount, token, chain ID, nonce, and signed authorization timestamp. - service endpoint. - authorization version, audience, method, resource, content type, and authorization request hash. - `request_hash`. diff --git a/web/src/hooks/use-x402.ts b/web/src/hooks/use-x402.ts index 0bf0bb08..6e6aaee1 100644 --- a/web/src/hooks/use-x402.ts +++ b/web/src/hooks/use-x402.ts @@ -26,7 +26,10 @@ import { } from "@/lib/wallet"; import { saveReceipt } from "@/lib/receipt-storage"; import { classifyError, type ClassifiedError } from "@/lib/errors"; -import type { SignedReceipt } from "@/lib/verify-receipt"; +import { + receiptMatchesPaymentAuthorization, + type SignedReceipt, +} from "@/lib/verify-receipt"; import type { X402Step } from "@/lib/types"; type UseX402State = { @@ -293,6 +296,9 @@ export function useX402() { update({ step: "receipt" }); const { summary, receipt } = await readSummarizeSuccess(retry); + if (receipt && (!payer || !receiptMatchesPaymentAuthorization(receipt, context, payer))) { + throw new Error("Receipt does not match the signed payment authorization"); + } if (receipt) saveReceipt(receipt, text); track(AnalyticsEvent.SummaryCompleted, { ...flowProps, diff --git a/web/src/lib/verify-receipt.test.ts b/web/src/lib/verify-receipt.test.ts index 2ad4335b..73edadf0 100644 --- a/web/src/lib/verify-receipt.test.ts +++ b/web/src/lib/verify-receipt.test.ts @@ -1,18 +1,23 @@ import { describe, expect, it } from 'bun:test'; import { ethers } from 'ethers'; import { + receiptMatchesPaymentAuthorization, validateReceiptFormat, verifyReceipt, type Receipt, type SignedReceipt, } from './verify-receipt'; +import type { PaymentContextV2 } from './types'; const signingKey = new ethers.SigningKey( '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', ); function signedReceipt(receipt: Receipt): SignedReceipt { - const digest = ethers.keccak256(ethers.toUtf8Bytes(JSON.stringify(receipt))); + const goJSON = JSON.stringify(receipt).replace(/[<>&\u2028\u2029]/g, (character) => + `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); + const digest = ethers.keccak256(ethers.toUtf8Bytes(goJSON)); const signature = signingKey.sign(digest); return { receipt, @@ -30,7 +35,7 @@ function receipt(version: '1.0' | '2.0'): Receipt { authorization_version: 2, audience: 'https://gateway.example.com', method: 'POST', - resource: '/api/ai/summarize', + resource: '/api/ai/summarize?mode=brief&tag=\u2028', content_type: 'application/json', authorization_request_hash: '0x8187d0879ad19b46b277e1b761d3f70d51bc9de6459530b686cfaa503ae8d0e9', @@ -49,6 +54,7 @@ function receipt(version: '1.0' | '2.0'): Receipt { token: 'USDC', chainId: 84532, nonce: 'receipt-version-test', + ...(version === '2.0' && { timestamp: 1760918400 }), }, service, }; @@ -68,5 +74,44 @@ describe('versioned receipt verification', () => { const legacyWithV2Metadata = signedReceipt(receipt('1.0')); legacyWithV2Metadata.receipt.service.authorization_version = 2; expect(validateReceiptFormat(legacyWithV2Metadata)).toBe(false); + + const extraProperty = signedReceipt(receipt('1.0')) as SignedReceipt & { + receipt: Receipt & { status?: string }; + }; + extraProperty.receipt.status = 'refunded'; + expect(validateReceiptFormat(extraProperty)).toBe(false); + + const extraServiceProperty = signedReceipt(receipt('1.0')) as SignedReceipt & { + receipt: Receipt & { service: Receipt['service'] & { status?: string } }; + }; + extraServiceProperty.receipt.service.status = 'refunded'; + expect(validateReceiptFormat(extraServiceProperty)).toBe(false); + }); + + it('binds a v2 receipt to the exact browser authorization', () => { + const signed = signedReceipt(receipt('2.0')); + const authorization: PaymentContextV2 = { + authorizationVersion: 2, + recipient: signed.receipt.payment.recipient, + token: signed.receipt.payment.token, + amount: signed.receipt.payment.amount, + nonce: signed.receipt.payment.nonce, + chainId: signed.receipt.payment.chainId, + timestamp: signed.receipt.payment.timestamp!, + audience: signed.receipt.service.audience!, + method: signed.receipt.service.method!, + resource: signed.receipt.service.resource!, + contentType: signed.receipt.service.content_type!, + requestHash: signed.receipt.service.authorization_request_hash!, + }; + + expect(receiptMatchesPaymentAuthorization(signed, authorization, signed.receipt.payment.payer)).toBe(true); + expect( + receiptMatchesPaymentAuthorization( + signed, + { ...authorization, timestamp: authorization.timestamp + 1 }, + signed.receipt.payment.payer, + ), + ).toBe(false); }); }); diff --git a/web/src/lib/verify-receipt.ts b/web/src/lib/verify-receipt.ts index 4398826d..e9291587 100644 --- a/web/src/lib/verify-receipt.ts +++ b/web/src/lib/verify-receipt.ts @@ -7,7 +7,8 @@ * @module verify-receipt */ -import { ethers } from 'ethers'; +import { ethers } from 'ethers'; +import type { PaymentContext } from './types'; // Type definitions matching backend Go structs @@ -16,8 +17,9 @@ export interface PaymentDetails { recipient: string; amount: string; token: string; - chainId: number; - nonce: string; + chainId: number; + nonce: string; + timestamp?: number; } export interface ServiceDetails { @@ -46,6 +48,16 @@ export interface SignedReceipt { server_public_key: string; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasExactKeys(value: Record, keys: string[]): boolean { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, index) => key === expected[index]); +} + function serializeReceiptForGateway(receipt: Receipt): string { const service = receipt.version === '1.0' ? { @@ -65,7 +77,7 @@ function serializeReceiptForGateway(receipt: Receipt): string { response_hash: receipt.service.response_hash, }; - return JSON.stringify({ + return stringifyLikeGo({ id: receipt.id, version: receipt.version, timestamp: receipt.timestamp, @@ -76,10 +88,45 @@ function serializeReceiptForGateway(receipt: Receipt): string { token: receipt.payment.token, chainId: receipt.payment.chainId, nonce: receipt.payment.nonce, + ...(receipt.version === '2.0' && { timestamp: receipt.payment.timestamp }), }, service, }); } + +function stringifyLikeGo(value: unknown): string { + return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) => + `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); +} + +export function receiptMatchesPaymentAuthorization( + signedReceipt: SignedReceipt, + authorization: PaymentContext, + payer: string, +): boolean { + if (authorization.authorizationVersion !== 2) return false; + const payment = signedReceipt.receipt.payment; + const service = signedReceipt.receipt.service; + try { + return signedReceipt.receipt.version === '2.0' && + ethers.getAddress(payment.payer) === ethers.getAddress(payer) && + ethers.getAddress(payment.recipient) === ethers.getAddress(authorization.recipient) && + payment.amount === authorization.amount && + payment.token === authorization.token && + payment.chainId === authorization.chainId && + payment.nonce === authorization.nonce && + payment.timestamp === authorization.timestamp && + service.authorization_version === authorization.authorizationVersion && + service.audience === authorization.audience && + service.method === authorization.method && + service.resource === authorization.resource && + service.content_type === authorization.contentType && + service.authorization_request_hash === authorization.requestHash; + } catch { + return false; + } +} /** * Verifies a cryptographic receipt signature @@ -144,12 +191,15 @@ export async function verifyReceipt(signedReceipt: SignedReceipt): Promise, ['id', 'version', 'timestamp', 'payment', 'service']) && r.id?.startsWith('rcpt_') && (r.version === '1.0' || r.version === '2.0') && r.timestamp && @@ -175,9 +225,14 @@ export function validateReceiptFormat(signedReceipt: SignedReceipt): boolean { r.service.authorization_request_hash, ]; if (r.version === '1.0') { - return v2Fields.every((field) => field === undefined); + return hasExactKeys(r.payment as unknown as Record, ['payer', 'recipient', 'amount', 'token', 'chainId', 'nonce']) && + hasExactKeys(r.service as unknown as Record, ['endpoint', 'request_hash', 'response_hash']) && + v2Fields.every((field) => field === undefined); } - return r.service.authorization_version === 2 && + return hasExactKeys(r.payment as unknown as Record, ['payer', 'recipient', 'amount', 'token', 'chainId', 'nonce', 'timestamp']) && + hasExactKeys(r.service as unknown as Record, ['endpoint', 'authorization_version', 'audience', 'method', 'resource', 'content_type', 'authorization_request_hash', 'request_hash', 'response_hash']) && + typeof r.payment.timestamp === 'number' && Number.isSafeInteger(r.payment.timestamp) && r.payment.timestamp > 0 && + r.service.authorization_version === 2 && [ r.service.audience, r.service.method, From e9f55ed81bf7403e104ac10a4ec1d04428276323 Mon Sep 17 00:00:00 2001 From: AnkanMisra Date: Mon, 20 Jul 2026 19:44:29 +0530 Subject: [PATCH 16/16] fix: verify receipt exchange binding Co-authored-by: codex --- .../src/__tests__/client-flow.test.ts | 34 +++++++++++++++++++ sdk/typescript/src/client.ts | 3 ++ web/src/hooks/use-x402.ts | 13 +++++-- web/src/lib/verify-receipt.test.ts | 12 +++++++ web/src/lib/verify-receipt.ts | 20 +++++++++-- web/src/lib/x402-client.ts | 6 ++-- 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/sdk/typescript/src/__tests__/client-flow.test.ts b/sdk/typescript/src/__tests__/client-flow.test.ts index d3f844ef..35ee0c8e 100644 --- a/sdk/typescript/src/__tests__/client-flow.test.ts +++ b/sdk/typescript/src/__tests__/client-flow.test.ts @@ -5,6 +5,7 @@ import authorizationV2Fixture from "../../../../tests/fixtures/payment-authoriza import { MicroAIPaygateProtocol, PaygateClient, + type PaygateProtocolAdapter, type PaymentContext, type PaymentContextV2, type PaymentRequestBinding, @@ -316,6 +317,39 @@ describe("PaygateClient request flow", () => { ).toBe(true); }); + it("falls back to signer getAddress for v2 adapters without getPayer", async () => { + const requestBody = JSON.stringify({ text: "hello" }); + const responseBody = JSON.stringify({ result: "summarized text" }); + const receipt = signedReceiptForPayloads({ requestBody, responseBody }); + const base = new MicroAIPaygateProtocol(); + const protocol: PaygateProtocolAdapter = { + readPaymentContext: (response) => base.readPaymentContext(response), + validatePaymentContext: (context, request) => base.validatePaymentContext(context, request), + signPaymentContext: (signer, context, payer) => base.signPaymentContext(signer, context, payer), + buildSignedHeaders: (context, signature, payer) => base.buildSignedHeaders(context, signature, payer), + readReceipt: (response) => base.readReceipt(response), + }; + const { calls, fetcher } = scriptedFetch([ + jsonResponse({ paymentContext }, { status: 402 }), + jsonResponse( + { result: "summarized text" }, + { status: 200, headers: { "X-402-Receipt": receiptHeader(receipt) } }, + ), + ]); + const client = new PaygateClient({ + gatewayUrl: "http://gateway.test", + signer: wallet, + fetch: fetcher, + protocol, + trustedServerPublicKey, + }); + + const response = await client.summarize("hello"); + + expect(response.receiptVerified).toBe(true); + expect(calls[1].init?.headers).toMatchObject({ "X-402-Payer": wallet.address }); + }); + it("throws typed errors for missing paymentContext and non-JSON 402 bodies", async () => { for (const firstResponse of [ jsonResponse({ error: "Payment Required" }, { status: 402 }), diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 042a75dc..1f9529f4 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -87,6 +87,9 @@ export class PaygateClient { let payer: string | undefined; try { payer = await this.protocol.getPayer?.(this.signer, paymentContext); + if (isPaymentContextV2(paymentContext) && payer === undefined) { + payer = await this.signer.getAddress?.(); + } signature = await this.protocol.signPaymentContext(this.signer, paymentContext, payer); } catch (error) { throw new PaygateSdkError("payment_signature_failed", "Failed to sign payment context", { diff --git a/web/src/hooks/use-x402.ts b/web/src/hooks/use-x402.ts index 6e6aaee1..77a98e98 100644 --- a/web/src/hooks/use-x402.ts +++ b/web/src/hooks/use-x402.ts @@ -27,6 +27,7 @@ import { import { saveReceipt } from "@/lib/receipt-storage"; import { classifyError, type ClassifiedError } from "@/lib/errors"; import { + receiptMatchesExchange, receiptMatchesPaymentAuthorization, type SignedReceipt, } from "@/lib/verify-receipt"; @@ -295,8 +296,16 @@ export function useX402() { } update({ step: "receipt" }); - const { summary, receipt } = await readSummarizeSuccess(retry); - if (receipt && (!payer || !receiptMatchesPaymentAuthorization(receipt, context, payer))) { + const { summary, receipt, responseBodyText } = await readSummarizeSuccess(retry); + const receiptMatches = receipt && payer && + receiptMatchesPaymentAuthorization(receipt, context, payer) && + receiptMatchesExchange( + receipt, + new URL(getSummarizeUrl()).pathname, + requestBodyText, + responseBodyText, + ); + if (receipt && !receiptMatches) { throw new Error("Receipt does not match the signed payment authorization"); } if (receipt) saveReceipt(receipt, text); diff --git a/web/src/lib/verify-receipt.test.ts b/web/src/lib/verify-receipt.test.ts index 73edadf0..81eaba33 100644 --- a/web/src/lib/verify-receipt.test.ts +++ b/web/src/lib/verify-receipt.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test'; import { ethers } from 'ethers'; import { + receiptMatchesExchange, receiptMatchesPaymentAuthorization, validateReceiptFormat, verifyReceipt, @@ -114,4 +115,15 @@ describe('versioned receipt verification', () => { ), ).toBe(false); }); + + it('binds a receipt to the exact browser request and response bytes', () => { + const signed = signedReceipt(receipt('2.0')); + const requestBodyText = '{"text":"hello"}'; + const responseBodyText = '{"result":"summary"}'; + signed.receipt.service.request_hash = `sha256:${ethers.sha256(ethers.toUtf8Bytes(requestBodyText)).slice(2)}`; + signed.receipt.service.response_hash = `sha256:${ethers.sha256(ethers.toUtf8Bytes(responseBodyText)).slice(2)}`; + + expect(receiptMatchesExchange(signed, '/api/ai/summarize', requestBodyText, responseBodyText)).toBe(true); + expect(receiptMatchesExchange(signed, '/api/ai/summarize', requestBodyText, '{"result":"tampered"}')).toBe(false); + }); }); diff --git a/web/src/lib/verify-receipt.ts b/web/src/lib/verify-receipt.ts index e9291587..11513576 100644 --- a/web/src/lib/verify-receipt.ts +++ b/web/src/lib/verify-receipt.ts @@ -127,8 +127,24 @@ export function receiptMatchesPaymentAuthorization( return false; } } - -/** + +function sha256Body(bodyText: string): string { + return `sha256:${ethers.sha256(ethers.toUtf8Bytes(bodyText)).slice(2)}`; +} + +export function receiptMatchesExchange( + signedReceipt: SignedReceipt, + endpoint: string, + requestBodyText: string, + responseBodyText: string, +): boolean { + const service = signedReceipt.receipt.service; + return service.endpoint === endpoint && + service.request_hash === sha256Body(requestBodyText) && + service.response_hash === sha256Body(responseBodyText); +} + +/** * Verifies a cryptographic receipt signature * * @param signedReceipt - The signed receipt from the API response diff --git a/web/src/lib/x402-client.ts b/web/src/lib/x402-client.ts index b9d2ea58..a03799cc 100644 --- a/web/src/lib/x402-client.ts +++ b/web/src/lib/x402-client.ts @@ -227,15 +227,17 @@ export function buildSignedHeaders( export type SummarizeSuccess = { summary: string; receipt: SignedReceipt | null; + responseBodyText: string; }; export async function readSummarizeSuccess(res: Response): Promise { - const data = (await res.json()) as { result?: string }; + const responseBodyText = await res.text(); + const data = JSON.parse(responseBodyText) as { result?: string }; const summary = data.result ?? ""; const headerVal = res.headers.get("x-402-receipt") ?? res.headers.get("X-402-Receipt"); const receipt = headerVal ? safeDecodeReceiptHeader(headerVal) : null; - return { summary, receipt }; + return { summary, receipt, responseBodyText }; } export function safeDecodeReceiptHeader(b64: string): SignedReceipt | null {