package x402 import ( "crypto/ed25519" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) // TestVerifyAndSettle checks that Require verifies then settles against an // HTTP facilitator and surfaces the settlement in both response headers. func TestVerifyAndSettle(t *testing.T) { var verifyHit, settleHit bool srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/verify": verifyHit = true _ = json.NewEncoder(w).Encode(map[string]any{"isValid": true, "payer": "0xabc"}) case "/settle": settleHit = true _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xdeadbeef"}) default: http.NotFound(w, r) } })) defer srv.Close() cfg := Config{PayTo: "0xpay", Network: "eip155:8453", FacilitatorURL: srv.URL} r := httptest.NewRequest(http.MethodGet, "/tool", nil) r.Header.Set(PaymentHeader, base64.StdEncoding.EncodeToString([]byte(`{"network":"eip155:8453"}`))) rec := httptest.NewRecorder() if !cfg.Require(rec, r, "10000", "chat") { t.Fatalf("Require returned false; body=%s", rec.Body.String()) } if !verifyHit || !settleHit { t.Fatalf("expected both verify and settle to be hit: verify=%v settle=%v", verifyHit, settleHit) } if got := rec.Header().Get(PaymentResponseHeader); got != "0xdeadbeef" { t.Errorf("X-PAYMENT-RESPONSE = %q, want 0xdeadbeef", got) } if got := rec.Header().Get(PaymentResponseHeaderV2); got != "0xdeadbeef" { t.Errorf("PAYMENT-RESPONSE = %q, want 0xdeadbeef", got) } } // TestSettlementFailureChallenges checks that a failed settlement blocks the // request with a fresh 402 rather than letting it through. func TestSettlementFailureChallenges(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/verify": _ = json.NewEncoder(w).Encode(map[string]any{"isValid": true}) case "/settle": _ = json.NewEncoder(w).Encode(map[string]any{"success": false, "errorReason": "insufficient_funds"}) } })) defer srv.Close() cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL} r := httptest.NewRequest(http.MethodGet, "/tool", nil) r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==") rec := httptest.NewRecorder() if cfg.Require(rec, r, "10000", "chat") { t.Fatal("Require should return false when settlement fails") } if rec.Code != http.StatusPaymentRequired { t.Errorf("status = %d, want 402", rec.Code) } } // TestPaymentSignatureHeaderAccepted checks the v2 request header is honored. func TestPaymentSignatureHeaderAccepted(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"isValid": true, "success": true, "transaction": "0x1"}) })) defer srv.Close() cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL} r := httptest.NewRequest(http.MethodGet, "/tool", nil) r.Header.Set(PaymentHeaderV2, "eyJ4IjoxfQ==") // PAYMENT-SIGNATURE only rec := httptest.NewRecorder() if !cfg.Require(rec, r, "10000", "chat") { t.Fatalf("Require should honor PAYMENT-SIGNATURE; body=%s", rec.Body.String()) } } // TestRequirementsExtraForKnownNetwork checks the EIP-712 domain is filled in. func TestRequirementsExtraForKnownNetwork(t *testing.T) { for _, net := range []string{"base", "eip155:8453"} { req := Config{PayTo: "0xpay", Network: net}.requirements("10000", "chat") if req.Extra["name"] == "" || req.Extra["version"] == "" { t.Errorf("network %q: extra not filled: %v", net, req.Extra) } if req.Asset == "" { t.Errorf("network %q: asset not defaulted", net) } } } // TestDecodePayment checks the base64 header is decoded to an object for the // facilitator (which expects the payload object, not the raw string). func TestDecodePayment(t *testing.T) { enc := base64.StdEncoding.EncodeToString([]byte(`{"network":"eip155:8453","payload":{"x":1}}`)) obj := decodePayment(enc) m, ok := obj.(map[string]any) if !ok { t.Fatalf("decodePayment did not return an object: %T", obj) } if m["network"] != "eip155:8453" { t.Errorf("decoded network = %v", m["network"]) } // Non-base64 passes through unchanged. if got := decodePayment("not-base64!"); got != "not-base64!" { t.Errorf("passthrough failed: %v", got) } } // TestCDPBearer certifies the CDP JWT is well-formed and its signature verifies. func TestCDPBearer(t *testing.T) { pub, priv, _ := ed25519.GenerateKey(nil) secret := base64.StdEncoding.EncodeToString(priv) tok, err := cdpBearer("key-id", secret, "POST", "api.cdp.coinbase.com", "/platform/v2/x402/verify") if err != nil { t.Fatalf("cdpBearer: %v", err) } parts := strings.Split(tok, ".") if len(parts) != 3 { t.Fatalf("want 3 JWT segments, got %d", len(parts)) } sig, _ := base64.RawURLEncoding.DecodeString(parts[2]) if !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) { t.Fatal("JWT signature does not verify") } var claims map[string]any cb, _ := base64.RawURLEncoding.DecodeString(parts[1]) _ = json.Unmarshal(cb, &claims) if claims["iss"] != "cdp" || claims["uri"] != "POST api.cdp.coinbase.com/platform/v2/x402/verify" { t.Errorf("bad claims: %v", claims) } } // TestCDPAuthorizeAttachesBearer checks CDP() signs facilitator requests. func TestCDPAuthorizeAttachesBearer(t *testing.T) { _, priv, _ := ed25519.GenerateKey(nil) fac := CDP("key-id", base64.StdEncoding.EncodeToString(priv)) req, _ := http.NewRequest(http.MethodPost, "https://api.cdp.coinbase.com/platform/v2/x402/verify", nil) if err := fac.Authorize(req); err != nil { t.Fatalf("authorize: %v", err) } if !strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ey") { t.Errorf("missing Bearer JWT: %q", req.Header.Get("Authorization")) } } // TestRequireSettlementFailsClosed checks that a paid config with // RequireSettlement refuses to serve when the facilitator only verifies (does // not settle) — otherwise the resource is released while no funds move. func TestRequireSettlementFailsClosed(t *testing.T) { // mockFacilitator implements Verify but not Settler. cfg := Config{PayTo: "0xpay", Facilitator: mockFacilitator{valid: true}, RequireSettlement: true} r := httptest.NewRequest(http.MethodGet, "/tool", nil) r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==") rec := httptest.NewRecorder() if cfg.Require(rec, r, "10000", "chat") { t.Fatal("Require should fail closed when settlement is required but unavailable") } if rec.Code != http.StatusPaymentRequired { t.Errorf("status = %d, want 402", rec.Code) } // Without RequireSettlement the verify-only facilitator still serves. cfg.RequireSettlement = false rec = httptest.NewRecorder() r = httptest.NewRequest(http.MethodGet, "/tool", nil) r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==") if !cfg.Require(rec, r, "10000", "chat") { t.Fatalf("verify-only should serve when settlement is not required; body=%s", rec.Body.String()) } } // TestRequireSettlementServesWithSettler checks that a paid config with // RequireSettlement serves when the facilitator can settle. func TestRequireSettlementServesWithSettler(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/verify": _ = json.NewEncoder(w).Encode(map[string]any{"isValid": true}) case "/settle": _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xabc"}) } })) defer srv.Close() // HTTPFacilitator implements Settler. cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL, RequireSettlement: true} r := httptest.NewRequest(http.MethodGet, "/tool", nil) r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==") rec := httptest.NewRecorder() if !cfg.Require(rec, r, "10000", "chat") { t.Fatalf("Require should serve with a settling facilitator; body=%s", rec.Body.String()) } if got := rec.Header().Get(PaymentResponseHeader); got != "0xabc" { t.Errorf("settlement header = %q, want 0xabc", got) } } var _ Settler = (*HTTPFacilitator)(nil)