diff --git a/examples/go/clients/batch-settlement/README.md b/examples/go/clients/batch-settlement/README.md index eaf93f2497..11c1c012e8 100644 --- a/examples/go/clients/batch-settlement/README.md +++ b/examples/go/clients/batch-settlement/README.md @@ -1,6 +1,6 @@ # Batch-Settlement Client (Go) -Go port of [`examples/typescript/clients/batch-settlement`](../../../typescript/clients/batch-settlement). Sequential batch-settlement payment client. Opens a payment channel on the first request (deposit) and pays subsequent requests with off-chain vouchers that update the cumulative claimable amount. +Sequential batch-settlement payment client. Opens a payment channel on the first request (deposit) and pays subsequent requests with off-chain vouchers that update the cumulative claimable amount. ## Run @@ -11,7 +11,7 @@ cp .env-example .env go run . ``` -The companion server is in `examples/go/servers/batch-settlement` and the facilitator is in `examples/go/facilitator/batch-settlement`. The Go and TS clients share the same env keys, default route (`/weather`), and behavior — point the same `.env` at either binary. +The companion server is in `examples/go/servers/batch-settlement` and the facilitator is in `examples/go/facilitator/batch-settlement`. ## Voucher Signer Delegation diff --git a/examples/go/clients/batch-settlement/main.go b/examples/go/clients/batch-settlement/main.go index 07403d9fde..0f8d755f63 100644 --- a/examples/go/clients/batch-settlement/main.go +++ b/examples/go/clients/batch-settlement/main.go @@ -65,7 +65,7 @@ func main() { Salt: channelSalt, } - // Optional dedicated voucher-signing key (matches TS EVM_VOUCHER_SIGNER_PRIVATE_KEY). + // Optional dedicated voucher-signing key. if voucherKey := os.Getenv("EVM_VOUCHER_SIGNER_PRIVATE_KEY"); voucherKey != "" { voucherSigner, err := evmsigners.NewClientSignerFromPrivateKey(voucherKey) if err != nil { @@ -171,7 +171,7 @@ func readJSON(resp *http.Response) (interface{}, error) { return out, nil } -func extractSettleResponse(resp *http.Response) (*map[string]interface{}, error) { +func extractSettleResponse(resp *http.Response) (*x402.SettleResponse, error) { header := resp.Header.Get("PAYMENT-RESPONSE") if header == "" { header = resp.Header.Get("X-PAYMENT-RESPONSE") @@ -183,7 +183,7 @@ func extractSettleResponse(resp *http.Response) (*map[string]interface{}, error) if err != nil { return nil, err } - var out map[string]interface{} + var out x402.SettleResponse if err := json.Unmarshal(decoded, &out); err != nil { return nil, err } diff --git a/examples/go/facilitator/batch-settlement/.env-example b/examples/go/facilitator/batch-settlement/.env-example index e5c23fec20..5700a55cfa 100644 --- a/examples/go/facilitator/batch-settlement/.env-example +++ b/examples/go/facilitator/batch-settlement/.env-example @@ -4,5 +4,5 @@ EVM_RPC_URL=https://sepolia.base.org # Optional dedicated authorizer key (defaults to EVM_PRIVATE_KEY) EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= -# Optional (default 4022). Same as examples/typescript/facilitator/batch-settlement. +# Optional listen port (default 4022). PORT= diff --git a/examples/go/facilitator/batch-settlement/README.md b/examples/go/facilitator/batch-settlement/README.md index 6751f0dcdd..72df340ae6 100644 --- a/examples/go/facilitator/batch-settlement/README.md +++ b/examples/go/facilitator/batch-settlement/README.md @@ -23,8 +23,7 @@ cp .env-example .env go run . ``` -Listens on `http://localhost:4022` by default (`PORT` overrides; same as the -TypeScript facilitator example). +Listens on `http://localhost:4022` by default (`PORT` overrides). ## Environment diff --git a/examples/go/servers/batch-settlement/README.md b/examples/go/servers/batch-settlement/README.md index 3ca8f0e4e1..22807c17c3 100644 --- a/examples/go/servers/batch-settlement/README.md +++ b/examples/go/servers/batch-settlement/README.md @@ -1,6 +1,6 @@ # Batch-Settlement Server (Go) -Go port of [`examples/typescript/servers/batch-settlement`](../../../typescript/servers/batch-settlement). Demo resource server using the batch-settlement scheme: a client opens a payment channel with a single deposit; subsequent paid requests update an off-chain voucher. The `ChannelManager` periodically claims and settles onchain. +Demo resource server using the batch-settlement scheme: a client opens a payment channel with a single deposit; subsequent paid requests update an off-chain voucher. The `ChannelManager` periodically claims and settles onchain. The route demonstrates **dynamic pricing**: the client authorizes up to `$0.01` per request, and the handler bills a random fraction of that via `Settlement-Overrides`. @@ -15,18 +15,6 @@ go run . The server listens on `http://localhost:4021` and exposes `GET /weather`. Pair with `examples/go/clients/batch-settlement` and `examples/go/facilitator/batch-settlement`. -### Cross-SDK local testing - -The Go and TS servers share the same `.env-example` keys, route (`GET /weather`), response shape (`{report: {weather, temperature}}`), and channel-manager cadences, so you can swap one for the other without changing client config: - -```bash -# Go server -EVM_ADDRESS=0x... FACILITATOR_URL=http://localhost:4022 go run . - -# TS server (same .env) -cd examples/typescript/servers/batch-settlement && pnpm dev -``` - ## Environment | Variable | Required | Description | @@ -39,7 +27,7 @@ cd examples/typescript/servers/batch-settlement && pnpm dev ## Auto-settlement -The example wires up a `ChannelManager` with the same triggers as the TS demo: +The example wires up a `ChannelManager` with simple local-demo triggers: - **Claim** every 60 s. - **Settle** every 120 s (sweeps claimed funds to `payTo`). diff --git a/examples/go/servers/batch-settlement/go.mod b/examples/go/servers/batch-settlement/go.mod index 2eedeec0ce..6b637a6e80 100644 --- a/examples/go/servers/batch-settlement/go.mod +++ b/examples/go/servers/batch-settlement/go.mod @@ -35,6 +35,8 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect golang.org/x/crypto v0.46.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect ) diff --git a/examples/go/servers/batch-settlement/go.sum b/examples/go/servers/batch-settlement/go.sum index 8f3d453689..97963a23e2 100644 --- a/examples/go/servers/batch-settlement/go.sum +++ b/examples/go/servers/batch-settlement/go.sum @@ -178,6 +178,8 @@ golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/examples/go/servers/batch-settlement/main.go b/examples/go/servers/batch-settlement/main.go index fe905aeb07..63ff1f1484 100644 --- a/examples/go/servers/batch-settlement/main.go +++ b/examples/go/servers/batch-settlement/main.go @@ -21,7 +21,6 @@ import ( batchedserver "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement/server" ) -// Mirrors examples/typescript/servers/batch-settlement/index.ts. const ( defaultPort = "4021" network = x402.Network("eip155:84532") @@ -43,7 +42,7 @@ func main() { os.Exit(1) } - // TS code default: 86400 (1 day). Falls back to that when env unset. + // Default channel withdraw delay is 1 day when the env var is unset. withdrawDelay := 86400 if v := os.Getenv("DEFERRED_WITHDRAW_DELAY_SECONDS"); v != "" { if n, err := strconv.Atoi(v); err == nil { @@ -83,7 +82,7 @@ func main() { SettleIntervalSecs: 120, RefundIntervalSecs: 180, MaxClaimsPerBatch: 100, - // Refund channels after 3 minutes of inactivity (mirrors TS demo). + // Refund channels after 3 minutes of inactivity. SelectRefundChannels: func(channels []*batchedserver.ChannelSession, ctx batchedserver.AutoSettlementContext) ([]*batchedserver.ChannelSession, error) { out := make([]*batchedserver.ChannelSession, 0, len(channels)) for _, c := range channels { @@ -114,7 +113,7 @@ func main() { }, }) - // SIGINT-only graceful shutdown (no SIGTERM), mirroring TS. + // Flush pending channel work during interactive shutdown. sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGINT) @@ -135,8 +134,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("GET /weather", func(w http.ResponseWriter, r *http.Request) { - // Bill a random fraction of maxPrice (1–100%) to demonstrate - // usage-based pricing. Mirrors TS demo verbatim. + // Bill a random fraction of maxPrice (1-100%) to demonstrate usage-based pricing. chargedPercent := 1 + rand.Intn(100) nethttpmw.SetSettlementOverrides(w, &x402.SettlementOverrides{ Amount: fmt.Sprintf("%d%%", chargedPercent), diff --git a/go/SERVER.md b/go/SERVER.md index 4b79a00f21..ba5b37f47e 100644 --- a/go/SERVER.md +++ b/go/SERVER.md @@ -152,7 +152,7 @@ settleResult := httpServer.ProcessSettlement(ctx, payload, requirements, nil, &x Request: &reqCtx, ResponseBody: responseBody, ResponseHeaders: responseHeaders, -}) +}, nil) ``` ### 4. Facilitator Client diff --git a/go/http/client.go b/go/http/client.go index b314ab5047..eb6302e6ac 100644 --- a/go/http/client.go +++ b/go/http/client.go @@ -529,6 +529,7 @@ func decodePaymentRequiredHeader(header string) (x402.PaymentRequired, error) { // encodePaymentResponseHeader encodes a settlement response as base64 func encodePaymentResponseHeader(response x402.SettleResponse) (string, error) { + response = withTypedChannelStateExtra(response) data, err := json.Marshal(response) if err != nil { return "", fmt.Errorf("failed to marshal settle response: %w", err) @@ -536,6 +537,42 @@ func encodePaymentResponseHeader(response x402.SettleResponse) (string, error) { return base64.StdEncoding.EncodeToString(data), nil } +type paymentResponseChannelStateExtra struct { + ChannelId string `json:"channelId,omitempty"` + Balance string `json:"balance,omitempty"` + TotalClaimed string `json:"totalClaimed,omitempty"` + WithdrawRequestedAt interface{} `json:"withdrawRequestedAt,omitempty"` + RefundNonce string `json:"refundNonce,omitempty"` + ChargedCumulativeAmount string `json:"chargedCumulativeAmount,omitempty"` +} + +func withTypedChannelStateExtra(response x402.SettleResponse) x402.SettleResponse { + raw, ok := response.Extra["channelState"].(map[string]interface{}) + if !ok { + return response + } + + extra := make(map[string]interface{}, len(response.Extra)) + for key, value := range response.Extra { + extra[key] = value + } + extra["channelState"] = paymentResponseChannelStateExtra{ + ChannelId: stringField(raw, "channelId"), + Balance: stringField(raw, "balance"), + TotalClaimed: stringField(raw, "totalClaimed"), + WithdrawRequestedAt: raw["withdrawRequestedAt"], + RefundNonce: stringField(raw, "refundNonce"), + ChargedCumulativeAmount: stringField(raw, "chargedCumulativeAmount"), + } + response.Extra = extra + return response +} + +func stringField(data map[string]interface{}, key string) string { + value, _ := data[key].(string) + return value +} + // decodePaymentResponseHeader decodes a base64 payment response header func decodePaymentResponseHeader(header string) (*x402.SettleResponse, error) { data, err := base64.StdEncoding.DecodeString(header) diff --git a/go/http/client_test.go b/go/http/client_test.go index 1c6c1249c4..f7b8460848 100644 --- a/go/http/client_test.go +++ b/go/http/client_test.go @@ -215,6 +215,55 @@ func TestGetPaymentSettleResponse(t *testing.T) { } } +func TestEncodePaymentResponseHeader_ChannelStateOrder(t *testing.T) { + encoded, err := encodePaymentResponseHeader(x402.SettleResponse{ + Success: true, + Payer: "0xpayer", + Transaction: "0xtx", + Network: "eip155:1", + Amount: "1000", + Extra: map[string]interface{}{ + "channelState": map[string]interface{}{ + "balance": "5000", + "channelId": "0xchan", + "chargedCumulativeAmount": "3000", + "refundNonce": "1", + "totalClaimed": "2000", + "withdrawRequestedAt": 0, + }, + "chargedAmount": "1000", + }, + }) + if err != nil { + t.Fatalf("encodePaymentResponseHeader: %v", err) + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("decode: %v", err) + } + body := string(decoded) + fields := []string{ + `"channelId":"0xchan"`, + `"balance":"5000"`, + `"totalClaimed":"2000"`, + `"withdrawRequestedAt":0`, + `"refundNonce":"1"`, + `"chargedCumulativeAmount":"3000"`, + } + last := -1 + for _, field := range fields { + idx := strings.Index(body, field) + if idx == -1 { + t.Fatalf("missing %s in %s", field, body) + } + if idx <= last { + t.Fatalf("field %s out of order in %s", field, body) + } + last = idx + } +} + func TestPaymentRoundTripper(t *testing.T) { // Create a test server that returns 402 first, then 200 callCount := 0 diff --git a/go/http/echo/middleware.go b/go/http/echo/middleware.go index 286ddb91a6..cb4dc3a491 100644 --- a/go/http/echo/middleware.go +++ b/go/http/echo/middleware.go @@ -362,7 +362,7 @@ func handlePaymentVerified(c echo.Context, next echo.HandlerFunc, server *x402ht // SkipHandler directive: bypass downstream handler, settle inline using the // directive body. Used for refund acknowledgements where there is no resource - // response to return. Mirrors nethttp middleware behavior. + // response to return. var err error if result.SkipHandler != nil { contentType := result.SkipHandler.ContentType @@ -427,7 +427,7 @@ func handlePaymentVerified(c echo.Context, next echo.HandlerFunc, server *x402ht return nil } - settleResult := server.ProcessSettlementWithExtensions( + settleResult := server.ProcessSettlement( ctx, *result.PaymentPayload, *result.PaymentRequirements, diff --git a/go/http/gin/middleware.go b/go/http/gin/middleware.go index 8e31ecb77f..993618de0d 100644 --- a/go/http/gin/middleware.go +++ b/go/http/gin/middleware.go @@ -363,7 +363,7 @@ func handlePaymentVerified(c *gin.Context, server *x402http.HTTPServer, ctx cont // SkipHandler directive: bypass downstream handler, settle inline using the // directive body. Used for refund acknowledgements where there is no resource - // response to return. Mirrors nethttp middleware behavior. + // response to return. skipHandler := result.SkipHandler != nil if skipHandler { contentType := result.SkipHandler.ContentType @@ -430,7 +430,7 @@ func handlePaymentVerified(c *gin.Context, server *x402http.HTTPServer, ctx cont return } - settleResult := server.ProcessSettlementWithExtensions( + settleResult := server.ProcessSettlement( ctx, *result.PaymentPayload, *result.PaymentRequirements, diff --git a/go/http/nethttp/middleware.go b/go/http/nethttp/middleware.go index 01de635f85..c77fc55560 100644 --- a/go/http/nethttp/middleware.go +++ b/go/http/nethttp/middleware.go @@ -339,7 +339,7 @@ func handlePaymentVerified(w http.ResponseWriter, r *http.Request, next http.Han return } - settleResult := server.ProcessSettlementWithExtensions( + settleResult := server.ProcessSettlement( ctx, *result.PaymentPayload, *result.PaymentRequirements, diff --git a/go/http/server.go b/go/http/server.go index 4c13c4c112..5632ce8772 100644 --- a/go/http/server.go +++ b/go/http/server.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "html" "log" @@ -186,7 +187,7 @@ type HTTPProcessResult struct { // `paymentRequiredResponse.extensions` flowing into both calls. DeclaredExtensions map[string]interface{} // SkipHandler is set when an AfterVerifyHook signals that the resource handler - // should be bypassed and settlement performed inline (e.g. cooperative refund). + // should be bypassed and settlement performed inline. SkipHandler *x402.SkipHandlerDirective // CancellationDispatcher fires onVerifiedPaymentCanceled hooks if the resource // handler errors or returns a non-2xx status before settlement runs. Set when @@ -615,14 +616,15 @@ func (s *x402HTTPResourceServer) ProcessHTTPRequest(ctx context.Context, reqCtx // Verify payment (type-safe). Pass `extensions` so per-extension hooks // (registered via ResourceServerExtensionHookProvider) gate on declared - // extension keys — mirrors TS `verifyPayment(..., declaredExtensions)`. + // extension keys. verifyResp, verifyErr := s.VerifyPaymentWithExtensions(ctx, *typedPayload, *matchingReqs, extensions) if verifyErr != nil { err = verifyErr // Prefer InvalidReason (the protocol error code) over the free-form // message so enrichers can match on a stable identifier. errorMsg := err.Error() - if ve, ok := verifyErr.(*x402.VerifyError); ok && ve.InvalidReason != "" { + var ve *x402.VerifyError + if errors.As(verifyErr, &ve) && ve.InvalidReason != "" { errorMsg = ve.InvalidReason } @@ -692,23 +694,15 @@ func MarshalSettlementOverrides(overrides *x402.SettlementOverrides) string { return string(data) } -// ProcessSettlement handles settlement after successful response with no -// declared extensions. Equivalent to ProcessSettlementWithExtensions(..., -// nil). Kept for transport adapters that don't yet thread extensions -// through. -func (s *x402HTTPResourceServer) ProcessSettlement(ctx context.Context, payload types.PaymentPayload, requirements types.PaymentRequirements, overrides *x402.SettlementOverrides, transportContext *HTTPTransportContext) *ProcessSettleResult { - return s.ProcessSettlementWithExtensions(ctx, payload, requirements, overrides, transportContext, nil) -} - -// ProcessSettlementWithExtensions handles settlement after successful response. +// ProcessSettlement handles settlement after successful response. // If overrides is non-nil, it takes precedence. Otherwise, falls back to reading // the settlement-overrides header from the transport context's ResponseHeaders // (set by the route handler via SetSettlementOverrides). The header is deleted // from ResponseHeaders to prevent it from being sent to the client. // -// `declaredExtensions` is forwarded to SettlePaymentWithExtensions so per- -// extension settle hooks fire only when their key is declared on the route. -func (s *x402HTTPResourceServer) ProcessSettlementWithExtensions( +// declaredExtensions is forwarded to SettlePaymentWithExtensions so per-extension +// settle hooks fire only when their key is declared on the route. +func (s *x402HTTPResourceServer) ProcessSettlement( ctx context.Context, payload types.PaymentPayload, requirements types.PaymentRequirements, diff --git a/go/http/server_test.go b/go/http/server_test.go index 7c51e8ad08..97f15269b6 100644 --- a/go/http/server_test.go +++ b/go/http/server_test.go @@ -467,7 +467,7 @@ func TestProcessSettlement(t *testing.T) { } // Test settlement processing - result := server.ProcessSettlement(ctx, payload, requirements, nil, nil) + result := server.ProcessSettlement(ctx, payload, requirements, nil, nil, nil) if !result.Success { t.Fatalf("Unexpected failure: %v", result.ErrorReason) } @@ -513,7 +513,7 @@ func TestProcessSettlement_Failure(t *testing.T) { Payload: map[string]interface{}{}, } - result := server.ProcessSettlement(ctx, payload, requirements, nil, nil) + result := server.ProcessSettlement(ctx, payload, requirements, nil, nil, nil) if result.Success { t.Fatal("Expected settlement failure") } @@ -571,7 +571,7 @@ func TestProcessSettlement_OverridesFromTransportContext(t *testing.T) { ResponseHeaders: h, } - result := server.ProcessSettlement(ctx, payload, requirements, nil, tc) + result := server.ProcessSettlement(ctx, payload, requirements, nil, tc, nil) if !result.Success { t.Fatalf("unexpected failure: %v", result.ErrorReason) } @@ -594,7 +594,7 @@ func TestProcessSettlement_OverridesFromTransportContext(t *testing.T) { } explicit := &x402.SettlementOverrides{Amount: "200"} - result := server.ProcessSettlement(ctx, payload, requirements, explicit, tc) + result := server.ProcessSettlement(ctx, payload, requirements, explicit, tc, nil) if !result.Success { t.Fatalf("unexpected failure: %v", result.ErrorReason) } @@ -616,7 +616,7 @@ func TestProcessSettlement_OverridesFromTransportContext(t *testing.T) { ResponseHeaders: h, } - result := server.ProcessSettlement(ctx, payload, requirements, nil, tc) + result := server.ProcessSettlement(ctx, payload, requirements, nil, tc, nil) if !result.Success { t.Fatalf("unexpected failure: %v", result.ErrorReason) } @@ -638,7 +638,7 @@ func TestProcessSettlement_OverridesFromTransportContext(t *testing.T) { ResponseHeaders: h, } - server.ProcessSettlement(ctx, payload, requirements, nil, tc) + server.ProcessSettlement(ctx, payload, requirements, nil, tc, nil) if tc.ResponseHeaders.Get(SettlementOverridesHeader) != "" { t.Error("expected settlement-overrides header to be deleted from transport context") @@ -649,7 +649,7 @@ func TestProcessSettlement_OverridesFromTransportContext(t *testing.T) { }) t.Run("nil transport context is safe", func(t *testing.T) { - result := server.ProcessSettlement(ctx, payload, requirements, nil, nil) + result := server.ProcessSettlement(ctx, payload, requirements, nil, nil, nil) if !result.Success { t.Fatalf("unexpected failure: %v", result.ErrorReason) } @@ -660,7 +660,7 @@ func TestProcessSettlement_OverridesFromTransportContext(t *testing.T) { Request: &HTTPRequestContext{Path: "/test", Method: "GET"}, ResponseHeaders: nil, } - result := server.ProcessSettlement(ctx, payload, requirements, nil, tc) + result := server.ProcessSettlement(ctx, payload, requirements, nil, tc, nil) if !result.Success { t.Fatalf("unexpected failure: %v", result.ErrorReason) } @@ -674,7 +674,7 @@ func TestProcessSettlement_OverridesFromTransportContext(t *testing.T) { ResponseHeaders: h, } - result := server.ProcessSettlement(ctx, payload, requirements, nil, tc) + result := server.ProcessSettlement(ctx, payload, requirements, nil, tc, nil) if !result.Success { t.Fatalf("unexpected failure: %v", result.ErrorReason) } @@ -697,7 +697,7 @@ func TestProcessSettlement_OverridesFromTransportContext(t *testing.T) { ResponseHeaders: h, } - result := server.ProcessSettlement(ctx, payload, requirements, nil, tc) + result := server.ProcessSettlement(ctx, payload, requirements, nil, tc, nil) if !result.Success { t.Fatalf("unexpected failure: %v", result.ErrorReason) } diff --git a/go/mechanisms/evm/batch-settlement/client/eip3009.go b/go/mechanisms/evm/batch-settlement/client/eip3009.go index 255c23c330..893ee0ac91 100644 --- a/go/mechanisms/evm/batch-settlement/client/eip3009.go +++ b/go/mechanisms/evm/batch-settlement/client/eip3009.go @@ -7,7 +7,7 @@ import ( "time" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) diff --git a/go/mechanisms/evm/batch-settlement/client/extensions.go b/go/mechanisms/evm/batch-settlement/client/extensions.go index d871be198e..d7456326b3 100644 --- a/go/mechanisms/evm/batch-settlement/client/extensions.go +++ b/go/mechanisms/evm/batch-settlement/client/extensions.go @@ -22,9 +22,8 @@ import ( var _ x402.ExtensionAwareClient = (*BatchSettlementEvmScheme)(nil) // CreatePaymentPayloadWithExtensions creates a batched payment payload with -// extension awareness, mirroring TS `BatchSettlementEvmScheme.createPaymentPayload` -// when `paymentRequired.extensions` advertises EIP-2612 or ERC-20 approval gas -// sponsoring. +// extension awareness when `paymentRequired.extensions` advertises EIP-2612 or +// ERC-20 approval gas sponsoring. // // Behavior matches the exact / upto schemes: // @@ -65,9 +64,9 @@ func (c *BatchSettlementEvmScheme) CreatePaymentPayloadWithExtensions( // Only Permit2 deposits gas-sponsor through these extensions; ERC-3009 // deposits authorize the transfer in the same signature. Read - // `assetTransferMethod` from requirements.Extra (TS source of truth) - // and fall back to inspecting the deposit authorization shape so a - // missing wire field still routes correctly. + // `assetTransferMethod` from requirements.Extra and fall back to inspecting + // the deposit authorization shape so a missing wire field still routes + // correctly. isPermit2 := false if requirements.Extra != nil { if v, ok := requirements.Extra["assetTransferMethod"].(string); ok && v != "" { diff --git a/go/mechanisms/evm/batch-settlement/client/extensions_test.go b/go/mechanisms/evm/batch-settlement/client/extensions_test.go index bbc4e6dca9..965f844749 100644 --- a/go/mechanisms/evm/batch-settlement/client/extensions_test.go +++ b/go/mechanisms/evm/batch-settlement/client/extensions_test.go @@ -12,10 +12,10 @@ import ( ) const ( - extTestNetwork = "eip155:8453" - extTestAsset = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" // USDC Base - extTestPayTo = "0x3333333333333333333333333333333333333333" - extTestSigner = "0x4444444444444444444444444444444444444444" + extTestNetwork = "eip155:8453" + extTestAsset = "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" // USDC Base + extTestPayTo = "0x3333333333333333333333333333333333333333" + extTestSigner = "0x4444444444444444444444444444444444444444" ) // extReadSigner combines mockSigner with read-contract + tx-signing capabilities @@ -24,17 +24,17 @@ const ( // type assertions. type extReadSigner struct { *mockSigner - allowance *big.Int - allowanceErr error - noncesResult *big.Int - noncesErr error - signTxResult []byte - signTxErr error - feesPriority *big.Int - feesMax *big.Int - feesErr error - txCount uint64 - txCountErr error + allowance *big.Int + allowanceErr error + noncesResult *big.Int + noncesErr error + signTxResult []byte + signTxErr error + feesPriority *big.Int + feesMax *big.Int + feesErr error + txCount uint64 + txCountErr error } // ReadContract dispatches based on the function name so we can stub both the @@ -56,12 +56,6 @@ func (r *extReadSigner) ReadContract(_ context.Context, _ string, _ []byte, func return nil, nil } -// signTx-related stubs so the signer satisfies ClientEvmSignerWithTxSigning. -// The ERC-20 approval branch wires through `exactclient.SignErc20ApprovalTransaction`, -// which in turn calls these methods to assemble the signed approve tx. - -type stubTx struct{} - // SignTransaction satisfies ClientEvmSignerWithSignTransaction. The exact // content doesn't matter — we only assert that the extension info ends up // attached to the payload, not the binary correctness of the signed tx. @@ -114,7 +108,7 @@ func eip2612OnlyDeclared() map[string]interface{} { func bothExtensionsDeclared() map[string]interface{} { return map[string]interface{}{ - eip2612gassponsor.EIP2612GasSponsoring.Key(): map[string]interface{}{}, + eip2612gassponsor.EIP2612GasSponsoring.Key(): map[string]interface{}{}, erc20approvalgassponsor.ERC20ApprovalGasSponsoring.Key(): map[string]interface{}{}, } } @@ -144,7 +138,7 @@ func TestCreatePaymentPayloadWithExtensions_NoExtensionsDeclared(t *testing.T) { // TestCreatePaymentPayloadWithExtensions_AllowanceShortCircuit confirms the // EIP-2612 path is skipped when the user has already approved Permit2 for at -// least the deposit amount. Mirrors TS `signEip2612Permit` allowance check. +// least the deposit amount. func TestCreatePaymentPayloadWithExtensions_AllowanceShortCircuit(t *testing.T) { // Deposit defaults to amount * DefaultDepositMultiplier (5) = 500. // Allowance of 1e18 is way more than enough → no permit signed. @@ -213,8 +207,8 @@ func TestCreatePaymentPayloadWithExtensions_Eip2612SignedWhenAllowanceZero(t *te } // TestCreatePaymentPayloadWithExtensions_Eip2612TakesPriorityOverErc20 pins -// the TS priority: when both extensions are advertised, EIP-2612 is tried -// first; if it succeeds, the ERC-20 approval branch is NOT exercised. +// priority: when both extensions are advertised, EIP-2612 is tried first; if it +// succeeds, the ERC-20 approval branch is NOT exercised. func TestCreatePaymentPayloadWithExtensions_Eip2612TakesPriorityOverErc20(t *testing.T) { signer := &extReadSigner{ mockSigner: &mockSigner{address: extTestSigner, sig: make([]byte, 65)}, @@ -240,10 +234,10 @@ func TestCreatePaymentPayloadWithExtensions_Eip2612TakesPriorityOverErc20(t *tes } // TestCreatePaymentPayloadWithExtensions_Eip2612SkippedWithoutNameVersion -// matches TS: without name/version on requirements.Extra, the token's -// EIP-712 domain is unknown and the client silently skips signing instead of -// erroring. The downstream request will then 402 with permit2_allowance_required -// (the standard Permit2 path's diagnosis). +// confirms that without name/version on requirements.Extra, the token's EIP-712 +// domain is unknown and the client silently skips signing instead of erroring. +// The downstream request will then 402 with permit2_allowance_required (the +// standard Permit2 path's diagnosis). func TestCreatePaymentPayloadWithExtensions_Eip2612SkippedWithoutNameVersion(t *testing.T) { signer := &extReadSigner{ mockSigner: &mockSigner{address: extTestSigner, sig: []byte{0xab}}, diff --git a/go/mechanisms/evm/batch-settlement/client/file_storage.go b/go/mechanisms/evm/batch-settlement/client/file_storage.go index b927b61c69..64e9bfe2b6 100644 --- a/go/mechanisms/evm/batch-settlement/client/file_storage.go +++ b/go/mechanisms/evm/batch-settlement/client/file_storage.go @@ -5,7 +5,7 @@ import ( "path/filepath" "strings" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) // FileClientChannelStorage persists each channel's client context as diff --git a/go/mechanisms/evm/batch-settlement/client/file_storage_test.go b/go/mechanisms/evm/batch-settlement/client/file_storage_test.go index 5d68eba840..3c4b575164 100644 --- a/go/mechanisms/evm/batch-settlement/client/file_storage_test.go +++ b/go/mechanisms/evm/batch-settlement/client/file_storage_test.go @@ -6,7 +6,7 @@ import ( "reflect" "testing" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) func newFileStore(t *testing.T) (*FileClientChannelStorage, string) { diff --git a/go/mechanisms/evm/batch-settlement/client/permit2.go b/go/mechanisms/evm/batch-settlement/client/permit2.go index 3ff23e1bd8..a4e7e4c497 100644 --- a/go/mechanisms/evm/batch-settlement/client/permit2.go +++ b/go/mechanisms/evm/batch-settlement/client/permit2.go @@ -7,7 +7,7 @@ import ( "time" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -16,8 +16,6 @@ import ( // `PermitWitnessTransferFrom` authorization. The witness binds the transfer to // the derived channelId so the Permit2DepositCollector can verify which // channel the funds belong to. -// -// Mirrors TS `createBatchSettlementPermit2DepositPayload`. func CreateBatchedPermit2DepositPayload( ctx context.Context, signer evm.ClientEvmSigner, diff --git a/go/mechanisms/evm/batch-settlement/client/refund.go b/go/mechanisms/evm/batch-settlement/client/refund.go index 6b2d56aa31..fc705ef8ae 100644 --- a/go/mechanisms/evm/batch-settlement/client/refund.go +++ b/go/mechanisms/evm/batch-settlement/client/refund.go @@ -13,7 +13,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -256,9 +256,9 @@ func executeRefund( return nil, fmt.Errorf("refund: decode PAYMENT-RESPONSE: %w", err) } - // Mirror TS: the caller knows it just initiated a refund, so reconcile - // directly via UpdateSessionAfterRefund (deletes on full drain). The - // channelId is read from the canonical nested `channelState` shape. + // The caller knows it just initiated a refund, so reconcile directly via + // UpdateSessionAfterRefund (deletes on full drain). The channelId is read + // from the nested `channelState` shape. if settle != nil && settle.Extra != nil { if cs, ok := settle.Extra["channelState"].(map[string]interface{}); ok { if channelId, ok := cs["channelId"].(string); ok && channelId != "" { diff --git a/go/mechanisms/evm/batch-settlement/client/refund_test.go b/go/mechanisms/evm/batch-settlement/client/refund_test.go index c454475757..fb8ec1cdb3 100644 --- a/go/mechanisms/evm/batch-settlement/client/refund_test.go +++ b/go/mechanisms/evm/batch-settlement/client/refund_test.go @@ -12,7 +12,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) diff --git a/go/mechanisms/evm/batch-settlement/client/scheme.go b/go/mechanisms/evm/batch-settlement/client/scheme.go index ad41d6053b..08159d5da5 100644 --- a/go/mechanisms/evm/batch-settlement/client/scheme.go +++ b/go/mechanisms/evm/batch-settlement/client/scheme.go @@ -11,13 +11,13 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) const ( // DefaultDepositMultiplier is the default multiplier for the initial deposit. - // Matches the TS SDK default of 5× the per-request amount. + // It is applied to the per-request amount. DefaultDepositMultiplier = 5 // DefaultWithdrawDelay is the default withdraw delay in seconds (15 min). DefaultWithdrawDelay = 900 @@ -26,8 +26,7 @@ const ( ) // DepositStrategyContext is supplied to a DepositStrategy callback before the -// client signs a deposit authorization. Mirrors TS -// `BatchSettlementDepositStrategyContext`. +// client signs a deposit authorization. type DepositStrategyContext struct { PaymentRequirements types.PaymentRequirements ChannelConfig batchsettlement.ChannelConfig @@ -47,21 +46,18 @@ type DepositStrategyContext struct { // verify time; the caller is opting out of auto-top-up. // - Amount overrides the computed deposit. Must be a positive integer string // (base units) and at least MinimumDepositAmount, or the call errors. -// - Both empty/zero means "use the SDK-computed amount" (equivalent to TS -// returning `undefined`). +// - Both empty/zero means "use the SDK-computed amount". type DepositStrategyResult struct { Skip bool Amount string } // DepositStrategy is an optional caller hook for per-request deposit sizing. -// Mirrors the TS `BatchSettlementDepositStrategy` callback. type DepositStrategy func(ctx context.Context, c DepositStrategyContext) (DepositStrategyResult, error) // BatchSettlementEvmSchemeOptions configures the batched client scheme. // -// `MaxDeposit` and `AutoTopUp` were removed in upstream TS parity (commit -// 2d190b80f). Use `DepositStrategy` for app-specific sizing or skipping. +// Use `DepositStrategy` for app-specific sizing or skipping. type BatchSettlementEvmSchemeOptions struct { // DepositMultiplier is the multiplier applied to the required amount for deposits. // E.g., 5 means deposit 5× the per-request amount. Defaults to 5. @@ -129,10 +125,8 @@ func (c *BatchSettlementEvmScheme) Scheme() string { // CreatePaymentPayload creates a batched payment payload. // -// Mirrors the TS scheme's flow: load local session, fall back to onchain -// recovery when storage is empty (so a cold client picks up the channel's -// existing chargedCumulativeAmount/balance), then choose deposit vs voucher -// from the resulting context. +// The client loads local session state, falls back to onchain recovery when +// storage is empty, then chooses deposit vs voucher from the resulting context. func (c *BatchSettlementEvmScheme) CreatePaymentPayload( ctx context.Context, requirements types.PaymentRequirements, @@ -228,8 +222,7 @@ type resolveDepositAmountResult struct { } // resolveDepositAmount applies the optional DepositStrategy callback to the -// SDK-computed deposit amount. Mirrors TS `resolveDepositAmount` / -// `normalizeStrategyDepositAmount`. +// computed deposit amount. func (c *BatchSettlementEvmScheme) resolveDepositAmount( ctx context.Context, strategyCtx DepositStrategyContext, @@ -264,8 +257,7 @@ func (c *BatchSettlementEvmScheme) resolveDepositAmount( // // Returns an error when `requirements.Extra["receiverAuthorizer"]` is missing // or zero — without it the derived channelId would not match the onchain -// channel and the deposit transaction would revert. Mirrors TS -// `buildChannelConfig`, which throws under the same condition. +// channel and the deposit transaction would revert. func (c *BatchSettlementEvmScheme) BuildChannelConfig(requirements types.PaymentRequirements) (batchsettlement.ChannelConfig, error) { var receiverAuthorizer string if requirements.Extra != nil { @@ -287,8 +279,8 @@ func (c *BatchSettlementEvmScheme) BuildChannelConfig(requirements types.Payment } } - // Resolution order mirrors TS `buildChannelConfig`: - // explicit `PayerAuthorizer` → `VoucherSigner.Address()` → `signer.Address()`. + // Authorizer resolution order: + // explicit `PayerAuthorizer` -> `VoucherSigner.Address()` -> `signer.Address()`. // Falling straight through to the signer when a voucher signer is configured // would commit the wrong authorizer into the channel and the facilitator // would later reject vouchers signed by the voucher key. @@ -320,8 +312,7 @@ func (c *BatchSettlementEvmScheme) Refund(ctx context.Context, url string, optio } // OnPaymentResponse implements x402.PaymentResponseHandler so the transport can -// auto-sync local session state after every paid response, matching the TS -// schemeHooks.onPaymentResponse contract. +// auto-sync local session state after every paid response. // // On a successful settle (HTTP 200 + PAYMENT-RESPONSE), folds the server-tracked // channel snapshot back into the local session so the next request signs a @@ -359,7 +350,7 @@ func (c *BatchSettlementEvmScheme) OnPaymentResponse( } // ProcessSettleResponse updates local session state from a settle response. -// Mirrors TS processSettleResponse: merges present fields into existing session. +// It merges present fields into the existing session. // Refund-specific reconciliation is handled at the refund call site via // UpdateSessionAfterRefund. func (c *BatchSettlementEvmScheme) ProcessSettleResponse(settle map[string]interface{}) error { @@ -509,7 +500,7 @@ func (c *BatchSettlementEvmScheme) ProcessCorrectivePaymentRequired( } // readChannelStateFromExtra extracts the corrective-402 recovery fields from -// accept.Extra. Reads the canonical TS shape: extra.channelState.chargedCumulativeAmount +// accept.Extra: extra.channelState.chargedCumulativeAmount // + extra.voucherState.{signedMaxClaimable,signature}. func readChannelStateFromExtra(ex map[string]interface{}) (charged, signed, sig string, ok bool) { if ex == nil { @@ -526,7 +517,7 @@ func readChannelStateFromExtra(ex map[string]interface{}) (charged, signed, sig c, hasC := cs["chargedCumulativeAmount"] s, hasS := vs["signedMaxClaimable"] g, hasG := vs["signature"] - if !(hasC && hasS && hasG) { + if !hasC || !hasS || !hasG { return "", "", "", false } return fmt.Sprintf("%v", c), fmt.Sprintf("%v", s), fmt.Sprintf("%v", g), true @@ -537,8 +528,7 @@ func readChannelStateFromExtra(ex map[string]interface{}) (charged, signed, sig // client's own signing key before accepting. // // Errors from individual recovery steps are intentionally swallowed (returning -// false) to match the TypeScript SDK behavior where catch blocks silently return -// false, allowing the caller to fall back to alternative recovery or retry. +// false), allowing the caller to fall back to alternative recovery or retry. func (c *BatchSettlementEvmScheme) recoverFromSignature( ctx context.Context, accept types.PaymentRequirements, @@ -548,7 +538,7 @@ func (c *BatchSettlementEvmScheme) recoverFromSignature( ) (bool, error) { charged, ok := new(big.Int).SetString(chargedStr, 10) if !ok { - return false, nil //nolint:nilerr // parse failure = unrecoverable, matches TS try/catch + return false, nil //nolint:nilerr // parse failure = unrecoverable } signed, ok := new(big.Int).SetString(signedStr, 10) if !ok { @@ -565,11 +555,11 @@ func (c *BatchSettlementEvmScheme) recoverFromSignature( config, err := c.BuildChannelConfig(accept) if err != nil { - return false, nil //nolint:nilerr // matches TS catch-all + return false, nil //nolint:nilerr } channelId, err := batchsettlement.ComputeChannelId(config, accept.Network) if err != nil { - return false, nil //nolint:nilerr // matches TS catch-all + return false, nil //nolint:nilerr } channelId = batchsettlement.NormalizeChannelId(channelId) @@ -583,7 +573,7 @@ func (c *BatchSettlementEvmScheme) recoverFromSignature( channelIdBytes, ) if err != nil { - return false, nil //nolint:nilerr // matches TS catch + return false, nil //nolint:nilerr } var chBalance, chTotalClaimed *big.Int @@ -603,7 +593,7 @@ func (c *BatchSettlementEvmScheme) recoverFromSignature( // Verify the signature was produced by our key chainId, err := evm.GetEvmChainId(string(accept.Network)) if err != nil { - return false, nil //nolint:nilerr // matches TS catch + return false, nil //nolint:nilerr } sigBytes, err := evm.HexToBytes(sig) @@ -676,7 +666,7 @@ func (c *BatchSettlementEvmScheme) recoverFromOnChainState( ) (bool, error) { _, err := c.RecoverSession(ctx, accept) if err != nil { - return false, nil //nolint:nilerr // matches TS catch returning false + return false, nil //nolint:nilerr // recovery failures are non-fatal } return true, nil } @@ -714,8 +704,7 @@ func (c *BatchSettlementEvmScheme) createVoucherPayload( // createDepositPayload dispatches the deposit transfer mechanism on // `requirements.Extra["assetTransferMethod"]`, falling back to EIP-3009 when -// the field is omitted or set to the default value. Mirrors the TS dispatch in -// `BatchSettlementEvmScheme.createPaymentPayload`. +// the field is omitted or set to the default value. func (c *BatchSettlementEvmScheme) createDepositPayload( ctx context.Context, channelConfig batchsettlement.ChannelConfig, @@ -775,9 +764,8 @@ func (a *refundContextAdapter) ProcessCorrectivePaymentRequired(ctx context.Cont return a.scheme.ProcessCorrectivePaymentRequired(ctx, errorReason, accepts) } -// calculateDepositAmount returns `requiredAmount * DepositMultiplier`. Mirrors -// TS `depositAmountForRequest`. Callers wanting a cap should use a -// DepositStrategy callback. +// calculateDepositAmount returns `requiredAmount * DepositMultiplier`. Callers +// wanting a cap should use a DepositStrategy callback. func (c *BatchSettlementEvmScheme) calculateDepositAmount(requiredAmount *big.Int) *big.Int { multiplier := big.NewInt(int64(c.config.DepositMultiplier)) return new(big.Int).Mul(requiredAmount, multiplier) diff --git a/go/mechanisms/evm/batch-settlement/client/scheme_test.go b/go/mechanisms/evm/batch-settlement/client/scheme_test.go index e47a91a61f..abdbc07f34 100644 --- a/go/mechanisms/evm/batch-settlement/client/scheme_test.go +++ b/go/mechanisms/evm/batch-settlement/client/scheme_test.go @@ -8,7 +8,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -327,7 +327,7 @@ func TestProcessSettleResponse_StoresSession(t *testing.T) { } } -// ProcessSettleResponse is a pure-merge updater (mirrors TS processSettleResponse). +// ProcessSettleResponse is a pure-merge updater. // It does NOT delete sessions on zero balance — that responsibility belongs to // UpdateSessionAfterRefund, called explicitly at the refund call site. func TestProcessSettleResponse_DoesNotDeleteOnZeroBalance(t *testing.T) { @@ -413,7 +413,7 @@ func TestCreatePaymentPayload_TopsUpOnInsufficient(t *testing.T) { // DepositStrategy returning Skip=true causes an insufficient-balance request to // fall through as a voucher (the request will then fail at verify; the caller -// is opting out of auto top-up). Mirrors TS depositStrategy returning false. +// is opting out of auto top-up). func TestCreatePaymentPayload_DepositStrategySkipYieldsVoucher(t *testing.T) { storage := NewInMemoryClientChannelStorage() signer := &mockSigner{address: "0x1111111111111111111111111111111111111111", sig: []byte{0xad}} @@ -487,9 +487,8 @@ func TestRecoverSession_ReadError(t *testing.T) { // ---------- ProcessCorrectivePaymentRequired ---------- -// readChannelStateFromExtra accepts the canonical TS shape -// (extra.channelState + extra.voucherState, camelCase split) emitted by both -// the Go and TS servers. +// readChannelStateFromExtra accepts the corrective split shape: +// extra.channelState + extra.voucherState. func TestReadChannelStateFromExtra_CanonicalSplitShape(t *testing.T) { extra := map[string]interface{}{ "channelState": map[string]interface{}{ @@ -582,7 +581,7 @@ func TestProcessCorrective_RecoverFromSignatureBadCharged(t *testing.T) { } scheme := NewBatchSettlementEvmScheme(signer, nil) req := defaultRequirements() - // Canonical TS shape with bad charged amount. + // Corrective split shape with bad charged amount. req.Extra["channelState"] = map[string]interface{}{ "chargedCumulativeAmount": "not-a-number", } @@ -607,7 +606,7 @@ func TestProcessCorrective_RecoverFromSignatureChargedBeyondSigned(t *testing.T) } scheme := NewBatchSettlementEvmScheme(signer, nil) req := defaultRequirements() - // Canonical TS shape with charged > signed. + // Corrective split shape with charged > signed. req.Extra["channelState"] = map[string]interface{}{ "chargedCumulativeAmount": "200", } diff --git a/go/mechanisms/evm/batch-settlement/client/voucher.go b/go/mechanisms/evm/batch-settlement/client/voucher.go index 36815640f3..6ae8330969 100644 --- a/go/mechanisms/evm/batch-settlement/client/voucher.go +++ b/go/mechanisms/evm/batch-settlement/client/voucher.go @@ -6,7 +6,7 @@ import ( "math/big" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) // SignVoucher signs a cumulative voucher using EIP-712. diff --git a/go/mechanisms/evm/batch-settlement/constants.go b/go/mechanisms/evm/batch-settlement/constants.go index 5ccc6e37e6..5b1614bb49 100644 --- a/go/mechanisms/evm/batch-settlement/constants.go +++ b/go/mechanisms/evm/batch-settlement/constants.go @@ -108,9 +108,6 @@ const Permit2DomainName = "Permit2" // the permit2 transfer method. The witness binds the transfer to a specific // batch-settlement channel id so the Permit2DepositCollector can route funds // on the receiver's behalf. -// -// Mirrors TS `batchPermit2WitnessTypes` in -// `typescript/.../batch-settlement/constants.ts`. var BatchPermit2WitnessTypes = map[string][]evm.TypedDataField{ "PermitWitnessTransferFrom": { {Name: "permitted", Type: "TokenPermissions"}, @@ -130,7 +127,7 @@ var BatchPermit2WitnessTypes = map[string][]evm.TypedDataField{ // DepositWitnessTypeString is the canonical witness type-string fragment that // must be appended (without the leading `,`) when computing the Permit2 -// witness type hash. Mirrors TS `DEPOSIT_WITNESS_TYPE_STRING`. +// witness type hash. const DepositWitnessTypeString = "DepositWitness witness)DepositWitness(bytes32 channelId)TokenPermissions(address token,uint256 amount)" // ============================================================================ diff --git a/go/mechanisms/evm/batch-settlement/encoding.go b/go/mechanisms/evm/batch-settlement/encoding.go index 4e24039513..79132b028b 100644 --- a/go/mechanisms/evm/batch-settlement/encoding.go +++ b/go/mechanisms/evm/batch-settlement/encoding.go @@ -117,7 +117,6 @@ type Eip2612PermitInput struct { // BuildEip2612PermitData ABI-encodes (value, deadline, v, r, s) for the // optional EIP-2612 permit segment consumed by Permit2DepositCollector. -// Mirrors TS `buildEip2612PermitData`. func BuildEip2612PermitData(input Eip2612PermitInput) ([]byte, error) { value, ok := new(big.Int).SetString(input.Value, 10) if !ok { @@ -147,7 +146,7 @@ func BuildEip2612PermitData(input Eip2612PermitInput) ([]byte, error) { // eip2612PermitData) as the collectorData passed to deposit(..., collector, // collectorData) when using the Permit2 transfer method. Pass an empty // `eip2612PermitData` ([]byte{} or `0x`) when no EIP-2612 permit accompanies -// the Permit2 authorization. Mirrors TS `buildPermit2CollectorData`. +// the Permit2 authorization. func BuildPermit2CollectorData(nonce, deadline, permit2Signature string, eip2612PermitData []byte) ([]byte, error) { n, ok := new(big.Int).SetString(nonce, 10) if !ok { diff --git a/go/mechanisms/evm/batch-settlement/errors.go b/go/mechanisms/evm/batch-settlement/errors.go index 17e2cdeb4b..03166492c7 100644 --- a/go/mechanisms/evm/batch-settlement/errors.go +++ b/go/mechanisms/evm/batch-settlement/errors.go @@ -1,8 +1,7 @@ // Package batched holds shared batch-settlement error constants used across // client / facilitator / server. All reasons share the // `invalid_batch_settlement_evm_*` prefix and describe mechanism-level -// failures only — no policy/business semantics. Mirrors TS -// `typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts`. +// failures only — no policy/business semantics. // // A small subset is duplicated here (and authoritatively defined in // `go/mechanisms/evm/batch-settlement/facilitator/errors.go`) so non-facilitator diff --git a/go/mechanisms/evm/batch-settlement/facilitator/claim.go b/go/mechanisms/evm/batch-settlement/facilitator/claim.go index 78dba4787e..75ebf017fe 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/claim.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/claim.go @@ -8,7 +8,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) diff --git a/go/mechanisms/evm/batch-settlement/facilitator/deposit.go b/go/mechanisms/evm/batch-settlement/facilitator/deposit.go index fbd660c5b1..0a87b2f10f 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/deposit.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/deposit.go @@ -12,14 +12,14 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/extensions/erc20approvalgassponsor" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) // resolveDepositTransferMethod inspects the payload + requirements to pick the // deposit transport. Defaults to ERC-3009 to preserve historical behavior; // callers opt into Permit2 by setting `accepts.extra.assetTransferMethod` -// (matches the TS facilitator's `resolveDepositTransferMethod`). +// or by sending a Permit2 authorization. func resolveDepositTransferMethod( payload *batchsettlement.BatchSettlementDepositPayload, requirements types.PaymentRequirements, @@ -43,9 +43,8 @@ func resolveDepositTransferMethod( // // `extensions` is the top-level `payment.extensions` envelope and `fctx` is the // facilitator's registered extension context. Together they enable the EIP-2612 -// and ERC-20 approval gas-sponsoring branches for Permit2 deposits (mirrors TS -// `resolvePermit2DepositBranch`). Both may be nil for the standard Permit2 -// path or for ERC-3009 deposits. +// and ERC-20 approval gas-sponsoring branches for Permit2 deposits. Both may +// be nil for the standard Permit2 path or for ERC-3009 deposits. func VerifyDeposit( ctx context.Context, signer evm.FacilitatorEvmSigner, @@ -198,9 +197,7 @@ func VerifyDeposit( // ERC-20 approval branch: the user has not yet approved Permit2, so the // standalone deposit() simulation would always revert with insufficient // allowance. The execution path is multi-tx (approve+deposit handled by the - // extension signer in `SettleDeposit`); skip the eth_call here. TS does the - // same in `verifyDepositPermit2WithExtensions` when no - // `simulateTransactions` capability is present. + // extension signer in `SettleDeposit`); skip the eth_call here. skipSimulation := permit2Branch != nil && permit2Branch.kind == permit2BranchErc20Approval if !skipSimulation { _, simErr := signer.ReadContract( @@ -384,7 +381,7 @@ func SettleDeposit( // Optimistic post-deposit extra (fallback if RPC hasn't caught up to // the just-confirmed tx). The settle response intentionally omits // `chargedCumulativeAmount` — that field is added by the resource - // server's `enrichSettlementResponse` hook (matching TS), and emitting + // server's `enrichSettlementResponse` hook, and emitting // it from the facilitator violates the additive-enrichment policy. priorState, _ := ReadChannelState(ctx, signer, payload.Voucher.ChannelId) priorBalance := big.NewInt(0) @@ -447,7 +444,7 @@ func SettleDeposit( // gas-sponsorship execution path (standard / EIP-2612 / ERC-20 approval) and // its pre-encoded `collectorData` (with EIP-2612 permit bytes appended where // applicable). When `branch` is nil for Permit2 (legacy callers), the standard -// path is used. Mirrors the dispatch in TS `verifyDeposit` / `settleDeposit`. +// path is used. func buildDepositCollectorCall( payload *batchsettlement.BatchSettlementDepositPayload, method batchsettlement.AssetTransferMethod, @@ -486,16 +483,13 @@ func buildDepositCollectorCall( } // verifyErc3009DepositAuthorization validates the time window and EIP-712 -// `ReceiveWithAuthorization` signature for an ERC-3009 deposit. Mirrors TS -// `verifyEip3009DepositAuthorization` in -// typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts. +// `ReceiveWithAuthorization` signature for an ERC-3009 deposit. // // The token's EIP-712 domain (`name` / `version`) is consumed from -// `extra.name` / `extra.version` exactly as TS does. Resource servers populate -// these from cached asset metadata when constructing payment requirements -// (see `BatchSettlementEvmScheme.GetExtra` in the server package); a missing or -// blank field is reported as `ErrMissingEip712Domain` so callers see the -// same machine-readable cause TS would emit. +// `extra.name` / `extra.version`. Resource servers populate these from cached +// asset metadata when constructing payment requirements (see +// `BatchSettlementEvmScheme.GetExtra` in the server package); a missing or +// blank field is reported as `ErrMissingEip712Domain`. // // Returns ("invalidReason", nil) when the authorization is well-formed but // invalid, ("", err) when an RPC/parse error blocked verification entirely, @@ -525,8 +519,7 @@ func verifyErc3009DepositAuthorization( // Token EIP-712 domain — required to recompute the // `ReceiveWithAuthorization` digest. Read from `requirements.extra` // (populated by the resource server's GetExtra hook); missing fields are - // reported as a structured ErrMissingEip712Domain rejection so cross-SDK - // clients see the same reason TS would emit. + // reported as a structured ErrMissingEip712Domain rejection. tokenName, _ := extra["name"].(string) tokenVersion, _ := extra["version"].(string) if tokenName == "" || tokenVersion == "" { @@ -589,9 +582,8 @@ func verifyErc3009DepositAuthorization( // - the EIP-712 signature recovers to channelConfig.payer // // Returns ("invalidReason", nil) on a well-formed but rejected authorization. -// Mirrors TS Permit2 reason codes: token mismatch, spender mismatch, deadline -// expired, amount mismatch, signature invalid each map to a dedicated error -// string so cross-SDK clients see the same machine-readable failure cause. +// Token mismatch, spender mismatch, deadline expiry, amount mismatch, and +// signature failure each map to a dedicated machine-readable error string. func verifyPermit2DepositAuthorization( ctx context.Context, signer evm.FacilitatorEvmSigner, diff --git a/go/mechanisms/evm/batch-settlement/facilitator/deposit_eip3009_test.go b/go/mechanisms/evm/batch-settlement/facilitator/deposit_eip3009_test.go index c70bc14f93..f5f40ddd2d 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/deposit_eip3009_test.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/deposit_eip3009_test.go @@ -11,7 +11,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) const ( @@ -43,8 +43,7 @@ func goodErc3009Config() batchsettlement.ChannelConfig { } // goodErc3009Extra returns the resource-server-populated `requirements.extra` -// shape the ERC-3009 verifier consumes (see TS server scheme.ts populating -// `name` / `version` from cached asset metadata). +// shape the ERC-3009 verifier consumes. func goodErc3009Extra() map[string]interface{} { return map[string]interface{}{ "name": "USD Coin", @@ -139,12 +138,11 @@ func TestVerifyErc3009_ValidAfterInFuture(t *testing.T) { } } -// TestVerifyErc3009_MissingExtraName pins the structured-rejection branch -// when the resource server forgot to populate `requirements.extra.name`. The -// ERC-3009 deposit collector needs the token's EIP-712 domain to recompute -// the digest, so the facilitator must surface ErrMissingEip712Domain (mirrors -// TS Errors.ErrMissingEip712Domain in deposit-eip3009.ts) instead of a -// generic invalid-payload reason. +// TestVerifyErc3009_MissingExtraName pins the structured-rejection branch when +// the resource server forgot to populate `requirements.extra.name`. The ERC-3009 +// deposit collector needs the token's EIP-712 domain to recompute the digest, so +// the facilitator must surface ErrMissingEip712Domain instead of a generic +// invalid-payload reason. func TestVerifyErc3009_MissingExtraName(t *testing.T) { extra := goodErc3009Extra() delete(extra, "name") @@ -163,8 +161,7 @@ func TestVerifyErc3009_MissingExtraName(t *testing.T) { // TestVerifyErc3009_MissingExtraVersion mirrors the missing-name case for the // version field — both are required and the facilitator should not assume a -// silent default like "1" (TS doesn't, and the assumption would mask -// resource-server misconfiguration cross-SDK). +// silent default like "1", which would mask resource-server misconfiguration. func TestVerifyErc3009_MissingExtraVersion(t *testing.T) { extra := goodErc3009Extra() delete(extra, "version") diff --git a/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2.go b/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2.go index a3b8c4ed18..8d7a53167b 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2.go @@ -10,17 +10,17 @@ import ( "github.com/x402-foundation/x402/go/extensions/eip2612gassponsor" "github.com/x402-foundation/x402/go/extensions/erc20approvalgassponsor" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) // permit2DepositBranchKind enumerates the three Permit2 deposit settlement -// strategies, mirroring TS `resolvePermit2DepositBranch`. +// strategies. type permit2DepositBranchKind string const ( - permit2BranchStandard permit2DepositBranchKind = "standard" - permit2BranchEip2612 permit2DepositBranchKind = "eip2612" - permit2BranchErc20Approval permit2DepositBranchKind = "erc20Approval" + permit2BranchStandard permit2DepositBranchKind = "standard" + permit2BranchEip2612 permit2DepositBranchKind = "eip2612" + permit2BranchErc20Approval permit2DepositBranchKind = "erc20Approval" ) // permit2DepositBranch captures the resolved gas-sponsorship branch for a @@ -46,9 +46,7 @@ type permit2DepositBranch struct { } // resolvePermit2DepositBranch parses the payment payload's `extensions` -// envelope and decides which gas-sponsorship branch to take. Mirrors -// TS `resolvePermit2DepositBranch` in -// `typescript/.../batch-settlement/facilitator/deposit-permit2.ts`. +// envelope and decides which gas-sponsorship branch to take. // // On a well-formed but rejected extension (e.g. payer/asset/amount mismatch) // returns ("invalidReason", nil); on a successful branch resolution returns @@ -65,15 +63,13 @@ func resolvePermit2DepositBranch( tokenAddress := evm.NormalizeAddress(requirements.Token) payer := requirements.Payer - // EIP-2612 takes priority over ERC-20 approval, matching TS - // `trySignEip2612PermitExtension` ordering on the client side and - // `resolvePermit2DepositBranch` on the facilitator side. + // EIP-2612 takes priority over ERC-20 approval because it keeps settlement + // to a single deposit() transaction. eip2612Info, _ := eip2612gassponsor.ExtractEip2612GasSponsoringInfo(extensions) if eip2612Info != nil { // Wrap the shared evm validator with the batch-specific rule that // `info.amount == deposit.amount`, then translate the shared reason - // strings into the batched error codes (mirrors TS - // `validateBatchEip2612Permit`). + // strings into the batched error codes. if sharedReason := evm.ValidateEip2612PermitForPayment(eip2612Info, payer, tokenAddress); sharedReason != "" { var batchedReason string switch sharedReason { @@ -95,8 +91,8 @@ func resolvePermit2DepositBranch( if eip2612Info.Amount != depositAmount { return nil, ErrEip2612AmountMismatch, nil } - v, r, s, splitErr := evm.SplitEip2612Signature(eip2612Info.Signature) - if splitErr != nil { + v, r, s, signatureOK := splitEip2612Signature(eip2612Info.Signature) + if !signatureOK { return nil, ErrEip2612InvalidSignature, nil } eip2612Bytes, encodeErr := batchsettlement.BuildEip2612PermitData(batchsettlement.Eip2612PermitInput{ @@ -182,6 +178,11 @@ type payerAssetView struct { Token string } +func splitEip2612Signature(signature string) (uint8, [32]byte, [32]byte, bool) { + v, r, s, err := evm.SplitEip2612Signature(signature) + return v, r, s, err == nil +} + // bytes32Hex converts a [32]byte to a 0x-prefixed hex string suitable for // `BuildEip2612PermitData`'s R/S inputs (which accept either prefixed or // unprefixed hex). Used twice (R and S) by `resolvePermit2DepositBranch`. diff --git a/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2_extensions_test.go b/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2_extensions_test.go index 900a36f865..c480fe8d0b 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2_extensions_test.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2_extensions_test.go @@ -2,7 +2,6 @@ package facilitator import ( "context" - "strings" "testing" x402 "github.com/x402-foundation/x402/go" @@ -301,11 +300,9 @@ func TestResolvePermit2DepositBranch_Erc20ApprovalAssetMismatch(t *testing.T) { } } -// TestResolvePermit2DepositBranch_Eip2612TakesPriorityOverErc20 pins the TS -// behaviour: when both extensions are advertised AND populated by the client, -// EIP-2612 wins because it executes atomically (single deposit() tx) versus -// ERC-20 approval (multi-tx). Mirrors the priority in -// `BatchSettlementEvmScheme.createPaymentPayload`. +// TestResolvePermit2DepositBranch_Eip2612TakesPriorityOverErc20 ensures that +// when both extensions are advertised and populated by the client, EIP-2612 +// wins because it executes atomically in a single deposit() transaction. func TestResolvePermit2DepositBranch_Eip2612TakesPriorityOverErc20(t *testing.T) { stub := &stubExtensionSigner{fakeFacilitatorSigner: &fakeFacilitatorSigner{}} ext := &erc20approvalgassponsor.Erc20ApprovalFacilitatorExtension{Signer: stub} @@ -314,7 +311,7 @@ func TestResolvePermit2DepositBranch_Eip2612TakesPriorityOverErc20(t *testing.T) }) exts := map[string]interface{}{ - eip2612gassponsor.EIP2612GasSponsoring.Key(): map[string]interface{}{"info": goodEip2612Info()}, + eip2612gassponsor.EIP2612GasSponsoring.Key(): map[string]interface{}{"info": goodEip2612Info()}, erc20approvalgassponsor.ERC20ApprovalGasSponsoring.Key(): map[string]interface{}{"info": goodErc20ApprovalInfo()}, } @@ -347,7 +344,3 @@ type stubExtensionSigner struct { func (s *stubExtensionSigner) SendTransactions(_ context.Context, _ []erc20approvalgassponsor.TransactionRequest) ([]string, error) { return nil, nil } - -// satisfy the linter / unused-import rule for `strings` in case future -// edits remove the only reference; left as a tiny helper. -var _ = strings.Repeat diff --git a/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2_test.go b/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2_test.go index 614c247c63..6e19db197f 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2_test.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/deposit_permit2_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) const ( diff --git a/go/mechanisms/evm/batch-settlement/facilitator/errors.go b/go/mechanisms/evm/batch-settlement/facilitator/errors.go index 76c12778dd..3933ce3de6 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/errors.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/errors.go @@ -7,7 +7,7 @@ // carry no policy/business semantics. package facilitator -import "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" +import batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" const ( // Payload parsing errors diff --git a/go/mechanisms/evm/batch-settlement/facilitator/errors_test.go b/go/mechanisms/evm/batch-settlement/facilitator/errors_test.go index 2eb002a60b..c11011bef3 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/errors_test.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/errors_test.go @@ -140,11 +140,11 @@ func TestExportedErrorReasonsAreStable(t *testing.T) { // after this revision does identity passthrough on these tokens, so any drift // here surfaces as an undefined-enum error at the API boundary. // -// `_invalid_*` suffixes coming from TS are preserved verbatim (the leading -// `invalid_` envelope replaces the old `batch_settlement_evm_*` prefix and -// does NOT swallow inner `_invalid_*` segments). Earlier revisions collapsed -// these — that produced abbreviated wire strings (e.g. -// `…permit2_spender`) that CDP had to denormalize, so we restored the full +// `_invalid_*` suffixes are preserved verbatim (the leading `invalid_` envelope +// replaces the old `batch_settlement_evm_*` prefix and does NOT swallow inner +// `_invalid_*` segments). Earlier revisions collapsed these and produced +// abbreviated wire strings (e.g. `...permit2_spender`) that CDP had to +// denormalize, so we restored the full // form here so the SDK can identity-map onto CDP's OpenAPI enum names. func TestRequiredCdpFacilitatorContract(t *testing.T) { required := map[string]string{ diff --git a/go/mechanisms/evm/batch-settlement/facilitator/exec_test.go b/go/mechanisms/evm/batch-settlement/facilitator/exec_test.go index fbbec8ce55..c4de956af2 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/exec_test.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/exec_test.go @@ -7,7 +7,7 @@ import ( "testing" x402 "github.com/x402-foundation/x402/go" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -449,12 +449,11 @@ func zeros(n int) string { // ----- buildRefundResponse ----- -// TestBuildRefundResponse pins the canonical TS-aligned shape: +// TestBuildRefundResponse pins the refund response shape: // - top-level: success, tx, network, payer, amount // - extra.channelState: channelId, balance, totalClaimed, withdrawRequestedAt, refundNonce -// - NO `refund: true` flag at any level (TS doesn't emit it; it was a Go-only -// legacy that confused the resource server's afterSettle hook into reading -// stale fields) +// - NO `refund: true` flag at any level; resource hooks use the payload type +// and channelState fields instead // - NO `chargedCumulativeAmount` (the resource server's // enrichSettlementResponse hook adds it via additive merge) func TestBuildRefundResponse(t *testing.T) { @@ -504,8 +503,7 @@ func TestBuildRefundResponse(t *testing.T) { // TestComputeRefundSettlementDetails_AnalyticPathNoClaims covers the common // case: no pending withdrawal, no claims accompanying the refund. The -// snapshot is computed from preState + payload alone (no post-state poll), -// matching TS `buildRefundExtra(payload, channelId, preState)`. +// snapshot is computed from preState + payload alone, without a post-state poll. func TestComputeRefundSettlementDetails_AnalyticPathNoClaims(t *testing.T) { preState := &batchsettlement.ChannelState{ Balance: big.NewInt(5000), @@ -538,9 +536,8 @@ func TestComputeRefundSettlementDetails_AnalyticPathNoClaims(t *testing.T) { } } -// TestComputeRefundSettlementDetails_CapsAtAvailable mirrors TS: -// when the requested refund exceeds preBalance - postClaimTotalClaimed, -// actualRefund must be capped at the available remainder. +// TestComputeRefundSettlementDetails_CapsAtAvailable ensures a requested refund +// above preBalance - postClaimTotalClaimed is capped at the available remainder. func TestComputeRefundSettlementDetails_CapsAtAvailable(t *testing.T) { preState := &batchsettlement.ChannelState{ Balance: big.NewInt(5000), @@ -572,9 +569,9 @@ func TestComputeRefundSettlementDetails_CapsAtAvailable(t *testing.T) { } // TestComputeRefundSettlementDetails_NoPreState exercises the RPC-failure -// fallback (TS treats null preState the same way: zero pre-balance → -// available zero → actualRefund zero, postBalance zero, refundNonce -// becomes 1). Ensures the response never panics on a missing chain read. +// fallback: missing preState means zero available balance, zero actual refund, +// zero postBalance, and refundNonce advances to 1. Ensures the response never +// panics on a missing chain read. func TestComputeRefundSettlementDetails_NoPreState(t *testing.T) { payload := &batchsettlement.BatchSettlementEnrichedRefundPayload{ Type: "refund", @@ -619,4 +616,3 @@ func TestBuildVoucherClaimArgs_Length(t *testing.T) { t.Fatal("expected non-nil result") } } - diff --git a/go/mechanisms/evm/batch-settlement/facilitator/refund.go b/go/mechanisms/evm/batch-settlement/facilitator/refund.go index b151874ab8..795e6eb2dd 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/refund.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/refund.go @@ -11,16 +11,14 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) -// refundStatePollDeadline / refundStatePollInterval mirror TS -// REFUND_STATE_POLL_MS / REFUND_STATE_POLL_INTERVAL_MS in -// `batch-settlement/facilitator/refund.ts`. The post-refund state is only -// polled when the channel was in pending-withdrawal at refund time, since -// withdraw cancellation makes a simple `preBalance - actualRefund` formula -// inaccurate; otherwise the formula is exact and a re-read is unnecessary. +// The post-refund state is only polled when the channel was in +// pending-withdrawal at refund time, since withdraw cancellation makes a simple +// `preBalance - actualRefund` formula inaccurate; otherwise the formula is +// exact and a re-read is unnecessary. const ( refundStatePollDeadline = 2 * time.Second refundStatePollInterval = 150 * time.Millisecond @@ -90,8 +88,8 @@ func ExecuteRefundWithSignature( // Read pre-refund onchain state. Errors are non-fatal — without a // pre-state we still execute the refund and synthesize an extra from - // the payload alone (matches TS `buildRefundExtra(..., null)`), which - // the resource server's afterSettle hook can still parse. + // the payload alone, which the resource server's afterSettle hook can + // still parse. preState, _ := ReadChannelState(ctx, signer, channelId) // Handle claims + refund atomically if claims are present @@ -237,12 +235,11 @@ func ExecuteRefundWithSignature( // refundSettlementDetails captures the per-refund response fields the // facilitator computes from pre/post onchain state and the enriched payload. -// Mirrors the TS `RefundSettlementDetails` shape (refund.ts ~27-30). type refundSettlementDetails struct { // amount is the actual refund amount in token base units (decimal string). // May differ from `payload.amount` when the requested amount exceeds the // channel's available balance after preceding claims; in that case - // available is used (capped). Mirrors TS `actualRefund`. + // available is used. amount string // channelState is the post-refund snapshot. balance reflects // `preBalance - actualRefund`; totalClaimed reflects the last claim's @@ -255,9 +252,8 @@ type refundSettlementDetails struct { // computeRefundSettlementDetails builds the response fields after a successful // refund onchain. When the pre-state shows an active pending withdrawal, the // facilitator polls for confirmation that the refund nonce advanced before -// computing the snapshot from chain (mirrors TS `readPostRefundState`); in -// the common case the snapshot is computed analytically from preState + -// payload, matching TS `buildRefundExtra` / `buildRefundExtraFromPostState`. +// computing the snapshot from chain; in the common case the snapshot is +// computed analytically from preState + payload. func computeRefundSettlementDetails( ctx context.Context, signer evm.FacilitatorEvmSigner, @@ -266,8 +262,8 @@ func computeRefundSettlementDetails( preState *batchsettlement.ChannelState, requestedAmount *big.Int, ) refundSettlementDetails { - // Default zero values when preState is unavailable. TS treats null - // preState the same way: skip pre-balance-based capping. + // Default zero values when preState is unavailable; skip pre-balance-based + // capping in that case. preBalance := big.NewInt(0) preTotalClaimed := big.NewInt(0) preRefundNonce := big.NewInt(0) @@ -287,10 +283,9 @@ func computeRefundSettlementDetails( // If the channel was in pending withdrawal, polling the post-state is // the only way to know the final balance because `refundWithSignature` - // also cancels the withdrawal in a single transaction. Mirrors TS - // `readPostRefundState` + `buildRefundExtraFromPostState`. On RPC lag - // (deadline elapsed without nonce advancement) we fall through to the - // analytic path below. + // also cancels the withdrawal in a single transaction. On RPC lag (deadline + // elapsed without nonce advancement) we fall through to the analytic path + // below. if preState != nil && preWithdrawRequestedAt != 0 { expectedNonce := new(big.Int).Add(preRefundNonce, big.NewInt(1)) var postState *batchsettlement.ChannelState @@ -360,11 +355,10 @@ func computeRefundSettlementDetails( } } -// buildRefundResponse assembles a SettleResponse for a refund mirroring TS -// `executeRefundWithSignature` return shape: success + tx + payer + amount + -// extra.channelState (no `refund: true` flag — TS does not emit it). The -// resource server's `enrichSettlementResponse` hook adds -// `chargedCumulativeAmount` on top via additive merge. +// buildRefundResponse assembles a SettleResponse for a refund: success + tx + +// payer + amount + extra.channelState. The resource server's +// `enrichSettlementResponse` hook adds `chargedCumulativeAmount` on top via +// additive merge. func buildRefundResponse( txHash string, network x402.Network, @@ -388,4 +382,3 @@ func buildRefundResponse( }, } } - diff --git a/go/mechanisms/evm/batch-settlement/facilitator/scheme.go b/go/mechanisms/evm/batch-settlement/facilitator/scheme.go index ee9ba7a7ba..4527c5cbe2 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/scheme.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/scheme.go @@ -6,7 +6,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -55,7 +55,7 @@ func (f *BatchSettlementEvmScheme) Verify( requirements types.PaymentRequirements, fctx *x402.FacilitatorContext, ) (*x402.VerifyResponse, error) { - // Defensive scheme and network validation (matches TS facilitator) + // Defensive scheme and network validation. if payload.Accepted.Scheme != batchsettlement.SchemeBatched || requirements.Scheme != batchsettlement.SchemeBatched { return &x402.VerifyResponse{IsValid: false, InvalidReason: ErrInvalidScheme}, nil } @@ -84,8 +84,8 @@ func (f *BatchSettlementEvmScheme) Verify( } // Cooperative refund: client sends a zero-charge voucher with type="refund". - // Mirrors TS facilitator scheme.ts which routes refund and voucher payloads - // through the same verifyVoucher path with a refund-aware cumulative check. + // Refund and voucher payloads share the same voucher-verification path with + // a refund-aware cumulative check. if batchsettlement.IsRefundPayload(data) { refundPayload, err := batchsettlement.RefundPayloadFromMap(data) if err != nil { diff --git a/go/mechanisms/evm/batch-settlement/facilitator/scheme_test.go b/go/mechanisms/evm/batch-settlement/facilitator/scheme_test.go index b7677c8e3c..477ab41af4 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/scheme_test.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/scheme_test.go @@ -8,7 +8,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -235,11 +235,8 @@ func TestSettle_MalformedClaimPayload(t *testing.T) { } } -// TestVerify_MalformedRefundPayload is a regression test for the bug where the -// Go facilitator's Verify dispatch only handled deposit and voucher payloads, -// causing cooperative-refund flows from cross-SDK clients (TS axios) to fail -// with `invalid_batch_settlement_evm_payload_type`. Refund payloads must now -// route to VerifyRefundVoucher; a malformed refund must surface as +// TestVerify_MalformedRefundPayload ensures refund payloads route to +// VerifyRefundVoucher. A malformed refund must surface as // `invalid_batch_settlement_evm_refund_payload`, not the generic invalid type. func TestVerify_MalformedRefundPayload(t *testing.T) { s := newScheme() diff --git a/go/mechanisms/evm/batch-settlement/facilitator/settle.go b/go/mechanisms/evm/batch-settlement/facilitator/settle.go index 1139765897..0507025b58 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/settle.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/settle.go @@ -8,7 +8,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) diff --git a/go/mechanisms/evm/batch-settlement/facilitator/utils.go b/go/mechanisms/evm/batch-settlement/facilitator/utils.go index 6f1290f017..bcdb775ec7 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/utils.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/utils.go @@ -11,7 +11,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -32,7 +32,7 @@ type ContractChannelConfigTuple struct { // ToContractChannelConfig normalizes a ChannelConfig into the address-checksummed // Solidity tuple expected by the batch-settlement contract's deposit / refund / -// claim entry points. Mirrors TS toContractChannelConfig. +// claim entry points. func ToContractChannelConfig(config batchsettlement.ChannelConfig) ContractChannelConfigTuple { withdrawDelay := new(big.Int).SetInt64(int64(config.WithdrawDelay)) @@ -166,9 +166,8 @@ func ValidateChannelConfig( // only exist to round-trip to the client, so they don't need to be // decoded here. // - // receiverAuthorizer is mandatory. Mirrors TS `validateChannelConfig` - // which rejects when extra.receiverAuthorizer is missing, zero, or - // disagrees with the channel's bound authorizer. + // receiverAuthorizer is mandatory and must agree with the channel's bound + // authorizer. expectedAuthorizer, _ := requirements.Extra["receiverAuthorizer"].(string) if expectedAuthorizer == "" || strings.EqualFold(expectedAuthorizer, zeroAddress) || !strings.EqualFold(config.ReceiverAuthorizer, expectedAuthorizer) { @@ -258,11 +257,10 @@ func VerifyBatchedVoucherTypedData( // channelStateFields builds the shared field set used by both verify and // settle response extras: { channelId, balance, totalClaimed, -// withdrawRequestedAt, refundNonce }. Mirrors TS facilitator output verbatim; -// crucially does NOT include `chargedCumulativeAmount` — that field is the -// SERVER's responsibility to enrich (the resource server's -// `enrichSettlementResponse` hook adds it, and the additive enrichment policy -// rejects duplicates emitted by the facilitator). +// withdrawRequestedAt, refundNonce }. It does not include +// `chargedCumulativeAmount` because the resource server adds that field during +// settlement-response enrichment; the additive enrichment policy rejects +// duplicates emitted by the facilitator. func channelStateFields(channelId string, state *batchsettlement.ChannelState) map[string]interface{} { return map[string]interface{}{ "channelId": channelId, @@ -273,20 +271,18 @@ func channelStateFields(channelId string, state *batchsettlement.ChannelState) m } } -// BuildVerifyExtra creates the Extensions map for VERIFY responses in the -// canonical FLAT TS shape used by `verifyVoucher` / `verifyDeposit`: +// BuildVerifyExtra creates the Extensions map for VERIFY responses: // // { channelId, balance, totalClaimed, withdrawRequestedAt, refundNonce } // -// Server-side `AfterVerifyHook` (Go and TS) reads these fields directly off -// `extra` (e.g. `extra["balance"]`); wrapping them in `channelState` like the -// settle response would silently break state tracking. +// Server-side `AfterVerifyHook` reads these fields directly off `extra` (e.g. +// `extra["balance"]`); wrapping them in `channelState` like the settle +// response would silently break state tracking. func BuildVerifyExtra(channelId string, state *batchsettlement.ChannelState) map[string]interface{} { return channelStateFields(channelId, state) } -// BuildSettleExtra creates the Extensions map for SETTLE responses in the -// canonical NESTED TS shape used by `settleDeposit` / `executeRefundWithSignature`: +// BuildSettleExtra creates the Extensions map for SETTLE responses: // // { "channelState": { channelId, balance, totalClaimed, withdrawRequestedAt, // refundNonce } } diff --git a/go/mechanisms/evm/batch-settlement/facilitator/utils_test.go b/go/mechanisms/evm/batch-settlement/facilitator/utils_test.go index 911500eb90..5c970e7b6c 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/utils_test.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/utils_test.go @@ -8,7 +8,7 @@ import ( "time" x402 "github.com/x402-foundation/x402/go" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -224,11 +224,10 @@ func TestValidateChannelConfig_ExtraTypeTolerance(t *testing.T) { } // TestBuildVerifyExtra_FlatShape pins the wire shape of the verify response. -// TS `verifyVoucher` and `verifyDeposit` return a FLAT extra (channelId, -// balance, totalClaimed, withdrawRequestedAt, refundNonce). The TS server's -// `handleAfterVerify` reads `result.extra.balance` directly; if the Go -// facilitator wraps these under `channelState`, the server falls back to "0" -// for balance/totalClaimed and silently corrupts its tracked channel record. +// Verify responses return a flat extra (channelId, balance, totalClaimed, +// withdrawRequestedAt, refundNonce). If the facilitator wraps these under +// `channelState`, the server falls back to "0" for balance/totalClaimed and +// silently corrupts its tracked channel record. // The downstream symptom is `invalid_batch_settlement_evm_refund_no_balance` at refund // time because `channel.balance == 0 < chargedCumulativeAmount`. func TestBuildVerifyExtra_FlatShape(t *testing.T) { @@ -264,13 +263,13 @@ func TestBuildVerifyExtra_FlatShape(t *testing.T) { } // TestBuildSettleExtra_NestedShapeNoChargedCumulative pins the wire shape of -// the settle response. TS `settleDeposit` and `executeRefundWithSignature` -// return a NESTED `channelState` containing channelId/balance/totalClaimed/ -// withdrawRequestedAt/refundNonce — but NOT chargedCumulativeAmount, which -// the resource server's `enrichSettlementResponse` hook adds via additive -// merge afterwards. Emitting `chargedCumulativeAmount` from the facilitator -// triggers TS's enrichment policy to throw with "...already exists on the -// settlement result", which suppresses the merge and breaks downstream state. +// the settle response. It returns a nested `channelState` containing +// channelId/balance/totalClaimed/withdrawRequestedAt/refundNonce, but NOT +// chargedCumulativeAmount, which the resource server's +// `enrichSettlementResponse` hook adds via additive merge afterwards. Emitting +// `chargedCumulativeAmount` from the facilitator triggers the enrichment policy +// to reject the duplicate field, which suppresses the merge and breaks +// downstream state. func TestBuildSettleExtra_NestedShapeNoChargedCumulative(t *testing.T) { state := &batchsettlement.ChannelState{ Balance: big.NewInt(900), diff --git a/go/mechanisms/evm/batch-settlement/facilitator/voucher.go b/go/mechanisms/evm/batch-settlement/facilitator/voucher.go index 773e9fd9a9..ca5440eda3 100644 --- a/go/mechanisms/evm/batch-settlement/facilitator/voucher.go +++ b/go/mechanisms/evm/batch-settlement/facilitator/voucher.go @@ -7,7 +7,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -25,8 +25,7 @@ func VerifyVoucher( // VerifyRefundVoucher verifies a cooperative-refund payload's voucher. // The voucher is zero-charge: maxClaimableAmount == chargedCumulativeAmount, -// which on a fresh channel may equal totalClaimed exactly. Mirrors TS -// `verifyVoucher` with `payload.type === "refund"`. +// which on a fresh channel may equal totalClaimed exactly. func VerifyRefundVoucher( ctx context.Context, signer evm.FacilitatorEvmSigner, diff --git a/go/mechanisms/evm/batch-settlement/server/channel_manager.go b/go/mechanisms/evm/batch-settlement/server/channel_manager.go index 4c0f3590fb..192e4a4f60 100644 --- a/go/mechanisms/evm/batch-settlement/server/channel_manager.go +++ b/go/mechanisms/evm/batch-settlement/server/channel_manager.go @@ -12,14 +12,14 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) // ChannelManagerConfig wires the channel manager to its dependencies. // // Receiver and Token are required: the manager calls // `settle(receiver, token)` directly, so storage may be empty when settle() -// fires (e.g. immediately after a flush). Mirrors TS `ChannelManagerConfig`. +// fires, for example immediately after a flush. type ChannelManagerConfig struct { Scheme *BatchSettlementEvmScheme Facilitator x402.FacilitatorClient @@ -95,8 +95,7 @@ type SettleResult struct { // RefundResult is one cooperative refund transaction (one channel). // -// The TS API moved from a single `{Channels, Transaction}` to one result per -// refunded channel; the Go SDK matches that shape. +// Each refunded channel returns one result. type RefundResult struct { Channel string Transaction string @@ -111,7 +110,7 @@ const ( ) // autoJobPriority orders the queue: claim drains before settle drains before -// refund. Mirrors TS `AUTO_JOB_PRIORITY`. +// refund. var autoJobPriority = []autoJob{autoJobClaim, autoJobSettle, autoJobRefund} // BatchSettlementChannelManager handles auto-settlement of batched payment channels. @@ -135,8 +134,6 @@ type BatchSettlementChannelManager struct { pendingSettle bool pendingJobs map[autoJob]struct{} pendingJobsCh chan struct{} - drainingJobsDone chan struct{} - currentDrainCtx context.Context } // NewBatchSettlementChannelManager creates a new channel manager. @@ -151,7 +148,7 @@ func NewBatchSettlementChannelManager(config ChannelManagerConfig) *BatchSettlem } // hasLivePendingRequest returns true when the channel currently has a -// non-expired payer request reservation. Mirrors TS `hasLivePendingRequest`. +// non-expired payer request reservation. func hasLivePendingRequest(s *ChannelSession, nowMs int64) bool { return s != nil && s.PendingRequest != nil && s.PendingRequest.ExpiresAt > nowMs } @@ -209,7 +206,7 @@ func (m *BatchSettlementChannelManager) GetWithdrawalPendingSessions() ([]*Chann // Claim collects claimable vouchers and submits them in batches. func (m *BatchSettlementChannelManager) Claim(ctx context.Context, opts *ClaimOptions) ([]ClaimResult, error) { resolved := normalizeClaimOptions(opts) - channels, err := m.selectClaimTargets(ctx, resolved.SelectClaimChannels) + channels, err := m.selectClaimTargets(resolved.SelectClaimChannels) if err != nil { return nil, err } @@ -572,7 +569,7 @@ func normalizeClaimOptions(opts *ClaimOptions) resolvedClaimOptions { return out } -func (m *BatchSettlementChannelManager) selectClaimTargets(ctx context.Context, selector ClaimChannelSelector) ([]*ChannelSession, error) { +func (m *BatchSettlementChannelManager) selectClaimTargets(selector ClaimChannelSelector) ([]*ChannelSession, error) { channels, err := m.scheme.storage.List() if err != nil { return nil, err @@ -606,7 +603,7 @@ func (m *BatchSettlementChannelManager) collectClaimsFromChannels(channels []*Ch out = append(out, batchsettlement.BatchSettlementVoucherClaim{ Voucher: struct { Channel batchsettlement.ChannelConfig `json:"channel"` - MaxClaimableAmount string `json:"maxClaimableAmount"` + MaxClaimableAmount string `json:"maxClaimableAmount"` }{ Channel: c.ChannelConfig, MaxClaimableAmount: c.SignedMaxClaimable, @@ -713,7 +710,7 @@ func (m *BatchSettlementChannelManager) refundChannel(ctx context.Context, targe claims = []batchsettlement.BatchSettlementVoucherClaim{{ Voucher: struct { Channel batchsettlement.ChannelConfig `json:"channel"` - MaxClaimableAmount string `json:"maxClaimableAmount"` + MaxClaimableAmount string `json:"maxClaimableAmount"` }{ Channel: target.ChannelConfig, MaxClaimableAmount: target.SignedMaxClaimable, @@ -761,7 +758,6 @@ func (m *BatchSettlementChannelManager) refundChannel(ctx context.Context, targe } // Drop the session so it doesn't churn through future refund cycles. - // Mirrors TS `updateChannel(... => undefined)` which deletes the entry. _, _ = m.scheme.storage.UpdateChannel(normalizedId, func(current *ChannelSession) *ChannelSession { if current == nil { return current @@ -810,11 +806,10 @@ func (m *BatchSettlementChannelManager) updateClaimedSessions(claims []batchsett // getIdleChannelsForRefund returns channels that have been idle for at least // `idleSecs` seconds and still hold a non-zero balance. Skips channels with a -// live in-flight request reservation. Mirrors TS -// `getIdleChannelsForRefundFromChannels` (also private). +// live in-flight request reservation. // // Callers wanting "refund all idle channels" should inline this predicate -// inside their SelectRefundChannels callback (the TS canonical demo does so). +// inside their SelectRefundChannels callback. func getIdleChannelsForRefund(channels []*ChannelSession, idleSecs int) []*ChannelSession { if idleSecs <= 0 { return nil @@ -870,7 +865,7 @@ func (m *BatchSettlementChannelManager) facilitatorSettle(ctx context.Context, p } // requirementsMap returns the minimal PaymentRequirements shape used for the -// manager's own facilitator calls. Mirrors TS `buildPaymentRequirements`. +// manager's own facilitator calls. func (m *BatchSettlementChannelManager) requirementsMap() map[string]interface{} { return map[string]interface{}{ "scheme": batchsettlement.SchemeBatched, diff --git a/go/mechanisms/evm/batch-settlement/server/channel_manager_test.go b/go/mechanisms/evm/batch-settlement/server/channel_manager_test.go index 4885160067..d5fbf18cde 100644 --- a/go/mechanisms/evm/batch-settlement/server/channel_manager_test.go +++ b/go/mechanisms/evm/batch-settlement/server/channel_manager_test.go @@ -9,7 +9,7 @@ import ( "time" x402 "github.com/x402-foundation/x402/go" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) // fakeFacilitator records Settle/Verify calls and returns canned responses. @@ -117,7 +117,7 @@ func TestClaim_SingleBatch(t *testing.T) { // Regression: a successful claim must advance session.TotalClaimed in storage so // that GetClaimableVouchers no longer returns the same channel until a fresh // voucher pushes ChargedCumulativeAmount higher. Without this fix, every tick -// re-submits the same claim transaction. Mirrors TS updateClaimedSessions. +// re-submits the same claim transaction. func TestClaim_AdvancesTotalClaimedInStorageAfterSuccess(t *testing.T) { s := NewBatchSettlementEvmScheme("0xreceiver", nil) // Use a real ChannelConfig so the manager's post-claim storage lookup @@ -242,7 +242,7 @@ func TestSettle_FacilitatorError(t *testing.T) { } func TestRefund_EmptyChannelIdsRefundsAll(t *testing.T) { - // Per upstream parity: passing nil/empty refunds every stored channel. + // Passing nil/empty refunds every stored channel. s := NewBatchSettlementEvmScheme("0xreceiver", nil) f := &fakeFacilitator{} m := newManager(s, f) @@ -627,32 +627,6 @@ func TestRunRefundJob_NoOpWithoutSelectRefundChannels(t *testing.T) { } } -// slowFacilitator sleeps inside Settle so concurrent tick() invocations contend. -type slowFacilitator struct { - mu sync.Mutex - calls int - delay time.Duration -} - -func (s *slowFacilitator) Verify(_ context.Context, _ []byte, _ []byte) (*x402.VerifyResponse, error) { - return &x402.VerifyResponse{IsValid: true}, nil -} -func (s *slowFacilitator) Settle(_ context.Context, _ []byte, _ []byte) (*x402.SettleResponse, error) { - time.Sleep(s.delay) - s.mu.Lock() - s.calls++ - s.mu.Unlock() - return &x402.SettleResponse{Success: true, Transaction: "0xtx"}, nil -} -func (s *slowFacilitator) GetSupported(_ context.Context) (x402.SupportedResponse, error) { - return x402.SupportedResponse{}, nil -} -func (s *slowFacilitator) settleCalls() int { - s.mu.Lock() - defer s.mu.Unlock() - return s.calls -} - func TestGetClaimableVouchers_NoSessions(t *testing.T) { s := NewBatchSettlementEvmScheme("0xreceiver", nil) m := newManager(s, &fakeFacilitator{}) diff --git a/go/mechanisms/evm/batch-settlement/server/file_storage.go b/go/mechanisms/evm/batch-settlement/server/file_storage.go index 7126b9fc52..b809966bd2 100644 --- a/go/mechanisms/evm/batch-settlement/server/file_storage.go +++ b/go/mechanisms/evm/batch-settlement/server/file_storage.go @@ -9,7 +9,7 @@ import ( "sort" "strings" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) // FileChannelStorage is a file-backed SessionStorage. Each session is stored @@ -87,8 +87,8 @@ func (s *FileChannelStorage) List() ([]*ChannelSession, error) { } // CompareAndSet uses an exclusive lock file to serialise concurrent writers. -// The mkdir call mirrors the TS fix in 5a007ae70 — without it, the very first -// CompareAndSet on a fresh directory fails with ENOENT on the lock file. +// The mkdir call ensures the very first CompareAndSet on a fresh directory +// does not fail with ENOENT on the lock file. func (s *FileChannelStorage) CompareAndSet(channelId string, expectedCharged string, session *ChannelSession) (bool, error) { path := s.filePath(channelId) lockPath := path + ".lock" diff --git a/go/mechanisms/evm/batch-settlement/server/file_storage_test.go b/go/mechanisms/evm/batch-settlement/server/file_storage_test.go index 95af557fae..2a6a1c940e 100644 --- a/go/mechanisms/evm/batch-settlement/server/file_storage_test.go +++ b/go/mechanisms/evm/batch-settlement/server/file_storage_test.go @@ -7,7 +7,7 @@ import ( "sort" "testing" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) func newServerFileStore(t *testing.T) (*FileChannelStorage, string) { diff --git a/go/mechanisms/evm/batch-settlement/server/hooks.go b/go/mechanisms/evm/batch-settlement/server/hooks.go index 3b8d472376..c28f64aac4 100644 --- a/go/mechanisms/evm/batch-settlement/server/hooks.go +++ b/go/mechanisms/evm/batch-settlement/server/hooks.go @@ -12,7 +12,7 @@ import ( "github.com/ethereum/go-ethereum/common" x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement/facilitator" "github.com/x402-foundation/x402/go/types" ) @@ -96,10 +96,9 @@ func (s *BatchSettlementEvmScheme) BeforeVerifyHook() x402.BeforeVerifyHook { prevCharged, _ = new(big.Int).SetString(current.ChargedCumulativeAmount, 10) } if prevCharged == nil { - // Mirror TS `inferMissingLocalChargedAmount`: when storage has - // no row yet, derive a sensible charged base so the mismatch - // check still works for the first request on a brand-new - // channel. + // When storage has no row yet, derive a sensible charged base so + // the mismatch check still works for the first request on a + // brand-new channel. switch { case !isPaid: prevCharged = new(big.Int).Set(signedMax) @@ -206,9 +205,8 @@ func (s *BatchSettlementEvmScheme) BeforeVerifyHook() x402.BeforeVerifyHook { // payerAuthorizer. Returns nil on any check that requires falling back to the // facilitator, and an explicit invalid VerifyResponse when a local check fails. // -// Mirrors TS `verifyVoucherLocally`. The smart-wallet (ERC-1271) path is -// intentionally not supported — vouchers signed by a non-zero EOA payerAuthorizer -// are the only candidates. +// The smart-wallet (ERC-1271) path is intentionally not supported — vouchers +// signed by a non-zero EOA payerAuthorizer are the only candidates. func (s *BatchSettlementEvmScheme) verifyVoucherLocally( requirements x402.PaymentRequirementsView, payload map[string]interface{}, @@ -248,7 +246,8 @@ func (s *BatchSettlementEvmScheme) verifyVoucherLocally( } if cfgErr := facilitator.ValidateChannelConfig(vp.ChannelConfig, vp.Voucher.ChannelId, reqs); cfgErr != nil { reason := facilitator.ErrChannelIdMismatch - if ve, ok := cfgErr.(*x402.VerifyError); ok && ve.InvalidReason != "" { + var ve *x402.VerifyError + if errors.As(cfgErr, &ve) && ve.InvalidReason != "" { reason = ve.InvalidReason } return invalidLocalVerifyResponse(payer, reason) @@ -352,7 +351,6 @@ func buildProvisionalChannelFromPayload( return &ChannelSession{ ChannelId: channelId, ChannelConfig: cfg, - Payer: strings.ToLower(cfg.Payer), ChargedCumulativeAmount: chargedCumulativeAmount, SignedMaxClaimable: signedMax, Signature: signature, @@ -384,7 +382,7 @@ func (s *BatchSettlementEvmScheme) AfterVerifyHook() x402.AfterVerifyHook { payload := ctx.Payload.GetPayload() - var channelId, signedMaxClaimable, signature, payer string + var channelId, signedMaxClaimable, signature string var channelConfig *batchsettlement.ChannelConfig isRefundVoucher := false @@ -399,7 +397,6 @@ func (s *BatchSettlementEvmScheme) AfterVerifyHook() x402.AfterVerifyHook { signature = dp.Voucher.Signature cfg := dp.ChannelConfig channelConfig = &cfg - payer = cfg.Payer case batchsettlement.IsVoucherPayload(payload): vp, parseErr := batchsettlement.VoucherPayloadFromMap(payload) if parseErr != nil { @@ -410,7 +407,6 @@ func (s *BatchSettlementEvmScheme) AfterVerifyHook() x402.AfterVerifyHook { signature = vp.Voucher.Signature cfg := vp.ChannelConfig channelConfig = &cfg - payer = cfg.Payer case batchsettlement.IsRefundPayload(payload): rp, parseErr := batchsettlement.RefundPayloadFromMap(payload) if parseErr != nil { @@ -421,16 +417,11 @@ func (s *BatchSettlementEvmScheme) AfterVerifyHook() x402.AfterVerifyHook { signature = rp.Voucher.Signature cfg := rp.ChannelConfig channelConfig = &cfg - payer = cfg.Payer isRefundVoucher = true default: return nil, nil } - if payer == "" { - payer = ctx.Result.Payer - } - ex := ctx.Result.Extra balance := mapStringField(ex, "balance", "0") totalClaimed := mapStringField(ex, "totalClaimed", "0") @@ -461,7 +452,6 @@ func (s *BatchSettlementEvmScheme) AfterVerifyHook() x402.AfterVerifyHook { session := &ChannelSession{ ChannelId: normalizedId, ChannelConfig: *resolvedConfig, - Payer: strings.ToLower(payer), ChargedCumulativeAmount: prevCharged, SignedMaxClaimable: signedMaxClaimable, Signature: signature, @@ -504,7 +494,6 @@ func (s *BatchSettlementEvmScheme) AfterVerifyHook() x402.AfterVerifyHook { } next := &ChannelSession{ ChannelId: normalizedId, - Payer: strings.ToLower(payer), ChargedCumulativeAmount: current.ChargedCumulativeAmount, SignedMaxClaimable: signedMaxClaimable, Signature: signature, @@ -546,6 +535,16 @@ func (s *BatchSettlementEvmScheme) AfterVerifyHook() x402.AfterVerifyHook { } } +// OnVerifyFailureHook releases a reservation when facilitator verification fails. +func (s *BatchSettlementEvmScheme) OnVerifyFailureHook() x402.OnVerifyFailureHook { + return func(ctx x402.VerifyFailureContext) (*x402.VerifyFailureHookResult, error) { + if ctx.Requirements.GetScheme() != batchsettlement.SchemeBatched { + return nil, nil + } + return nil, s.ClearPendingRequest(ctx.Payload) + } +} + // BeforeSettleHook returns a hook that implements the core batched settlement // logic. For voucher payloads it: // - Increments chargedCumulativeAmount locally via UpdateChannel @@ -565,8 +564,7 @@ func (s *BatchSettlementEvmScheme) BeforeSettleHook() x402.BeforeSettleHook { // Deposit and refund payloads pass through to the facilitator. Server- // owned enrichment for refunds (claims + authorizer signatures) lives - // in EnrichSettlementPayload below — mirrors TS - // `handleEnrichSettlementPayload`. + // in EnrichSettlementPayload below. if !batchsettlement.IsVoucherPayload(payload) { return nil, nil } @@ -676,7 +674,7 @@ func (s *BatchSettlementEvmScheme) BeforeSettleHook() x402.BeforeSettleHook { } s.TakeRequestContext(ctx.Payload) - // Emit nested wire shape: chargedAmount + channelState. Mirrors TS. + // Emit the nested response shape: chargedAmount + channelState. skipExtra := &batchsettlement.BatchSettlementPaymentResponseExtra{ ChargedAmount: ctx.Requirements.GetAmount(), ChannelState: &batchsettlement.BatchSettlementChannelStateExtra{ @@ -694,7 +692,7 @@ func (s *BatchSettlementEvmScheme) BeforeSettleHook() x402.BeforeSettleHook { Success: true, Transaction: "", Network: x402.Network(ctx.Requirements.GetNetwork()), - Payer: committedPrev.Payer, + Payer: committedPrev.ChannelConfig.Payer, Amount: "", Extra: skipExtra.ToMap(), }, @@ -702,6 +700,16 @@ func (s *BatchSettlementEvmScheme) BeforeSettleHook() x402.BeforeSettleHook { } } +// OnSettleFailureHook releases a reservation when facilitator settlement fails. +func (s *BatchSettlementEvmScheme) OnSettleFailureHook() x402.OnSettleFailureHook { + return func(ctx x402.SettleFailureContext) (*x402.SettleFailureHookResult, error) { + if ctx.Requirements.GetScheme() != batchsettlement.SchemeBatched { + return nil, nil + } + return nil, s.ClearPendingRequest(ctx.Payload) + } +} + // EnrichSettlementPayload supplies server-owned settlement-payload fields // before the facilitator settles. For refund payloads it returns the additive // `{amount?, refundNonce, claims, refundAuthorizerSignature?, claimAuthorizerSignature?}` @@ -710,7 +718,7 @@ func (s *BatchSettlementEvmScheme) BeforeSettleHook() x402.BeforeSettleHook { // // Returns nil for non-refund payloads. Returns a structured error on // validation failure; the framework converts it into a settle abort with -// the error string as the reason. Mirrors TS `handleEnrichSettlementPayload`. +// the error string as the reason. func (s *BatchSettlementEvmScheme) EnrichSettlementPayload(ctx x402.SettleContext) (map[string]interface{}, error) { if ctx.Requirements.GetScheme() != batchsettlement.SchemeBatched { return nil, nil @@ -796,7 +804,7 @@ func (s *BatchSettlementEvmScheme) EnrichSettlementPayload(ctx x402.SettleContex } if !hasRequestedAmount { // Only fill `amount` when the client omitted it; otherwise the additive - // policy would reject the overwrite. Mirrors TS spread guard. + // policy would reject the overwrite. enrichment["amount"] = refundAmount.String() } @@ -815,8 +823,7 @@ func (s *BatchSettlementEvmScheme) EnrichSettlementPayload(ctx x402.SettleContex } // Snapshot the pre-refund channel state for EnrichSettlementResponse, which - // adds chargedCumulativeAmount onto the post-facilitator response. Mirrors - // TS `scheme.rememberChannelSnapshot(paymentPayload, channel)`. + // adds chargedCumulativeAmount onto the post-facilitator response. s.RememberChannelSnapshot(ctx.Payload, session) return enrichment, nil @@ -830,8 +837,7 @@ func (s *BatchSettlementEvmScheme) EnrichSettlementPayload(ctx x402.SettleContex // For deposits: read the facilitator's channelState snapshot, compute // chargedCumulativeAmount = current + requirements.amount, store the new // session state, and remember the channel snapshot so EnrichSettlementResponse -// can echo chargedCumulativeAmount back to the client. Mirrors TS -// `handleAfterSettle`. +// can echo chargedCumulativeAmount back to the client. // // For refunds: read the facilitator's post-refund channelState, store the // updated session (or delete on full-refund when balance <= chargedCumulative). @@ -943,13 +949,16 @@ func (s *BatchSettlementEvmScheme) AfterSettleHook() x402.AfterSettleHook { return nil } now := time.Now().UnixMilli() + outcome := "" _, updateErr := s.storage.UpdateChannel(normalizedId, func(current *ChannelSession) *ChannelSession { if current == nil { + outcome = "missing" return current } if pendingId == "" || current.PendingRequest == nil || current.PendingRequest.PendingId != pendingId { + outcome = "pending_mismatch" return current } postBalance, _ := new(big.Int).SetString(snapshot.Balance, 10) @@ -962,6 +971,7 @@ func (s *BatchSettlementEvmScheme) AfterSettleHook() x402.AfterSettleHook { } if postBalance.Cmp(curCharged) <= 0 { // Full refund: delete the channel session. + outcome = "deleted" return nil } next := *current @@ -982,11 +992,15 @@ func (s *BatchSettlementEvmScheme) AfterSettleHook() x402.AfterSettleHook { next.OnchainSyncedAt = now next.LastRequestTimestamp = now next.PendingRequest = nil + outcome = "updated" return &next }) if updateErr != nil { return updateErr } + if outcome == "pending_mismatch" { + return errors.New(batchsettlement.ErrChannelBusy) + } return nil } @@ -999,7 +1013,7 @@ func (s *BatchSettlementEvmScheme) AfterSettleHook() x402.AfterSettleHook { // `{channelState: {chargedCumulativeAmount}, chargedAmount?}` map so the // framework can deep-merge it into result.extra without overwriting the // channelState.{balance,totalClaimed,...} fields the facilitator already -// populated. Mirrors TS `handleEnrichSettlementResponse`. +// populated. // // The snapshot is set by EnrichSettlementPayload (refund) or by // AfterSettleHook (deposit) via RememberChannelSnapshot. @@ -1027,8 +1041,7 @@ func (s *BatchSettlementEvmScheme) EnrichSettlementResponse(ctx x402.SettleResul } // readChannelStateFromExtra extracts the nested channelState map from a -// settle-response extra. Returns nil when absent or wrong-typed. Mirrors -// TS `readChannelStateExtra`. +// settle-response extra. Returns nil when absent or wrong-typed. func readChannelStateFromExtra(extra map[string]interface{}) *batchsettlement.BatchSettlementChannelStateExtra { if extra == nil { return nil diff --git a/go/mechanisms/evm/batch-settlement/server/hooks_test.go b/go/mechanisms/evm/batch-settlement/server/hooks_test.go index beebb2aa99..e34fff8e18 100644 --- a/go/mechanisms/evm/batch-settlement/server/hooks_test.go +++ b/go/mechanisms/evm/batch-settlement/server/hooks_test.go @@ -6,7 +6,7 @@ import ( "time" x402 "github.com/x402-foundation/x402/go" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -214,6 +214,24 @@ func TestBeforeVerifyHook_RefundFreshCumulativePasses(t *testing.T) { } } +func TestBeforeVerifyHook_LivePendingRejectsSameChannel(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + sess := sampleSession("0xabcd", "10") + sess.PendingRequest = &PendingRequest{PendingId: "p-live", ExpiresAt: time.Now().Add(time.Minute).UnixMilli()} + _ = s.UpdateSession("0xabcd", sess) + + res, err := s.BeforeVerifyHook()(x402.VerifyContext{ + Payload: &stubPayload{data: voucherPayload("0xabcd", "20", "0xsig")}, + Requirements: batchedReqs(), + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if res == nil || !res.Abort || res.Reason != batchsettlement.ErrChannelBusy { + t.Fatalf("got %+v", res) + } +} + // ----- AfterVerifyHook ----- func TestAfterVerifyHook_NonBatchedIgnored(t *testing.T) { @@ -309,6 +327,27 @@ func TestAfterVerifyHook_RefundReturnsSkipHandler(t *testing.T) { } } +func TestOnVerifyFailureHook_ClearsPendingRequest(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + id := "0xabcd" + sess := sampleSession(id, "10") + _ = s.UpdateSession(id, sess) + reserveDepositPending(t, s, id, "p-verify") + stub := &stubPayload{data: voucherPayload(id, "20", "0xsig")} + s.MergeRequestContext(stub, BatchSettlementRequestContext{ChannelId: id, PendingId: "p-verify", ChannelSnapshot: sess}) + + res, err := s.OnVerifyFailureHook()(x402.VerifyFailureContext{ + VerifyContext: x402.VerifyContext{Payload: stub, Requirements: batchedReqs()}, + }) + if err != nil || res != nil { + t.Fatalf("got res=%+v err=%v", res, err) + } + got, _ := s.GetSession(id) + if got == nil || got.PendingRequest != nil { + t.Fatalf("pending not cleared: %+v", got) + } +} + // ----- BeforeSettleHook ----- // TestBeforeSettleHook_DepositPassThrough pins the new BeforeSettleHook @@ -370,10 +409,7 @@ func TestBeforeSettleHook_VoucherSkipsAndUpdates(t *testing.T) { } func TestBeforeSettleHook_VoucherExceedsSignedCapAborts(t *testing.T) { - // Defensive-guard test: simulate a race where chargedCumulativeAmount - // is bumped between reservation and settle, making the voucher's signed - // cap unreachable. Install a reservation for cap=20 first, then advance - // stored charged so 15+10>20 trips the in-tx cap check. + // Simulate chargedCumulativeAmount changing after reservation. s := NewBatchSettlementEvmScheme("0xreceiver", nil) _ = s.UpdateSession("0xabcd", sampleSession("0xabcd", "10")) stub := &stubPayload{data: voucherPayload("0xabcd", "20", "0xsig")} @@ -383,8 +419,6 @@ func TestBeforeSettleHook_VoucherExceedsSignedCapAborts(t *testing.T) { cur, _ := s.GetSession("0xabcd") cur.ChargedCumulativeAmount = "15" _ = s.UpdateSession("0xabcd", cur) - // Issue settle against the lower-cap voucher (cap=15) so 15+10>15 trips cap_exceeded. - stub.data = voucherPayload("0xabcd", "15", "0xsig") res, err := s.BeforeSettleHook()(x402.SettleContext{ Payload: stub, Requirements: batchedReqs(), @@ -415,8 +449,7 @@ func reserveRefundPending(t *testing.T, s *BatchSettlementEvmScheme, id, pending // TestEnrichSettlementPayload_RefundReturnsAdditiveFields pins the new // EnrichSettlementPayload behavior for refund payloads: returns additive // `{amount, refundNonce, claims}` (plus signatures when an authorizer signer -// is configured) — never mutates the input payload. Mirrors TS -// `handleEnrichSettlementPayload`. +// is configured) and never mutates the input payload. func TestEnrichSettlementPayload_RefundReturnsAdditiveFields(t *testing.T) { s := NewBatchSettlementEvmScheme("0xreceiver", nil) id, _ := batchsettlement.ComputeChannelId(testConfig(), "eip155:8453") @@ -500,6 +533,27 @@ func TestEnrichSettlementPayload_RefundAmountExceedsRemainderErrors(t *testing.T } } +func TestOnSettleFailureHook_ClearsPendingRequest(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + id := "0xabcd" + sess := sampleSession(id, "10") + _ = s.UpdateSession(id, sess) + reserveDepositPending(t, s, id, "p-settle") + stub := &stubPayload{data: voucherPayload(id, "20", "0xsig")} + s.MergeRequestContext(stub, BatchSettlementRequestContext{ChannelId: id, PendingId: "p-settle", ChannelSnapshot: sess}) + + res, err := s.OnSettleFailureHook()(x402.SettleFailureContext{ + SettleContext: x402.SettleContext{Payload: stub, Requirements: batchedReqs()}, + }) + if err != nil || res != nil { + t.Fatalf("got res=%+v err=%v", res, err) + } + got, _ := s.GetSession(id) + if got == nil || got.PendingRequest != nil { + t.Fatalf("pending not cleared: %+v", got) + } +} + // ----- AfterSettleHook ----- func TestAfterSettleHook_NonBatchedIgnored(t *testing.T) { @@ -666,6 +720,52 @@ func TestAfterSettleHook_RefundFullDeletes(t *testing.T) { } } +func TestAfterSettleHook_RefundFullDeletesAfterPayloadEnrichment(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + id, _ := batchsettlement.ComputeChannelId(testConfig(), "eip155:8453") + sess := sampleSession(id, "100") + sess.ChannelConfig = testConfig() + sess.Balance = "1000" + _ = s.UpdateSession(id, sess) + pp := &types.PaymentPayload{ + X402Version: 2, + Payload: refundPayload(id, "100", "0xsig"), + Accepted: types.PaymentRequirements{Scheme: batchsettlement.SchemeBatched, Network: "eip155:8453"}, + } + + res, err := s.BeforeVerifyHook()(x402.VerifyContext{Payload: pp, Requirements: batchedReqs()}) + if err != nil || res != nil { + t.Fatalf("reserve got %+v / %v", res, err) + } + pp.Payload["amount"] = "900" + pp.Payload["refundNonce"] = "0" + pp.Payload["claims"] = []interface{}{} + + err = s.AfterSettleHook()(x402.SettleResultContext{ + SettleContext: x402.SettleContext{ + Payload: pp, + Requirements: batchedReqs(), + }, + Result: &x402.SettleResponse{ + Success: true, + Extra: map[string]interface{}{ + "channelState": map[string]interface{}{ + "channelId": id, + "balance": "100", + "totalClaimed": "100", + "refundNonce": "1", + }, + }, + }, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if got, _ := s.GetSession(id); got != nil { + t.Fatalf("expected nil after full refund, got %+v", got) + } +} + func TestAfterSettleHook_RefundPartialUpdates(t *testing.T) { s := NewBatchSettlementEvmScheme("0xreceiver", nil) id, _ := batchsettlement.ComputeChannelId(testConfig(), "eip155:8453") @@ -719,6 +819,54 @@ func TestAfterSettleHook_RefundPartialUpdates(t *testing.T) { if got.RefundNonce != 1 { t.Fatalf("nonce = %d", got.RefundNonce) } + if got.PendingRequest != nil { + t.Fatalf("PendingRequest not cleared after partial refund: %+v", got.PendingRequest) + } +} + +func TestAfterSettleHook_RefundPendingMismatchReturnsBusy(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + id, _ := batchsettlement.ComputeChannelId(testConfig(), "eip155:8453") + sess := sampleSession(id, "100") + sess.ChannelConfig = testConfig() + sess.Balance = "1000" + _ = s.UpdateSession(id, sess) + reserveDepositPending(t, s, id, "p-current") + rp := map[string]interface{}{ + "type": "refund", + "channelConfig": batchsettlement.ChannelConfigToMap(testConfig()), + "voucher": map[string]interface{}{ + "channelId": id, + "maxClaimableAmount": "100", + "signature": "0xsig", + }, + "amount": "100", + "refundNonce": "0", + "claims": []interface{}{}, + } + stub := &stubPayload{data: rp} + s.MergeRequestContext(stub, BatchSettlementRequestContext{ChannelId: id, PendingId: "p-stale"}) + + err := s.AfterSettleHook()(x402.SettleResultContext{ + SettleContext: x402.SettleContext{ + Payload: stub, + Requirements: batchedReqs(), + }, + Result: &x402.SettleResponse{ + Success: true, + Extra: map[string]interface{}{ + "channelState": map[string]interface{}{ + "channelId": id, + "balance": "900", + "totalClaimed": "100", + "refundNonce": "1", + }, + }, + }, + }) + if err == nil || err.Error() != batchsettlement.ErrChannelBusy { + t.Fatalf("got %v", err) + } } // ----- helpers ----- diff --git a/go/mechanisms/evm/batch-settlement/server/scheme.go b/go/mechanisms/evm/batch-settlement/server/scheme.go index 687ffa1003..8042e43e56 100644 --- a/go/mechanisms/evm/batch-settlement/server/scheme.go +++ b/go/mechanisms/evm/batch-settlement/server/scheme.go @@ -2,7 +2,6 @@ package server import ( "context" - "encoding/json" "errors" "fmt" "math/big" @@ -12,12 +11,12 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) -// BatchSettlementRequestContext carries per-request state across the verify→settle -// lifecycle for a single payment. Mirrors TS `BatchSettlementRequestContext`. +// BatchSettlementRequestContext carries per-request state across the verify->settle +// lifecycle for a single payment. type BatchSettlementRequestContext struct { ChannelId string PendingId string @@ -65,59 +64,69 @@ type BatchSettlementEvmScheme struct { onchainStateTtlMs int64 moneyParsers []x402.MoneyParser - // requestContexts maps a per-request key to its state (channel id, pending - // reservation id, snapshot). Mirrors the TS WeakMap - // used to thread reservation id from BeforeVerify through AfterVerify and - // BeforeSettle. - // - // Go's sync.Map can't key on types.PaymentPayload directly because the - // struct contains a `map[string]interface{}` (unhashable). Instead we key - // on a stable string identity derived from the payload — the - // JSON-serialized PayloadBytes when available, or a fingerprint computed - // from interface methods otherwise. The same payload produces the same - // key across verify → settle phases. - // - // Lifecycle: entries are cleared on the normal happy path via - // TakeRequestContext / TakeChannelSnapshot in BeforeSettleHook, - // EnrichPaymentRequiredResponse, and ClearPendingRequest (failure paths). - // Unlike a JS WeakMap, Go has no GC notification, so a payload verified - // successfully but never reaching Settle (and never firing the cancellation - // hook) would leak an entry. Resource server lifecycles always fire one of - // those terminal hooks today. + // requestContexts maps a per-payment key to state carried across verify and + // settle hooks. requestContextsMu sync.Mutex requestContexts map[string]*BatchSettlementRequestContext } -// requestContextKey returns a stable identity for the given payload, used as -// the map key for per-request state. Prefers an explicit fingerprint string -// when the caller wraps the payload in a (key, view) pair (test usage), then -// the payload's JSON-marshal output, and finally interface fields. +// requestContextKey returns a payment identity that stays stable when server +// enrichment adds settlement-only fields to the payload. func requestContextKey(payload any) string { if payload == nil { return "" } - if k, ok := payload.(interface{ RequestKey() string }); ok { - if rk := k.RequestKey(); rk != "" { - return rk - } + if view, ok := payload.(x402.PaymentPayloadView); ok { + payloadMap := view.GetPayload() + payloadType, _ := payloadMap["type"].(string) + voucher, _ := payloadMap["voucher"].(map[string]interface{}) + channelId, _ := voucher["channelId"].(string) + maxClaimable, _ := voucher["maxClaimableAmount"].(string) + signature, _ := voucher["signature"].(string) + return strings.Join([]string{ + strconv.Itoa(view.GetVersion()), + view.GetScheme(), + view.GetNetwork(), + payloadType, + batchsettlement.NormalizeChannelId(channelId), + maxClaimable, + signature, + channelConfigKey(payloadMap["channelConfig"]), + }, "\x00") } - if v, ok := payload.(types.PaymentPayload); ok { - if data, err := json.Marshal(v); err == nil { - return string(data) + return fmt.Sprintf("%p", payload) +} + +func channelConfigKey(raw any) string { + switch cfg := raw.(type) { + case batchsettlement.ChannelConfig: + return formatChannelConfigKey(cfg) + case *batchsettlement.ChannelConfig: + if cfg == nil { + return "" } - } - if v, ok := payload.(*types.PaymentPayload); ok && v != nil { - if data, err := json.Marshal(*v); err == nil { - return string(data) + return formatChannelConfigKey(*cfg) + case map[string]interface{}: + parsed, err := batchsettlement.ChannelConfigFromMap(cfg) + if err != nil { + return "" } + return formatChannelConfigKey(parsed) + default: + return "" } - if view, ok := payload.(x402.PaymentPayloadView); ok { - // Fallback for arbitrary view implementations (e.g. test stubs that - // embed a non-marshalable payload). Use pointer identity if available. - return fmt.Sprintf("%p|%d|%s|%s", - view, view.GetVersion(), view.GetScheme(), view.GetNetwork()) - } - return fmt.Sprintf("%p", payload) +} + +func formatChannelConfigKey(cfg batchsettlement.ChannelConfig) string { + return strings.Join([]string{ + strings.ToLower(cfg.Payer), + strings.ToLower(cfg.PayerAuthorizer), + strings.ToLower(cfg.Receiver), + strings.ToLower(cfg.ReceiverAuthorizer), + strings.ToLower(cfg.Token), + strconv.Itoa(cfg.WithdrawDelay), + cfg.Salt, + }, "\x00") } // NewBatchSettlementEvmScheme creates a new batched server scheme. @@ -162,7 +171,7 @@ func (s *BatchSettlementEvmScheme) GetOnchainStateTtlMs() int64 { } // defaultOnchainStateTtlMs derives a reasonable TTL from the channel withdraw -// delay: WithdrawDelay/3, clamped to [30s, 5min]. Mirrors TS. +// delay: WithdrawDelay/3, clamped to [30s, 5min]. func defaultOnchainStateTtlMs(withdrawDelaySeconds int) int64 { if withdrawDelaySeconds < 0 { withdrawDelaySeconds = 0 @@ -181,7 +190,7 @@ func defaultOnchainStateTtlMs(withdrawDelaySeconds int) int64 { } // MergeRequestContext merges fields into the per-payload request context, -// creating one if none exists. Mirrors TS `mergeRequestContext`. +// creating one if none exists. func (s *BatchSettlementEvmScheme) MergeRequestContext(payload any, partial BatchSettlementRequestContext) { key := requestContextKey(payload) if key == "" { @@ -256,7 +265,7 @@ func (s *BatchSettlementEvmScheme) TakeChannelSnapshot(payload any) *ChannelSess // ClearPendingRequest clears this request's pending reservation in storage, // without affecting any newer reservation that may have replaced it. If the // stored channel only existed for this reservation (no snapshot), the channel -// record is deleted entirely. Mirrors TS `clearPendingRequest`. +// record is deleted entirely. func (s *BatchSettlementEvmScheme) ClearPendingRequest(payload any) error { rc := s.TakeRequestContext(payload) if rc == nil || rc.ChannelId == "" || rc.PendingId == "" { @@ -341,7 +350,7 @@ func (s *BatchSettlementEvmScheme) EnrichPaymentRequiredResponse(ctx x402.Paymen // OnVerifiedPaymentCanceledHook returns a hook that releases this request's // pending reservation when the resource handler errors or returns a non-2xx -// response. Mirrors TS `handleVerifiedPaymentCanceled`. +// response. func (s *BatchSettlementEvmScheme) OnVerifiedPaymentCanceledHook() x402.OnVerifiedPaymentCanceledHook { return func(ctx x402.VerifiedPaymentCanceledContext) error { if ctx.Reason != x402.CancellationReasonHandlerThrew && @@ -497,14 +506,10 @@ func (s *BatchSettlementEvmScheme) EnhancePaymentRequirements( requirements.Extra = make(map[string]interface{}) } - // Token EIP-712 domain (`name` / `version`). Always populated when the - // asset metadata provides them — both the ERC-3009 deposit collector and - // the gas-sponsored EIP-2612 permit segment recompute the token's EIP-712 - // digest off-chain, so the facilitator needs these even when the - // configured AssetTransferMethod is "eip3009" (the previous restrictive - // conditional only included them for the legacy default and the - // SupportsEip2612 path, leaving ERC-3009-only assets without a domain). - // Mirrors TS server `BatchSettlementEvmScheme.computeRequirementsExtra`. + // Token EIP-712 domain (`name` / `version`). Always populated when the asset + // metadata provides them because the ERC-3009 deposit collector and the + // gas-sponsored EIP-2612 permit segment recompute the token's EIP-712 digest + // off-chain. if _, ok := requirements.Extra["name"]; !ok { requirements.Extra["name"] = assetInfo.Name } @@ -512,15 +517,14 @@ func (s *BatchSettlementEvmScheme) EnhancePaymentRequirements( requirements.Extra["version"] = assetInfo.Version } - // Add batched-specific fields. Resolution order (mirrors TS scheme): + // Add batched-specific fields. Receiver authorizer resolution order: // 1. Pre-existing requirements.Extra["receiverAuthorizer"] (caller override). // 2. Locally-configured ReceiverAuthorizerSigner address. // 3. Facilitator-advertised authorizer from supportedKind.Extra (delegated mode). // // Hard-fails if all three sources are empty/zero — clients would otherwise // derive the wrong channelId, and the onchain deposit transaction would - // revert at the contract boundary. Mirrors TS `enhancePaymentRequirements` - // which throws when no non-zero authorizer is available. + // revert at the contract boundary. if existing, ok := requirements.Extra["receiverAuthorizer"].(string); !ok || existing == "" || strings.EqualFold(existing, zeroAddress) { receiverAuth := s.GetReceiverAuthorizerAddress() if (receiverAuth == "" || strings.EqualFold(receiverAuth, zeroAddress)) && supportedKind.Extra != nil { @@ -654,8 +658,7 @@ func (s *BatchSettlementEvmScheme) SignClaimBatch(ctx context.Context, claims [] // rooted at this scheme's receiver and the network's default settlement asset. // // Pass a custom token via NewBatchSettlementChannelManager directly when you need a -// non-default settlement asset for this manager (mirrors TS -// `BatchSettlementEvmScheme.createChannelManager`). +// non-default settlement asset for this manager. func (s *BatchSettlementEvmScheme) CreateChannelManager(facilitator x402.FacilitatorClient, network x402.Network) *BatchSettlementChannelManager { token := "" if cfg, err := evm.GetNetworkConfig(string(network)); err == nil { diff --git a/go/mechanisms/evm/batch-settlement/server/scheme_test.go b/go/mechanisms/evm/batch-settlement/server/scheme_test.go index 8441494509..49888e001a 100644 --- a/go/mechanisms/evm/batch-settlement/server/scheme_test.go +++ b/go/mechanisms/evm/batch-settlement/server/scheme_test.go @@ -8,7 +8,7 @@ import ( x402 "github.com/x402-foundation/x402/go" "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" "github.com/x402-foundation/x402/go/types" ) @@ -256,7 +256,7 @@ func TestEnhancePaymentRequirements_DecimalAmountNormalized(t *testing.T) { } } -// Mirrors TS test "throws when neither server nor facilitator provides receiverAuthorizer". +// Rejects requirements when neither the server nor facilitator provides receiverAuthorizer. func TestEnhancePaymentRequirements_RejectsMissingReceiverAuthorizer(t *testing.T) { s := NewBatchSettlementEvmScheme("0xreceiver", nil) req := types.PaymentRequirements{ diff --git a/go/mechanisms/evm/batch-settlement/server/storage.go b/go/mechanisms/evm/batch-settlement/server/storage.go index ea27cd004d..864ed2afa9 100644 --- a/go/mechanisms/evm/batch-settlement/server/storage.go +++ b/go/mechanisms/evm/batch-settlement/server/storage.go @@ -3,13 +3,13 @@ package server import ( "sync" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) // PendingRequest reserves a channel against concurrent same-channel requests. -// Mirrors TS `PendingRequest`: a request is allowed when no live (unexpired) -// pending entry exists. Cleanup hooks clear the reservation; the bounded TTL -// guarantees release if cleanup never runs. +// A request is allowed when no live (unexpired) pending entry exists. Cleanup +// hooks clear the reservation; the bounded TTL guarantees release if cleanup +// never runs. type PendingRequest struct { PendingId string `json:"pendingId"` SignedMaxClaimable string `json:"signedMaxClaimable"` @@ -18,17 +18,16 @@ type PendingRequest struct { // ChannelSession holds per-channel session state on the server side. type ChannelSession struct { - ChannelId string `json:"channelId"` + ChannelId string `json:"channelId"` ChannelConfig batchsettlement.ChannelConfig `json:"channelConfig"` - Payer string `json:"payer"` - ChargedCumulativeAmount string `json:"chargedCumulativeAmount"` - SignedMaxClaimable string `json:"signedMaxClaimable"` - Signature string `json:"signature"` - Balance string `json:"balance"` - TotalClaimed string `json:"totalClaimed"` - WithdrawRequestedAt int `json:"withdrawRequestedAt"` - RefundNonce int `json:"refundNonce"` - LastRequestTimestamp int64 `json:"lastRequestTimestamp"` + ChargedCumulativeAmount string `json:"chargedCumulativeAmount"` + SignedMaxClaimable string `json:"signedMaxClaimable"` + Signature string `json:"signature"` + Balance string `json:"balance"` + TotalClaimed string `json:"totalClaimed"` + WithdrawRequestedAt int `json:"withdrawRequestedAt"` + RefundNonce int `json:"refundNonce"` + LastRequestTimestamp int64 `json:"lastRequestTimestamp"` // OnchainSyncedAt is the wall-clock time (unix millis) when balance/totalClaimed/ // withdrawRequestedAt/refundNonce were last refreshed from onchain state. // Used by the local voucher verifier to decide whether to skip facilitator verify. diff --git a/go/mechanisms/evm/batch-settlement/server/storage_test.go b/go/mechanisms/evm/batch-settlement/server/storage_test.go index 66e11e8bb2..4f056c3790 100644 --- a/go/mechanisms/evm/batch-settlement/server/storage_test.go +++ b/go/mechanisms/evm/batch-settlement/server/storage_test.go @@ -6,14 +6,13 @@ import ( "sync" "testing" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" ) func sampleSession(id, charged string) *ChannelSession { return &ChannelSession{ ChannelId: id, ChannelConfig: batchsettlement.ChannelConfig{Payer: "0x1", Receiver: "0x2"}, - Payer: "0x1", ChargedCumulativeAmount: charged, SignedMaxClaimable: "1000", Signature: "0xsig", diff --git a/go/mechanisms/evm/batch-settlement/storage_utils.go b/go/mechanisms/evm/batch-settlement/storage_utils.go index d85ff9b7c8..8f4bdc0e53 100644 --- a/go/mechanisms/evm/batch-settlement/storage_utils.go +++ b/go/mechanisms/evm/batch-settlement/storage_utils.go @@ -12,7 +12,6 @@ import ( ) // IsNotExist returns true when err is a "file does not exist" error. -// Mirrors TS isNodeEnoent. func IsNotExist(err error) bool { return errors.Is(err, fs.ErrNotExist) || errors.Is(err, os.ErrNotExist) } diff --git a/go/mechanisms/evm/batch-settlement/types.go b/go/mechanisms/evm/batch-settlement/types.go index 15d9b02b9b..125ceefc30 100644 --- a/go/mechanisms/evm/batch-settlement/types.go +++ b/go/mechanisms/evm/batch-settlement/types.go @@ -38,8 +38,7 @@ type ChannelState struct { // either an ERC-3009 ReceiveWithAuthorization (default) or a Permit2 // channel-bound PermitWitnessTransferFrom. Servers opt into a non-default // method by setting `accepts.extra.assetTransferMethod` on payment -// requirements; clients dispatch on the same field. Mirrors the TS -// `BatchSettlementAssetTransferMethod` union. +// requirements; clients dispatch on the same field. type AssetTransferMethod string const ( @@ -76,15 +75,15 @@ type BatchSettlementPermit2Witness struct { // BatchSettlementPermit2Authorization is the Permit2 PermitWitnessTransferFrom // authorization signed by the payer when the deposit uses the permit2 transfer -// method. Mirrors the TS `BatchSettlementPermit2Authorization` shape. +// method. type BatchSettlementPermit2Authorization struct { - From string `json:"from"` + From string `json:"from"` Permitted BatchSettlementPermit2TokenPermissions `json:"permitted"` - Spender string `json:"spender"` - Nonce string `json:"nonce"` - Deadline string `json:"deadline"` + Spender string `json:"spender"` + Nonce string `json:"nonce"` + Deadline string `json:"deadline"` Witness BatchSettlementPermit2Witness `json:"witness"` - Signature string `json:"signature"` + Signature string `json:"signature"` } // BatchSettlementVoucherFields holds the cumulative-ceiling voucher. @@ -94,9 +93,8 @@ type BatchSettlementVoucherFields struct { Signature string `json:"signature"` } -// BatchSettlementDepositAuthorization wraps asset-transfer authorization data. Exactly -// one of the fields is populated per deposit, matching the TS -// `BatchSettlementDepositAuthorization` discriminated union (`erc3009Authorization` +// BatchSettlementDepositAuthorization wraps asset-transfer authorization data. +// Exactly one of the fields is populated per deposit (`erc3009Authorization` // XOR `permit2Authorization`). type BatchSettlementDepositAuthorization struct { Erc3009Authorization *BatchSettlementErc3009Authorization `json:"erc3009Authorization,omitempty"` @@ -105,32 +103,32 @@ type BatchSettlementDepositAuthorization struct { // BatchSettlementDepositData is the deposit portion of a deposit payload. type BatchSettlementDepositData struct { - Amount string `json:"amount"` + Amount string `json:"amount"` Authorization BatchSettlementDepositAuthorization `json:"authorization"` } // BatchSettlementDepositPayload is sent on the first request to fund a channel. type BatchSettlementDepositPayload struct { - Type string `json:"type"` // "deposit" - ChannelConfig ChannelConfig `json:"channelConfig"` + Type string `json:"type"` // "deposit" + ChannelConfig ChannelConfig `json:"channelConfig"` Voucher BatchSettlementVoucherFields `json:"voucher"` Deposit BatchSettlementDepositData `json:"deposit"` } // BatchSettlementVoucherPayload is sent on subsequent requests (no new deposit). type BatchSettlementVoucherPayload struct { - Type string `json:"type"` // "voucher" - ChannelConfig ChannelConfig `json:"channelConfig"` + Type string `json:"type"` // "voucher" + ChannelConfig ChannelConfig `json:"channelConfig"` Voucher BatchSettlementVoucherFields `json:"voucher"` } // BatchSettlementRefundPayload is the client-side cooperative-refund request. // `Amount` is optional — when absent, it defaults to the full remaining balance. type BatchSettlementRefundPayload struct { - Type string `json:"type"` // "refund" - ChannelConfig ChannelConfig `json:"channelConfig"` + Type string `json:"type"` // "refund" + ChannelConfig ChannelConfig `json:"channelConfig"` Voucher BatchSettlementVoucherFields `json:"voucher"` - Amount string `json:"amount,omitempty"` + Amount string `json:"amount,omitempty"` } // BatchSettlementVoucherClaim is used in claim operations onchain. @@ -144,7 +142,7 @@ type BatchSettlementVoucherClaim struct { } // BatchSettlementChannelStateExtra is the public per-channel state snapshot embedded in -// settle/verify response extras. Mirrors TS `BatchSettlementChannelStateExtra`. +// settle/verify response extras. type BatchSettlementChannelStateExtra struct { ChannelId string `json:"channelId"` Balance string `json:"balance"` @@ -155,31 +153,30 @@ type BatchSettlementChannelStateExtra struct { } // BatchSettlementVoucherStateExtra is the public latest-voucher snapshot embedded in -// settle/verify response extras. Mirrors TS `BatchSettlementVoucherStateExtra`. +// settle/verify response extras. type BatchSettlementVoucherStateExtra struct { SignedMaxClaimable string `json:"signedMaxClaimable,omitempty"` Signature string `json:"signature,omitempty"` } // BatchSettlementPaymentResponseExtra carries channel state in settle/verify responses. -// Mirrors TS `BatchSettlementPaymentResponseExtra`. type BatchSettlementPaymentResponseExtra struct { - ChargedAmount string `json:"chargedAmount,omitempty"` + ChargedAmount string `json:"chargedAmount,omitempty"` ChannelState *BatchSettlementChannelStateExtra `json:"channelState,omitempty"` VoucherState *BatchSettlementVoucherStateExtra `json:"voucherState,omitempty"` } // BatchSettlementPaymentRequirementsExtra is the typed shape of the `extra` -// field on PaymentRequirements for the batch-settlement scheme. Mirrors the TS -// `BatchSettlementPaymentRequirementsExtra` type — the corrective-402 recovery -// payload is split across two camelCase keys: `channelState` (channel snapshot) -// and `voucherState` (latest signed voucher proof). +// field on PaymentRequirements for the batch-settlement scheme. The +// corrective-402 recovery payload is split across two camelCase keys: +// `channelState` (channel snapshot) and `voucherState` (latest signed voucher +// proof). type BatchSettlementPaymentRequirementsExtra struct { - ReceiverAuthorizer string `json:"receiverAuthorizer"` - WithdrawDelay int `json:"withdrawDelay"` - Name string `json:"name"` - Version string `json:"version"` - AssetTransferMethod string `json:"assetTransferMethod,omitempty"` // "eip3009" or "permit2" + ReceiverAuthorizer string `json:"receiverAuthorizer"` + WithdrawDelay int `json:"withdrawDelay"` + Name string `json:"name"` + Version string `json:"version"` + AssetTransferMethod string `json:"assetTransferMethod,omitempty"` // "eip3009" or "permit2" ChannelState *BatchSettlementChannelStateExtra `json:"channelState,omitempty"` VoucherState *BatchSettlementVoucherStateExtra `json:"voucherState,omitempty"` } @@ -191,16 +188,16 @@ type FileChannelStorageOptions struct { } // --- Settle Action Payloads (server -> facilitator) --- -// All settle-action payloads use the `type` discriminator (same field as -// client-side payloads), matching TS BatchSettlementFacilitatorSettlePayload. +// All settle-action payloads use the `type` discriminator, the same field as +// client-side payloads. // BatchSettlementClaimPayload batches claims with receiverAuthorizer signature. // ClaimAuthorizerSignature is optional — when absent, the facilitator auto-signs // using its AuthorizerSigner. type BatchSettlementClaimPayload struct { - Type string `json:"type"` // "claim" + Type string `json:"type"` // "claim" Claims []BatchSettlementVoucherClaim `json:"claims"` - ClaimAuthorizerSignature string `json:"claimAuthorizerSignature,omitempty"` + ClaimAuthorizerSignature string `json:"claimAuthorizerSignature,omitempty"` } // BatchSettlementSettlePayload transfers claimed funds to receiver. @@ -216,14 +213,14 @@ type BatchSettlementSettlePayload struct { // ClaimAuthorizerSignature are optional — when absent, the facilitator // auto-signs via its AuthorizerSigner. type BatchSettlementEnrichedRefundPayload struct { - Type string `json:"type"` // "refund" - ChannelConfig ChannelConfig `json:"channelConfig"` + Type string `json:"type"` // "refund" + ChannelConfig ChannelConfig `json:"channelConfig"` Voucher BatchSettlementVoucherFields `json:"voucher"` - Amount string `json:"amount"` - RefundNonce string `json:"refundNonce"` + Amount string `json:"amount"` + RefundNonce string `json:"refundNonce"` Claims []BatchSettlementVoucherClaim `json:"claims"` - RefundAuthorizerSignature string `json:"refundAuthorizerSignature,omitempty"` - ClaimAuthorizerSignature string `json:"claimAuthorizerSignature,omitempty"` + RefundAuthorizerSignature string `json:"refundAuthorizerSignature,omitempty"` + ClaimAuthorizerSignature string `json:"claimAuthorizerSignature,omitempty"` } // ============================================================================ diff --git a/go/mechanisms/evm/batch-settlement/utils.go b/go/mechanisms/evm/batch-settlement/utils.go index 69c97aeace..9abc7570e3 100644 --- a/go/mechanisms/evm/batch-settlement/utils.go +++ b/go/mechanisms/evm/batch-settlement/utils.go @@ -13,7 +13,7 @@ import ( // ComputeChannelId computes the chain-bound channel ID from a ChannelConfig // via EIP-712 hashTypedData. The networkOrChainID argument may be either a // CAIP-2 network identifier (e.g. "eip155:84532") or a numeric chain id as -// a *big.Int. Matches TS computeChannelId(config, networkOrChainId). +// a *big.Int. func ComputeChannelId(config ChannelConfig, networkOrChainID interface{}) (string, error) { chainID, err := resolveChainID(networkOrChainID) if err != nil { @@ -78,7 +78,7 @@ func NormalizeChannelId(channelId string) string { } // GetBatchSettlementEip712Domain returns the EIP-712 domain for the -// batch-settlement contract on the given chain. Mirrors TS getBatchSettlementEip712Domain. +// batch-settlement contract on the given chain. func GetBatchSettlementEip712Domain(chainID *big.Int) evm.TypedDataDomain { return evm.TypedDataDomain{ Name: BatchSettlementDomain.Name, diff --git a/go/server.go b/go/server.go index 6f35ff3e88..88834a72ce 100644 --- a/go/server.go +++ b/go/server.go @@ -685,8 +685,7 @@ func (s *x402ResourceServer) VerifyPayment(ctx context.Context, payload types.Pa // VerifyPaymentWithExtensions verifies a V2 payment, gating extension hooks // on the supplied `declaredExtensions` map (keys must be present for the // extension's hook to fire). Hook execution order: manual → matched scheme → -// declared extensions. Mirrors TS `verifyPayment(payload, requirements, -// declaredExtensions)`. +// declared extensions. func (s *x402ResourceServer) VerifyPaymentWithExtensions( ctx context.Context, payload types.PaymentPayload, @@ -904,7 +903,7 @@ func (s *x402ResourceServer) SettlePaymentWithExtensions( // framework merges into payload.Payload after the additive policy has // rejected any attempt to overwrite existing keys. s.mu.RLock() - matchedSchemeServer, _ := s.schemes[network][scheme] + matchedSchemeServer := s.schemes[network][scheme] s.mu.RUnlock() if enricher, ok := matchedSchemeServer.(EnrichSettlementPayloadProvider); ok { enrichment, err := enricher.EnrichSettlementPayload(hookCtx) diff --git a/go/server_hook_policy.go b/go/server_hook_policy.go index 327dc7fe35..69560024a6 100644 --- a/go/server_hook_policy.go +++ b/go/server_hook_policy.go @@ -12,25 +12,22 @@ import ( // Hook Mutation Policy Guards // ============================================================================ // -// Mirrors TS `typescript/packages/core/src/server/hookPolicy.ts`. These -// helpers replace the compile-time `DeepReadonly` enforcement TS uses on hook -// contexts: extensions and schemes are free to inspect everything but -// allowed to mutate only specific fields. The framework snapshots the -// affected structures before invoking a hook and asserts the diff afterwards. +// These helpers enforce hook-context immutability at runtime: extensions and +// schemes are free to inspect everything but allowed to mutate only specific +// fields. The framework snapshots the affected structures before invoking a +// hook and asserts the diff afterwards. // // Violations panic-via-error rather than silently corrupting downstream // state — catching policy bugs at the point of misuse. // IsVacantStringField reports whether a string field is treated as unset -// and may be filled by `enrichPaymentRequiredResponse`. Mirrors TS -// `isVacantStringField`. +// and may be filled by `enrichPaymentRequiredResponse`. func IsVacantStringField(value string) bool { return strings.TrimSpace(value) == "" } // SnapshotPaymentRequirementsList deep-clones `requirements` so the result -// can serve as an immutable baseline for policy checks. Mirrors TS -// `snapshotPaymentRequirementsList`. +// can serve as an immutable baseline for policy checks. func SnapshotPaymentRequirementsList(requirements []types.PaymentRequirements) []types.PaymentRequirements { if requirements == nil { return nil @@ -47,7 +44,7 @@ func SnapshotPaymentRequirementsList(requirements []types.PaymentRequirements) [ // AssertAcceptsAllowlistedAfterExtensionEnrich enforces the extension-side // `enrichPaymentRequiredResponse` mutation policy: extensions may fill vacant // `payTo` / `amount` / `asset` and add new `extra` keys; everything else is -// immutable. Mirrors TS `assertAcceptsAllowlistedAfterExtensionEnrich`. +// immutable. func AssertAcceptsAllowlistedAfterExtensionEnrich( baseline, current []types.PaymentRequirements, extensionKey string, @@ -95,7 +92,7 @@ func AssertAcceptsAllowlistedAfterExtensionEnrich( // `enrichPaymentRequiredResponse` policy: schemes may only ADD new `extra` // keys to the matching accept entry; payment terms (payTo / amount / asset / // maxTimeoutSeconds) and scheme/network are immutable; non-matching accepts -// must be untouched. Mirrors TS `assertAcceptsAdditiveExtraAfterSchemeEnrich`. +// must be untouched. func AssertAcceptsAdditiveExtraAfterSchemeEnrich( baseline, current []types.PaymentRequirements, scheme, network string, @@ -131,7 +128,7 @@ func AssertAcceptsAdditiveExtraAfterSchemeEnrich( } // SettleResponseCoreSnapshot captures facilitator-settled fields that -// extensions must not rewrite. Mirrors TS `SettleResponseCoreSnapshot`. +// extensions must not rewrite. type SettleResponseCoreSnapshot struct { Success bool Transaction string @@ -142,8 +139,7 @@ type SettleResponseCoreSnapshot struct { ErrorMessage string } -// SnapshotSettleResponseCore captures facilitator-settled fields. Mirrors -// TS `snapshotSettleResponseCore`. +// SnapshotSettleResponseCore captures facilitator-settled fields. func SnapshotSettleResponseCore(result *SettleResponse) SettleResponseCoreSnapshot { if result == nil { return SettleResponseCoreSnapshot{} @@ -160,8 +156,7 @@ func SnapshotSettleResponseCore(result *SettleResponse) SettleResponseCoreSnapsh } // AssertSettleResponseCoreUnchanged enforces that an extension did not -// rewrite facilitator outcome fields. Mirrors TS -// `assertSettleResponseCoreUnchanged`. +// rewrite facilitator outcome fields. func AssertSettleResponseCoreUnchanged(before SettleResponseCoreSnapshot, after *SettleResponse, extensionKey string) error { if after == nil { return fmt.Errorf(`[x402] extension %q violated settlement mutation policy: settle result became nil`, extensionKey) @@ -192,7 +187,6 @@ func AssertSettleResponseCoreUnchanged(before SettleResponseCoreSnapshot, after // AssertAdditivePayloadEnrichment ensures a scheme's // `EnrichSettlementPayload` only ADDS new keys to the existing payload. -// Mirrors TS `assertAdditivePayloadEnrichment`. func AssertAdditivePayloadEnrichment(payload, enrichment map[string]interface{}, callerLabel string) error { for key := range enrichment { if _, exists := payload[key]; exists { @@ -204,15 +198,13 @@ func AssertAdditivePayloadEnrichment(payload, enrichment map[string]interface{}, // AssertAdditiveSettlementExtra ensures a scheme's // `EnrichSettlementResponse` only ADDS new fields to the response extra, -// recursively for nested plain objects. Mirrors TS -// `assertAdditiveSettlementExtra`. +// recursively for nested plain objects. func AssertAdditiveSettlementExtra(extra, enrichment map[string]interface{}, callerLabel string) error { return assertAdditiveRecord(extra, enrichment, callerLabel, "extra") } // MergeAdditiveSettlementExtra deep-merges `enrichment` into `extra` after -// the additive policy has been validated. Mirrors TS -// `mergeAdditiveSettlementExtra`. +// the additive policy has been validated. func MergeAdditiveSettlementExtra(extra, enrichment map[string]interface{}) map[string]interface{} { return mergeAdditiveRecord(extra, enrichment) } diff --git a/go/server_hooks.go b/go/server_hooks.go index b3ea8a0241..429a1c4a73 100644 --- a/go/server_hooks.go +++ b/go/server_hooks.go @@ -38,7 +38,7 @@ type VerifyFailureContext struct { // SkipHandlerDirective is an optional acknowledgement body returned to the caller // when an AfterVerifyHook requests that the resource handler be skipped for a -// self-contained operation (e.g. cooperative refund). Travels in-process only — +// self-contained operation. Travels in-process only — // never on the facilitator wire. type SkipHandlerDirective struct { ContentType string diff --git a/go/test/integration/evm_batch_settlement_test.go b/go/test/integration/evm_batch_settlement_test.go index aefd9ba2c8..e86bbf0be1 100644 --- a/go/test/integration/evm_batch_settlement_test.go +++ b/go/test/integration/evm_batch_settlement_test.go @@ -40,7 +40,7 @@ import ( x402http "github.com/x402-foundation/x402/go/http" nethttpmw "github.com/x402-foundation/x402/go/http/nethttp" evmmech "github.com/x402-foundation/x402/go/mechanisms/evm" - "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" + batchsettlement "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement" batchedclient "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement/client" batchedfacilitator "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement/facilitator" batchedserver "github.com/x402-foundation/x402/go/mechanisms/evm/batch-settlement/server" diff --git a/go/test/integration/http_test.go b/go/test/integration/http_test.go index d25d0e6941..99dc5a9cb4 100644 --- a/go/test/integration/http_test.go +++ b/go/test/integration/http_test.go @@ -218,6 +218,7 @@ func TestHTTPIntegration(t *testing.T) { *httpProcessResult2.PaymentRequirements, nil, nil, + nil, ) if !settlementResult.Success { t.Fatalf("Failed to process settlement: %v", settlementResult.ErrorReason)