From 8306ef593c14c5a4b113257f336378867179add4 Mon Sep 17 00:00:00 2001 From: pisum-sativum Date: Sun, 5 Jul 2026 04:31:54 +0530 Subject: [PATCH 1/5] feat: stream AI response tokens via SSE This prevents holding up the HTTP handler goroutine for the duration of inference. Co-authored-by: codex --- gateway/cache.go | 12 +-- gateway/cache_integration_test.go | 35 +++++-- gateway/errors_test.go | 5 + gateway/internal/ai/ollama.go | 55 +++++++++++ gateway/internal/ai/openrouter.go | 86 +++++++++++++++++ gateway/internal/ai/provider.go | 10 ++ gateway/main.go | 112 ++++++++++++++++------ gateway/receipt_store_integration_test.go | 21 +++- gateway/timeout_test.go | 2 +- tests/e2e.test.ts | 5 +- web/src/hooks/use-x402.ts | 8 +- web/src/lib/x402-client.ts | 40 +++++++- 12 files changed, 333 insertions(+), 58 deletions(-) diff --git a/gateway/cache.go b/gateway/cache.go index 031d1723..3f43209d 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -174,9 +174,9 @@ func CacheMiddleware() gin.HandlerFunc { // Generate receipt for cache hit using current request and cached result. // Note: request_hash matches current request, response is from cache, // but both are cryptographically valid since cache key ensures identical text. - if err := generateAndSendReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, cached.Result); err != nil { + if err := streamResultAndReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, cached.Result); err != nil { log.Printf("Failed to send cached response receipt: %v", err) - // generateAndSendReceipt already sent an error response (500) + // streamResultAndReceipt already sent an error response } c.Abort() return @@ -208,10 +208,10 @@ func CacheMiddleware() gin.HandlerFunc { writer.mu.RUnlock() if statusCode == 200 { - // Response format: {"result": "...", "receipt": ...} - var resp map[string]interface{} - if err := json.Unmarshal(bodyBytes, &resp); err == nil { - if result, ok := resp["result"].(string); ok { + // Instead of parsing the SSE stream from bodyBytes, we retrieve the full summary + // which handleSummarize saves in the gin context. + if fullSummaryVal, exists := c.Get("full_summary"); exists { + if result, ok := fullSummaryVal.(string); ok { // Store asynchronously with a deadline to prevent indefinite goroutines go func(k, v string) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) diff --git a/gateway/cache_integration_test.go b/gateway/cache_integration_test.go index a0bf11a4..b0e75829 100644 --- a/gateway/cache_integration_test.go +++ b/gateway/cache_integration_test.go @@ -198,18 +198,33 @@ func TestCacheIntegration_FullFlow(t *testing.T) { } // Verify Body - var resp1, resp2 map[string]interface{} - if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil { - t.Fatalf("Failed to unmarshal response 1: %v", err) - } - if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil { - t.Fatalf("Failed to unmarshal response 2: %v", err) + parseSSEResultForTest := func(body []byte) string { + lines := strings.Split(string(body), "\n") + var result string + for _, line := range lines { + if strings.HasPrefix(line, "data: ") { + data := strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + if data == "[DONE]" || data == "" { + continue + } + var chunk map[string]interface{} + if err := json.Unmarshal([]byte(data), &chunk); err == nil { + if text, ok := chunk["text"].(string); ok { + result += text + } + } + } + } + return result } - if val, ok := resp1["result"].(string); !ok || val != "AI Summary Result" { - t.Errorf("Unexpected result 1: %v", resp1["result"]) + result1 := parseSSEResultForTest(w1.Body.Bytes()) + result2 := parseSSEResultForTest(w2.Body.Bytes()) + + if result1 != "AI Summary Result" { + t.Errorf("Unexpected result 1: %v", result1) } - if val, ok := resp2["result"].(string); !ok || val != "AI Summary Result" { - t.Errorf("Unexpected result 2: %v", resp2["result"]) + if result2 != "AI Summary Result" { + t.Errorf("Unexpected result 2: %v", result2) } } diff --git a/gateway/errors_test.go b/gateway/errors_test.go index d1f17039..85afff5e 100644 --- a/gateway/errors_test.go +++ b/gateway/errors_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "gateway/internal/ai" "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" @@ -24,6 +25,10 @@ func (p failingProvider) Generate(context.Context, string) (string, error) { return "", p.err } +func (p failingProvider) GenerateStream(context.Context, string) (ai.Stream, error) { + return nil, p.err +} + func newSummarizeTestRouter() *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() diff --git a/gateway/internal/ai/ollama.go b/gateway/internal/ai/ollama.go index 57847cba..92b837fc 100644 --- a/gateway/internal/ai/ollama.go +++ b/gateway/internal/ai/ollama.go @@ -77,3 +77,58 @@ func (p *OllamaProvider) Generate(ctx context.Context, text string) (string, err return response, nil } + +type ollamaStream struct { + resp *http.Response + decoder *json.Decoder +} + +func (s *ollamaStream) Recv() (string, error) { + var chunk map[string]interface{} + if err := s.decoder.Decode(&chunk); err != nil { + return "", err // will return io.EOF when stream ends + } + if response, ok := chunk["response"].(string); ok { + return response, nil + } + return "", fmt.Errorf("invalid response from Ollama: missing response field") +} + +func (s *ollamaStream) Close() error { + return s.resp.Body.Close() +} + +func (p *OllamaProvider) GenerateStream(ctx context.Context, text string) (Stream, error) { + prompt := fmt.Sprintf("Summarize this text in 2 sentences: %s", text) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "model": p.model, + "prompt": prompt, + "stream": true, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", p.url+"/api/generate", bytes.NewBuffer(reqBody)) + if err != nil { + return nil, fmt.Errorf("failed to create Ollama request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || ctx.Err() == context.DeadlineExceeded { + return nil, context.DeadlineExceeded + } + return nil, fmt.Errorf("failed to connect to Ollama: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("ollama returned status %d: %s", resp.StatusCode, string(body)) + } + + return &ollamaStream{ + resp: resp, + decoder: json.NewDecoder(resp.Body), + }, nil +} diff --git a/gateway/internal/ai/openrouter.go b/gateway/internal/ai/openrouter.go index 36bc48ab..2418008a 100644 --- a/gateway/internal/ai/openrouter.go +++ b/gateway/internal/ai/openrouter.go @@ -1,6 +1,7 @@ package ai import ( + "bufio" "bytes" "context" "encoding/json" @@ -100,3 +101,88 @@ func (p *OpenRouterProvider) Generate(ctx context.Context, text string) (string, return content, nil } + +type openRouterStream struct { + resp *http.Response + reader *bufio.Reader +} + +func (s *openRouterStream) Recv() (string, error) { + for { + line, err := s.reader.ReadBytes('\n') + if err != nil { + return "", err + } + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + if bytes.HasPrefix(line, []byte("data: ")) { + data := bytes.TrimPrefix(line, []byte("data: ")) + if string(data) == "[DONE]" { + return "", io.EOF + } + var chunk map[string]interface{} + if err := json.Unmarshal(data, &chunk); err != nil { + continue // skip invalid json + } + choices, ok := chunk["choices"].([]interface{}) + if !ok || len(choices) == 0 { + continue + } + choice, ok := choices[0].(map[string]interface{}) + if !ok { + continue + } + delta, ok := choice["delta"].(map[string]interface{}) + if !ok { + continue + } + if content, ok := delta["content"].(string); ok { + return content, nil + } + } + } +} + +func (s *openRouterStream) Close() error { + return s.resp.Body.Close() +} + +func (p *OpenRouterProvider) GenerateStream(ctx context.Context, text string) (Stream, error) { + prompt := fmt.Sprintf("Summarize this text in 2 sentences: %s", text) + + reqBody, _ := json.Marshal(map[string]interface{}{ + "model": p.model, + "messages": []map[string]string{ + {"role": "user", "content": prompt}, + }, + "stream": true, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", p.url, bytes.NewBuffer(reqBody)) + if err != nil { + return nil, fmt.Errorf("failed to create OpenRouter request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+p.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || ctx.Err() == context.DeadlineExceeded { + return nil, context.DeadlineExceeded + } + return nil, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("openrouter returned status %d: %s", resp.StatusCode, string(body)) + } + + return &openRouterStream{ + resp: resp, + reader: bufio.NewReader(resp.Body), + }, nil +} diff --git a/gateway/internal/ai/provider.go b/gateway/internal/ai/provider.go index ec027973..cc0314d6 100644 --- a/gateway/internal/ai/provider.go +++ b/gateway/internal/ai/provider.go @@ -6,10 +6,20 @@ import ( "os" ) +// Stream defines the interface for streaming AI responses +type Stream interface { + // Recv returns the next token or an error (io.EOF when done) + Recv() (string, error) + // Close releases any resources associated with the stream + Close() error +} + // Provider defines the interface for AI service providers type Provider interface { // Generate takes a context and prompt text, returns the AI-generated response Generate(ctx context.Context, prompt string) (string, error) + // GenerateStream takes a context and prompt text, returns a Stream of generated tokens + GenerateStream(ctx context.Context, prompt string) (Stream, error) } // NewProvider creates an AI provider based on the AI_PROVIDER environment variable diff --git a/gateway/main.go b/gateway/main.go index 953c94d1..1c817a62 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -521,7 +521,7 @@ func handleSummarize(c *gin.Context) { } // 3. Call AI Service - summary, err := aiProvider.Generate(c.Request.Context(), req.Text) + stream, err := aiProvider.GenerateStream(c.Request.Context(), req.Text) if err != nil { if errors.Is(err, context.DeadlineExceeded) || errors.Is(c.Request.Context().Err(), context.DeadlineExceeded) { respondError(c, 504, "upstream_timeout", err) @@ -530,15 +530,65 @@ func handleSummarize(c *gin.Context) { } return } + defer stream.Close() - // 4. Generate & Send Receipt - if err := generateAndSendReceipt(c, *paymentCtx, verifyResp.RecoveredAddress, requestBody, summary); err != nil { - log.Printf("Failed to generate receipt: %v", err) - // generateAndSendReceipt sends error response if it fails? - // No, it returns error, we might have already written status if we aren't careful. - // Let's implement generateAndSendReceipt to handle sending response. + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + respondError(c, 500, "streaming_unsupported", fmt.Errorf("streaming unsupported")) + return + } + + var fullSummary string + for { + text, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + fmt.Fprintf(c.Writer, "data: {\"error\": %q}\n\n", err.Error()) + flusher.Flush() + return + } + + fullSummary += text + chunkBytes, _ := json.Marshal(map[string]string{"text": text}) + fmt.Fprintf(c.Writer, "data: %s\n\n", chunkBytes) + flusher.Flush() + } + + // 4. Generate Receipt + responseMap := map[string]interface{}{ + "result": fullSummary, + } + responseBody, _ := json.Marshal(responseMap) + + receipt, err := GenerateReceipt(*paymentCtx, verifyResp.RecoveredAddress, c.Request.URL.Path, requestBody, responseBody) + if err != nil { + fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_generation_failed\"}\n\n") + flusher.Flush() + return + } + + if err := storeReceiptWithContext(c.Request.Context(), receipt, getReceiptTTL()); err != nil { + fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_store_failed\"}\n\n") + flusher.Flush() return } + + receiptJSON, _ := json.Marshal(receipt) + receiptBase64 := base64.StdEncoding.EncodeToString(receiptJSON) + + // Send receipt as final event + fmt.Fprintf(c.Writer, "data: {\"receipt\": %q}\n\n", receiptBase64) + fmt.Fprintf(c.Writer, "data: [DONE]\n\n") + flusher.Flush() + + // Store for cache middleware + c.Set("full_summary", fullSummary) } // verifyPayment calls the verification service. @@ -609,42 +659,48 @@ func verifyPayment(ctx context.Context, signature, nonce string, timestamp uint6 return &verifyResp, &paymentCtx, nil } -// generateAndSendReceipt handles receipt generation, storage, and sending the final JSON response. -// The receipt is sent ONLY in the X-402-Receipt header, not in the response body, -// to ensure the ResponseHash in the receipt matches the actual JSON body clients receive. -func generateAndSendReceipt(c *gin.Context, paymentCtx PaymentContext, recoveredAddr string, requestBody []byte, aiResult string) error { - // Construct the response body that will be sent to client (without receipt) +// streamResultAndReceipt is used by CacheMiddleware for cache hits to stream the response identically to a live request. +func streamResultAndReceipt(c *gin.Context, paymentCtx PaymentContext, recoveredAddr string, requestBody []byte, aiResult string) error { + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + respondError(c, 500, "streaming_unsupported", fmt.Errorf("streaming unsupported")) + return fmt.Errorf("streaming unsupported") + } + + chunkBytes, _ := json.Marshal(map[string]string{"text": aiResult}) + fmt.Fprintf(c.Writer, "data: %s\n\n", chunkBytes) + flusher.Flush() + + // Generate Receipt responseMap := map[string]interface{}{ "result": aiResult, } - responseBody, err := json.Marshal(responseMap) - if err != nil { - respondError(c, 500, "response_encoding_failed", err) - return err - } + responseBody, _ := json.Marshal(responseMap) - // Generate receipt with the actual response body hash receipt, err := GenerateReceipt(paymentCtx, recoveredAddr, c.Request.URL.Path, requestBody, responseBody) if err != nil { - respondError(c, 500, "receipt_generation_failed", err) + fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_generation_failed\"}\n\n") + flusher.Flush() return err } if err := storeReceiptWithContext(c.Request.Context(), receipt, getReceiptTTL()); err != nil { - respondError(c, 500, "receipt_store_failed", err) + fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_store_failed\"}\n\n") + flusher.Flush() return err } - receiptJSON, err := json.Marshal(receipt) - if err != nil { - respondError(c, 500, "receipt_encoding_failed", err) - return err - } + receiptJSON, _ := json.Marshal(receipt) receiptBase64 := base64.StdEncoding.EncodeToString(receiptJSON) - // Send receipt in header only (not in body) so ResponseHash matches body - c.Header("X-402-Receipt", receiptBase64) - c.JSON(200, responseMap) + // Send receipt as final event + fmt.Fprintf(c.Writer, "data: {\"receipt\": %q}\n\n", receiptBase64) + fmt.Fprintf(c.Writer, "data: [DONE]\n\n") + flusher.Flush() return nil } diff --git a/gateway/receipt_store_integration_test.go b/gateway/receipt_store_integration_test.go index c7a2e400..2175f0d8 100644 --- a/gateway/receipt_store_integration_test.go +++ b/gateway/receipt_store_integration_test.go @@ -83,11 +83,24 @@ func TestRedisReceiptStore_PersistsAcrossGatewayRestart(t *testing.T) { t.Fatalf("create receipt status=%d body=%s", createResp.Code, createResp.Body.String()) } - receiptHeader := createResp.Header().Get("X-402-Receipt") - if receiptHeader == "" { - t.Fatal("missing X-402-Receipt header") + var receiptBase64 string + lines := strings.Split(createResp.Body.String(), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "data: ") { + dataStr := strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + var chunk map[string]interface{} + if err := json.Unmarshal([]byte(dataStr), &chunk); err == nil { + if r, ok := chunk["receipt"].(string); ok { + receiptBase64 = r + } + } + } + } + + if receiptBase64 == "" { + t.Fatal("missing receipt event in SSE stream") } - receiptJSON, err := base64.StdEncoding.DecodeString(receiptHeader) + receiptJSON, err := base64.StdEncoding.DecodeString(receiptBase64) if err != nil { t.Fatalf("decode receipt header: %v", err) } diff --git a/gateway/timeout_test.go b/gateway/timeout_test.go index b0e2e03e..7581c3f0 100644 --- a/gateway/timeout_test.go +++ b/gateway/timeout_test.go @@ -63,7 +63,7 @@ func TestCallOpenRouter_RespectsContextTimeout(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() - _, err = provider.Generate(ctx, "hello") + _, err = provider.GenerateStream(ctx, "hello") if err == nil { t.Fatalf("Expected timeout error from provider, got nil") } diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 3f5ce3b7..902b7156 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -86,8 +86,9 @@ describe("MicroAI Paygate E2E Flow", () => { } expect(res.status).toBe(200); - const data = await res.json() as any; - expect(data.result).toBeDefined(); + const textStr = await res.text(); + expect(textStr).toContain("data: {"); + expect(textStr).toContain("[DONE]"); }, 30000); it("should reject replayed signed payment context", async () => { diff --git a/web/src/hooks/use-x402.ts b/web/src/hooks/use-x402.ts index 7f5933fe..dea0eb33 100644 --- a/web/src/hooks/use-x402.ts +++ b/web/src/hooks/use-x402.ts @@ -111,7 +111,9 @@ export function useX402() { if (first.status === 200) { update({ step: "receipt" }); - const { summary, receipt } = await readSummarizeSuccess(first); + const { summary, receipt } = await readSummarizeSuccess(first, (text) => { + update({ summary: text }); + }); if (receipt) saveReceipt(receipt, text); track(AnalyticsEvent.SummaryCompleted, { ...flowProps, @@ -280,7 +282,9 @@ export function useX402() { } update({ step: "receipt" }); - const { summary, receipt } = await readSummarizeSuccess(retry); + const { summary, receipt } = await readSummarizeSuccess(retry, (text) => { + update({ summary: text }); + }); if (receipt) saveReceipt(receipt, text); track(AnalyticsEvent.SummaryCompleted, { ...flowProps, diff --git a/web/src/lib/x402-client.ts b/web/src/lib/x402-client.ts index 2a24c42a..70cfca06 100644 --- a/web/src/lib/x402-client.ts +++ b/web/src/lib/x402-client.ts @@ -68,12 +68,42 @@ export type SummarizeSuccess = { receipt: SignedReceipt | null; }; -export async function readSummarizeSuccess(res: Response): Promise { - const data = (await res.json()) as { result?: string }; - const summary = data.result ?? ""; +export async function readSummarizeSuccess( + res: Response, + onChunk?: (text: string) => void +): Promise { + if (!res.body) throw new Error("No response body"); + const reader = res.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let summary = ""; + let receipt: SignedReceipt | null = null; + let buffer = ""; - const headerVal = res.headers.get("x-402-receipt") ?? res.headers.get("X-402-Receipt"); - const receipt = headerVal ? safeDecodeReceiptHeader(headerVal) : null; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (line.startsWith("data: ")) { + const dataStr = line.slice("data: ".length).trim(); + if (dataStr === "[DONE]") continue; + try { + const parsed = JSON.parse(dataStr); + if (parsed.text) { + summary += parsed.text; + if (onChunk) onChunk(summary); + } else if (parsed.receipt) { + receipt = safeDecodeReceiptHeader(parsed.receipt); + } + } catch (e) { + // ignore + } + } + } + } return { summary, receipt }; } From 924fd70cd55fa3d74710cfaa8ebb83bacaa4f858 Mon Sep 17 00:00:00 2001 From: pisum-sativum Date: Sun, 5 Jul 2026 04:43:09 +0530 Subject: [PATCH 2/5] feat: fix transaction hash replay attack Fixes issue #241 where the same payment transaction hash could be replayed indefinitely. This adds an atomic check and set in Redis to record used transaction hashes (EIP-712 signatures) immediately after first successful verification. A TTL of 30 days is applied to the keys in Redis to match the on-chain finality + dispute window. Additionally, it implements an in-memory fallback to avoid breaking tests and adds a load test simulating concurrent replay submissions. Co-authored-by: codex --- gateway/cache.go | 19 ++++ gateway/main.go | 16 ++++ gateway/receipt_store_integration_test.go | 106 ++++++++++++++++++++++ gateway/redis.go | 27 ++++++ 4 files changed, 168 insertions(+) diff --git a/gateway/cache.go b/gateway/cache.go index 3f43209d..f3ecec3f 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -164,6 +164,25 @@ func CacheMiddleware() gin.HandlerFunc { c.Abort() return } + + // === Fix for #241: Prevent replay attacks on cache hits === + used, err := markTransactionUsed(c.Request.Context(), signature) + if err != nil { + respondError(c, 500, "internal_error", fmt.Errorf("failed to check transaction replay: %w", err)) + c.Abort() + return + } + if used { + c.JSON(http.StatusPaymentRequired, gin.H{ + "error": "Payment Required", + "message": "Transaction already used", + "paymentContext": createPaymentContext(), + }) + c.Abort() + return + } + // ========================================================== + verificationTotal.WithLabelValues("success").Inc() // Payment Verified. Store verification for downstream if needed (though we abort) c.Set("payment_verification", verifyResp) diff --git a/gateway/main.go b/gateway/main.go index 1c817a62..6886ad54 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -505,6 +505,22 @@ func handleSummarize(c *gin.Context) { return } + // === Fix for #241: Prevent replay attacks === + used, err := markTransactionUsed(c.Request.Context(), signature) + if err != nil { + respondError(c, 500, "internal_error", fmt.Errorf("failed to check transaction replay: %w", err)) + return + } + if used { + c.JSON(http.StatusPaymentRequired, gin.H{ + "error": "Payment Required", + "message": "Transaction already used", + "paymentContext": createPaymentContext(), + }) + return + } + // ============================================ + verificationTotal.WithLabelValues("success").Inc() // 2. Parse Request diff --git a/gateway/receipt_store_integration_test.go b/gateway/receipt_store_integration_test.go index 2175f0d8..4c79bdeb 100644 --- a/gateway/receipt_store_integration_test.go +++ b/gateway/receipt_store_integration_test.go @@ -194,3 +194,109 @@ func replaceReceiptGlobalsForTest(t *testing.T) func() { receiptGlobalsTestMu.Unlock() } } + +func TestHandleSummarize_ConcurrentReplayAttack(t *testing.T) { + ctx := t.Context() + redisServer := miniredis.RunT(t) + rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) + defer rdb.Close() + + verifier := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Mock verifier that always approves + _ = json.NewEncoder(w).Encode(VerifyResponse{ + IsValid: true, + RecoveredAddress: "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21", + }) + })) + defer verifier.Close() + + aiServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"AI response"}}]}`)) + })) + defer aiServer.Close() + + t.Setenv("CACHE_ENABLED", "false") + t.Setenv("RECEIPT_STORE", "redis") + t.Setenv("REDIS_URL", redisServer.Addr()) + t.Setenv("AI_PROVIDER", "openrouter") + t.Setenv("OPENROUTER_URL", aiServer.URL) + t.Setenv("OPENROUTER_API_KEY", "test-key") + t.Setenv("VERIFIER_URL", verifier.URL) + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("RECIPIENT_ADDRESS", "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219") + t.Setenv("RECEIPT_TTL", "86400") + + resetServerPrivateKeyForTest(t) + restoreReceiptGlobals := replaceReceiptGlobalsForTest(t) + defer restoreReceiptGlobals() + + if err := initRedis(); err != nil { + t.Fatalf("init redis: %v", err) + } + if err := initReceiptStore(); err != nil { + t.Fatalf("init redis receipt store: %v", err) + } + + var err error + aiProvider, err = ai.NewProvider() + if err != nil { + t.Fatalf("new AI provider: %v", err) + } + + gateway := newReceiptPersistenceTestRouter() + + const numConcurrentRequests = 20 + var wg sync.WaitGroup + wg.Add(numConcurrentRequests) + + successCount := 0 + conflictCount := 0 + var mu sync.Mutex + + // Create requests beforehand so they start at the exact same time + var requests []*http.Request + for i := 0; i < numConcurrentRequests; i++ { + req := httptest.NewRequest(http.MethodPost, "/api/ai/summarize", bytes.NewBufferString(`{"text":"summarize me"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-402-Signature", "0xSameSignatureReplayTest") // Same signature for all requests + req.Header.Set("X-402-Nonce", "replay-test-nonce") + req.Header.Set("X-402-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + requests = append(requests, req) + } + + start := make(chan struct{}) + + for i := 0; i < numConcurrentRequests; i++ { + go func(req *http.Request) { + defer wg.Done() + <-start // wait for signal to start simultaneously + + resp := httptest.NewRecorder() + gateway.ServeHTTP(resp, req) + + mu.Lock() + if resp.Code == http.StatusOK { + successCount++ + } else if resp.Code == http.StatusPaymentRequired { + var body map[string]interface{} + _ = json.Unmarshal(resp.Body.Bytes(), &body) + if body["message"] == "Transaction already used" { + conflictCount++ + } + } + mu.Unlock() + }(requests[i]) + } + + close(start) // Signal all goroutines to start + wg.Wait() + + if successCount != 1 { + t.Errorf("Expected exactly 1 successful request, got %d", successCount) + } + if conflictCount != numConcurrentRequests - 1 { + t.Errorf("Expected exactly %d blocked replays, got %d", numConcurrentRequests - 1, conflictCount) + } +} + diff --git a/gateway/redis.go b/gateway/redis.go index 53d7a5b2..c7eb96fc 100644 --- a/gateway/redis.go +++ b/gateway/redis.go @@ -6,6 +6,7 @@ import ( "log" "os" "strings" + "sync" "time" "github.com/redis/go-redis/v9" @@ -76,3 +77,29 @@ func getEnv(key, fallback string) string { } return fallback } + +var ( + memoryUsedTx = make(map[string]bool) + memoryUsedTxMu sync.Mutex +) + +// markTransactionUsed checks and records a transaction signature as used atomically. +// It returns true if the transaction was already used, or false if it was successfully marked. +func markTransactionUsed(ctx context.Context, txHash string) (bool, error) { + if redisClient == nil { + memoryUsedTxMu.Lock() + defer memoryUsedTxMu.Unlock() + if memoryUsedTx[txHash] { + return true, nil + } + memoryUsedTx[txHash] = true + return false, nil + } + key := "used_tx:" + txHash + // SetNX returns true if the key was set, meaning it wasn't used before. + added, err := redisClient.SetNX(ctx, key, "1", 30*24*time.Hour).Result() + if err != nil { + return false, err + } + return !added, nil +} From 72bc77af0a205f3076bcaae46d7066bdfe587d29 Mon Sep 17 00:00:00 2001 From: pisum-sativum Date: Sun, 5 Jul 2026 04:57:59 +0530 Subject: [PATCH 3/5] feat: Support advertised chain options in the web wallet flow Co-authored-by: codex --- web/README.md | 3 +- web/bun.lock | 42 +++++++++++- web/package.json | 2 + web/src/hooks/use-x402.test.ts | 116 +++++++++++++++++++++++++++++++++ web/src/hooks/use-x402.ts | 33 +++++++--- web/src/lib/errors.ts | 2 +- web/src/lib/types.ts | 1 + 7 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 web/src/hooks/use-x402.test.ts diff --git a/web/README.md b/web/README.md index c14d7c4b..f1c1949e 100644 --- a/web/README.md +++ b/web/README.md @@ -7,7 +7,8 @@ The web app is a Next.js/Bun frontend on port `3001`. It lets users submit text - Send unsigned summarize requests to the gateway. - Detect `402` responses and read `paymentContext`. - 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. +- Check if the wallet is on a chain listed in `supportedChains` (falling back to `chainId` if not advertised). +- Switch or add the requested default chain when the wallet is on an unsupported network. - Sign the gateway-provided EIP-712 payment context. - Retry with `X-402-Signature`, `X-402-Nonce`, and `X-402-Timestamp`. - Display summary results or user-facing errors. diff --git a/web/bun.lock b/web/bun.lock index aadebcc1..70cfba2f 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -17,6 +17,7 @@ "react-dom": "19.2.3", }, "devDependencies": { + "@happy-dom/global-registrator": "^20.10.6", "@tailwindcss/postcss": "^4", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", @@ -27,6 +28,7 @@ "bun-types": "1.3.14", "eslint": "^9", "eslint-config-next": "16.1.1", + "happy-dom": "^20.10.6", "jsdom": "^29.1.1", "tailwindcss": "^4", "typescript": "5.9.3", @@ -120,6 +122,8 @@ "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.10.6" } }, "sha512-Nu/IjRkkNxmeG2ywWsyJSO4d1BrWTqVzxCPL+gXj0b97klhmjd6wLzt6Bx/laNpkZ3WLW7zNCqmtMIbIlahqug=="], + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -308,6 +312,10 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.49.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/type-utils": "8.49.0", "@typescript-eslint/utils": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.49.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.49.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", "@typescript-eslint/typescript-estree": "8.49.0", "@typescript-eslint/visitor-keys": "8.49.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA=="], @@ -426,6 +434,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], @@ -514,7 +524,7 @@ "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], - "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "es-abstract": ["es-abstract@1.24.0", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg=="], @@ -644,6 +654,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw=="], + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -1166,7 +1178,7 @@ "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], - "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], @@ -1182,7 +1194,7 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], @@ -1212,6 +1224,8 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + "@happy-dom/global-registrator/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], + "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tybys/wasm-util/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1220,16 +1234,22 @@ "@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@types/ws/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "buffer-image-size/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], + "bun-types/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "data-urls/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -1242,26 +1262,42 @@ "ethers/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], + "ethers/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "happy-dom/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="], + "is-bun-module/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "jsdom/whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "parse5/entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "sharp/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], + "@types/jsdom/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], + "@types/ws/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "buffer-image-size/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], + "bun-types/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], "ethers/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], + + "happy-dom/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], } } diff --git a/web/package.json b/web/package.json index cb142942..7336047f 100644 --- a/web/package.json +++ b/web/package.json @@ -27,6 +27,7 @@ "react-dom": "19.2.3" }, "devDependencies": { + "@happy-dom/global-registrator": "^20.10.6", "@tailwindcss/postcss": "^4", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", @@ -37,6 +38,7 @@ "bun-types": "1.3.14", "eslint": "^9", "eslint-config-next": "16.1.1", + "happy-dom": "^20.10.6", "jsdom": "^29.1.1", "tailwindcss": "^4", "typescript": "5.9.3" diff --git a/web/src/hooks/use-x402.test.ts b/web/src/hooks/use-x402.test.ts new file mode 100644 index 00000000..afe240d2 --- /dev/null +++ b/web/src/hooks/use-x402.test.ts @@ -0,0 +1,116 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +GlobalRegistrator.register(); + +import { describe, it, expect, beforeEach, mock, afterEach, spyOn } from "bun:test"; +import { renderHook, act } from "@testing-library/react"; +import { useX402 } from "./use-x402"; +import * as wallet from "@/lib/wallet"; +import * as x402Client from "@/lib/x402-client"; + +// Mock ethers +mock.module("ethers", () => { + return { + ethers: { + BrowserProvider: class { + getSigner() { + return { signTypedData: () => Promise.resolve("0xmock-signature") }; + } + }, + }, + }; +}); + +// Polyfill window.ethereum for the provider check +(globalThis as any).window = { ethereum: {} }; + +describe("useX402 chain switching logic", () => { + beforeEach(() => { + // Reset all mocks + mock.restore(); + + spyOn(wallet, "hasWallet").mockReturnValue(true); + spyOn(wallet, "getProvider").mockReturnValue(true as any); + spyOn(wallet, "getCurrentAccount").mockResolvedValue("0xmock-account"); + + // Default fetch mocks for summarize + spyOn(x402Client, "postSummarize").mockResolvedValue({ + ok: false, + status: 402, + json: async () => ({ + paymentContext: { + recipient: "0x123", + token: "USDC", + amount: "0.001", + nonce: "nonce1", + chainId: 84532, + timestamp: 123456789, + } + }), + text: async () => "Payment Required", + } as unknown as Response); + + spyOn(x402Client, "readPaymentChallenge").mockResolvedValue({ + recipient: "0x123", + token: "USDC", + amount: "0.001", + nonce: "nonce1", + chainId: 84532, + timestamp: 123456789, + supportedChains: [84532, 8453] + }); + }); + + afterEach(() => { + mock.restore(); + }); + + it("skips chain switch if currently on a supported chain", async () => { + spyOn(wallet, "getCurrentChainId").mockResolvedValue(8453); + const switchSpy = spyOn(wallet, "switchOrAddChain").mockResolvedValue(); + const signSpy = spyOn(x402Client, "signPaymentContext").mockResolvedValue("0xmock"); + + const { result } = renderHook(() => useX402()); + await act(async () => { + await result.current.submit("test"); + }); + + expect(switchSpy).not.toHaveBeenCalled(); + // Should have signed with the chain we were actually on + const signArgs = signSpy.mock.calls[0][1]; + expect(signArgs.chainId).toBe(8453); + }); + + it("triggers switch to default chain if on unsupported chain", async () => { + spyOn(wallet, "getCurrentChainId") + .mockResolvedValueOnce(1) // Initial check + .mockResolvedValueOnce(84532); // Post-switch check + + const switchSpy = spyOn(wallet, "switchOrAddChain").mockResolvedValue(); + const signSpy = spyOn(x402Client, "signPaymentContext").mockResolvedValue("0xmock"); + + const { result } = renderHook(() => useX402()); + await act(async () => { + await result.current.submit("test"); + }); + + expect(switchSpy).toHaveBeenCalledWith(84532); + const signArgs = signSpy.mock.calls[0][1]; + expect(signArgs.chainId).toBe(84532); + }); + + it("surfaces error if wallet fails to switch to a supported chain", async () => { + spyOn(wallet, "getCurrentChainId") + .mockResolvedValueOnce(1) // Initial check + .mockResolvedValueOnce(1); // Post-switch check fails + + const switchSpy = spyOn(wallet, "switchOrAddChain").mockResolvedValue(); + + const { result } = renderHook(() => useX402()); + await act(async () => { + await result.current.submit("test"); + }); + + expect(switchSpy).toHaveBeenCalledWith(84532); + expect(result.current.error?.message).toContain("Switch manually to one of: Base Sepolia, Base"); + }); +}); diff --git a/web/src/hooks/use-x402.ts b/web/src/hooks/use-x402.ts index dea0eb33..e785d15f 100644 --- a/web/src/hooks/use-x402.ts +++ b/web/src/hooks/use-x402.ts @@ -19,6 +19,7 @@ import { getProvider, hasWallet, switchOrAddChain, + getChainMeta, } from "@/lib/wallet"; import { saveReceipt } from "@/lib/receipt-storage"; import { classifyError, type ClassifiedError } from "@/lib/errors"; @@ -180,7 +181,14 @@ export function useX402() { // awaiting it to avoid corrupting the connect-conversion metric. stage = "chain-lookup"; const currentChain = await getCurrentChainId(); - if (currentChain !== context.chainId) { + const supported = context.supportedChains?.length + ? context.supportedChains + : [context.chainId]; + const isSupported = currentChain != null && supported.includes(currentChain); + + let finalChain = currentChain; + + if (!isSupported) { stage = "chain-switch"; track(AnalyticsEvent.ChainSwitchRequested, { ...flowProps, @@ -192,17 +200,24 @@ export function useX402() { // (e.g. Brave) won't auto-switch after adding. Re-check before signing // so we never embed the wrong chainId in EIP-712 typed data. const postSwitch = await getCurrentChainId(); - if (postSwitch !== context.chainId) { + if (postSwitch == null || !supported.includes(postSwitch)) { + const chainNames = supported.map(id => { + try { return getChainMeta(id).name; } + catch { return `Chain ${id}`; } + }).join(", "); throw new Error( - `Wallet did not switch to chain ${context.chainId} (still on ${postSwitch}). Switch manually and retry.`, + `Wallet did not switch to a supported chain. Switch manually to one of: ${chainNames}.`, ); } + finalChain = postSwitch; track(AnalyticsEvent.ChainSwitchSucceeded, { ...flowProps, chain_id: context.chainId, }); } + const signContext = { ...context, chainId: finalChain ?? context.chainId }; + stage = "signer"; const refreshedProvider = new ethers.BrowserProvider(window.ethereum!); const signer = await refreshedProvider.getSigner(account); @@ -211,9 +226,9 @@ export function useX402() { stage = "sign"; track(AnalyticsEvent.PaymentSignatureStarted, { ...flowProps, - chain_id: context.chainId, + chain_id: signContext.chainId, }); - const signature = await signPaymentContext(signer, context); + const signature = await signPaymentContext(signer, signContext); // The signature prompt is modal and can take arbitrarily long. If the // user switched or disconnected accounts while it was open, the wallet's // accountsChanged handler has already reset analytics identity — so only @@ -223,12 +238,12 @@ export function useX402() { if (liveAccount && liveAccount.toLowerCase() === account.toLowerCase()) { identify(account, { wallet_connected: true, - chain_id: context.chainId, + chain_id: signContext.chainId, }); } track(AnalyticsEvent.PaymentSignatureSucceeded, { ...flowProps, - chain_id: context.chainId, + chain_id: signContext.chainId, }); update({ step: "verify" }); @@ -245,10 +260,10 @@ export function useX402() { try { track(AnalyticsEvent.SignedRetrySent, { ...flowProps, - chain_id: context.chainId, + chain_id: signContext.chainId, }); retry = await postSummarize(text, { - ...buildSignedHeaders(context, signature), + ...buildSignedHeaders(signContext, signature), "X-Correlation-ID": flow.correlationId, }); } finally { diff --git a/web/src/lib/errors.ts b/web/src/lib/errors.ts index 729f4e52..81899eab 100644 --- a/web/src/lib/errors.ts +++ b/web/src/lib/errors.ts @@ -150,7 +150,7 @@ function looksWrongChain(message: string): boolean { m.includes("unsupported chain") || m.includes("chain not supported") || m.includes("incorrect network") || - m.includes("did not switch to chain") + m.includes("did not switch to") ); } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 3715fe71..ed0c2cd3 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -6,6 +6,7 @@ export type PaymentContext = { amount: string; nonce: string; chainId: number; + supportedChains?: number[]; timestamp: number; }; From a203e7831a03ee9da16fd9207dab6cde7c3b57bf Mon Sep 17 00:00:00 2001 From: pisum-sativum Date: Sun, 5 Jul 2026 22:41:15 +0530 Subject: [PATCH 4/5] fix: resolve lint and build errors Fixes eslint warnings/errors in the frontend web client and tests. Removes unused variables in gateway cache and test files. Adds missing strings import for gateway cache tests. Co-authored-by: codex --- gateway/cache.go | 1 - gateway/cache_integration_test.go | 1 + gateway/receipt_store_integration_test.go | 2 +- web/src/hooks/use-x402.test.ts | 4 ++-- web/src/lib/x402-client.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gateway/cache.go b/gateway/cache.go index f3ecec3f..55b00e91 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -223,7 +223,6 @@ func CacheMiddleware() gin.HandlerFunc { // Handler finished. Check status and extract result with proper locking writer.mu.RLock() statusCode := writer.ResponseWriter.Status() - bodyBytes := writer.body.Bytes() writer.mu.RUnlock() if statusCode == 200 { diff --git a/gateway/cache_integration_test.go b/gateway/cache_integration_test.go index b0e75829..afbf4917 100644 --- a/gateway/cache_integration_test.go +++ b/gateway/cache_integration_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "sync/atomic" "testing" "time" diff --git a/gateway/receipt_store_integration_test.go b/gateway/receipt_store_integration_test.go index 4c79bdeb..3d66fb32 100644 --- a/gateway/receipt_store_integration_test.go +++ b/gateway/receipt_store_integration_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "sync" "testing" "time" @@ -196,7 +197,6 @@ func replaceReceiptGlobalsForTest(t *testing.T) func() { } func TestHandleSummarize_ConcurrentReplayAttack(t *testing.T) { - ctx := t.Context() redisServer := miniredis.RunT(t) rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()}) defer rdb.Close() diff --git a/web/src/hooks/use-x402.test.ts b/web/src/hooks/use-x402.test.ts index afe240d2..7adf60f7 100644 --- a/web/src/hooks/use-x402.test.ts +++ b/web/src/hooks/use-x402.test.ts @@ -21,7 +21,7 @@ mock.module("ethers", () => { }); // Polyfill window.ethereum for the provider check -(globalThis as any).window = { ethereum: {} }; +Object.defineProperty(globalThis, "window", { value: { ethereum: {} }, writable: true }); describe("useX402 chain switching logic", () => { beforeEach(() => { @@ -29,7 +29,7 @@ describe("useX402 chain switching logic", () => { mock.restore(); spyOn(wallet, "hasWallet").mockReturnValue(true); - spyOn(wallet, "getProvider").mockReturnValue(true as any); + spyOn(wallet, "getProvider").mockReturnValue(true as unknown as ethers.BrowserProvider); spyOn(wallet, "getCurrentAccount").mockResolvedValue("0xmock-account"); // Default fetch mocks for summarize diff --git a/web/src/lib/x402-client.ts b/web/src/lib/x402-client.ts index 70cfca06..6f969698 100644 --- a/web/src/lib/x402-client.ts +++ b/web/src/lib/x402-client.ts @@ -98,7 +98,7 @@ export async function readSummarizeSuccess( } else if (parsed.receipt) { receipt = safeDecodeReceiptHeader(parsed.receipt); } - } catch (e) { + } catch { // ignore } } From c872f73311ca7e9e4f60072866645b5beb60545b Mon Sep 17 00:00:00 2001 From: pisum-sativum Date: Sun, 5 Jul 2026 22:52:08 +0530 Subject: [PATCH 5/5] fix: address PR review feedback on payment and streaming flows - Keep EIP-712 chain aligned with gateway verification by avoiding finalChain override - Preserve JSON response and header contract for standard clients using Accept header - Hash actual streamed bytes in receipts for SSE mode - Reject SSE error events in the web client instead of completing silently - Return 409 Conflict instead of 402 for gateway-level replay cache hits Co-authored-by: codex --- gateway/cache.go | 7 +- gateway/main.go | 167 ++++++++++++++++++++++----------- web/src/hooks/use-x402.test.ts | 5 +- web/src/hooks/use-x402.ts | 2 +- web/src/lib/x402-client.ts | 20 ++-- 5 files changed, 132 insertions(+), 69 deletions(-) diff --git a/gateway/cache.go b/gateway/cache.go index 55b00e91..488cf7fc 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -173,10 +173,9 @@ func CacheMiddleware() gin.HandlerFunc { return } if used { - c.JSON(http.StatusPaymentRequired, gin.H{ - "error": "Payment Required", - "message": "Transaction already used", - "paymentContext": createPaymentContext(), + respondVerificationFailure(c, &VerifyResponse{ + ErrorCode: "nonce_already_used", + Error: "Transaction already used", }) c.Abort() return diff --git a/gateway/main.go b/gateway/main.go index 6886ad54..054fb464 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -512,10 +512,9 @@ func handleSummarize(c *gin.Context) { return } if used { - c.JSON(http.StatusPaymentRequired, gin.H{ - "error": "Payment Required", - "message": "Transaction already used", - "paymentContext": createPaymentContext(), + respondVerificationFailure(c, &VerifyResponse{ + ErrorCode: "nonce_already_used", + Error: "Transaction already used", }) return } @@ -536,6 +535,9 @@ func handleSummarize(c *gin.Context) { return } + acceptHeader := c.GetHeader("Accept") + useSSE := strings.Contains(acceptHeader, "text/event-stream") + // 3. Call AI Service stream, err := aiProvider.GenerateStream(c.Request.Context(), req.Text) if err != nil { @@ -548,60 +550,92 @@ func handleSummarize(c *gin.Context) { } defer stream.Close() - c.Writer.Header().Set("Content-Type", "text/event-stream") - c.Writer.Header().Set("Cache-Control", "no-cache") - c.Writer.Header().Set("Connection", "keep-alive") + var flusher http.Flusher + if useSSE { + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") - flusher, ok := c.Writer.(http.Flusher) - if !ok { - respondError(c, 500, "streaming_unsupported", fmt.Errorf("streaming unsupported")) - return + var ok bool + flusher, ok = c.Writer.(http.Flusher) + if !ok { + respondError(c, 500, "streaming_unsupported", fmt.Errorf("streaming unsupported")) + return + } } var fullSummary string + var streamedBytes bytes.Buffer for { text, err := stream.Recv() if errors.Is(err, io.EOF) { break } if err != nil { - fmt.Fprintf(c.Writer, "data: {\"error\": %q}\n\n", err.Error()) - flusher.Flush() + if useSSE { + fmt.Fprintf(c.Writer, "data: {\"error\": %q}\n\n", err.Error()) + flusher.Flush() + return + } + respondError(c, 502, "upstream_unavailable", err) return } fullSummary += text - chunkBytes, _ := json.Marshal(map[string]string{"text": text}) - fmt.Fprintf(c.Writer, "data: %s\n\n", chunkBytes) - flusher.Flush() + if useSSE { + chunkBytes, _ := json.Marshal(map[string]string{"text": text}) + chunk := fmt.Sprintf("data: %s\n\n", chunkBytes) + streamedBytes.WriteString(chunk) + fmt.Fprint(c.Writer, chunk) + flusher.Flush() + } } // 4. Generate Receipt - responseMap := map[string]interface{}{ - "result": fullSummary, + var responseBody []byte + if useSSE { + // Hash the actual streamed payload for SSE to maintain receipt integrity + responseBody = streamedBytes.Bytes() + } else { + responseMap := map[string]interface{}{ + "result": fullSummary, + } + responseBody, _ = json.Marshal(responseMap) } - responseBody, _ := json.Marshal(responseMap) receipt, err := GenerateReceipt(*paymentCtx, verifyResp.RecoveredAddress, c.Request.URL.Path, requestBody, responseBody) if err != nil { - fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_generation_failed\"}\n\n") - flusher.Flush() + if useSSE { + fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_generation_failed\"}\n\n") + flusher.Flush() + } else { + respondError(c, 500, "receipt_generation_failed", err) + } return } if err := storeReceiptWithContext(c.Request.Context(), receipt, getReceiptTTL()); err != nil { - fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_store_failed\"}\n\n") - flusher.Flush() + if useSSE { + fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_store_failed\"}\n\n") + flusher.Flush() + } else { + respondError(c, 500, "receipt_store_failed", err) + } return } receiptJSON, _ := json.Marshal(receipt) receiptBase64 := base64.StdEncoding.EncodeToString(receiptJSON) - // Send receipt as final event - fmt.Fprintf(c.Writer, "data: {\"receipt\": %q}\n\n", receiptBase64) - fmt.Fprintf(c.Writer, "data: [DONE]\n\n") - flusher.Flush() + if useSSE { + // Send receipt as final event + fmt.Fprintf(c.Writer, "data: {\"receipt\": %q}\n\n", receiptBase64) + fmt.Fprintf(c.Writer, "data: [DONE]\n\n") + flusher.Flush() + } else { + c.Header("X-402-Receipt", receiptBase64) + c.Data(http.StatusOK, "application/json", responseBody) + } // Store for cache middleware c.Set("full_summary", fullSummary) @@ -675,48 +709,73 @@ func verifyPayment(ctx context.Context, signature, nonce string, timestamp uint6 return &verifyResp, &paymentCtx, nil } -// streamResultAndReceipt is used by CacheMiddleware for cache hits to stream the response identically to a live request. func streamResultAndReceipt(c *gin.Context, paymentCtx PaymentContext, recoveredAddr string, requestBody []byte, aiResult string) error { - c.Writer.Header().Set("Content-Type", "text/event-stream") - c.Writer.Header().Set("Cache-Control", "no-cache") - c.Writer.Header().Set("Connection", "keep-alive") - - flusher, ok := c.Writer.(http.Flusher) - if !ok { - respondError(c, 500, "streaming_unsupported", fmt.Errorf("streaming unsupported")) - return fmt.Errorf("streaming unsupported") - } - - chunkBytes, _ := json.Marshal(map[string]string{"text": aiResult}) - fmt.Fprintf(c.Writer, "data: %s\n\n", chunkBytes) - flusher.Flush() - - // Generate Receipt - responseMap := map[string]interface{}{ - "result": aiResult, + acceptHeader := c.GetHeader("Accept") + useSSE := strings.Contains(acceptHeader, "text/event-stream") + + var responseBody []byte + var chunk string + if useSSE { + chunkBytes, _ := json.Marshal(map[string]string{"text": aiResult}) + chunk = fmt.Sprintf("data: %s\n\n", chunkBytes) + responseBody = []byte(chunk) + } else { + responseMap := map[string]interface{}{ + "result": aiResult, + } + responseBody, _ = json.Marshal(responseMap) } - responseBody, _ := json.Marshal(responseMap) receipt, err := GenerateReceipt(paymentCtx, recoveredAddr, c.Request.URL.Path, requestBody, responseBody) if err != nil { - fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_generation_failed\"}\n\n") - flusher.Flush() + if useSSE { + c.Writer.Header().Set("Content-Type", "text/event-stream") + if flusher, ok := c.Writer.(http.Flusher); ok { + fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_generation_failed\"}\n\n") + flusher.Flush() + } + } else { + respondError(c, 500, "receipt_generation_failed", err) + } return err } if err := storeReceiptWithContext(c.Request.Context(), receipt, getReceiptTTL()); err != nil { - fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_store_failed\"}\n\n") - flusher.Flush() + if useSSE { + c.Writer.Header().Set("Content-Type", "text/event-stream") + if flusher, ok := c.Writer.(http.Flusher); ok { + fmt.Fprintf(c.Writer, "data: {\"error\": \"receipt_store_failed\"}\n\n") + flusher.Flush() + } + } else { + respondError(c, 500, "receipt_store_failed", err) + } return err } receiptJSON, _ := json.Marshal(receipt) receiptBase64 := base64.StdEncoding.EncodeToString(receiptJSON) - // Send receipt as final event - fmt.Fprintf(c.Writer, "data: {\"receipt\": %q}\n\n", receiptBase64) - fmt.Fprintf(c.Writer, "data: [DONE]\n\n") - flusher.Flush() + if useSSE { + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + + flusher, ok := c.Writer.(http.Flusher) + if !ok { + respondError(c, 500, "streaming_unsupported", fmt.Errorf("streaming unsupported")) + return fmt.Errorf("streaming unsupported") + } + + fmt.Fprint(c.Writer, chunk) + fmt.Fprintf(c.Writer, "data: {\"receipt\": %q}\n\n", receiptBase64) + fmt.Fprintf(c.Writer, "data: [DONE]\n\n") + flusher.Flush() + } else { + c.Header("X-402-Receipt", receiptBase64) + c.Data(http.StatusOK, "application/json", responseBody) + } + return nil } diff --git a/web/src/hooks/use-x402.test.ts b/web/src/hooks/use-x402.test.ts index 7adf60f7..8ad583df 100644 --- a/web/src/hooks/use-x402.test.ts +++ b/web/src/hooks/use-x402.test.ts @@ -3,6 +3,7 @@ GlobalRegistrator.register(); import { describe, it, expect, beforeEach, mock, afterEach, spyOn } from "bun:test"; import { renderHook, act } from "@testing-library/react"; +import { ethers } from "ethers"; import { useX402 } from "./use-x402"; import * as wallet from "@/lib/wallet"; import * as x402Client from "@/lib/x402-client"; @@ -75,9 +76,9 @@ describe("useX402 chain switching logic", () => { }); expect(switchSpy).not.toHaveBeenCalled(); - // Should have signed with the chain we were actually on + // Should have signed with the challenge's chainId, not the wallet's current chain const signArgs = signSpy.mock.calls[0][1]; - expect(signArgs.chainId).toBe(8453); + expect(signArgs.chainId).toBe(84532); }); it("triggers switch to default chain if on unsupported chain", async () => { diff --git a/web/src/hooks/use-x402.ts b/web/src/hooks/use-x402.ts index e785d15f..4dc96dac 100644 --- a/web/src/hooks/use-x402.ts +++ b/web/src/hooks/use-x402.ts @@ -216,7 +216,7 @@ export function useX402() { }); } - const signContext = { ...context, chainId: finalChain ?? context.chainId }; + const signContext = { ...context }; stage = "signer"; const refreshedProvider = new ethers.BrowserProvider(window.ethereum!); diff --git a/web/src/lib/x402-client.ts b/web/src/lib/x402-client.ts index 6f969698..b1003511 100644 --- a/web/src/lib/x402-client.ts +++ b/web/src/lib/x402-client.ts @@ -90,16 +90,20 @@ export async function readSummarizeSuccess( if (line.startsWith("data: ")) { const dataStr = line.slice("data: ".length).trim(); if (dataStr === "[DONE]") continue; + let parsed: { error?: string; text?: string; receipt?: string }; try { - const parsed = JSON.parse(dataStr); - if (parsed.text) { - summary += parsed.text; - if (onChunk) onChunk(summary); - } else if (parsed.receipt) { - receipt = safeDecodeReceiptHeader(parsed.receipt); - } + parsed = JSON.parse(dataStr); } catch { - // ignore + continue; + } + if (parsed.error) { + throw new Error(parsed.error); + } + if (parsed.text) { + summary += parsed.text; + if (onChunk) onChunk(summary); + } else if (parsed.receipt) { + receipt = safeDecodeReceiptHeader(parsed.receipt); } } }