From aa74352ced389bd356d810462227102dfbb008db Mon Sep 17 00:00:00 2001 From: bussyjd Date: Mon, 20 Jul 2026 18:24:31 +0400 Subject: [PATCH 1/2] feat(x402): auth-capture unlock gate + fee revenue metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline first-message fee capture for gate:auth offers: the SIWX session is minted by settling an x402 auth-capture charge (EIP-3009 single-shot, autoCapture) instead of a plain signature; subsequent requests ride the session cookie free. The escrow enforces the client-signed fee split (feeBps -> feeRecipient) on-chain at charge() time. - internal/x402/authcapture.go: auth-capture requirement builder + signed payload validation (validateSignedUnlockRequirement pins every economic field of the client-signed requirement against config) - internal/x402/unlockgate.go: unlock flow — 402 (x402Version 2) -> verify+settle against the signed payload -> mint SIWX cookie - internal/x402/metrics.go: obol_x402_verifier_fee_revenue_atomic_total + settled_volume_atomic_total (network/asset/fee_recipient), excluded from route-pruning - config: global authCaptureUnlock block (offerPrefix, price, payTo, feeRecipient, min/maxFeeBps, captureAuthorizer) Config-gated, off by default. Settlement must use the client's signed 'accepted' requirement verbatim (PaymentInfo hash commits server-issued deadlines; rebuilding drifts them and breaks the signature). Proven end-to-end on Base Sepolia and Base mainnet (on-chain fee split, cookie mint, free-ride, metrics materialization). Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v --- internal/monetizeapi/types.go | 1 + internal/x402/authcapture.go | 123 ++++++++ internal/x402/authcapture_test.go | 486 ++++++++++++++++++++++++++++++ internal/x402/authgate.go | 8 + internal/x402/config.go | 3 + internal/x402/metrics.go | 30 +- internal/x402/unlockgate.go | 281 +++++++++++++++++ internal/x402/verifier.go | 4 + 8 files changed, 930 insertions(+), 6 deletions(-) create mode 100644 internal/x402/authcapture.go create mode 100644 internal/x402/authcapture_test.go create mode 100644 internal/x402/unlockgate.go diff --git a/internal/monetizeapi/types.go b/internal/monetizeapi/types.go index 91f851e5..b2142dec 100644 --- a/internal/monetizeapi/types.go +++ b/internal/monetizeapi/types.go @@ -304,6 +304,7 @@ type ServiceOfferPayment struct { // x402 payment scheme. // +kubebuilder:default="exact" // +kubebuilder:validation:Enum=exact + // TODO(auth-capture): regenerate CRD manifests + wire per-offer unlock config Scheme string `json:"scheme,omitempty"` // Chain identifier for payments (human-friendly). Reconciler resolves // to CAIP-2 format (e.g., "base-sepolia" → "eip155:84532"). diff --git a/internal/x402/authcapture.go b/internal/x402/authcapture.go new file mode 100644 index 00000000..1c18e9da --- /dev/null +++ b/internal/x402/authcapture.go @@ -0,0 +1,123 @@ +package x402 + +import ( + "fmt" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + x402types "github.com/x402-foundation/x402/go/v2/types" +) + +const ( + defaultCaptureDeadlineSecs = 900 + defaultRefundDeadlineSecs = 1800 +) + +// AuthCaptureUnlockConfig configures the single offer whose SIWX session is +// minted only after an auth-capture payment verifies and settles. +type AuthCaptureUnlockConfig struct { + Enabled bool `yaml:"enabled"` + OfferPrefix string `yaml:"offerPrefix"` + Price string `yaml:"price"` + Network string `yaml:"network"` + PayTo string `yaml:"payTo"` + Asset string `yaml:"asset"` + FeeRecipient string `yaml:"feeRecipient"` + MinFeeBps uint16 `yaml:"minFeeBps"` + MaxFeeBps uint16 `yaml:"maxFeeBps"` + CaptureAuthorizer string `yaml:"captureAuthorizer"` + CaptureDeadlineSecs uint64 `yaml:"captureDeadlineSecs"` + RefundDeadlineSecs uint64 `yaml:"refundDeadlineSecs"` +} + +// Validate applies deadline defaults and rejects unusable auth-capture +// configuration before a payment is sent to the facilitator. +func (c *AuthCaptureUnlockConfig) Validate() error { + if c == nil { + return fmt.Errorf("authCaptureUnlock config is nil") + } + if c.CaptureDeadlineSecs == 0 { + c.CaptureDeadlineSecs = defaultCaptureDeadlineSecs + } + if c.RefundDeadlineSecs == 0 { + c.RefundDeadlineSecs = defaultRefundDeadlineSecs + } + + if c.Enabled { + if c.OfferPrefix == "" { + return fmt.Errorf("offerPrefix must be non-empty when enabled") + } + if c.FeeRecipient == "" { + return fmt.Errorf("feeRecipient must be non-empty when enabled") + } + if c.CaptureAuthorizer == "" { + return fmt.Errorf("captureAuthorizer must be non-empty when enabled") + } + } + if c.MinFeeBps > c.MaxFeeBps { + return fmt.Errorf("minFeeBps %d exceeds maxFeeBps %d", c.MinFeeBps, c.MaxFeeBps) + } + if c.MaxFeeBps > 10000 { + return fmt.Errorf("maxFeeBps %d exceeds 10000", c.MaxFeeBps) + } + if c.MaxFeeBps > 0 && !validNonZeroAddress(c.FeeRecipient) { + return fmt.Errorf("feeRecipient must be a valid non-zero 0x address when maxFeeBps is positive") + } + if !validNonZeroAddress(c.CaptureAuthorizer) { + return fmt.Errorf("captureAuthorizer must be a valid non-zero 0x address") + } + if c.CaptureDeadlineSecs <= 6 { + return fmt.Errorf("captureDeadlineSecs must be greater than 6, got %d", c.CaptureDeadlineSecs) + } + if c.RefundDeadlineSecs < c.CaptureDeadlineSecs { + return fmt.Errorf("refundDeadlineSecs %d is less than captureDeadlineSecs %d", c.RefundDeadlineSecs, c.CaptureDeadlineSecs) + } + + price, _, err := new(big.Float).SetPrec(128).Parse(c.Price, 10) + if err != nil { + return fmt.Errorf("price %q is not a valid decimal: %w", c.Price, err) + } + if price == nil || price.Sign() <= 0 { + return fmt.Errorf("price must be a positive decimal, got %q", c.Price) + } + + return nil +} + +func validNonZeroAddress(address string) bool { + return common.IsHexAddress(address) && common.HexToAddress(address) != (common.Address{}) +} + +// BuildAuthCaptureRequirement builds the strict auth-capture wire shape +// expected by the facilitator. +func BuildAuthCaptureRequirement(chain ChainInfo, asset AssetInfo, c *AuthCaptureUnlockConfig, payTo string, now time.Time) (x402types.PaymentRequirements, error) { + if err := c.Validate(); err != nil { + return x402types.PaymentRequirements{}, fmt.Errorf("validate auth-capture config: %w", err) + } + atomicAmount, err := decimalToAtomic(c.Price, asset.Decimals) + if err != nil { + return x402types.PaymentRequirements{}, fmt.Errorf("invalid price %q: %w", c.Price, err) + } + + return x402types.PaymentRequirements{ + Scheme: "auth-capture", + Network: chain.CAIP2Network, + Asset: asset.Address, + Amount: atomicAmount, + PayTo: payTo, + MaxTimeoutSeconds: int(ClampMaxTimeoutSeconds(int64(c.CaptureDeadlineSecs))), + Extra: map[string]interface{}{ + "name": asset.EIP712Name, + "version": asset.EIP712Version, + "captureAuthorizer": c.CaptureAuthorizer, + "captureDeadline": now.Unix() + int64(c.CaptureDeadlineSecs), + "refundDeadline": now.Unix() + int64(c.RefundDeadlineSecs), + "feeRecipient": c.FeeRecipient, + "minFeeBps": c.MinFeeBps, + "maxFeeBps": c.MaxFeeBps, + "autoCapture": true, + "assetTransferMethod": asset.TransferMethod, + }, + }, nil +} diff --git a/internal/x402/authcapture_test.go b/internal/x402/authcapture_test.go new file mode 100644 index 00000000..bcef5a31 --- /dev/null +++ b/internal/x402/authcapture_test.go @@ -0,0 +1,486 @@ +package x402 + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + x402types "github.com/x402-foundation/x402/go/v2/types" +) + +const ( + testFeeRecipient = "0x1111111111111111111111111111111111111111" + testCaptureAuthorizer = "0x2222222222222222222222222222222222222222" + testUnlockPayer = "0xAbCdEfabcdefABCDefAbcdefabCDefABcDefAbCd" +) + +func validAuthCaptureConfig() AuthCaptureUnlockConfig { + return AuthCaptureUnlockConfig{ + Enabled: true, + OfferPrefix: "/services/agent", + Price: "1.00", + FeeRecipient: testFeeRecipient, + MinFeeBps: 100, + MaxFeeBps: 250, + CaptureAuthorizer: testCaptureAuthorizer, + } +} + +func TestBuildAuthCaptureRequirement_ExtraShape(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + config := validAuthCaptureConfig() + asset := AssetInfo{ + Address: ChainBaseSepolia.USDCAddress, + Symbol: "USDC", + Decimals: 6, + TransferMethod: "eip3009", + EIP712Name: "USDC", + EIP712Version: "2", + } + req, err := BuildAuthCaptureRequirement( + ChainBaseSepolia, + asset, + &config, + "0x3333333333333333333333333333333333333333", + now, + ) + if err != nil { + t.Fatalf("BuildAuthCaptureRequirement: %v", err) + } + if req.Scheme != "auth-capture" { + t.Errorf("Scheme = %q, want auth-capture", req.Scheme) + } + if req.Network != ChainBaseSepolia.CAIP2Network { + t.Errorf("Network = %q, want %q", req.Network, ChainBaseSepolia.CAIP2Network) + } + if req.Amount != "1000000" { + t.Errorf("Amount = %q, want 1000000", req.Amount) + } + if len(req.Extra) != 10 { + t.Fatalf("Extra has %d keys, want 10: %#v", len(req.Extra), req.Extra) + } + + stringValues := map[string]string{ + "name": asset.EIP712Name, + "version": asset.EIP712Version, + "captureAuthorizer": testCaptureAuthorizer, + "feeRecipient": testFeeRecipient, + "assetTransferMethod": asset.TransferMethod, + } + for key, want := range stringValues { + got, ok := req.Extra[key].(string) + if !ok || got != want { + t.Errorf("Extra[%q] = %#v (%T), want string %q", key, req.Extra[key], req.Extra[key], want) + } + } + captureDeadline, ok := req.Extra["captureDeadline"].(int64) + if !ok { + t.Fatalf("captureDeadline = %#v (%T), want int64", req.Extra["captureDeadline"], req.Extra["captureDeadline"]) + } + refundDeadline, ok := req.Extra["refundDeadline"].(int64) + if !ok { + t.Fatalf("refundDeadline = %#v (%T), want int64", req.Extra["refundDeadline"], req.Extra["refundDeadline"]) + } + if captureDeadline <= now.Unix() { + t.Errorf("captureDeadline = %d, want after %d", captureDeadline, now.Unix()) + } + if refundDeadline < captureDeadline { + t.Errorf("refundDeadline = %d, want >= captureDeadline %d", refundDeadline, captureDeadline) + } + if got, ok := req.Extra["minFeeBps"].(uint16); !ok || got != config.MinFeeBps { + t.Errorf("minFeeBps = %#v (%T), want uint16(%d)", req.Extra["minFeeBps"], req.Extra["minFeeBps"], config.MinFeeBps) + } + if got, ok := req.Extra["maxFeeBps"].(uint16); !ok || got != config.MaxFeeBps { + t.Errorf("maxFeeBps = %#v (%T), want uint16(%d)", req.Extra["maxFeeBps"], req.Extra["maxFeeBps"], config.MaxFeeBps) + } + if got, ok := req.Extra["autoCapture"].(bool); !ok || !got { + t.Errorf("autoCapture = %#v (%T), want bool(true)", req.Extra["autoCapture"], req.Extra["autoCapture"]) + } +} + +func TestAuthCaptureUnlockConfig_Validate(t *testing.T) { + tests := []struct { + name string + mutate func(*AuthCaptureUnlockConfig) + wantErr bool + }{ + { + name: "rejects min fee above max fee", + mutate: func(c *AuthCaptureUnlockConfig) { + c.MinFeeBps = 251 + c.MaxFeeBps = 250 + }, + wantErr: true, + }, + { + name: "rejects max fee above 10000", + mutate: func(c *AuthCaptureUnlockConfig) { + c.MaxFeeBps = 10001 + }, + wantErr: true, + }, + { + name: "rejects positive max fee with empty recipient", + mutate: func(c *AuthCaptureUnlockConfig) { + c.FeeRecipient = "" + }, + wantErr: true, + }, + { + name: "rejects zero capture authorizer", + mutate: func(c *AuthCaptureUnlockConfig) { + c.CaptureAuthorizer = "0x0000000000000000000000000000000000000000" + }, + wantErr: true, + }, + { + name: "rejects refund deadline before capture deadline", + mutate: func(c *AuthCaptureUnlockConfig) { + c.CaptureDeadlineSecs = 1000 + c.RefundDeadlineSecs = 500 + }, + wantErr: true, + }, + { + name: "accepts good config and applies defaults", + mutate: func(*AuthCaptureUnlockConfig) {}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := validAuthCaptureConfig() + tt.mutate(&config) + wantCaptureDeadline := config.CaptureDeadlineSecs + if wantCaptureDeadline == 0 { + wantCaptureDeadline = defaultCaptureDeadlineSecs + } + wantRefundDeadline := config.RefundDeadlineSecs + if wantRefundDeadline == 0 { + wantRefundDeadline = defaultRefundDeadlineSecs + } + err := config.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + if config.CaptureDeadlineSecs != wantCaptureDeadline { + t.Errorf("CaptureDeadlineSecs = %d, want %d", config.CaptureDeadlineSecs, wantCaptureDeadline) + } + if config.RefundDeadlineSecs != wantRefundDeadline { + t.Errorf("RefundDeadlineSecs = %d, want %d", config.RefundDeadlineSecs, wantRefundDeadline) + } + }) + } +} + +func TestPaidUnlock_InlineFirstMessage(t *testing.T) { + var facilitatorCalls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/verify", func(w http.ResponseWriter, r *http.Request) { + facilitatorCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"isValid":true,"payer":%q}`, testUnlockPayer) + }) + mux.HandleFunc("/settle", func(w http.ResponseWriter, r *http.Request) { + facilitatorCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"success":true,"payer":%q,"transaction":"0xabc","network":"eip155:84532"}`, testUnlockPayer) + }) + facilitator := httptest.NewServer(mux) + t.Cleanup(facilitator.Close) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("CHAT_OK")) + })) + t.Cleanup(upstream.Close) + + unlockConfig := validAuthCaptureConfig() + v, err := NewVerifier(&PricingConfig{ + Wallet: "0x3333333333333333333333333333333333333333", + Chain: "base-sepolia", + FacilitatorURL: facilitator.URL, + Routes: []RouteRule{{ + Pattern: "/services/agent/*", + Gate: "auth", + StripPrefix: unlockConfig.OfferPrefix, + UpstreamURL: upstream.URL, + OfferNamespace: "test", + OfferName: "agent", + }}, + AuthCaptureUnlock: &unlockConfig, + }) + if err != nil { + t.Fatalf("NewVerifier: %v", err) + } + + path := unlockConfig.OfferPrefix + "/chat" + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + v.HandleProxy(w, req) + if w.Code != http.StatusPaymentRequired { + t.Fatalf("challenge status = %d, want 402; body: %s", w.Code, w.Body.String()) + } + var challenge struct { + X402Version int `json:"x402Version"` + Accepts []x402types.PaymentRequirements `json:"accepts"` + } + if err := json.Unmarshal(w.Body.Bytes(), &challenge); err != nil { + t.Fatalf("decode challenge: %v", err) + } + if challenge.X402Version != 2 { + t.Errorf("x402Version = %d, want 2", challenge.X402Version) + } + if len(challenge.Accepts) != 1 || challenge.Accepts[0].Scheme != "auth-capture" { + t.Errorf("challenge accepts = %#v, want one auth-capture requirement", challenge.Accepts) + } + if got := facilitatorCalls.Load(); got != 0 { + t.Fatalf("facilitator calls after challenge = %d, want 0", got) + } + + // A real v2 wire PaymentPayload carries the signed requirement in `accepted` + // (scheme/network are read from it); the verifier settles against that, so it + // must pass validateSignedUnlockRequirement (which it does — it IS the + // challenge requirement the verifier just issued). + wireJSON, _ := json.Marshal(map[string]any{ + "x402Version": 2, + "payload": map[string]any{"stub": true}, + "accepted": challenge.Accepts[0], + }) + paymentHeader := base64.StdEncoding.EncodeToString(wireJSON) + req = httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-PAYMENT", paymentHeader) + w = httptest.NewRecorder() + v.HandleProxy(w, req) + if w.Code != http.StatusOK { + t.Fatalf("paid message status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "CHAT_OK") { + t.Fatalf("paid message body = %q, want CHAT_OK", w.Body.String()) + } + + var sessionCookie *http.Cookie + for _, cookie := range w.Result().Cookies() { + if cookie.Name == SIWXSessionCookie { + sessionCookie = cookie + break + } + } + if sessionCookie == nil { + t.Fatal("unlock response did not set obol_siwx cookie") + } + wallet, err := v.siwx.VerifySession(sessionCookie.Value, time.Now()) + if err != nil { + t.Fatalf("VerifySession: %v", err) + } + if want := strings.ToLower(testUnlockPayer); wallet != want { + t.Errorf("session wallet = %q, want %q", wallet, want) + } + labels := prometheus.Labels{ + "network": challenge.Accepts[0].Network, + "asset": challenge.Accepts[0].Asset, + "fee_recipient": testFeeRecipient, + } + if got := testutil.ToFloat64(v.metrics.feeRevenueAtomic.With(labels)); got != 25000 { + t.Errorf("fee revenue atomic = %v, want 25000", got) + } + if got := testutil.ToFloat64(v.metrics.settledVolumeAtomic.With(labels)); got != 1000000 { + t.Errorf("settled volume atomic = %v, want 1000000", got) + } + + paidCalls := facilitatorCalls.Load() + if paidCalls == 0 { + t.Fatal("paid message did not call facilitator") + } + + req = httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(sessionCookie) + w = httptest.NewRecorder() + v.HandleProxy(w, req) + if w.Code != http.StatusOK { + t.Fatalf("free-ride status = %d, want 200; body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "CHAT_OK") { + t.Fatalf("free-ride body = %q, want CHAT_OK", w.Body.String()) + } + if got := facilitatorCalls.Load(); got != paidCalls { + t.Errorf("facilitator calls after free ride = %d, want unchanged %d", got, paidCalls) + } +} + +// TestValidateSignedUnlockRequirement guards the money path: the verifier +// settles the requirement the CLIENT signed (payload.accepted), so a client +// must not be able to redirect the fee, underpay, or swap the authorizer. Each +// case takes a genuine requirement, JSON-round-trips it (so Extra numbers are +// float64 exactly as they arrive via x402types.ToPaymentPayload — this is what +// exercises the ex* coercion), flips one field, and asserts rejection. The +// permitted exception is deadline drift (the server reissues fresh deadlines on +// each 402, so the signed ones legitimately differ from `expected`). +func TestValidateSignedUnlockRequirement(t *testing.T) { + now := time.Unix(1_790_000_000, 0) + cfg := validAuthCaptureConfig() + asset := AssetInfo{ + Address: ChainBaseSepolia.USDCAddress, + Symbol: "USDC", + Decimals: 6, + TransferMethod: "eip3009", + EIP712Name: "USDC", + EIP712Version: "2", + } + const payTo = "0x3333333333333333333333333333333333333333" + const attacker = "0x9999999999999999999999999999999999999999" + expected, err := BuildAuthCaptureRequirement(ChainBaseSepolia, asset, &cfg, payTo, now) + if err != nil { + t.Fatalf("BuildAuthCaptureRequirement: %v", err) + } + // signedFrom returns `expected` after a JSON round-trip (Extra numbers -> + // float64), then applies mutate — mirroring the real client->ToPaymentPayload + // decode path so the ex* coercion is actually exercised. + signedFrom := func(mutate func(*x402types.PaymentRequirements)) x402types.PaymentRequirements { + raw, _ := json.Marshal(expected) + var s x402types.PaymentRequirements + _ = json.Unmarshal(raw, &s) + if mutate != nil { + mutate(&s) + } + return s + } + captureDeadline := func(s *x402types.PaymentRequirements) int64 { + return int64(s.Extra["captureDeadline"].(float64)) + } + + tests := []struct { + name string + mutate func(*x402types.PaymentRequirements) + wantErr string // substring; "" means expect nil + }{ + {"accepts genuine requirement", nil, ""}, + {"accepts deadline drift within bounds", func(s *x402types.PaymentRequirements) { + s.Extra["captureDeadline"] = float64(now.Unix() + 7) + }, ""}, + {"rejects wrong scheme", func(s *x402types.PaymentRequirements) { s.Scheme = "exact" }, "scheme"}, + {"rejects wrong network", func(s *x402types.PaymentRequirements) { s.Network = "eip155:1" }, "network"}, + {"rejects wrong asset", func(s *x402types.PaymentRequirements) { s.Asset = attacker }, "asset"}, + {"rejects redirected payTo", func(s *x402types.PaymentRequirements) { s.PayTo = attacker }, "payTo"}, + {"rejects reduced amount", func(s *x402types.PaymentRequirements) { s.Amount = "1" }, "amount"}, + {"rejects swapped captureAuthorizer", func(s *x402types.PaymentRequirements) { s.Extra["captureAuthorizer"] = attacker }, "captureAuthorizer"}, + {"rejects redirected feeRecipient", func(s *x402types.PaymentRequirements) { s.Extra["feeRecipient"] = attacker }, "feeRecipient"}, + {"rejects lowered maxFeeBps", func(s *x402types.PaymentRequirements) { s.Extra["maxFeeBps"] = float64(1) }, "maxFeeBps"}, + {"rejects altered minFeeBps", func(s *x402types.PaymentRequirements) { s.Extra["minFeeBps"] = float64(0) }, "minFeeBps"}, + {"rejects autoCapture false", func(s *x402types.PaymentRequirements) { s.Extra["autoCapture"] = false }, "autoCapture"}, + {"rejects wrong assetTransferMethod", func(s *x402types.PaymentRequirements) { s.Extra["assetTransferMethod"] = "permit2" }, "assetTransferMethod"}, + {"rejects missing extra", func(s *x402types.PaymentRequirements) { s.Extra = nil }, "extra"}, + {"rejects expired captureDeadline", func(s *x402types.PaymentRequirements) { s.Extra["captureDeadline"] = float64(now.Unix() + 6) }, "captureDeadline"}, + {"rejects inverted refundDeadline", func(s *x402types.PaymentRequirements) { s.Extra["refundDeadline"] = float64(captureDeadline(s) - 1) }, "refundDeadline"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + signed := signedFrom(tt.mutate) + err := validateSignedUnlockRequirement(signed, expected, cfg, asset, now.Unix()) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("want nil, got %v", err) + } + return + } + if err == nil { + t.Fatalf("want error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + }) + } +} + +// TestPaidUnlock_RejectsTamperedPayment is the end-to-end guard: a client that +// resubmits a payment whose signed `accepted` redirects the fee to itself must +// be rejected BEFORE the facilitator is called and MUST NOT mint a session. +func TestPaidUnlock_RejectsTamperedPayment(t *testing.T) { + var facilitatorCalls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/verify", func(w http.ResponseWriter, r *http.Request) { + facilitatorCalls.Add(1) + fmt.Fprint(w, `{"isValid":true}`) + }) + mux.HandleFunc("/settle", func(w http.ResponseWriter, r *http.Request) { + facilitatorCalls.Add(1) + fmt.Fprint(w, `{"success":true}`) + }) + facilitator := httptest.NewServer(mux) + t.Cleanup(facilitator.Close) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("CHAT_OK")) + })) + t.Cleanup(upstream.Close) + + unlockConfig := validAuthCaptureConfig() + v, err := NewVerifier(&PricingConfig{ + Wallet: "0x3333333333333333333333333333333333333333", + Chain: "base-sepolia", + FacilitatorURL: facilitator.URL, + Routes: []RouteRule{{ + Pattern: "/services/agent/*", + Gate: "auth", + StripPrefix: unlockConfig.OfferPrefix, + UpstreamURL: upstream.URL, + OfferNamespace: "test", + OfferName: "agent", + }}, + AuthCaptureUnlock: &unlockConfig, + }) + if err != nil { + t.Fatalf("NewVerifier: %v", err) + } + + path := unlockConfig.OfferPrefix + "/chat" + cw := httptest.NewRecorder() + v.HandleProxy(cw, httptest.NewRequest(http.MethodGet, path, nil)) + var challenge struct { + Accepts []x402types.PaymentRequirements `json:"accepts"` + } + if err := json.Unmarshal(cw.Body.Bytes(), &challenge); err != nil { + t.Fatalf("decode challenge: %v", err) + } + + // Redirect the fee to an attacker in the signed requirement. + tampered := challenge.Accepts[0] + ex := make(map[string]any, len(tampered.Extra)) + for k, val := range tampered.Extra { + ex[k] = val + } + ex["feeRecipient"] = "0x9999999999999999999999999999999999999999" + tampered.Extra = ex + wireJSON, _ := json.Marshal(map[string]any{ + "x402Version": 2, + "payload": map[string]any{"stub": true}, + "accepted": tampered, + }) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-PAYMENT", base64.StdEncoding.EncodeToString(wireJSON)) + w := httptest.NewRecorder() + v.HandleProxy(w, req) + + if w.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want 402; body: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "payment_policy_mismatch") { + t.Errorf("body = %q, want payment_policy_mismatch", w.Body.String()) + } + if got := facilitatorCalls.Load(); got != 0 { + t.Errorf("facilitator called %d times on tampered payment, want 0 (rejected pre-forward)", got) + } + for _, c := range w.Result().Cookies() { + if c.Name == SIWXSessionCookie { + t.Error("tampered payment minted a session cookie") + } + } +} diff --git a/internal/x402/authgate.go b/internal/x402/authgate.go index 30321a04..96e110d7 100644 --- a/internal/x402/authgate.go +++ b/internal/x402/authgate.go @@ -281,6 +281,14 @@ func (v *Verifier) handleAuthEndpoints(w http.ResponseWriter, r *http.Request) b default: return false } + cfg := v.config.Load() + if cfg != nil && cfg.AuthCaptureUnlock != nil && cfg.AuthCaptureUnlock.Enabled && + strings.TrimSuffix(prefix, "/") == strings.TrimSuffix(cfg.AuthCaptureUnlock.OfferPrefix, "/") { + // ponytail: unlock-gated offer: paid /unlock is the only mint path; free SIWX signin + // suppressed here. Ceiling: one unlock offer (global config); per-offer via CRD is the + // follow-up. + return false + } // Only offers that actually declare an auth route get sign-in // endpoints — everything else falls through to normal route matching diff --git a/internal/x402/config.go b/internal/x402/config.go index 7864c8c9..7281c101 100644 --- a/internal/x402/config.go +++ b/internal/x402/config.go @@ -29,6 +29,9 @@ type PricingConfig struct { // Routes defines per-route pricing rules. First match wins. Routes []RouteRule `yaml:"routes"` + + // AuthCaptureUnlock configures the optional paid agent-unlock endpoint. + AuthCaptureUnlock *AuthCaptureUnlockConfig `yaml:"authCaptureUnlock,omitempty"` } // RoutePayment is one accepted payment option for a route — a single diff --git a/internal/x402/metrics.go b/internal/x402/metrics.go index 69c70c9f..d288f5e9 100644 --- a/internal/x402/metrics.go +++ b/internal/x402/metrics.go @@ -10,12 +10,14 @@ import ( type verifierMetrics struct { registry *prometheus.Registry - requestsTotal *prometheus.CounterVec - paymentRequired *prometheus.CounterVec - paymentVerified *prometheus.CounterVec - paymentFailed *prometheus.CounterVec - chargedRequests *prometheus.CounterVec - lastPaymentSuccess *prometheus.GaugeVec + requestsTotal *prometheus.CounterVec + paymentRequired *prometheus.CounterVec + paymentVerified *prometheus.CounterVec + paymentFailed *prometheus.CounterVec + chargedRequests *prometheus.CounterVec + feeRevenueAtomic *prometheus.CounterVec + settledVolumeAtomic *prometheus.CounterVec + lastPaymentSuccess *prometheus.GaugeVec // paymentFailureReasons splits paymentFailed by WHY (payment_invalid, // facilitator_unreachable, settlement_failed, ...). paymentFailed alone @@ -69,6 +71,20 @@ func newVerifierMetrics() *verifierMetrics { }, []string{"offer_namespace", "offer_name", "chain", "asset_symbol"}, ), + feeRevenueAtomic: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "obol_x402_verifier_fee_revenue_atomic_total", + Help: "Platform fee captured via auth-capture unlock, in atomic token units (fixed-rate: amount*maxFeeBps/10000).", + }, + []string{"network", "asset", "fee_recipient"}, + ), + settledVolumeAtomic: prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "obol_x402_verifier_settled_volume_atomic_total", + Help: "Total settled unlock volume, atomic token units.", + }, + []string{"network", "asset", "fee_recipient"}, + ), lastPaymentSuccess: prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: "obol_x402_verifier_last_payment_success_seconds", @@ -98,6 +114,8 @@ func newVerifierMetrics() *verifierMetrics { m.paymentVerified, m.paymentFailed, m.chargedRequests, + m.feeRevenueAtomic, + m.settledVolumeAtomic, m.lastPaymentSuccess, m.paymentFailureReasons, m.upstreamFailedAfterVerify, diff --git a/internal/x402/unlockgate.go b/internal/x402/unlockgate.go new file mode 100644 index 00000000..bce2ae63 --- /dev/null +++ b/internal/x402/unlockgate.go @@ -0,0 +1,281 @@ +package x402 + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "log" + "math/big" + "net/http" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus" + x402types "github.com/x402-foundation/x402/go/v2/types" +) + +// The unlock offer is a gate:auth route whose session is minted only by the +// inline auth-capture payment on its first request; handleAuthEndpoints +// suppresses its free SIWX sign-in endpoints. + +// isUnlockOffer reports whether rule is the configured auth-capture unlock offer. +func (v *Verifier) isUnlockOffer(cfg *PricingConfig, rule *RouteRule) bool { + return cfg != nil && cfg.AuthCaptureUnlock != nil && cfg.AuthCaptureUnlock.Enabled && + strings.TrimSuffix(rule.StripPrefix, "/") == strings.TrimSuffix(cfg.AuthCaptureUnlock.OfferPrefix, "/") +} + +// handlePaidUnlock runs the auth-capture pay->settle->mint flow for the unlock +// offer and, on success, proxies the request to the upstream (the first paid +// message's answer). It fully handles the response in all branches. +func (v *Verifier) handlePaidUnlock(w http.ResponseWriter, r *http.Request, rule *RouteRule) { + cfg := v.config.Load() + if cfg == nil || cfg.AuthCaptureUnlock == nil || !cfg.AuthCaptureUnlock.Enabled { + log.Printf("x402-verifier: auth-capture unlock config invalid: offer is disabled or missing") + writeUnlockJSON(w, http.StatusInternalServerError, map[string]any{"error": "unlock_misconfigured"}) + return + } + uc := *cfg.AuthCaptureUnlock + + if err := uc.Validate(); err != nil { + log.Printf("x402-verifier: auth-capture unlock config invalid: %v", err) + writeUnlockJSON(w, http.StatusInternalServerError, map[string]any{"error": "unlock_misconfigured"}) + return + } + + chainName := uc.Network + if chainName == "" { + chainName = cfg.Chain + } + chains := v.chains.Load() + if chains == nil { + log.Printf("x402-verifier: auth-capture unlock chain registry is not loaded") + writeUnlockJSON(w, http.StatusInternalServerError, map[string]any{"error": "unlock_misconfigured"}) + return + } + chain, ok := (*chains)[chainName] + if !ok { + log.Printf("x402-verifier: auth-capture unlock chain %q is not pre-resolved", chainName) + writeUnlockJSON(w, http.StatusInternalServerError, map[string]any{"error": "unlock_misconfigured"}) + return + } + + asset := chain.DefaultAsset() + if uc.Asset != "" { + asset = ResolveAssetInfoForPayment(chain, RoutePayment{AssetAddress: uc.Asset}) + } + payTo := uc.PayTo + if payTo == "" { + payTo = cfg.Wallet + } + req, err := BuildAuthCaptureRequirement(chain, asset, &uc, payTo, time.Now()) + if err != nil { + log.Printf("x402-verifier: build auth-capture unlock requirement: %v", err) + writeUnlockJSON(w, http.StatusInternalServerError, map[string]any{"error": "unlock_misconfigured"}) + return + } + + paymentHeader := r.Header.Get("X-PAYMENT") + if paymentHeader == "" { + paymentHeader = r.Header.Get("PAYMENT-SIGNATURE") + } + if paymentHeader == "" { + writeUnlockJSON(w, http.StatusPaymentRequired, struct { + X402Version int `json:"x402Version"` + Accepts []x402types.PaymentRequirements `json:"accepts"` + Error string `json:"error"` + }{ + // auth-capture is a v2 scheme (the verifier settles it against the + // facilitator with x402Version 2); the challenge MUST advertise v2 so + // a standard @x402 client builds a v2 payload (its auth-capture scheme + // rejects x402Version != 2). + X402Version: 2, + Accepts: []x402types.PaymentRequirements{req}, + Error: "payment required to unlock this agent", + }) + return + } + + payloadBytes, err := base64.StdEncoding.DecodeString(paymentHeader) + if err != nil { + writeUnlockJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid_payment_header"}) + return + } + // Settle against the requirement the client actually SIGNED + // (payload.accepted), not the freshly rebuilt `req`: auth-capture's signed + // PaymentInfo hash commits the server-issued captureDeadline/refundDeadline, + // which drift between the 402 and this request (both call time.Now) and would + // invalidate the signature. Validate the client did not tamper with the + // economically-sensitive fields against our policy (`req`, built from config) + // before forwarding — the facilitator does not know obol's intended + // feeRecipient/payTo/amount, so a blind forward would let a client redirect + // the fee or underpay. + payload, err := x402types.ToPaymentPayload(payloadBytes) + if err != nil { + writeUnlockJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid_payment_header"}) + return + } + signedReq := payload.Accepted + if err := validateSignedUnlockRequirement(signedReq, req, uc, asset, time.Now().Unix()); err != nil { + log.Printf("x402-verifier: unlock payment policy mismatch: %v", err) + writeUnlockJSON(w, http.StatusPaymentRequired, map[string]any{"error": "payment_policy_mismatch", "detail": err.Error()}) + return + } + verifyResp, err := facilitatorVerify(r.Context(), &http.Client{Timeout: facilitatorVerifyTimeout}, cfg.FacilitatorURL, payloadBytes, signedReq) + if err != nil { + writeUnlockJSON(w, http.StatusPaymentRequired, map[string]any{"error": "payment_invalid", "detail": err.Error()}) + return + } + if !verifyResp.IsValid { + writeUnlockJSON(w, http.StatusPaymentRequired, map[string]any{ + "error": "payment_invalid", + "detail": facilitatorDetail(verifyResp.InvalidReason, verifyResp.InvalidMessage), + }) + return + } + + settleResp, err := facilitatorSettle(r.Context(), &http.Client{Timeout: facilitatorSettleTimeout}, cfg.FacilitatorURL, payloadBytes, signedReq) + if err != nil { + writeUnlockJSON(w, http.StatusBadGateway, map[string]any{"error": "settle_failed", "detail": err.Error()}) + return + } + if !settleResp.Success { + writeUnlockJSON(w, http.StatusBadGateway, map[string]any{ + "error": "settle_failed", + "detail": facilitatorDetail(settleResp.ErrorReason, settleResp.ErrorMessage), + }) + return + } + + amount, ok := new(big.Int).SetString(req.Amount, 10) + if !ok { + log.Printf("x402-verifier: parse auth-capture unlock amount %q for metrics", req.Amount) + } else { + feeBps := uc.MaxFeeBps + fee := new(big.Int).Div(new(big.Int).Mul(amount, big.NewInt(int64(feeBps))), big.NewInt(10000)) + labels := prometheus.Labels{"network": req.Network, "asset": req.Asset, "fee_recipient": uc.FeeRecipient} + v.metrics.settledVolumeAtomic.With(labels).Add(bigIntToFloat(amount)) + v.metrics.feeRevenueAtomic.With(labels).Add(bigIntToFloat(fee)) + } + + wallet := settleResp.Payer + if wallet == "" { + wallet = verifyResp.Payer + } + token := v.siwx.MintSession(wallet, time.Now()) + http.SetCookie(w, &http.Cookie{ + Name: SIWXSessionCookie, + Value: token, + Path: "/", + MaxAge: int(DefaultSIWXSessionTTL.Seconds()), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) + r.Header.Set(HeaderVerifiedWallet, wallet) + r.Header.Set(HeaderPaymentPayer, wallet) + proxy, err := buildUpstreamProxy(rule) + if err != nil { + log.Printf("x402-verifier: build upstream proxy for %s/%s: %v", rule.OfferNamespace, rule.OfferName, err) + writeUnlockJSON(w, http.StatusInternalServerError, map[string]any{"error": "upstream_unavailable"}) + return + } + proxy.ServeHTTP(&statusRecorder{ResponseWriter: w, status: http.StatusOK}, r) +} + +func bigIntToFloat(x *big.Int) float64 { + f, _ := new(big.Float).SetInt(x).Float64() + return f +} + +// validateSignedUnlockRequirement checks the requirement the client signed +// (payload.accepted) against the offer's policy (expected, built from config). +// It permits the client's server-issued deadlines to differ from `expected`'s +// freshly rebuilt ones (only sanity-checking them), but pins every +// economically-sensitive field so a client cannot redirect the fee, underpay, +// or swap the authorizer. Returns nil when the signed requirement is safe to +// settle. +func validateSignedUnlockRequirement(signed, expected x402types.PaymentRequirements, uc AuthCaptureUnlockConfig, asset AssetInfo, now int64) error { + if signed.Scheme != "auth-capture" { + return fmt.Errorf("scheme %q, want auth-capture", signed.Scheme) + } + if signed.Network != expected.Network { + return fmt.Errorf("network %q, want %q", signed.Network, expected.Network) + } + if !strings.EqualFold(signed.Asset, expected.Asset) { + return fmt.Errorf("asset %q, want %q", signed.Asset, expected.Asset) + } + if !strings.EqualFold(signed.PayTo, expected.PayTo) { + return fmt.Errorf("payTo %q, want %q", signed.PayTo, expected.PayTo) + } + if signed.Amount != expected.Amount { + return fmt.Errorf("amount %q, want %q", signed.Amount, expected.Amount) + } + ex := signed.Extra + if ex == nil { + return fmt.Errorf("missing extra") + } + if !strings.EqualFold(exStr(ex, "captureAuthorizer"), uc.CaptureAuthorizer) { + return fmt.Errorf("captureAuthorizer mismatch") + } + if !strings.EqualFold(exStr(ex, "feeRecipient"), uc.FeeRecipient) { + return fmt.Errorf("feeRecipient mismatch") + } + if exU64(ex, "minFeeBps") != uint64(uc.MinFeeBps) { + return fmt.Errorf("minFeeBps %d, want %d", exU64(ex, "minFeeBps"), uc.MinFeeBps) + } + if exU64(ex, "maxFeeBps") != uint64(uc.MaxFeeBps) { + return fmt.Errorf("maxFeeBps %d, want %d", exU64(ex, "maxFeeBps"), uc.MaxFeeBps) + } + if b, _ := ex["autoCapture"].(bool); !b { + return fmt.Errorf("autoCapture not true") + } + if exStr(ex, "assetTransferMethod") != asset.TransferMethod { + return fmt.Errorf("assetTransferMethod %q, want %q", exStr(ex, "assetTransferMethod"), asset.TransferMethod) + } + cd, rd := exI64(ex, "captureDeadline"), exI64(ex, "refundDeadline") + if cd <= now+6 { + return fmt.Errorf("captureDeadline %d not > now+6 (%d)", cd, now+6) + } + if rd < cd { + return fmt.Errorf("refundDeadline %d < captureDeadline %d", rd, cd) + } + return nil +} + +// exStr/exF64/exU64/exI64 coerce a JSON-decoded Extra value (numbers arrive as +// float64) to a concrete type; missing/wrong-typed keys yield the zero value. +func exStr(m map[string]interface{}, k string) string { s, _ := m[k].(string); return s } + +func exF64(m map[string]interface{}, k string) float64 { + switch v := m[k].(type) { + case float64: + return v + case json.Number: + f, _ := v.Float64() + return f + case int64: + return float64(v) + case int: + return float64(v) + } + return 0 +} + +func exU64(m map[string]interface{}, k string) uint64 { return uint64(exF64(m, k)) } +func exI64(m map[string]interface{}, k string) int64 { return int64(exF64(m, k)) } + +func facilitatorDetail(reason, message string) string { + detail := strings.TrimSpace(strings.TrimSpace(reason) + " " + strings.TrimSpace(message)) + if detail == "" { + return "facilitator rejected the request" + } + return detail +} + +func writeUnlockJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + log.Printf("x402-verifier: encode auth-capture unlock response: %v", err) + } +} diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 7873837e..efb5afd4 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -385,6 +385,10 @@ func (v *Verifier) HandleProxy(w http.ResponseWriter, r *http.Request) { if rule.IsAuth() { wallet, err := v.siwx.Authenticate(r, requestHost(r), time.Now()) if err != nil { + if v.isUnlockOffer(cfg, rule) { + v.handlePaidUnlock(w, r, rule) + return + } v.writeSIWXChallenge(w, r, rule, err) return } From f420d7878345c1b30af5b187b9bf001894dc16c5 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Mon, 20 Jul 2026 20:24:51 +0400 Subject: [PATCH 2/2] fix(x402): surface on-chain settle tx hash on unlock settle failure handlePaidUnlock discarded the settle response when facilitatorSettle errored, losing the tx hash the facilitator returns when it submits the settle tx on-chain and then fails on the receipt path. A charged buyer got a bare settle_failed with no cookie, no answer, and no way to reconcile the on-chain debit. Mirror the per-request paid path (HandleProxy): surface settleResp.Transaction via X-PAYMENT-RESPONSE + a 'you may pay twice' hint, and log it, on both the transport-error and !Success branches. A failed settle still mints no session. Claude-Session: https://claude.ai/code/session_01PnhCQLz7CHuDBUhWd5xF8v --- internal/x402/authcapture_test.go | 80 +++++++++++++++++++++++++++++++ internal/x402/unlockgate.go | 38 +++++++++++++-- 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/internal/x402/authcapture_test.go b/internal/x402/authcapture_test.go index bcef5a31..78f7e4b8 100644 --- a/internal/x402/authcapture_test.go +++ b/internal/x402/authcapture_test.go @@ -316,6 +316,86 @@ func TestPaidUnlock_InlineFirstMessage(t *testing.T) { } } +// TestPaidUnlock_SettleErrorSurfacesTxHash locks in the forensic contract: when +// the facilitator submits the settle tx on-chain and THEN fails on the receipt +// path (non-200 with a tx hash in the body), the buyer must get the tx hash back +// (X-PAYMENT-RESPONSE + a "you may pay twice" hint) rather than a bare error — +// otherwise an on-chain debit goes silent with no way to reconcile. +func TestPaidUnlock_SettleErrorSurfacesTxHash(t *testing.T) { + const settledTx = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + mux := http.NewServeMux() + mux.HandleFunc("/verify", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"isValid":true,"payer":%q}`, testUnlockPayer) + }) + mux.HandleFunc("/settle", func(w http.ResponseWriter, r *http.Request) { + // tx landed, receipt path 5xx'd: return the tx hash with a non-200. + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, `{"success":false,"transaction":%q,"network":"eip155:84532","payer":%q,"errorReason":"receipt_timeout"}`, settledTx, testUnlockPayer) + }) + facilitator := httptest.NewServer(mux) + t.Cleanup(facilitator.Close) + + unlockConfig := validAuthCaptureConfig() + v, err := NewVerifier(&PricingConfig{ + Wallet: "0x3333333333333333333333333333333333333333", + Chain: "base-sepolia", + FacilitatorURL: facilitator.URL, + Routes: []RouteRule{{ + Pattern: "/services/agent/*", + Gate: "auth", + StripPrefix: unlockConfig.OfferPrefix, + UpstreamURL: "http://upstream.invalid", + OfferNamespace: "test", + OfferName: "agent", + }}, + AuthCaptureUnlock: &unlockConfig, + }) + if err != nil { + t.Fatalf("NewVerifier: %v", err) + } + + path := unlockConfig.OfferPrefix + "/chat" + challengeReq := httptest.NewRequest(http.MethodGet, path, nil) + cw := httptest.NewRecorder() + v.HandleProxy(cw, challengeReq) + var challenge struct { + Accepts []x402types.PaymentRequirements `json:"accepts"` + } + if err := json.Unmarshal(cw.Body.Bytes(), &challenge); err != nil { + t.Fatalf("decode challenge: %v", err) + } + wireJSON, _ := json.Marshal(map[string]any{ + "x402Version": 2, "payload": map[string]any{"stub": true}, "accepted": challenge.Accepts[0], + }) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("X-PAYMENT", base64.StdEncoding.EncodeToString(wireJSON)) + w := httptest.NewRecorder() + v.HandleProxy(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want 502; body: %s", w.Code, w.Body.String()) + } + if got := w.Header().Get("X-PAYMENT-RESPONSE"); got == "" { + t.Error("X-PAYMENT-RESPONSE header not set — the on-chain tx hash is lost") + } else { + decoded, derr := base64.StdEncoding.DecodeString(got) + if derr != nil || !strings.Contains(string(decoded), settledTx) { + t.Errorf("X-PAYMENT-RESPONSE does not carry the settle tx: %s", string(decoded)) + } + } + if !strings.Contains(w.Body.String(), "pay twice") { + t.Errorf("error body missing the reconcile hint: %s", w.Body.String()) + } + // A failed settle must NOT mint a session. + for _, c := range w.Result().Cookies() { + if c.Name == SIWXSessionCookie { + t.Error("session cookie minted despite settle failure") + } + } +} + // TestValidateSignedUnlockRequirement guards the money path: the verifier // settles the requirement the CLIENT signed (payload.accepted), so a client // must not be able to redirect the fee, underpay, or swap the authorizer. Each diff --git a/internal/x402/unlockgate.go b/internal/x402/unlockgate.go index bce2ae63..22e337eb 100644 --- a/internal/x402/unlockgate.go +++ b/internal/x402/unlockgate.go @@ -135,14 +135,24 @@ func (v *Verifier) handlePaidUnlock(w http.ResponseWriter, r *http.Request, rule settleResp, err := facilitatorSettle(r.Context(), &http.Client{Timeout: facilitatorSettleTimeout}, cfg.FacilitatorURL, payloadBytes, signedReq) if err != nil { - writeUnlockJSON(w, http.StatusBadGateway, map[string]any{"error": "settle_failed", "detail": err.Error()}) + // The facilitator can submit the settle tx on-chain and THEN fail on + // the receipt path (facilitatorSettle returns the tx-bearing response + // alongside the error). Surface the tx hash so a charged-but-unanswered + // buyer can reconcile against the chain — same forensic contract as the + // per-request paid path in HandleProxy. Without this the debit is silent. + body := map[string]any{"error": "settle_failed", "detail": err.Error()} + if v.surfaceOnChainSettle(w, settleResp) { + body["hint"] = "the settle tx in X-PAYMENT-RESPONSE may have landed on-chain — verify against the chain before retrying, or you may pay twice" + } + writeUnlockJSON(w, http.StatusBadGateway, body) return } if !settleResp.Success { - writeUnlockJSON(w, http.StatusBadGateway, map[string]any{ - "error": "settle_failed", - "detail": facilitatorDetail(settleResp.ErrorReason, settleResp.ErrorMessage), - }) + body := map[string]any{"error": "settle_failed", "detail": facilitatorDetail(settleResp.ErrorReason, settleResp.ErrorMessage)} + if v.surfaceOnChainSettle(w, settleResp) { + body["hint"] = "the settle tx in X-PAYMENT-RESPONSE may have landed on-chain — verify against the chain before retrying, or you may pay twice" + } + writeUnlockJSON(w, http.StatusBadGateway, body) return } @@ -182,6 +192,24 @@ func (v *Verifier) handlePaidUnlock(w http.ResponseWriter, r *http.Request, rule proxy.ServeHTTP(&statusRecorder{ResponseWriter: w, status: http.StatusOK}, r) } +// surfaceOnChainSettle sets X-PAYMENT-RESPONSE from a settle response that +// carries an on-chain tx hash (the facilitator submitted the tx then errored), +// so a charged buyer can reconcile. Must run before writeUnlockJSON commits the +// status. Returns true when a tx was surfaced. Mirrors the HandleProxy path. +func (v *Verifier) surfaceOnChainSettle(w http.ResponseWriter, settleResp *facilitatorSettleResponse) bool { + if settleResp == nil || settleResp.Transaction == "" { + return false + } + if encoded, err := json.Marshal(settleResp); err == nil { + b64 := base64.StdEncoding.EncodeToString(encoded) + w.Header().Set("X-PAYMENT-RESPONSE", b64) + w.Header().Set("PAYMENT-RESPONSE", b64) + } + log.Printf("x402-verifier: auth-capture unlock settle returned tx %s with an error — verify on-chain (network=%s payer=%s)", + settleResp.Transaction, settleResp.Network, settleResp.Payer) + return true +} + func bigIntToFloat(x *big.Int) float64 { f, _ := new(big.Float).SetInt(x).Float64() return f