diff --git a/docs/extensions/builder-code.mdx b/docs/extensions/builder-code.mdx index 3235523ef6..1a74b2ca7e 100644 --- a/docs/extensions/builder-code.mdx +++ b/docs/extensions/builder-code.mdx @@ -18,6 +18,7 @@ Three parties each contribute an attribution code: | `a` | Resource server | App code — identifies the application exposing the paid endpoint | | `w` | Facilitator | Wallet code — identifies the facilitator settling the payment on-chain | | `s` | Client, server, and/or facilitator | Service code(s) — attribution codes from the payment path; each party has its own dedicated reservation (`MAX_CLIENT_SERVICE_CODES`, `MAX_SERVER_SERVICE_CODES`, `MAX_FACILITATOR_SERVICE_CODES`) so declaring up to that amount never crowds out another party's codes | +| `m` | Facilitator (at settlement) | Settlement metadata — facilitator-authored key/value map supplied by the settling mechanism; keys must start with `x402` and values must be unsigned integers, strings, arrays, or maps with string keys | These codes are CBOR-encoded as an [ERC-8021](https://eip.tools/eip/8021) Schema 2 suffix and appended to the settlement transaction calldata. Off-chain tools can parse the calldata to verify attribution. @@ -337,7 +338,7 @@ const tx = await publicClient.getTransaction({ hash: txHash }); const attribution = parseBuilderCodeSuffixFromCalldata(tx.input); console.log(attribution); -// { a: "bc_my_app", s: ["bc_my_client"], w: "bc_my_facilitator" } +// { a: "bc_my_app", s: ["bc_my_client"], w: "bc_my_facilitator", m: { x402Example: 7n } } ``` ## ERC-8021 Schema 2 Calldata Format @@ -349,7 +350,7 @@ The suffix appended to settlement calldata (reading from the end backwards): | `ercMarker` | 16 bytes | Constant: `80218021802180218021802180218021` | | `schemaId` | 1 byte | `0x02` for Schema 2 | | `cborLength` | 2 bytes | Length of CBOR data (big-endian) | -| `cborData` | variable | CBOR-encoded map with `a`, `w`, and/or `s` fields | +| `cborData` | variable | CBOR-encoded map with `a`, `w`, `s`, and/or `m` fields | Wire order: `[cborData][cborLength (2B)][schemaId (1B)][ercMarker (16B)]` @@ -411,18 +412,25 @@ facilitator.registerExtension( ); ``` -Reads `a` and `s` from the payment payload at settlement time, adds `w`, appends its own `serviceCode` to `s` (deduped), and encodes the ERC-8021 CBOR suffix. On v1 payloads, `a` is omitted from the suffix (the resource-server echo gate that validates `a` does not run on v1). +Reads `a` and `s` from the payment payload at settlement time, adds `w`, appends its own `serviceCode` to `s` (deduped), includes `m` when the settling mechanism supplies settlement metadata, and encodes the ERC-8021 CBOR suffix. On v1 payloads, `a` is omitted from the suffix (the resource-server echo gate that validates `a` does not run on v1). + +The `m` field is written exclusively by the facilitator at settlement — it never appears in `PaymentRequired` or `PaymentPayload`, and the facilitator ignores any `m` found in a client payload. Keys inside `m` must start with `x402` and are defined by the scheme spec that emits them. ### Utilities #### `encodeBuilderCodeSuffix(data)` -Encodes builder code fields as an ERC-8021 Schema 2 hex suffix. +Encodes builder code fields as an ERC-8021 Schema 2 hex suffix. Accepts `a`, `w`, `s`, and `m` fields. Throws when the resulting CBOR exceeds 65,535 bytes. ```typescript import { encodeBuilderCodeSuffix } from "@x402/extensions/builder-code"; -const suffix = encodeBuilderCodeSuffix({ a: "bc_my_app", w: "bc_my_fac", s: ["bc_my_client"] }); +const suffix = encodeBuilderCodeSuffix({ + a: "bc_my_app", + w: "bc_my_fac", + s: ["bc_my_client"], + m: { x402Example: 7n }, +}); // Returns hex string to append to calldata ``` @@ -434,7 +442,8 @@ Parses ERC-8021 Schema 2 attribution from settlement transaction calldata. import { parseBuilderCodeSuffixFromCalldata } from "@x402/extensions/builder-code"; const attribution = parseBuilderCodeSuffixFromCalldata(tx.input); -// Returns { a?, w?, s? } or null if no valid suffix found +// Returns { a?, w?, s?, m? } or null if no valid suffix found +// Unsigned integers inside `m` are returned as bigint ``` ### Constants @@ -452,6 +461,8 @@ import { } from "@x402/extensions/builder-code"; ``` +Types exported include `BuilderCodeExtensionData`, `BuilderCodeSuffixData` (extends `BuilderCodeExtensionData` with `m?`), `SettlementMetadata`, `BuilderCodeFacilitatorConfig`, `BuilderCodeRequiredExtension`, and `DataSuffixContext`. + ## Examples Full working examples are available in the x402 repository: diff --git a/docs/schemes/overview.mdx b/docs/schemes/overview.mdx index d4c3914184..82a97ff2a9 100644 --- a/docs/schemes/overview.mdx +++ b/docs/schemes/overview.mdx @@ -1,9 +1,9 @@ --- title: "Payment Schemes" -description: "Choose between exact, upto, and batch-settlement payment schemes in x402." +description: "Choose between exact, upto, batch-settlement, and auth-capture payment schemes in x402." --- -Payment schemes define the payment semantics for a resource: `exact` is for fixed-price requests where the buyer authorizes the advertised amount, `upto` is for single-request usage-based billing where the buyer authorizes a maximum and the seller charges actual usage, and `batch-settlement` is for high-volume or repeated micropayments where per-request authorizations are accumulated against a reusable channel. Network implementations define how those semantics are encoded for EVM, SVM, TVM (TON), AVM, Stellar, Aptos, Hedera, Keeta, Concordium, NEAR, XRPL, and Bitcoin Lightning (`lnbtc`); see [SDK Features](/sdk-features) for current SDK support. +Payment schemes define the payment semantics for a resource: `exact` is for fixed-price requests where the buyer authorizes the advertised amount, `upto` is for single-request usage-based billing where the buyer authorizes a maximum and the seller charges actual usage, `batch-settlement` is for high-volume or repeated micropayments where per-request authorizations are accumulated against a reusable channel, and `auth-capture` is for refundable payments where funds are held in escrow and released (captured, voided, or refunded) after the resource is served. Network implementations define how those semantics are encoded for EVM, SVM, TVM (TON), AVM, Stellar, Aptos, Hedera, Keeta, Concordium, NEAR, XRPL, and Bitcoin Lightning (`lnbtc`); see [SDK Features](/sdk-features) for current SDK support. ### Schemes in a 402 Response @@ -60,7 +60,7 @@ Every scheme declares which payment flows it supports per asset transfer method. The server resolves the flow from the scheme's `paymentFlows` table using the `assetTransferMethod` in the payment requirements. When the resolved flow is not `authorization`, the server signals it to clients via `extra.paymentFlow` in the 402 response. -The `exact` scheme supports both `authorization` (default) and `upfront` flows on all networks. Servers opt in per route by setting `extra.paymentFlow: "upfront"` in the route's `accepts` config. The `upto` and `batch-settlement` schemes support `authorization` only. +The `exact` scheme supports both `authorization` (default) and `upfront` flows on all networks. Servers opt in per route by setting `extra.paymentFlow: "upfront"` in the route's `accepts` config. The `upto` and `batch-settlement` schemes support `authorization` only. The `auth-capture` scheme supports `escrow` (default) and `authorization` flows, selected per route via `extra.paymentFlow`. ### Specs @@ -69,3 +69,4 @@ For scheme-level payload semantics, see the network-agnostic specifications. Eac * [`exact` spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact.md) * [`upto` spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/upto/scheme_upto.md) * [`batch-settlement` spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/batch-settlement/scheme_batch_settlement.md) +* [`auth-capture` spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/auth-capture/scheme_auth_capture_evm.md) diff --git a/docs/sdk-features.md b/docs/sdk-features.md index a9b2c3c3c1..1e3709c6ff 100644 --- a/docs/sdk-features.md +++ b/docs/sdk-features.md @@ -66,8 +66,8 @@ This page tracks which features are implemented in each SDK (TypeScript, Go, Pyt | batch-settlement | evm | `eip3009` | ✅ | ✅ | ✅ | | batch-settlement | evm | `permit2` | ✅ | ✅ | ✅ | | batch-settlement | svm | - | ✅ | ✅ | ❌ | -| auth-capture | evm | `eip3009` | ✅ (client) | ✅ (client) | ❌ | -| auth-capture | evm | `permit2` | ✅ (client) | ✅ (client) | ❌ | +| auth-capture | evm | `eip3009` | ✅ | ✅ | ❌ | +| auth-capture | evm | `permit2` | ✅ | ✅ | ❌ | ## Extensions diff --git a/e2e/.env-local b/e2e/.env-local index 21bf175f78..77301f9eec 100644 --- a/e2e/.env-local +++ b/e2e/.env-local @@ -5,7 +5,7 @@ # Server payment addresses SERVER_EVM_ADDRESS= SERVER_SVM_ADDRESS= -# Optional batch-settlement / upto (see e2e/README.md) +# Optional: self-managed auth-capture sync routes (/auth-capture/evm/*). Leave unset for facilitator-delegated auth-capture routes. SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= SERVER_SVM_OPERATOR_PRIVATE_KEY= @@ -46,6 +46,8 @@ CLIENT_CARDANO_MNEMONIC= # Facilitator wallets (XRPL facilitator is keyless — no FACILITATOR_XRPL_*) FACILITATOR_EVM_PRIVATE_KEY= +# Optional auth-capture custom operator allowlist (defaults to Base Sepolia ForwardingOperator) +# FACILITATOR_EVM_AUTH_CAPTURE_CUSTOM_OPERATORS=0x8FE415CdB559fBF5B235B81CC4F7a69684A274bb FACILITATOR_SVM_PRIVATE_KEY= FACILITATOR_SVM_BATCH_BINDING_STORE= SVM_ARCHIVE_RPC_URL= diff --git a/e2e/README.md b/e2e/README.md index 044bc95b6b..9a62bae5b8 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -14,7 +14,7 @@ You do **not** need to edit `generic-server` / `generic-client` / `generic-facil - **`env`** — map of env key → `{ required: boolean, roles: ["server"|"client"|"facilitator", ...] }`. Every key a role reads (including unprefixed ones like `TVM_PROVIDER` or `EVM_PERMIT2_ASSET`) is declared here; [`src/mechanisms.ts`](src/mechanisms.ts) has no hardcoded role override table. Prefix (`SERVER_` / `CLIENT_` / `FACILITATOR_`) is only a fallback for undeclared keys. - **`testnet` / `mainnet`** — `name`, `caip2`, optional `rpcUrlDefault`, optional `permit2Asset`/`permit2AssetName`. RPC env is pure convention, not declared: an operator sets `${ID}_TESTNET_RPC_URL` / `${ID}_MAINNET_RPC_URL` (e.g. `EVM_TESTNET_RPC_URL`), and the harness injects it into every spawned component as `${ID}_RPC_URL`. Set `rpcUrlRequired: true` on a mode with no `rpcUrlDefault` and no free public endpoint at all (a network whose SDK has no built-in node default, unlike e.g. Hedera/Keeta) so the harness fails fast at startup — with the missing input key named in the same preflight list as other required env — instead of deep inside a scenario run. Network identity defaults (`${ID}_NETWORK`) fall back to catalog `testnet.caip2` via `resolveNetworkCaip2`. -- **`routes`** — one canonical definition per paid HTTP path: `scheme`, `sdks`, `assetTransferMethod`, `schemeOptions`, declared `extensions`, required `price`, and optional `settlementOverride`. Handlers always return `{ message: "Protected endpoint accessed successfully", timestamp }`. The loader injects `network` (the file id) — routes never declare it themselves. +- **`routes`** — one canonical definition per paid HTTP path: `scheme`, `sdks`, `assetTransferMethod`, `schemeOptions`, `schemeExtra`, declared `extensions`, required `price`, and optional `settlementOverride`. Handlers always return `{ message: "Protected endpoint accessed successfully", timestamp }`. The loader injects `network` (the file id) — routes never declare it themselves. CI family selection ([`scripts/ci-select-families.sh`](scripts/ci-select-families.sh) → [`scripts/ci-select-families.ts`](scripts/ci-select-families.ts)) prints families whose catalog `required: true` keys are all set — no per-family hardcoding in the shell script. @@ -28,13 +28,13 @@ Every SDK reads this same set of files. The harness ([`src/mechanisms.ts`](src/m Resource servers resolve the same data at boot — payment middleware config **and** route handlers — through a per-language loader: [`servers/typescript/catalog.ts`](servers/typescript/catalog.ts), [`servers/python/catalog.py`](servers/python/catalog.py), [`servers/go/catalog.go`](servers/go/catalog.go). No framework entrypoint hardcodes a path, price, or extension; each loops over its resolved routes. The harness passes the catalog directory in `E2E_MECHANISMS_CATALOG`, and each loader falls back to walking up to `e2e/config/` so a server still runs standalone from its own directory. -Route support is **listed** on each route via `sdks`, never inferred from a cartesian product. Scheme **registration** stays in the language-root client/server modules and facilitator mains. +Route support is **listed** on each route via `sdks` (and `clientSdks` for SDKs that implement only the client role), never inferred from a cartesian product. Scheme **registration** stays in the language-root client/server modules and facilitator mains. ## Add a mechanism Adding a paid route to every SDK that should serve it is a catalog edit: -1. **Define the route** — add an entry under `routes` in the relevant `config/mechanisms_.json`, keyed by its path, with `scheme`, `sdks` (e.g. `["typescript", "go", "python"]`), and `price`. Add `extensions`, `schemeOptions`, or `settlementOverride` only where the route needs them. +1. **Define the route** — add an entry under `routes` in the relevant `config/mechanisms_.json`, keyed by its path, with `scheme`, `sdks` (e.g. `["typescript", "go", "python"]`), and `price`. Add `extensions`, `schemeOptions`, `schemeExtra` (auth-capture deadlines/flow), or `settlementOverride` only where the route needs them. An SDK that implements only the client role goes in `clientSdks` instead; it then pairs with servers and facilitators from other SDKs but is never asked to serve the route. 2. **Register the scheme once per language**, if it is new: server module (`servers//`), client module (`clients//`), and the facilitator main. Servers pick up the route, its `402` payment requirements, and its handler with no per-framework edit. A surface that serves less than its SDK’s list can declare the narrowing in a local `test.config.json` (`excludeSchemes` / `excludeNetworks`); the harness applies it to the derived endpoints and forwards it to the server process (`E2E_EXCLUDE_SCHEMES` / `E2E_EXCLUDE_NETWORKS`), so declared and mounted routes cannot diverge. @@ -70,7 +70,7 @@ These keep local `test.config.json` overlays and/or special orchestration — no | Swig smart wallet | Client overlay [`clients/typescript/http/svm-smart-wallet/test.config.json`](clients/typescript/http/svm-smart-wallet/test.config.json) (`protocolFamilies`, `facilitators`, Swig env) + [`scripts/swig-setup.ts`](scripts/swig-setup.ts); uses catalog route `/exact/svm` | | Legacy (v1) | `legacy/` trees only — separate configs; do not extend the mechanisms catalog for v1 | -If an SDK implements a route end-to-end (client + server + facilitator), list it in that route’s `sdks`. Omit only when the mechanism package is missing (e.g. Go has no TVM; Python/Go have no AVM/NEAR/XRPL; Python has no SVM upto). +If an SDK implements a route end-to-end (client + server + facilitator), list it in that route’s `sdks`. Omit only when the mechanism package is missing (e.g. Go has no TVM; Python/Go have no AVM/NEAR/XRPL; Python has no SVM upto). Where an SDK has only a client, list it in `clientSdks`. ## Legacy @@ -136,7 +136,7 @@ Launches an interactive CLI where you can select: - **Clients** - Payment-capable HTTP clients (axios, fetch, httpx, requests, etc.) - **Extensions** - Additional features like Bazaar discovery - **Protocols** - EVM, SVM, AVM, Aptos, Concordium, Hedera, NEAR, Stellar, and/or TVM networks -- **Payment schemes** (when multiple apply) - `exact`, `upto`, or `batch-settlement` +- **Payment schemes** (when multiple apply) - `exact`, `upto`, `batch-settlement`, or `auth-capture` - **Payment flows** (when multiple apply) - `authorization`, `upfront`, or `escrow` - **Asset transfer methods** (when multiple apply) - `eip3009`, `permit2`, `sequence`, or `ticketSequence` @@ -302,9 +302,11 @@ Optional environment variables (batch-settlement scheme): ```bash # EVM -SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=0x... # optional: self-managed receiver authorizer (omit to delegate to facilitator /supported) +SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=0x... # self-managed auth-capture sync (/auth-capture/evm/*) and optional batch-settlement receiver authorizer; omit for facilitator-delegated auth-capture routes CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY=0x... # EOA the client uses to sign vouchers EVM_BATCH_SETTLEMENT_RECOVERY=true # test client state-loss recovery scenario (default: true) +FACILITATOR_EVM_AUTH_CAPTURE_CUSTOM_OPERATORS=0x8FE4... # optional comma-separated custom operator allowlist (defaults to ForwardingOperator on Base Sepolia) +# Auth-capture: SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY is required for /auth-capture/evm/* (server-signed sync capture). The facilitator-authorizer, deferred, and custom-forwarding routes always run in the same suite; deferred capture is triggered by the harness after the client GET. With the server key set you get full auth-capture coverage (5 EVM paths); without it only the three facilitator-delegated paths run (/auth-capture/evm/* are omitted via requiresEnv). # SVM SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=... # required for /upto/svm and /batch-settlement/svm; signs upto vouchers and the batch receiver authorizer (no SOL required) diff --git a/e2e/clients/go/client.go b/e2e/clients/go/client.go index ee5450205c..1a4a8c1d28 100644 --- a/e2e/clients/go/client.go +++ b/e2e/clients/go/client.go @@ -14,12 +14,13 @@ import ( x402 "github.com/x402-foundation/x402/go/v2" x402http "github.com/x402-foundation/x402/go/v2/http" + authcaptureclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/client" batchedclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/batch-settlement/client" - batchsvmclient "github.com/x402-foundation/x402/go/v2/mechanisms/svm/batch-settlement/client" exactevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/client" exactevmv1 "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/v1/client" uptoevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/upto/client" svmconfig "github.com/x402-foundation/x402/go/v2/mechanisms/svm" + batchsvmclient "github.com/x402-foundation/x402/go/v2/mechanisms/svm/batch-settlement/client" svm "github.com/x402-foundation/x402/go/v2/mechanisms/svm/exact/client" svmv1 "github.com/x402-foundation/x402/go/v2/mechanisms/svm/exact/v1/client" uptosvm "github.com/x402-foundation/x402/go/v2/mechanisms/svm/upto/client" @@ -51,9 +52,9 @@ type SettleResponseExtractor interface { // ClientContext holds a configured payment-capable HTTP client for e2e runs. type ClientContext struct { - URL string - HTTPClient *http.Client - Settle SettleResponseExtractor + URL string + HTTPClient *http.Client + Settle SettleResponseExtractor BatchedEvmScheme *batchedclient.BatchSettlementEvmScheme BatchedSvmScheme *batchsvmclient.BatchSvmScheme BatchPhase string @@ -64,7 +65,7 @@ type ClientContext struct { // Transports that speak something other than plain HTTP (e.g. MCP) wrap // Client themselves instead of going through Newx402HTTPClient. type PaymentClientContext struct { - Client *x402.X402Client + Client *x402.X402Client BatchedEvmScheme *batchedclient.BatchSettlementEvmScheme BatchedSvmScheme *batchsvmclient.BatchSvmScheme BatchPhase string @@ -129,6 +130,7 @@ func BuildPaymentClient() *PaymentClientContext { Register(evmPattern, exactevm.NewExactEvmScheme(evmSigner, evmConfig)). Register(evmPattern, uptoevm.NewUptoEvmScheme(evmSigner, uptoConfig)). Register(evmPattern, batchedEvmScheme). + Register(evmPattern, authcaptureclient.NewAuthCaptureEvmScheme(evmSigner)). RegisterV1("base-sepolia", exactevmv1.NewExactEvmSchemeV1(evmSigner)). RegisterV1("base", exactevmv1.NewExactEvmSchemeV1(evmSigner)) } diff --git a/e2e/clients/typescript/client.ts b/e2e/clients/typescript/client.ts index f69947bb99..d20460f6ae 100644 --- a/e2e/clients/typescript/client.ts +++ b/e2e/clients/typescript/client.ts @@ -6,6 +6,7 @@ import { UptoEvmScheme as UptoEvmClientScheme, type UptoEvmSchemeOptions, } from "@x402/evm/upto/client"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; import { ExactEvmSchemeV1 } from "@x402/evm/v1"; import { toClientEvmSigner } from "@x402/evm"; @@ -160,6 +161,7 @@ export async function createE2EClient(): Promise { client: new UptoEvmClientScheme(evmSigner, uptoSchemeOptions), }, { network: networkCaip2Pattern("evm"), client: evmBatchSettlementScheme }, + { network: networkCaip2Pattern("evm"), client: new AuthCaptureEvmScheme(evmSigner) }, { network: "base-sepolia", client: new ExactEvmSchemeV1(evmSigner), x402Version: 1 }, { network: "base", client: new ExactEvmSchemeV1(evmSigner), x402Version: 1 }, ); diff --git a/e2e/config/mechanisms_evm.json b/e2e/config/mechanisms_evm.json index 2343eaccd8..5b9eb08f55 100644 --- a/e2e/config/mechanisms_evm.json +++ b/e2e/config/mechanisms_evm.json @@ -326,6 +326,140 @@ "settlementOverride": { "amount": "1000" } + }, + "/auth-capture/evm/eip3009": { + "scheme": "auth-capture", + "assetTransferMethod": "eip3009", + "requiresEnv": "SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY", + "sdks": [ + "typescript", + "go" + ], + "paymentFlow": "escrow", + "price": { + "usd": "$0.001" + }, + "extensions": [ + "bazaar" + ], + "schemeExtra": { + "feeRecipient": "0x0000000000000000000000000000000000000000", + "minFeeBps": 0, + "maxFeeBps": 0, + "captureDeadlineSeconds": 3600, + "refundDeadlineSeconds": 7200, + "operatorType": "delegated", + "paymentFlow": "escrow", + "captureMode": "sync" + } + }, + "/auth-capture/evm/permit2": { + "scheme": "auth-capture", + "assetTransferMethod": "permit2", + "requiresEnv": "SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY", + "sdks": [ + "typescript", + "go" + ], + "paymentFlow": "escrow", + "schemeOptions": { + "permit2Direct": true + }, + "price": { + "amount": "1000", + "assetRef": "permit2", + "permit2Domain": true + }, + "extensions": [ + "bazaar" + ], + "schemeExtra": { + "feeRecipient": "0x0000000000000000000000000000000000000000", + "minFeeBps": 0, + "maxFeeBps": 0, + "captureDeadlineSeconds": 3600, + "refundDeadlineSeconds": 7200, + "operatorType": "delegated", + "paymentFlow": "escrow", + "captureMode": "sync" + } + }, + "/auth-capture-facilitator-authorizer/evm/eip3009": { + "scheme": "auth-capture", + "assetTransferMethod": "eip3009", + "sdks": [ + "typescript", + "go" + ], + "paymentFlow": "escrow", + "price": { + "usd": "$0.001" + }, + "extensions": [ + "bazaar" + ], + "schemeExtra": { + "feeRecipient": "0x0000000000000000000000000000000000000000", + "minFeeBps": 0, + "maxFeeBps": 0, + "captureDeadlineSeconds": 3600, + "refundDeadlineSeconds": 7200, + "operatorType": "delegated", + "paymentFlow": "escrow", + "captureMode": "sync" + } + }, + "/auth-capture-deferred/evm/eip3009": { + "scheme": "auth-capture", + "assetTransferMethod": "eip3009", + "sdks": [ + "typescript", + "go" + ], + "paymentFlow": "escrow", + "price": { + "usd": "$0.001" + }, + "extensions": [ + "bazaar" + ], + "schemeExtra": { + "feeRecipient": "0x0000000000000000000000000000000000000000", + "minFeeBps": 0, + "maxFeeBps": 0, + "captureDeadlineSeconds": 3600, + "refundDeadlineSeconds": 7200, + "operatorType": "delegated", + "paymentFlow": "escrow", + "captureMode": "deferred" + } + }, + "/auth-capture-custom-forwarding/evm/eip3009": { + "scheme": "auth-capture", + "assetTransferMethod": "eip3009", + "sdks": [ + "typescript", + "go" + ], + "paymentFlow": "escrow", + "price": { + "usd": "$0.001" + }, + "extensions": [ + "bazaar" + ], + "schemeExtra": { + "feeRecipient": "0x0000000000000000000000000000000000000000", + "minFeeBps": 0, + "maxFeeBps": 0, + "captureDeadlineSeconds": 3600, + "refundDeadlineSeconds": 7200, + "operatorType": "custom", + "paymentFlow": "escrow", + "captureMode": "deferred", + "captureAuthorizer": "0x8FE415CdB559fBF5B235B81CC4F7a69684A274bb", + "receiverAuthorizer": "0x0000000000000000000000000000000000000000" + } } } } \ No newline at end of file diff --git a/e2e/facilitators/go/auth_capture_evm_signer.go b/e2e/facilitators/go/auth_capture_evm_signer.go new file mode 100644 index 0000000000..42de2da549 --- /dev/null +++ b/e2e/facilitators/go/auth_capture_evm_signer.go @@ -0,0 +1,193 @@ +package main + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + gethtypes "github.com/ethereum/go-ethereum/core/types" + authcapturefacilitator "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/facilitator" + evmmech "github.com/x402-foundation/x402/go/v2/mechanisms/evm" +) + +func (s *realFacilitatorEvmSigner) WriteContractWithGas( + ctx context.Context, + contractAddress string, + abiJSON []byte, + method string, + dataSuffix []byte, + gas uint64, + args ...interface{}, +) (string, error) { + return s.writeContract(ctx, contractAddress, abiJSON, method, dataSuffix, gas, args...) +} + +func (s *realFacilitatorEvmSigner) writeContract( + ctx context.Context, + contractAddress string, + abiJSON []byte, + method string, + dataSuffix []byte, + gasLimit uint64, + args ...interface{}, +) (string, error) { + contractABI, err := abi.JSON(strings.NewReader(string(abiJSON))) + if err != nil { + return "", fmt.Errorf("failed to parse ABI: %w", err) + } + + data, err := contractABI.Pack(method, args...) + if err != nil { + return "", fmt.Errorf("failed to pack method call: %w", err) + } + data = evmmech.AppendDataSuffix(data, dataSuffix) + + nonce, err := s.reserveNonce(ctx) + if err != nil { + return "", err + } + + gasPrice, err := s.client.SuggestGasPrice(ctx) + if err != nil { + return "", fmt.Errorf("failed to get gas price: %w", err) + } + + if gasLimit == 0 { + gasLimit = 300_000 + } + + to := common.HexToAddress(contractAddress) + tx := gethtypes.NewTransaction( + nonce, + to, + big.NewInt(0), + gasLimit, + gasPrice, + data, + ) + + signedTx, err := gethtypes.SignTx(tx, gethtypes.LatestSignerForChainID(s.chainID), s.privateKey) + if err != nil { + return "", fmt.Errorf("failed to sign transaction: %w", err) + } + + if err := s.client.SendTransaction(ctx, signedTx); err != nil { + return "", fmt.Errorf("failed to send transaction: %w", err) + } + + return signedTx.Hash().Hex(), nil +} + +type rpcSimulateCall struct { + From string `json:"from"` + To string `json:"to"` + Data string `json:"data"` + Gas string `json:"gas,omitempty"` +} + +type rpcSimulateLog struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` +} + +type rpcSimulateCallOutcome struct { + Status string `json:"status"` + ReturnData string `json:"returnData"` + GasUsed string `json:"gasUsed"` + Logs []rpcSimulateLog `json:"logs"` + Error *struct { + Data string `json:"data"` + } `json:"error,omitempty"` +} + +type rpcSimulatedBlock struct { + Calls []rpcSimulateCallOutcome `json:"calls"` +} + +func (s *realFacilitatorEvmSigner) SimulateCalls( + ctx context.Context, + from string, + calls []authcapturefacilitator.SimulatedCall, +) ([]authcapturefacilitator.SimulatedCallResult, error) { + rpcCalls := make([]rpcSimulateCall, len(calls)) + for i, call := range calls { + data := call.Data + if len(data) == 0 { + data = []byte{} + } + entry := rpcSimulateCall{ + From: common.HexToAddress(from).Hex(), + To: common.HexToAddress(call.To).Hex(), + Data: hexutil.Encode(data), + } + if call.Gas > 0 { + entry.Gas = hexutil.EncodeUint64(call.Gas) + } + rpcCalls[i] = entry + } + + params := []interface{}{ + map[string]interface{}{ + "blockStateCalls": []map[string]interface{}{ + {"calls": rpcCalls}, + }, + }, + "latest", + } + + var blocks []rpcSimulatedBlock + if err := s.client.Client().CallContext(ctx, &blocks, "eth_simulateV1", params...); err != nil { + return nil, fmt.Errorf("eth_simulateV1 failed: %w", err) + } + if len(blocks) == 0 || len(blocks[0].Calls) != len(calls) { + return nil, fmt.Errorf("eth_simulateV1 returned unexpected call count") + } + + results := make([]authcapturefacilitator.SimulatedCallResult, len(calls)) + for i, outcome := range blocks[0].Calls { + returnData := outcome.ReturnData + if outcome.Error != nil && outcome.Error.Data != "" { + returnData = outcome.Error.Data + } + data, err := hexutil.Decode(returnData) + if err != nil { + return nil, fmt.Errorf("invalid simulate return data: %w", err) + } + gasUsed, err := hexutil.DecodeUint64(outcome.GasUsed) + if err != nil { + return nil, fmt.Errorf("invalid simulate gasUsed: %w", err) + } + results[i] = authcapturefacilitator.SimulatedCallResult{ + Success: outcome.Status == "0x1", + ReturnData: data, + GasUsed: gasUsed, + Logs: parseSimulateLogs(outcome.Logs), + } + } + return results, nil +} + +func parseSimulateLogs(logs []rpcSimulateLog) []*gethtypes.Log { + parsed := make([]*gethtypes.Log, 0, len(logs)) + for _, log := range logs { + topics := make([]common.Hash, len(log.Topics)) + for i, topic := range log.Topics { + topics[i] = common.HexToHash(topic) + } + data, err := hexutil.Decode(log.Data) + if err != nil { + data = []byte{} + } + parsed = append(parsed, &gethtypes.Log{ + Address: common.HexToAddress(log.Address), + Topics: topics, + Data: data, + }) + } + return parsed +} diff --git a/e2e/facilitators/go/auth_capture_facilitator.go b/e2e/facilitators/go/auth_capture_facilitator.go new file mode 100644 index 0000000000..4f5f708356 --- /dev/null +++ b/e2e/facilitators/go/auth_capture_facilitator.go @@ -0,0 +1,29 @@ +package main + +import ( + "os" + "strings" + + authcapturefacilitator "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/facilitator" +) + +const defaultAuthCaptureForwardingOperator = "0x8FE415CdB559fBF5B235B81CC4F7a69684A274bb" + +func authCaptureCustomOperators() []authcapturefacilitator.OperatorAllowlistEntry { + raw := strings.TrimSpace(os.Getenv("FACILITATOR_EVM_AUTH_CAPTURE_CUSTOM_OPERATORS")) + if raw == "" { + raw = defaultAuthCaptureForwardingOperator + } + operators := make([]authcapturefacilitator.OperatorAllowlistEntry, 0) + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + operators = append(operators, authcapturefacilitator.OperatorAllowlistEntry{ + Address: part, + OperatorType: "custom", + }) + } + return operators +} diff --git a/e2e/facilitators/go/batched_authorizer.go b/e2e/facilitators/go/batched_authorizer.go index 9c05f3dec9..eb5a52e8e0 100644 --- a/e2e/facilitators/go/batched_authorizer.go +++ b/e2e/facilitators/go/batched_authorizer.go @@ -37,6 +37,16 @@ func newBatchedAuthorizerSigner(privateKeyHex string) (*batchedAuthorizerSigner, func (a *batchedAuthorizerSigner) Address() string { return a.address.Hex() } +func (a *batchedAuthorizerSigner) SignTypedData( + _ context.Context, + domain evmmech.TypedDataDomain, + types map[string][]evmmech.TypedDataField, + primaryType string, + message map[string]interface{}, +) ([]byte, error) { + return a.signTypedData(domain, types, primaryType, message) +} + func (a *batchedAuthorizerSigner) SignClaimBatch(ctx context.Context, claims []batchsettlement.BatchSettlementVoucherClaim, network string) ([]byte, error) { chainId, err := evmmech.GetEvmChainId(network) if err != nil { diff --git a/e2e/facilitators/go/main.go b/e2e/facilitators/go/main.go index 31930a1f9c..9d9f0bd2c6 100644 --- a/e2e/facilitators/go/main.go +++ b/e2e/facilitators/go/main.go @@ -35,6 +35,7 @@ import ( "github.com/x402-foundation/x402/go/v2/extensions/erc20approvalgassponsor" exttypes "github.com/x402-foundation/x402/go/v2/extensions/types" evmmech "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapturefacilitator "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/facilitator" "github.com/x402-foundation/x402/go/v2/mechanisms/evm/batch-settlement" batchedevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/batch-settlement/facilitator" exactevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/facilitator" @@ -245,6 +246,19 @@ func (s *realFacilitatorEvmSigner) ReadContract( abiJSON []byte, method string, args ...interface{}, +) (interface{}, error) { + return s.ReadContractFrom(ctx, s.address.Hex(), contractAddress, abiJSON, method, args...) +} + +// ReadContractFrom reads as an explicit sender, which the auth-capture escrow requires +// because it gates authorize, capture and void on msg.sender. +func (s *realFacilitatorEvmSigner) ReadContractFrom( + ctx context.Context, + from string, + contractAddress string, + abiJSON []byte, + method string, + args ...interface{}, ) (interface{}, error) { // Parse ABI contractABI, err := abi.JSON(strings.NewReader(string(abiJSON))) @@ -267,7 +281,7 @@ func (s *realFacilitatorEvmSigner) ReadContract( // msg.sender == witness.facilitator in settle(). to := common.HexToAddress(contractAddress) msg := ethereum.CallMsg{ - From: s.address, + From: common.HexToAddress(from), To: &to, Data: data, } @@ -303,55 +317,7 @@ func (s *realFacilitatorEvmSigner) WriteContract( dataSuffix []byte, args ...interface{}, ) (string, error) { - // Parse ABI - contractABI, err := abi.JSON(strings.NewReader(string(abiJSON))) - if err != nil { - return "", fmt.Errorf("failed to parse ABI: %w", err) - } - - // Pack the method call - data, err := contractABI.Pack(method, args...) - if err != nil { - return "", fmt.Errorf("failed to pack method call: %w", err) - } - data = evmmech.AppendDataSuffix(data, dataSuffix) - - // Get nonce - nonce, err := s.reserveNonce(ctx) - if err != nil { - return "", err - } - - // Get gas price - gasPrice, err := s.client.SuggestGasPrice(ctx) - if err != nil { - return "", fmt.Errorf("failed to get gas price: %w", err) - } - - // Create transaction - to := common.HexToAddress(contractAddress) - tx := types.NewTransaction( - nonce, - to, - big.NewInt(0), // value - 300000, // gas limit - gasPrice, - data, - ) - - // Sign transaction - signedTx, err := types.SignTx(tx, types.LatestSignerForChainID(s.chainID), s.privateKey) - if err != nil { - return "", fmt.Errorf("failed to sign transaction: %w", err) - } - - // Send transaction - err = s.client.SendTransaction(ctx, signedTx) - if err != nil { - return "", fmt.Errorf("failed to send transaction: %w", err) - } - - return signedTx.Hash().Hex(), nil + return s.writeContract(ctx, contractAddress, abiJSON, method, dataSuffix, 0, args...) } func (s *realFacilitatorEvmSigner) SendTransaction( @@ -408,6 +374,7 @@ func (s *realFacilitatorEvmSigner) WaitForTransactionReceipt(ctx context.Context Status: uint64(receipt.Status), BlockNumber: receipt.BlockNumber.Uint64(), TxHash: receipt.TxHash.Hex(), + Logs: receipt.Logs, }, nil } time.Sleep(1 * time.Second) @@ -1063,6 +1030,32 @@ func main() { batchedevm.NewBatchSettlementEvmScheme(evmSigner, batchedAuthorizer), ) + authCaptureDelegatedStorage := authcapturefacilitator.NewInMemoryAuthCaptureDelegatedAuthStorage() + authCaptureAuthorizer, err := newBatchedAuthorizerSigner(evmPrivateKey) + if err != nil { + log.Fatalf("Failed to create auth-capture delegated authorizer: %v", err) + } + log.Printf("EVM Receiver Authorizer (auth-capture): %s", authCaptureAuthorizer.Address()) + customOperators := authCaptureCustomOperators() + if len(customOperators) > 0 { + log.Printf("EVM Auth-capture custom operators: %v", customOperators) + } + facilitator.Register( + []x402.Network{x402.Network(evmNetwork)}, + authcapturefacilitator.NewAuthCaptureEvmScheme(evmSigner, authcapturefacilitator.AuthCaptureEvmSchemeConfig{ + CaptureAuthorizer: addresses[0], + AuthorizerSigner: authCaptureAuthorizer, + DelegatedAuthStorage: authCaptureDelegatedStorage, + ResolveCallerIdentity: func(ctx context.Context, _ authcapturefacilitator.DelegatedSettleContext) (string, error) { + return "x402-e2e", nil + }, + OnStorageError: func(err error, network x402.Network, paymentInfoHash string) { + log.Printf("[delegated-auth-storage] network=%s paymentInfoHash=%s err=%v", network, paymentInfoHash, err) + }, + Operators: customOperators, + }), + ) + evmV1Config := &exactevmv1.ExactEvmSchemeV1Config{} facilitator.RegisterV1( []x402.Network{x402.Network(getV1EvmNetwork(evmNetwork))}, @@ -1489,3 +1482,8 @@ func main() { log.Fatalf("Failed to start server: %v", err) } } + +// WriteContractFrom ignores from: this signer holds a single address. +func (r *realFacilitatorEvmSigner) WriteContractFrom(ctx context.Context, _, address string, abiJSON []byte, functionName string, dataSuffix []byte, args ...interface{}) (string, error) { + return r.WriteContract(ctx, address, abiJSON, functionName, dataSuffix, args...) +} diff --git a/e2e/facilitators/typescript/index.ts b/e2e/facilitators/typescript/index.ts index 67c34ad4d3..ee0861671e 100644 --- a/e2e/facilitators/typescript/index.ts +++ b/e2e/facilitators/typescript/index.ts @@ -38,6 +38,10 @@ import { } from "@x402/core/types"; import { type AuthorizerSigner, toFacilitatorEvmSigner } from "@x402/evm"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/facilitator"; +import { + AuthCaptureEvmScheme, + InMemoryAuthCaptureDelegatedAuthStorage, +} from "@x402/evm/auth-capture/facilitator"; import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; import { UptoEvmScheme } from "@x402/evm/upto/facilitator"; import { ExactEvmSchemeV1 } from "@x402/evm/exact/v1/facilitator"; @@ -114,6 +118,7 @@ import { recoverTransactionAddress, type TransactionSerialized, } from "viem"; +import { getAddress } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { baseSepolia, base } from "viem/chains"; import { resolveNetworkCaip2 } from "./catalog-network.js"; @@ -239,6 +244,21 @@ if (authorizerSigner) { console.info(`EVM Receiver Authorizer: ${authorizerSigner.address}`); } +const defaultAuthCaptureForwardingOperator = + "0x8FE415CdB559fBF5B235B81CC4F7a69684A274bb"; +const authCaptureCustomOperatorAllowlist = ( + process.env.FACILITATOR_EVM_AUTH_CAPTURE_CUSTOM_OPERATORS?.trim() || + defaultAuthCaptureForwardingOperator +) + .split(",") + .map(entry => entry.trim()) + .filter(entry => entry.length > 0); +if (authCaptureCustomOperatorAllowlist.length > 0) { + console.info( + `EVM Auth-capture custom operators: ${authCaptureCustomOperatorAllowlist.join(", ")}`, + ); +} + // Initialize the SVM account from private key when configured const svmAccount = process.env.FACILITATOR_SVM_PRIVATE_KEY ? await createKeyPairSignerFromBytes( @@ -472,6 +492,17 @@ const evmSigner = waitForTransactionReceipt: (args: { hash: `0x${string}` }) => viemClient.waitForTransactionReceipt(args), getCode: (args: { address: `0x${string}` }) => viemClient.getCode(args), + simulateCalls: (args: { + account: `0x${string}`; + calls: readonly { + to: `0x${string}`; + data?: `0x${string}`; + gas?: bigint; + }[]; + }) => + viemClient.simulateCalls( + args as Parameters[0], + ), }) : undefined; @@ -681,6 +712,35 @@ if (evmSigner && authorizerSigner) { EVM_NETWORK as Network, new BatchSettlementEvmScheme(evmSigner, authorizerSigner), ) + .register( + EVM_NETWORK as Network, + new AuthCaptureEvmScheme(evmSigner, { + ...(authorizerSigner + ? { + authorizerSigner, + delegatedAuthStorage: new InMemoryAuthCaptureDelegatedAuthStorage(), + resolveCallerIdentity: async () => "x402-e2e", + onStorageError: (error: unknown, network: string, paymentInfoHash: string) => { + console.warn("[delegated-auth-storage]", { + network, + paymentInfoHash, + error: error instanceof Error ? error.message : error, + }); + }, + } + : {}), + ...(authCaptureCustomOperatorAllowlist.length > 0 + ? { + operators: authCaptureCustomOperatorAllowlist.map(address => ({ + address: getAddress(address), + operatorType: "custom" as const, + })), + } + : {}), + customOperatorAuthorizeGasLimit: 1_000_000n, + refundFunding: false, + }), + ) .registerV1(EVM_V1_NETWORKS as Network[], new ExactEvmSchemeV1(evmSigner)); } if (svmSigner) { diff --git a/e2e/mock-facilitator/index.ts b/e2e/mock-facilitator/index.ts index 02f4d06871..e9b740d100 100644 --- a/e2e/mock-facilitator/index.ts +++ b/e2e/mock-facilitator/index.ts @@ -30,7 +30,10 @@ const DUMMY_SIGNERS: Record = { function buildSupportedResponse() { const networkIds = catalogNetworkIds(); const schemesForNetwork = (networkId: string): string[] => { - if (networkId === "evm" || networkId === "svm") { + if (networkId === "evm") { + return ["exact", "upto", "auth-capture"]; + } + if (networkId === "svm") { return ["exact", "upto"]; } return ["exact"]; diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index f3fda7e2ae..327065c315 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -2265,6 +2265,111 @@ importers: specifier: ^5.7.3 version: 5.8.3 + facilitators/external-proxies/cdp: + dependencies: + '@coinbase/x402': + specifier: ^0.7.3 + version: 0.7.3(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.19.2 + version: 4.21.2 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.1 + version: 22.16.0 + eslint: + specifier: ^9.15.0 + version: 9.38.0(jiti@1.21.7) + prettier: + specifier: ^3.3.3 + version: 3.5.2 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.7.2 + version: 5.8.3 + + facilitators/external-proxies/cdp-dev: + dependencies: + '@coinbase/cdp-sdk': + specifier: ^1.22.0 + version: 1.38.4(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.19.2 + version: 4.21.2 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.1 + version: 22.16.0 + eslint: + specifier: ^9.15.0 + version: 9.38.0(jiti@1.21.7) + prettier: + specifier: ^3.3.3 + version: 3.5.2 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.7.2 + version: 5.8.3 + x402: + specifier: workspace:* + version: link:../../../../typescript/packages/legacy/x402 + + facilitators/external-proxies/x402.org: + dependencies: + '@coinbase/x402': + specifier: ^0.7.3 + version: 0.7.3(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.19.2 + version: 4.21.2 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.1 + version: 22.16.0 + eslint: + specifier: ^9.15.0 + version: 9.38.0(jiti@1.21.7) + prettier: + specifier: ^3.3.3 + version: 3.5.2 + tsx: + specifier: ^4.19.2 + version: 4.21.0 + typescript: + specifier: ^5.7.2 + version: 5.8.3 + facilitators/typescript: dependencies: '@aptos-labs/ts-sdk': @@ -3426,6 +3531,9 @@ packages: '@coinbase/wallet-sdk@4.3.6': resolution: {integrity: sha512-4q8BNG1ViL4mSAAvPAtpwlOs1gpC+67eQtgIwNvT3xyeyFFd+guwkc8bcX5rTmQhXpqnhzC4f0obACbP9CqMSA==} + '@coinbase/x402@0.7.3': + resolution: {integrity: sha512-mSxxbPnDCvSLfq6ZZAB5P1HyYQfQNSdWyY01Cn7yjzTuGUFFgZ7onDkbZnZ1Yry0UnG467aqrmjs6AgoYgpzCQ==} + '@concordium/rust-bindings@4.0.1': resolution: {integrity: sha512-poJUD5qFh57wenTUKP93gL4YLSzsfnznniRgEY7OaUwa2drkLyAw+SQO0Ou7THwz8elT7pXB4bLKvIm+J+4RpA==} engines: {node: '>=16'} @@ -12070,6 +12178,9 @@ packages: utf-8-validate: optional: true + x402@0.7.3: + resolution: {integrity: sha512-8CIZsdMTOn52PjMH/ErVke9ebeZ7ErwiZ5FL3tN3Wny7Ynxs3LkuB/0q7IoccRLdVXA7f2lueYBJ2iDrElhXnA==} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -13031,6 +13142,26 @@ snapshots: - utf-8-validate - zod + '@base-org/account@1.1.1(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@3.25.71)': + dependencies: + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@5.8.3)(zod@3.25.71) + preact: 10.24.2 + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + zustand: 5.0.3(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + '@bcoe/v8-coverage@1.0.2': {} '@blockshake/defly-connect@1.2.1(algosdk@3.5.2)(bufferutil@4.0.9)(utf-8-validate@5.0.10)': @@ -13055,6 +13186,29 @@ snapshots: dependencies: tslib: 2.8.1 + '@coinbase/cdp-sdk@1.38.4(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + dependencies: + '@solana-program/system': 0.8.1(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/token': 0.6.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) + abitype: 1.0.6(typescript@5.8.3)(zod@3.25.71) + axios: 1.12.2 + axios-retry: 4.5.0(axios@1.12.2) + jose: 6.1.3 + md5: 2.3.0 + uncrypto: 0.1.3 + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + zod: 3.25.71 + transitivePeerDependencies: + - bufferutil + - debug + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + - ws + '@coinbase/cdp-sdk@1.38.4(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana-program/system': 0.8.1(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) @@ -13179,6 +13333,67 @@ snapshots: - utf-8-validate - zod + '@coinbase/wallet-sdk@4.3.6(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@3.25.71)': + dependencies: + '@noble/hashes': 1.4.0 + clsx: 1.2.1 + eventemitter3: 5.0.1 + idb-keyval: 6.2.1 + ox: 0.6.9(typescript@5.8.3)(zod@3.25.71) + preact: 10.24.2 + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + zustand: 5.0.3(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - immer + - react + - typescript + - use-sync-external-store + - utf-8-validate + - zod + + '@coinbase/x402@0.7.3(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + dependencies: + '@coinbase/cdp-sdk': 1.38.4(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + x402: 0.7.3(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10) + zod: 3.25.71 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@solana/sysvars' + - '@tanstack/query-core' + - '@tanstack/react-query' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - debug + - encoding + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react + - supports-color + - typescript + - uploadthing + - utf-8-validate + - ws + '@concordium/rust-bindings@4.0.1': {} '@concordium/web-sdk@12.0.2(@protobuf-ts/runtime-rpc@2.11.1)(cbor2@1.12.0)': @@ -14898,6 +15113,41 @@ snapshots: - utf-8-validate - zod + '@reown/appkit-controllers@1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + valtio: 1.13.2(react@19.2.6) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + '@reown/appkit-pay@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) @@ -14970,18 +15220,14 @@ snapshots: - utf-8-validate - zod - '@reown/appkit-polyfills@1.7.8': - dependencies: - buffer: 6.0.3 - - '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.2)(react@19.2.0))(zod@4.1.12)': + '@reown/appkit-pay@1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.2)(react@19.2.0))(zod@4.1.12) - '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-controllers': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-ui': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-utils': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(react@19.2.6))(zod@3.25.71) lit: 3.3.0 + valtio: 1.13.2(react@19.2.6) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15008,15 +15254,18 @@ snapshots: - typescript - uploadthing - utf-8-validate - - valtio - zod - '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.2)(react@19.2.1))(zod@3.25.71)': + '@reown/appkit-polyfills@1.7.8': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) - '@reown/appkit-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) - '@reown/appkit-utils': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.2)(react@19.2.1))(zod@3.25.71) + buffer: 6.0.3 + + '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.2)(react@19.2.0))(zod@4.1.12)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.2)(react@19.2.0))(zod@4.1.12) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 transitivePeerDependencies: @@ -15048,13 +15297,14 @@ snapshots: - valtio - zod - '@reown/appkit-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': + '@reown/appkit-scaffold-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.2)(react@19.2.1))(zod@3.25.71)': dependencies: - '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-ui': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-utils': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.2)(react@19.2.1))(zod@3.25.71) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 - qrcode: 1.5.3 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15081,15 +15331,17 @@ snapshots: - typescript - uploadthing - utf-8-validate + - valtio - zod - '@reown/appkit-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)': + '@reown/appkit-scaffold-ui@1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(react@19.2.6))(zod@3.25.71)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) - '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-controllers': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-ui': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-utils': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(react@19.2.6))(zod@3.25.71) '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) lit: 3.3.0 - qrcode: 1.5.3 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15116,18 +15368,124 @@ snapshots: - typescript - uploadthing - utf-8-validate + - valtio - zod - '@reown/appkit-utils@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.2)(react@19.2.0))(zod@4.1.12)': + '@reown/appkit-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - '@reown/appkit-polyfills': 1.7.8 '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) - '@walletconnect/logger': 2.1.2 - '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) - valtio: 1.13.2(@types/react@19.2.2)(react@19.2.0) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + lit: 3.3.0 + qrcode: 1.5.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@reown/appkit-ui@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.1)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) + lit: 3.3.0 + qrcode: 1.5.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@reown/appkit-ui@1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-controllers': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) + lit: 3.3.0 + qrcode: 1.5.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + + '@reown/appkit-utils@1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(@types/react@19.2.2)(react@19.2.0))(zod@4.1.12)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit-controllers': 1.7.8(@types/react@19.2.2)(bufferutil@4.0.9)(react@19.2.0)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + '@reown/appkit-polyfills': 1.7.8 + '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@walletconnect/logger': 2.1.2 + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) + valtio: 1.13.2(@types/react@19.2.2)(react@19.2.0) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -15194,6 +15552,44 @@ snapshots: - utf-8-validate - zod + '@reown/appkit-utils@1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(react@19.2.6))(zod@3.25.71)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-controllers': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-polyfills': 1.7.8 + '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@walletconnect/logger': 2.1.2 + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + valtio: 1.13.2(react@19.2.6) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + '@reown/appkit-wallet@1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)': dependencies: '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.22.4) @@ -15291,6 +15687,49 @@ snapshots: - utf-8-validate - zod + '@reown/appkit@1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)': + dependencies: + '@reown/appkit-common': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-controllers': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-pay': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-polyfills': 1.7.8 + '@reown/appkit-scaffold-ui': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(react@19.2.6))(zod@3.25.71) + '@reown/appkit-ui': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@reown/appkit-utils': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(valtio@1.13.2(react@19.2.6))(zod@3.25.71) + '@reown/appkit-wallet': 1.7.8(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@walletconnect/types': 2.21.0 + '@walletconnect/universal-provider': 2.21.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + bs58: 6.0.0 + valtio: 1.13.2(react@19.2.6) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + '@rollup/rollup-android-arm-eabi@4.52.5': optional: true @@ -15490,6 +15929,10 @@ snapshots: dependencies: '@solana/kit': 6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@solana-program/system@0.8.1(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + dependencies: + '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/system@0.8.1(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -15526,6 +15969,10 @@ snapshots: dependencies: '@solana/kit': 6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@solana-program/token@0.6.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + dependencies: + '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/token@0.6.0(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -16135,6 +16582,32 @@ snapshots: - fastestsmallesttextencoderdecoder - ws + '@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + dependencies: + '@solana/accounts': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/codecs': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/errors': 3.0.3(typescript@5.8.3) + '@solana/functional': 3.0.3(typescript@5.8.3) + '@solana/instruction-plans': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/instructions': 3.0.3(typescript@5.8.3) + '@solana/keys': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/programs': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/rpc': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/rpc-parsed-types': 3.0.3(typescript@5.8.3) + '@solana/rpc-spec-types': 3.0.3(typescript@5.8.3) + '@solana/rpc-subscriptions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-types': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/signers': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/sysvars': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/transaction-confirmation': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-messages': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/transactions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - ws + '@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -16695,6 +17168,15 @@ snapshots: typescript: 5.8.3 ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@solana/rpc-subscriptions-channel-websocket@3.0.3(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + dependencies: + '@solana/errors': 3.0.3(typescript@5.8.3) + '@solana/functional': 3.0.3(typescript@5.8.3) + '@solana/rpc-subscriptions-spec': 3.0.3(typescript@5.8.3) + '@solana/subscribable': 3.0.3(typescript@5.8.3) + typescript: 5.8.3 + ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@solana/rpc-subscriptions-channel-websocket@3.0.3(typescript@5.8.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 3.0.3(typescript@5.8.3) @@ -16815,6 +17297,24 @@ snapshots: - fastestsmallesttextencoderdecoder - ws + '@solana/rpc-subscriptions@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + dependencies: + '@solana/errors': 3.0.3(typescript@5.8.3) + '@solana/fast-stable-stringify': 3.0.3(typescript@5.8.3) + '@solana/functional': 3.0.3(typescript@5.8.3) + '@solana/promises': 3.0.3(typescript@5.8.3) + '@solana/rpc-spec-types': 3.0.3(typescript@5.8.3) + '@solana/rpc-subscriptions-api': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/rpc-subscriptions-channel-websocket': 3.0.3(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-spec': 3.0.3(typescript@5.8.3) + '@solana/rpc-transformers': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/rpc-types': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/subscribable': 3.0.3(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - ws + '@solana/rpc-subscriptions@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 3.0.3(typescript@5.8.3) @@ -17308,6 +17808,23 @@ snapshots: - fastestsmallesttextencoderdecoder - ws + '@solana/transaction-confirmation@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + dependencies: + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/codecs-strings': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/errors': 3.0.3(typescript@5.8.3) + '@solana/keys': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/promises': 3.0.3(typescript@5.8.3) + '@solana/rpc': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/rpc-subscriptions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-types': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/transaction-messages': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + '@solana/transactions': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - ws + '@solana/transaction-confirmation@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) @@ -17559,7 +18076,7 @@ snapshots: '@solana/buffer-layout': 4.0.1 '@solana/codecs-numbers': 2.3.0(typescript@5.8.3) agentkeepalive: 4.6.0 - bn.js: 5.2.2 + bn.js: 5.2.5 borsh: 0.7.0 bs58: 4.0.1 buffer: 6.0.3 @@ -17781,6 +18298,11 @@ snapshots: '@tanstack/query-core': 5.90.8 react: 19.2.1 + '@tanstack/react-query@5.90.8(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.90.8 + react: 19.2.6 + '@tanstack/store@0.8.0': {} '@ton/core@0.63.1(@ton/crypto@3.3.0)': @@ -18477,6 +18999,53 @@ snapshots: - wagmi - zod + '@wagmi/connectors@6.1.0(@tanstack/react-query@5.90.8(react@19.2.6))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.8)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)))(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(wagmi@2.18.2(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(zod@3.25.71))(zod@3.25.71)': + dependencies: + '@base-org/account': 1.1.1(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@3.25.71) + '@coinbase/wallet-sdk': 4.3.6(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@3.25.71) + '@gemini-wallet/core': 0.2.0(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)) + '@metamask/sdk': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.8)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)) + '@walletconnect/ethereum-provider': 2.21.1(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + cbw-sdk: '@coinbase/wallet-sdk@3.9.3' + porto: 0.2.19(@tanstack/react-query@5.90.8(react@19.2.6))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.8)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)))(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(wagmi@2.18.2(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(zod@3.25.71)) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@tanstack/react-query' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - immer + - ioredis + - react + - supports-color + - uploadthing + - use-sync-external-store + - utf-8-validate + - wagmi + - zod + '@wagmi/core@2.22.1(@tanstack/query-core@5.90.8)(@types/react@19.2.2)(react@19.2.0)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.0))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@4.1.12))': dependencies: eventemitter3: 5.0.1 @@ -18507,6 +19076,21 @@ snapshots: - react - use-sync-external-store + '@wagmi/core@2.22.1(@tanstack/query-core@5.90.8)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))': + dependencies: + eventemitter3: 5.0.1 + mipd: 0.0.7(typescript@5.8.3) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + zustand: 5.0.0(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)) + optionalDependencies: + '@tanstack/query-core': 5.90.8 + typescript: 5.8.3 + transitivePeerDependencies: + - '@types/react' + - immer + - react + - use-sync-external-store + '@wallet-standard/app@1.1.0': dependencies: '@wallet-standard/base': 1.1.0 @@ -18865,6 +19449,47 @@ snapshots: - utf-8-validate - zod + '@walletconnect/ethereum-provider@2.21.1(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)': + dependencies: + '@reown/appkit': 1.7.8(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@walletconnect/jsonrpc-http-connection': 1.0.8 + '@walletconnect/jsonrpc-provider': 1.0.14 + '@walletconnect/jsonrpc-types': 1.0.4 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/sign-client': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@walletconnect/types': 2.21.1 + '@walletconnect/universal-provider': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + '@walletconnect/utils': 2.21.1(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - ioredis + - react + - typescript + - uploadthing + - utf-8-validate + - zod + '@walletconnect/events@1.0.1': dependencies: keyvaluestorage-interface: 1.0.0 @@ -20688,6 +21313,10 @@ snapshots: dependencies: valtio: 1.13.2(@types/react@19.2.2)(react@19.2.1) + derive-valtio@0.1.0(valtio@1.13.2(react@19.2.6)): + dependencies: + valtio: 1.13.2(react@19.2.6) + des.js@1.1.0: dependencies: inherits: 2.0.4 @@ -23575,6 +24204,26 @@ snapshots: - immer - use-sync-external-store + porto@0.2.19(@tanstack/react-query@5.90.8(react@19.2.6))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.8)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)))(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(wagmi@2.18.2(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(zod@3.25.71)): + dependencies: + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.8)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)) + hono: 4.10.2 + idb-keyval: 6.2.2 + mipd: 0.0.7(typescript@5.8.3) + ox: 0.9.12(typescript@5.8.3)(zod@4.1.12) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + zod: 4.1.12 + zustand: 5.0.8(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)) + optionalDependencies: + '@tanstack/react-query': 5.90.8(react@19.2.6) + react: 19.2.6 + typescript: 5.8.3 + wagmi: 2.18.2(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(zod@3.25.71) + transitivePeerDependencies: + - '@types/react' + - immer + - use-sync-external-store + poseidon-lite@0.2.1: {} possible-typed-array-names@1.1.0: {} @@ -25015,6 +25664,10 @@ snapshots: dependencies: react: 19.2.1 + use-sync-external-store@1.2.0(react@19.2.6): + dependencies: + react: 19.2.6 + use-sync-external-store@1.4.0(react@19.2.0): dependencies: react: 19.2.0 @@ -25023,6 +25676,10 @@ snapshots: dependencies: react: 19.2.1 + use-sync-external-store@1.4.0(react@19.2.6): + dependencies: + react: 19.2.6 + utf-8-validate@5.0.10: dependencies: node-gyp-build: 4.8.4 @@ -25065,6 +25722,14 @@ snapshots: '@types/react': 19.2.2 react: 19.2.1 + valtio@1.13.2(react@19.2.6): + dependencies: + derive-valtio: 0.1.0(valtio@1.13.2(react@19.2.6)) + proxy-compare: 2.6.0 + use-sync-external-store: 1.2.0(react@19.2.6) + optionalDependencies: + react: 19.2.6 + vary@1.1.2: {} viem@2.23.2(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71): @@ -25329,6 +25994,45 @@ snapshots: - utf-8-validate - zod + wagmi@2.18.2(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(zod@3.25.71): + dependencies: + '@tanstack/react-query': 5.90.8(react@19.2.6) + '@wagmi/connectors': 6.1.0(@tanstack/react-query@5.90.8(react@19.2.6))(@wagmi/core@2.22.1(@tanstack/query-core@5.90.8)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)))(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(wagmi@2.18.2(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(zod@3.25.71))(zod@3.25.71) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.8)(react@19.2.6)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71)) + react: 19.2.6 + use-sync-external-store: 1.4.0(react@19.2.6) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@tanstack/query-core' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - immer + - ioredis + - supports-color + - uploadthing + - utf-8-validate + - zod + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -25473,6 +26177,54 @@ snapshots: bufferutil: 4.0.9 utf-8-validate: 5.0.10 + x402@0.7.3(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3))(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10): + dependencies: + '@scure/base': 1.2.6 + '@solana-program/compute-budget': 0.11.0(@solana/kit@5.3.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@solana-program/token': 0.9.0(@solana/kit@5.3.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)) + '@solana-program/token-2022': 0.6.1(@solana/kit@5.3.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10))(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)) + '@solana/kit': 5.3.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@solana/transaction-confirmation': 5.3.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@solana/wallet-standard-features': 1.3.0 + '@wallet-standard/app': 1.1.0 + '@wallet-standard/base': 1.1.0 + '@wallet-standard/features': 1.1.0 + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71) + wagmi: 2.18.2(@tanstack/query-core@5.90.8)(@tanstack/react-query@5.90.8(react@19.2.6))(bufferutil@4.0.9)(react@19.2.6)(typescript@5.8.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.71))(zod@3.25.71) + zod: 3.25.71 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@solana/sysvars' + - '@tanstack/query-core' + - '@tanstack/react-query' + - '@types/react' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - bufferutil + - db0 + - encoding + - fastestsmallesttextencoderdecoder + - immer + - ioredis + - react + - supports-color + - typescript + - uploadthing + - utf-8-validate + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} @@ -25564,6 +26316,11 @@ snapshots: react: 19.2.1 use-sync-external-store: 1.4.0(react@19.2.1) + zustand@5.0.0(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)): + optionalDependencies: + react: 19.2.6 + use-sync-external-store: 1.4.0(react@19.2.6) + zustand@5.0.3(@types/react@19.2.2)(react@19.2.0)(use-sync-external-store@1.4.0(react@19.2.0)): optionalDependencies: '@types/react': 19.2.2 @@ -25576,6 +26333,11 @@ snapshots: react: 19.2.1 use-sync-external-store: 1.4.0(react@19.2.1) + zustand@5.0.3(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)): + optionalDependencies: + react: 19.2.6 + use-sync-external-store: 1.4.0(react@19.2.6) + zustand@5.0.8(@types/react@19.2.2)(react@19.2.0)(use-sync-external-store@1.4.0(react@19.2.0)): optionalDependencies: '@types/react': 19.2.2 @@ -25587,3 +26349,8 @@ snapshots: '@types/react': 19.2.2 react: 19.2.1 use-sync-external-store: 1.4.0(react@19.2.1) + + zustand@5.0.8(react@19.2.6)(use-sync-external-store@1.4.0(react@19.2.6)): + optionalDependencies: + react: 19.2.6 + use-sync-external-store: 1.4.0(react@19.2.6) diff --git a/e2e/servers/go/auth_capture_e2e.go b/e2e/servers/go/auth_capture_e2e.go new file mode 100644 index 0000000000..e04536eaa9 --- /dev/null +++ b/e2e/servers/go/auth_capture_e2e.go @@ -0,0 +1,99 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "net/http" + "sort" + + authcaptureserver "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/server" + x402 "github.com/x402-foundation/x402/go/v2" + x402http "github.com/x402-foundation/x402/go/v2/http" +) + +// AuthCaptureE2eCapturePath is the harness-only deferred capture endpoint. +const AuthCaptureE2eCapturePath = "/__e2e/auth-capture/capture" + +var authCaptureEvmScheme *authcaptureserver.AuthCaptureEvmScheme + +func setAuthCaptureEvmScheme(scheme *authcaptureserver.AuthCaptureEvmScheme) { + authCaptureEvmScheme = scheme +} + +type authCaptureCaptureRequest struct { + Path string `json:"path"` +} + +type authCaptureCaptureResponse struct { + Success bool `json:"success"` + Transaction string `json:"transaction,omitempty"` + PaymentInfoHash string `json:"paymentInfoHash,omitempty"` + Path string `json:"path,omitempty"` + ErrorReason string `json:"errorReason,omitempty"` +} + +// HandleAuthCaptureE2eCapture triggers lifecycle capture for the latest stored payment. +func HandleAuthCaptureE2eCapture(facilitator *x402http.HTTPFacilitatorClient, body []byte) (int, interface{}) { + if authCaptureEvmScheme == nil { + return http.StatusNotImplemented, map[string]string{"error": "Auth-capture lifecycle is not configured on this server"} + } + + var req authCaptureCaptureRequest + if len(body) > 0 { + _ = json.Unmarshal(body, &req) + } + + manager := authCaptureEvmScheme.NewLifecycleManager(facilitator) + ctx := context.Background() + payments, err := manager.List(ctx) + if err != nil { + return http.StatusInternalServerError, map[string]string{"error": err.Error()} + } + if len(payments) == 0 { + return http.StatusNotFound, map[string]string{"error": "No authorized payments in storage"} + } + + sort.Slice(payments, func(i, j int) bool { + return payments[i].CreatedAt.After(payments[j].CreatedAt) + }) + record := payments[0] + + settle, err := manager.Capture(ctx, record.PaymentInfoHash, nil) + if err != nil { + return http.StatusInternalServerError, map[string]string{"error": err.Error()} + } + + resp := authCaptureCaptureResponse{ + Success: settle != nil && settle.Success, + Transaction: settleTransaction(settle), + PaymentInfoHash: record.PaymentInfoHash, + Path: req.Path, + } + if settle != nil && settle.ErrorReason != "" { + resp.ErrorReason = settle.ErrorReason + } + return http.StatusOK, resp +} + +func settleTransaction(settle *x402.SettleResponse) string { + if settle == nil { + return "" + } + return settle.Transaction +} + +// RegisterAuthCaptureE2eCapture registers POST /__e2e/auth-capture/capture on a net/http mux. +func RegisterAuthCaptureE2eCapture(mux *http.ServeMux, facilitator *x402http.HTTPFacilitatorClient) { + mux.HandleFunc("POST "+AuthCaptureE2eCapturePath, func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + status, payload := HandleAuthCaptureE2eCapture(facilitator, body) + writeJSON(w, status, payload) + }) +} + +func writeJSON(w http.ResponseWriter, status int, payload interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/e2e/servers/go/catalog.go b/e2e/servers/go/catalog.go index c7b797e4b3..69961f7e3d 100644 --- a/e2e/servers/go/catalog.go +++ b/e2e/servers/go/catalog.go @@ -95,6 +95,8 @@ type catalogRouteDefinition struct { AssetTransferMethod string `json:"assetTransferMethod"` Sdks []string `json:"sdks"` RequiresEnv string `json:"requiresEnv"` + RequiresEnvAbsent string `json:"requiresEnvAbsent"` + SchemeExtra map[string]interface{} `json:"schemeExtra"` Price catalogPrice `json:"price"` Extensions []string `json:"extensions"` SettlementOverride *SettlementAmount `json:"settlementOverride"` @@ -123,6 +125,8 @@ type CatalogRoute struct { Network string AssetTransferMethod string RequiresEnv string + RequiresEnvAbsent string + SchemeExtra map[string]interface{} Price catalogPrice Extensions []string SettlementOverride *SettlementAmount @@ -362,6 +366,9 @@ func CatalogRoutes() []CatalogRoute { if definition.RequiresEnv != "" && os.Getenv(definition.RequiresEnv) == "" { continue } + if definition.RequiresEnvAbsent != "" && os.Getenv(definition.RequiresEnvAbsent) != "" { + continue + } routes = append(routes, CatalogRoute{ Path: path, @@ -369,6 +376,8 @@ func CatalogRoutes() []CatalogRoute { Network: definition.Network, AssetTransferMethod: definition.AssetTransferMethod, RequiresEnv: definition.RequiresEnv, + RequiresEnvAbsent: definition.RequiresEnvAbsent, + SchemeExtra: definition.SchemeExtra, Price: definition.Price, Extensions: definition.Extensions, SettlementOverride: definition.SettlementOverride, @@ -551,6 +560,14 @@ func ResolvedRoutes() []ResolvedRoute { os.Exit(1) } extra := mergeRouteExtra(priceExtra, route.RequirementsExtra, route.PaymentFlow) + if len(route.SchemeExtra) > 0 { + if extra == nil { + extra = map[string]interface{}{} + } + for key, value := range route.SchemeExtra { + extra[key] = value + } + } resolved = append(resolved, ResolvedRoute{ Path: route.Path, diff --git a/e2e/servers/go/config.go b/e2e/servers/go/config.go index c6a79def2b..5cfc4505c1 100644 --- a/e2e/servers/go/config.go +++ b/e2e/servers/go/config.go @@ -4,9 +4,9 @@ import ( "fmt" "os" "strings" - x402 "github.com/x402-foundation/x402/go/v2" x402http "github.com/x402-foundation/x402/go/v2/http" + authcaptureserver "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/server" batchsettlement "github.com/x402-foundation/x402/go/v2/mechanisms/evm/batch-settlement" batchedserver "github.com/x402-foundation/x402/go/v2/mechanisms/evm/batch-settlement/server" exactevm "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/server" @@ -106,9 +106,10 @@ func SchemeBindings(cfg Config) []SchemeBinding { exactEVM *exactevm.ExactEvmScheme uptoEVM *uptoevm.UptoEvmScheme batched *batchedserver.BatchSettlementEvmScheme - exactSVM *svm.ExactSvmScheme - uptoSVM *uptosvm.UptoSvmScheme - batchSVM *batchsvmserver.BatchSvmScheme + authCap *authcaptureserver.AuthCaptureEvmScheme + exactSVM *svm.ExactSvmScheme + uptoSVM *uptosvm.UptoSvmScheme + batchSVM *batchsvmserver.BatchSvmScheme ) schemeFor := func(networkID, scheme string) x402.SchemeNetworkServer { @@ -142,6 +143,23 @@ func SchemeBindings(cfg Config) []SchemeBinding { batched = batchedserver.NewBatchSettlementEvmScheme(cfg.Payee("evm"), batchedCfg) } return batched + case "auth-capture": + if authCap == nil { + authCfg := &authcaptureserver.Config{} + if authorizer, err := authCaptureAuthorizerSigner(); err != nil { + fmt.Printf("Failed to create auth-capture receiver authorizer: %v\n", err) + os.Exit(1) + } else if authorizer != nil { + authCfg.ReceiverAuthorizerSigner = authorizer + fmt.Printf("Auth-capture receiver authorizer (self-managed): %s\n", authorizer.Address()) + } else { + authCfg.CollectOnlyRoutes = true + fmt.Println("Auth-capture receiver authorizer: facilitator-delegated (collect-only routes enabled)") + } + authCap = authcaptureserver.NewAuthCaptureEvmScheme(authCfg) + setAuthCaptureEvmScheme(authCap) + } + return authCap } case "svm": switch scheme { @@ -221,3 +239,12 @@ func SchemeBindings(cfg Config) []SchemeBinding { return bindings } + +// authCaptureAuthorizerSigner returns the signer for auth-capture Capture and Void messages when +// SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY is set; otherwise nil for facilitator-delegated mode. +func authCaptureAuthorizerSigner() (*BatchedAuthorizerSigner, error) { + if key := os.Getenv("SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY"); key != "" { + return NewBatchedAuthorizerSigner(key) + } + return nil, nil +} diff --git a/e2e/servers/go/http/echo/main.go b/e2e/servers/go/http/echo/main.go index 05426d30f0..d236c6dd4f 100644 --- a/e2e/servers/go/http/echo/main.go +++ b/e2e/servers/go/http/echo/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "net/http" "os" "os/signal" @@ -82,6 +83,12 @@ func main() { }) } + e.POST(e2eserver.AuthCaptureE2eCapturePath, func(c echo.Context) error { + body, _ := io.ReadAll(c.Request().Body) + status, payload := e2eserver.HandleAuthCaptureE2eCapture(facilitatorClient, body) + return c.JSON(status, payload) + }) + e.GET("/health", func(c echo.Context) error { return c.JSON(http.StatusOK, e2eserver.HealthBody()) }) diff --git a/e2e/servers/go/http/gin/main.go b/e2e/servers/go/http/gin/main.go index d32e27b5b6..05812be8a3 100644 --- a/e2e/servers/go/http/gin/main.go +++ b/e2e/servers/go/http/gin/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io" "net/http" "os" "os/signal" @@ -86,6 +87,12 @@ func main() { c.JSON(http.StatusOK, e2eserver.HealthBody()) }) + r.POST(e2eserver.AuthCaptureE2eCapturePath, func(c *ginfw.Context) { + body, _ := io.ReadAll(c.Request.Body) + status, payload := e2eserver.HandleAuthCaptureE2eCapture(facilitatorClient, body) + c.JSON(status, payload) + }) + r.POST("/close", func(c *ginfw.Context) { shutdownRequested = true diff --git a/e2e/servers/go/http/nethttp/main.go b/e2e/servers/go/http/nethttp/main.go index 456f5385bb..997c231aae 100644 --- a/e2e/servers/go/http/nethttp/main.go +++ b/e2e/servers/go/http/nethttp/main.go @@ -51,6 +51,8 @@ func main() { }) } + e2eserver.RegisterAuthCaptureE2eCapture(mux, facilitatorClient) + mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, e2eserver.HealthBody()) }) diff --git a/e2e/servers/python/catalog.py b/e2e/servers/python/catalog.py index 2c45e52712..555763d96c 100644 --- a/e2e/servers/python/catalog.py +++ b/e2e/servers/python/catalog.py @@ -104,6 +104,7 @@ class CatalogRoute: extensions: list[str] settlement_override: dict[str, str] | None payment_flow: str | None + scheme_extra: dict[str, Any] | None = None @dataclass(frozen=True) @@ -116,7 +117,7 @@ class ResolvedRoute: network: str pay_to: str price: Any - extra: dict[str, str] | None + extra: dict[str, Any] | None extensions: list[str] = field(default_factory=list) settlement_override: dict[str, str] | None = None @@ -168,6 +169,7 @@ def catalog_routes() -> list[CatalogRoute]: extensions=list(definition.get("extensions", [])), settlement_override=definition.get("settlementOverride"), payment_flow=definition.get("paymentFlow"), + scheme_extra=definition.get("schemeExtra"), ) ) @@ -294,6 +296,8 @@ def resolve_routes(env: Callable[[str], str | None] = os.getenv) -> list[Resolve caip2 = network_caip2(route.network, env) price, extra = _resolve_price(route, caip2, env) extra = _merge_route_extra(extra, route.payment_flow) + if route.scheme_extra: + extra = {**(extra or {}), **route.scheme_extra} resolved.append( ResolvedRoute( diff --git a/e2e/servers/typescript/auth-capture-e2e.ts b/e2e/servers/typescript/auth-capture-e2e.ts new file mode 100644 index 0000000000..c7863fed4e --- /dev/null +++ b/e2e/servers/typescript/auth-capture-e2e.ts @@ -0,0 +1,71 @@ +import type { HTTPFacilitatorClient } from "@x402/core/server"; +import type { AuthCaptureLifecycleManager } from "@x402/evm/auth-capture/server"; +import { AUTH_CAPTURE_E2E_CAPTURE_PATH } from "../../src/mechanisms"; + +let lifecycleManager: AuthCaptureLifecycleManager | undefined; + +export function setAuthCaptureLifecycleManager(manager: AuthCaptureLifecycleManager | undefined): void { + lifecycleManager = manager; +} + +export function createAuthCaptureLifecycleManager( + scheme: { createLifecycleManager: (client: HTTPFacilitatorClient) => AuthCaptureLifecycleManager }, + facilitator: HTTPFacilitatorClient, +): AuthCaptureLifecycleManager { + return scheme.createLifecycleManager(facilitator); +} + +export async function runAuthCaptureE2eCapture( + bodyPath?: string, +): Promise<{ status: number; body: Record }> { + if (!lifecycleManager) { + return { status: 501, body: { error: "Auth-capture lifecycle is not configured on this server" } }; + } + + try { + const payments = await lifecycleManager.listAuthorizedPayments(); + if (payments.length === 0) { + return { status: 404, body: { error: "No authorized payments in storage" } }; + } + + const sorted = [...payments].sort((a, b) => b.createdAt - a.createdAt); + const record = sorted[0]; + const response = await lifecycleManager.capture(record.paymentInfoHash); + + return { + status: 200, + body: { + success: response.success, + transaction: response.transaction, + paymentInfoHash: record.paymentInfoHash, + ...(bodyPath ? { path: bodyPath } : {}), + ...(response.errorReason ? { errorReason: response.errorReason } : {}), + }, + }; + } catch (error) { + return { + status: 500, + body: { error: error instanceof Error ? error.message : "Unknown error" }, + }; + } +} + +type JsonHandlerApp = { + post: ( + path: string, + handler: ( + req: { body?: { path?: string } }, + res: { status: (code: number) => { json: (body: unknown) => void } }, + ) => void | Promise, + ) => void; +}; + +/** Registers the harness-only deferred capture endpoint (no payment middleware). */ +export function registerAuthCaptureE2eRoutes(app: JsonHandlerApp): void { + app.post(AUTH_CAPTURE_E2E_CAPTURE_PATH, async (req, res) => { + const result = await runAuthCaptureE2eCapture(req.body?.path); + res.status(result.status).json(result.body); + }); +} + +export { AUTH_CAPTURE_E2E_CAPTURE_PATH }; diff --git a/e2e/servers/typescript/config.ts b/e2e/servers/typescript/config.ts index 351ea0cb2d..c92405a2fe 100644 --- a/e2e/servers/typescript/config.ts +++ b/e2e/servers/typescript/config.ts @@ -2,6 +2,7 @@ import { ExactAvmScheme } from "@x402/avm/exact/server"; import { ExactEvmScheme } from "@x402/evm/exact/server"; import { UptoEvmScheme } from "@x402/evm/upto/server"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/server"; import { BatchSvmScheme as BatchSettlementSvmScheme } from "@x402/svm/batch-settlement/server"; import { ExactSvmScheme } from "@x402/svm/exact/server"; import { UptoSvmScheme } from "@x402/svm/upto/server"; @@ -23,7 +24,15 @@ import { declareEip2612GasSponsoringExtension, declareErc20ApprovalGasSponsoringExtension, } from "@x402/extensions"; -import { HTTPFacilitatorClient, type RoutesConfig, type x402ResourceServer } from "@x402/core/server"; +import { + HTTPFacilitatorClient, + type RoutesConfig, + type x402ResourceServer, +} from "@x402/core/server"; +import { + createAuthCaptureLifecycleManager, + setAuthCaptureLifecycleManager, +} from "./auth-capture-e2e"; import { privateKeyToAccount } from "viem/accounts"; import type { Caip2Network, ServerEnvConfig } from "../../src/server-env"; import { @@ -39,6 +48,7 @@ import { networkCaip2Pattern, routeDiscoveryOutput, mcpToolName, + schemesForSdkNetwork, type RouteTransport, } from "../../src/mechanisms"; @@ -62,6 +72,7 @@ async function registerFamilySchemes( server: x402ResourceServer, family: ProtocolFamily, cfg: ServerEnvConfig, + primaryFacilitator?: HTTPFacilitatorClient, ): Promise { const pattern = networkCaip2Pattern(family); @@ -101,6 +112,24 @@ async function registerFamilySchemes( ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), }), ); + if (schemesForSdkNetwork("typescript", "evm").includes("auth-capture")) { + const authCaptureScheme = new AuthCaptureEvmScheme( + receiverAuthorizerSigner + ? { receiverAuthorizerSigner } + : { collectOnlyRoutes: true }, + ); + if (receiverAuthorizerSigner) { + console.info(`Auth-capture receiver authorizer (self-managed): ${receiverAuthorizerSigner.address}`); + } else { + console.info("Auth-capture receiver authorizer: facilitator-delegated (collect-only routes enabled)"); + } + server.register(pattern, authCaptureScheme); + if (primaryFacilitator) { + setAuthCaptureLifecycleManager( + createAuthCaptureLifecycleManager(authCaptureScheme, primaryFacilitator), + ); + } + } return; } case "svm": { @@ -165,10 +194,14 @@ async function registerFamilySchemes( * Registers e2e schemes + bazaar extension for every family with a payee address * configured (catalog-driven via {@link isFamilyConfigured}). */ -export async function configureResourceServer(server: x402ResourceServer, cfg: ServerEnvConfig): Promise { +export async function configureResourceServer( + server: x402ResourceServer, + cfg: ServerEnvConfig, + primaryFacilitator?: HTTPFacilitatorClient, +): Promise { for (const family of PROTOCOL_FAMILIES) { if (isFamilyConfigured(cfg, family)) { - await registerFamilySchemes(server, family, cfg); + await registerFamilySchemes(server, family, cfg, primaryFacilitator); } } diff --git a/e2e/servers/typescript/http/express/index.ts b/e2e/servers/typescript/http/express/index.ts index 04a615a8c3..63d857f82f 100644 --- a/e2e/servers/typescript/http/express/index.ts +++ b/e2e/servers/typescript/http/express/index.ts @@ -13,6 +13,7 @@ import { buildHealthResponse, buildCloseResponse, formatStartupBanner, + registerAuthCaptureE2eRoutes, } from "../../index.ts"; async function main(): Promise { @@ -22,7 +23,7 @@ async function main(): Promise { const app = express(); const facilitatorClients = createFacilitatorClients(facilitatorUrl); const server = new x402ResourceServer(facilitatorClients); - await configureResourceServer(server, cfg); + await configureResourceServer(server, cfg, facilitatorClients[0]); console.log( `Facilitator account: ${facilitatorUrl ? facilitatorUrl.substring(0, 10) + "..." : "not configured"}`, @@ -38,6 +39,8 @@ async function main(): Promise { }); app.use(paymentMiddleware(buildPaymentRoutes(cfg), server)); + app.use(express.json()); + registerAuthCaptureE2eRoutes(app); for (const route of E2E_GET_ROUTES) { app.get(route.path, (req, res) => { diff --git a/e2e/servers/typescript/http/fastify/index.ts b/e2e/servers/typescript/http/fastify/index.ts index abf6078eee..58a5e4c6fd 100644 --- a/e2e/servers/typescript/http/fastify/index.ts +++ b/e2e/servers/typescript/http/fastify/index.ts @@ -13,6 +13,8 @@ import { buildHealthResponse, buildCloseResponse, formatStartupBanner, + runAuthCaptureE2eCapture, + AUTH_CAPTURE_E2E_CAPTURE_PATH, } from "../../index.ts"; async function main(): Promise { @@ -22,7 +24,7 @@ async function main(): Promise { const app = Fastify(); const facilitatorClients = createFacilitatorClients(facilitatorUrl); const server = new x402ResourceServer(facilitatorClients); - await configureResourceServer(server, cfg); + await configureResourceServer(server, cfg, facilitatorClients[0]); console.log( `Facilitator account: ${facilitatorUrl ? facilitatorUrl.substring(0, 10) + "..." : "not configured"}`, @@ -39,6 +41,11 @@ async function main(): Promise { paymentMiddleware(app, buildPaymentRoutes(cfg), server); + app.post<{ Body: { path?: string } }>(AUTH_CAPTURE_E2E_CAPTURE_PATH, async (request, reply) => { + const result = await runAuthCaptureE2eCapture(request.body?.path); + return reply.status(result.status).send(result.body); + }); + for (const route of E2E_GET_ROUTES) { app.get(route.path, async (_request, reply) => { if (route.settlementOverride) { diff --git a/e2e/servers/typescript/http/hono/index.ts b/e2e/servers/typescript/http/hono/index.ts index 56f832e9dc..ab12515dd9 100644 --- a/e2e/servers/typescript/http/hono/index.ts +++ b/e2e/servers/typescript/http/hono/index.ts @@ -14,6 +14,8 @@ import { buildHealthResponse, buildCloseResponse, formatStartupBanner, + runAuthCaptureE2eCapture, + AUTH_CAPTURE_E2E_CAPTURE_PATH, } from "../../index.ts"; async function main(): Promise { @@ -23,7 +25,7 @@ async function main(): Promise { const app = new Hono(); const facilitatorClients = createFacilitatorClients(facilitatorUrl); const x402Server = new x402ResourceServer(facilitatorClients); - await configureResourceServer(x402Server, cfg); + await configureResourceServer(x402Server, cfg, facilitatorClients[0]); console.log( `Facilitator account: ${facilitatorUrl ? facilitatorUrl.substring(0, 10) + "..." : "not configured"}`, @@ -41,6 +43,12 @@ async function main(): Promise { app.use("*", paymentMiddleware(buildPaymentRoutes(cfg), x402Server)); + app.post(AUTH_CAPTURE_E2E_CAPTURE_PATH, async c => { + const body = (await c.req.json().catch(() => ({}))) as { path?: string }; + const result = await runAuthCaptureE2eCapture(body.path); + return c.json(result.body, result.status as 200 | 404 | 500 | 501); + }); + for (const route of E2E_GET_ROUTES) { app.get(route.path, c => { if (route.settlementOverride) { diff --git a/e2e/servers/typescript/http/next/lib/setup.ts b/e2e/servers/typescript/http/next/lib/setup.ts index 353d7af332..854efc950c 100644 --- a/e2e/servers/typescript/http/next/lib/setup.ts +++ b/e2e/servers/typescript/http/next/lib/setup.ts @@ -21,7 +21,7 @@ import { export async function createResourceServer(cfg: ServerEnvConfig): Promise { const server = new x402ResourceServer(createFacilitatorClients(cfg.facilitatorUrl)); - await configureResourceServer(server, cfg); + await configureResourceServer(server, cfg, createFacilitatorClients(cfg.facilitatorUrl)[0]); return server; } diff --git a/e2e/servers/typescript/index.ts b/e2e/servers/typescript/index.ts index 934491a8c0..6a276b7a95 100644 --- a/e2e/servers/typescript/index.ts +++ b/e2e/servers/typescript/index.ts @@ -18,3 +18,8 @@ export { type E2eRouteDef, } from "./routes"; export type { ProtocolFamily } from "../../src/networks/networks"; +export { + registerAuthCaptureE2eRoutes, + runAuthCaptureE2eCapture, + AUTH_CAPTURE_E2E_CAPTURE_PATH, +} from "./auth-capture-e2e"; diff --git a/e2e/servers/typescript/mcp/index.ts b/e2e/servers/typescript/mcp/index.ts index 326d353521..af1601dba6 100644 --- a/e2e/servers/typescript/mcp/index.ts +++ b/e2e/servers/typescript/mcp/index.ts @@ -43,7 +43,7 @@ async function main(): Promise { const facilitatorClients = createFacilitatorClients(facilitatorUrl); const resourceServer = new x402ResourceServer(facilitatorClients); - await configureResourceServer(resourceServer, cfg); + await configureResourceServer(resourceServer, cfg, facilitatorClients[0]); // Unlike x402HTTPResourceServer (used by paymentMiddleware), the raw // x402ResourceServer used by createPaymentWrapper doesn't lazily initialize // on first call, so this must run before any buildPaymentRequirements call. diff --git a/e2e/src/cli/args.ts b/e2e/src/cli/args.ts index 4c77fb6c66..d5eeb08ab2 100644 --- a/e2e/src/cli/args.ts +++ b/e2e/src/cli/args.ts @@ -169,7 +169,7 @@ export function printHelp(): void { console.log(' --extensions= Comma-separated extensions (e.g., bazaar)'); console.log(' --versions= Comma-separated version numbers (e.g., 1,2)'); console.log(' --families= Comma-separated protocol families (e.g., evm,svm,hedera,tvm)'); - console.log(' --schemes= Payment schemes: exact, upto, batch-settlement'); + console.log(' --schemes= Payment schemes: exact, upto, batch-settlement, auth-capture'); console.log(' --sdk= SDK languages: ts, go, python (aliases: typescript, py)'); console.log(' --paymentflow= Payment flows: authorization, upfront, escrow'); console.log(' --assetTransferMethod= Asset transfer methods: eip3009, permit2, sequence, ticketSequence'); diff --git a/e2e/src/cli/filters.ts b/e2e/src/cli/filters.ts index bae22dd372..e2f0d82c02 100644 --- a/e2e/src/cli/filters.ts +++ b/e2e/src/cli/filters.ts @@ -8,7 +8,7 @@ import { } from '../types'; /** x402 payment scheme for filtering. */ -export type PaymentSchemeKind = 'exact' | 'upto' | 'batch-settlement'; +export type PaymentSchemeKind = 'exact' | 'upto' | 'batch-settlement' | 'auth-capture'; /** CLI aliases for catalog SDK ids (`typescript` / `python` / `go`). */ const SDK_ALIASES: Record = { diff --git a/e2e/src/cli/interactive.ts b/e2e/src/cli/interactive.ts index 926963a869..f2024b189a 100644 --- a/e2e/src/cli/interactive.ts +++ b/e2e/src/cli/interactive.ts @@ -317,7 +317,7 @@ export async function runInteractiveMode( message: 'Select payment schemes', choices: schemeChoices, min: 1, - hint: 'exact = eip3009/permit2-style; upto = usage-based; batch-settlement = voucher channel', + hint: 'exact = eip3009/permit2; upto = usage-based; batch-settlement = voucher channel; auth-capture = escrow (sync, deferred capture, or custom operator)', instructions: false, }); diff --git a/e2e/src/discovery.ts b/e2e/src/discovery.ts index 826bb25518..fb094913a6 100644 --- a/e2e/src/discovery.ts +++ b/e2e/src/discovery.ts @@ -242,6 +242,7 @@ export class TestDiscovery { clientLanguage, endpointProtocolFamily, client.config.schemes, + 'client', ); if (!clientSchemesForFamily.includes(endpointScheme)) { verboseLog(` ⚠️ Skipping ${client.name} ↔ ${server.name} ${endpoint.path}: Payment scheme mismatch (client supports [${clientSchemesForFamily.join(', ')}] on ${endpointProtocolFamily}, endpoint requires ${endpointScheme})`); @@ -274,6 +275,7 @@ export class TestDiscovery { facilLanguage, endpointProtocolFamily, f.config.schemes, + 'facilitator', ); if (!facilSchemesForFamily.includes(endpointScheme)) return false; if (endpointProtocolFamily === 'evm') { diff --git a/e2e/src/mechanisms.ts b/e2e/src/mechanisms.ts index bcc684fa8d..96264d9169 100644 --- a/e2e/src/mechanisms.ts +++ b/e2e/src/mechanisms.ts @@ -30,7 +30,7 @@ export type ConfigRole = 'server' | 'client' | 'facilitator'; /** Network id, e.g. "evm" — one per `mechanisms_.json` file. No fixed union: adding a network is a catalog-only edit. */ export type CatalogNetworkId = string; -type PaymentScheme = 'exact' | 'upto' | 'batch-settlement'; +type PaymentScheme = 'exact' | 'upto' | 'batch-settlement' | 'auth-capture'; type AssetTransferMethod = | 'eip3009' | 'permit2' @@ -117,8 +117,10 @@ export type RouteDefinition = { scheme: PaymentScheme; network: CatalogNetworkId; assetTransferMethod?: AssetTransferMethod; - /** SDKs that implement this route. */ + /** SDKs that implement this route end to end (client, server and facilitator). */ sdks: SdkId[]; + /** SDKs that implement only the client role. Servers and facilitators never see them. */ + clientSdks?: SdkId[]; schemeOptions?: Record; /** Every route declares its own price. */ price: PriceSpec; @@ -134,10 +136,14 @@ export type RouteDefinition = { paymentFlow?: PaymentFlow; /** Omit this route unless the named env var is set (optional add-on routes). */ requiresEnv?: string; + /** Omit this route when the named env var is set (mutually exclusive add-on routes). */ + requiresEnvAbsent?: string; /** Payment completion window advertised on this route. */ maxTimeoutSeconds?: number; /** Merged into the route payment option's `extra` (wire `PaymentRequirements.extra`). */ requirementsExtra?: Record; + /** Scheme-specific `accepts.extra` fields (e.g. auth-capture deadlines and flow). */ + schemeExtra?: Record; }; /** Fixed success body for every paid route (`timestamp` is added by the server). */ @@ -188,6 +194,7 @@ type EndpointLike = { paymentFlow?: PaymentFlow; schemeOptions?: Record; extensions?: string[]; + schemeExtra?: Record; health?: boolean; close?: boolean; /** MCP tool name, equal to `path` for MCP endpoints (`method: 'tool'`). */ @@ -283,9 +290,14 @@ export function getRouteDefinition(path: string): RouteDefinition { return def; } -export function sdkRoutesFor(sdk: string): SdkRoute[] { +/** Routes an SDK implements. With `role: 'client'`, routes it implements only as a client are included. */ +export function sdkRoutesFor(sdk: string, role?: ConfigRole): SdkRoute[] { return Object.entries(catalog.routes) - .filter(([, def]) => def.sdks.includes(sdk as SdkId)) + .filter( + ([, def]) => + def.sdks.includes(sdk as SdkId) || + (role === 'client' && def.clientSdks?.includes(sdk as SdkId)), + ) .map(([path, def]) => ({ path, ...def })); } @@ -308,9 +320,13 @@ export function schemesForSdk(sdk: string): PaymentScheme[] { } /** Schemes an SDK implements for one catalog network (from route `sdks`). */ -export function schemesForSdkNetwork(sdk: string, network: CatalogNetworkId): PaymentScheme[] { +export function schemesForSdkNetwork( + sdk: string, + network: CatalogNetworkId, + role?: ConfigRole, +): PaymentScheme[] { const schemes = new Set(); - for (const route of sdkRoutesFor(sdk)) { + for (const route of sdkRoutesFor(sdk, role)) { if (route.network === network) { schemes.add(route.scheme); } @@ -327,8 +343,9 @@ export function schemesForComponent( sdk: string, network: CatalogNetworkId, declaredSchemes: PaymentScheme[] | undefined, + role?: ConfigRole, ): PaymentScheme[] { - const sdkSchemes = schemesForSdkNetwork(sdk, network); + const sdkSchemes = schemesForSdkNetwork(sdk, network, role); if (!declaredSchemes?.length) { return sdkSchemes; } @@ -391,6 +408,7 @@ export function sdkRouteToEndpoint(route: SdkRoute, transport: RouteTransport = paymentFlow: route.paymentFlow, schemeOptions: route.schemeOptions, extensions: route.extensions, + ...(route.schemeExtra ? { schemeExtra: route.schemeExtra } : {}), }; } @@ -405,6 +423,7 @@ export function sdkRouteToEndpoint(route: SdkRoute, transport: RouteTransport = paymentFlow: route.paymentFlow, schemeOptions: route.schemeOptions, extensions: route.extensions, + ...(route.schemeExtra ? { schemeExtra: route.schemeExtra } : {}), }; } @@ -477,12 +496,18 @@ export type RouteFilter = { }; export function routeEnvSatisfied(route: SdkRoute, env: EnvLookup): boolean { - if (!route.requiresEnv) { - return true; + if (route.requiresEnv && !env(route.requiresEnv)?.trim()) { + return false; + } + if (route.requiresEnvAbsent && env(route.requiresEnvAbsent)?.trim()) { + return false; } - return Boolean(env(route.requiresEnv)?.trim()); + return true; } +/** Harness-only POST path to trigger deferred auth-capture lifecycle capture on e2e servers. */ +export const AUTH_CAPTURE_E2E_CAPTURE_PATH = '/__e2e/auth-capture/capture'; + export function filterRoutes(routes: SdkRoute[], filter?: RouteFilter): SdkRoute[] { if (!filter?.excludeSchemes?.length && !filter?.excludeNetworks?.length) { return routes; @@ -672,7 +697,7 @@ export function enrichConfigFromMechanisms( excludeSchemes: config.excludeSchemes as string[] | undefined, excludeNetworks: config.excludeNetworks as string[] | undefined, }; - const routes = availableRoutes(sdkRoutesFor(sdk), key => process.env[key], filter); + const routes = availableRoutes(sdkRoutesFor(sdk, type), key => process.env[key], filter); const fromCatalog = NETWORK_IDS.filter(id => routes.some(route => route.network === id)); const protocolFamilies = @@ -1032,6 +1057,7 @@ export function resolvePaymentRoutes( : cardanoRouteExtra(route, env), route.paymentFlow, ); + const mergedExtra = route.schemeExtra ? { ...(extra ?? {}), ...route.schemeExtra } : extra; resolved.push({ path: route.path, @@ -1040,7 +1066,7 @@ export function resolvePaymentRoutes( network: caip2, payTo, price, - ...(extra ? { extra } : {}), + ...(mergedExtra ? { extra: mergedExtra } : {}), ...(route.maxTimeoutSeconds ? { maxTimeoutSeconds: route.maxTimeoutSeconds } : {}), extensions: route.extensions ?? [], ...(route.settlementOverride ? { settlementOverride: route.settlementOverride } : {}), diff --git a/e2e/src/sampling.ts b/e2e/src/sampling.ts index 476fcd801d..75be49321f 100644 --- a/e2e/src/sampling.ts +++ b/e2e/src/sampling.ts @@ -1,4 +1,10 @@ -import { TestScenario, endpointAssetTransferMethod, endpointPaymentScheme } from './types'; +import { + TestScenario, + endpointAssetTransferMethod, + endpointAuthCaptureCoverageBranch, + endpointPathForCoverageMinimization, + endpointPaymentScheme, +} from './types'; import { log, verboseLog } from './logger'; /** @@ -38,6 +44,8 @@ export class CoverageTracker { private serversCovered = new Set(); private facilitatorsCovered = new Set(); private endpointsCovered = new Set(); + /** Each auth-capture catalog branch (self-sync, deferred, etc.) at least once per --min run. */ + private authCaptureBranchesCovered = new Set(); /** * Generate a coverage key for a component @@ -74,6 +82,10 @@ export class CoverageTracker { protocolFamily === 'evm' ? `${scheme}-${endpointAssetTransferMethod(scenario.endpoint) ?? 'eip3009'}` : scheme; + // One auth-capture permit2 scenario is enough to exercise the route across servers. + if (scheme === 'auth-capture' && endpointAssetTransferMethod(scenario.endpoint) === 'permit2') { + return `global-${endpointPath}-${protocolFamily}-${method}-v${version}`; + } return `${serverName}-${endpointPath}-${protocolFamily}-${method}-v${version}`; } @@ -110,7 +122,7 @@ export class CoverageTracker { ); const endpointKey = this.getEndpointCoverageKey( scenario.server.name, - scenario.endpoint.path, + endpointPathForCoverageMinimization(scenario.endpoint), protocolFamily, version, scenario, @@ -122,10 +134,20 @@ export class CoverageTracker { const facilitatorNew = !this.facilitatorsCovered.has(facilitatorKey); const endpointNew = !this.endpointsCovered.has(endpointKey); - const isNew = clientNew || serverNew || facilitatorNew || endpointNew; + const authCaptureBranch = endpointPaymentScheme(scenario.endpoint) === 'auth-capture' + ? endpointAuthCaptureCoverageBranch(scenario.endpoint) + : undefined; + const branchNew = + authCaptureBranch !== undefined && + authCaptureBranch !== 'other' && + !this.authCaptureBranchesCovered.has(authCaptureBranch); + + const isNew = clientNew || serverNew || facilitatorNew || endpointNew || branchNew; if (isNew) { - verboseLog(` 📊 New coverage: ${clientNew ? `client(${clientKey})` : ''} ${serverNew ? `server(${serverKey})` : ''} ${facilitatorNew ? `facilitator(${facilitatorKey})` : ''} ${endpointNew ? `endpoint(${endpointKey})` : ''}`); + verboseLog( + ` 📊 New coverage: ${clientNew ? `client(${clientKey})` : ''} ${serverNew ? `server(${serverKey})` : ''} ${facilitatorNew ? `facilitator(${facilitatorKey})` : ''} ${endpointNew ? `endpoint(${endpointKey})` : ''} ${branchNew ? `authCaptureBranch(${authCaptureBranch})` : ''}`, + ); } return isNew; @@ -158,7 +180,7 @@ export class CoverageTracker { ); const endpointKey = this.getEndpointCoverageKey( scenario.server.name, - scenario.endpoint.path, + endpointPathForCoverageMinimization(scenario.endpoint), protocolFamily, version, scenario, @@ -168,6 +190,13 @@ export class CoverageTracker { this.serversCovered.add(serverKey); this.facilitatorsCovered.add(facilitatorKey); this.endpointsCovered.add(endpointKey); + + const authCaptureBranch = endpointPaymentScheme(scenario.endpoint) === 'auth-capture' + ? endpointAuthCaptureCoverageBranch(scenario.endpoint) + : undefined; + if (authCaptureBranch !== undefined && authCaptureBranch !== 'other') { + this.authCaptureBranchesCovered.add(authCaptureBranch); + } } /** @@ -176,12 +205,19 @@ export class CoverageTracker { * Returns: * Object containing coverage counts for each component type */ - getStats(): { clients: number; servers: number; facilitators: number; endpoints: number } { + getStats(): { + clients: number; + servers: number; + facilitators: number; + endpoints: number; + authCaptureBranches: number; + } { return { clients: this.clientsCovered.size, servers: this.serversCovered.size, facilitators: this.facilitatorsCovered.size, endpoints: this.endpointsCovered.size, + authCaptureBranches: this.authCaptureBranchesCovered.size, }; } } @@ -194,6 +230,11 @@ export class CoverageTracker { * express / flask / gin servers) are distributed evenly rather than the * first alphabetical entry always being chosen for every coverage slot. * + * Auth-capture EIP-3009 catalog paths share one endpoint slot per server + * (`auth-capture~eip3009`), but each {@link endpointAuthCaptureCoverageBranch} + * must run at least once. Permit2 uses a single global endpoint slot across + * servers (`auth-capture~permit2`). + * * Pass a numeric `seed` for a reproducible shuffle; omit it (default) for a * fresh random distribution on every run. * @@ -239,6 +280,9 @@ export function minimizeScenarios(scenarios: TestScenario[], seed?: number): Tes log(` • Servers: ${stats.servers} unique combinations`); log(` • Facilitators: ${stats.facilitators} unique combinations`); log(` • Endpoints: ${stats.endpoints} unique combinations`); + if (stats.authCaptureBranches > 0) { + log(` • Auth-capture branches: ${stats.authCaptureBranches}`); + } log(''); return minimized; diff --git a/e2e/src/types.ts b/e2e/src/types.ts index bb862e2808..1d94032531 100644 --- a/e2e/src/types.ts +++ b/e2e/src/types.ts @@ -2,7 +2,7 @@ import type { NetworkSet, ProtocolFamily } from './networks/networks'; export type { ProtocolFamily } from './networks/networks'; export type Transport = 'http' | 'mcp'; -export type PaymentScheme = 'exact' | 'upto' | 'batch-settlement'; +export type PaymentScheme = 'exact' | 'upto' | 'batch-settlement' | 'auth-capture'; export type AssetTransferMethod = 'eip3009' | 'permit2' | 'sequence' | 'ticketSequence'; /** Payment ordering on the accept. Omitted on an endpoint means `authorization`. */ export type PaymentFlow = 'authorization' | 'upfront' | 'escrow'; @@ -56,6 +56,71 @@ export function endpointUsesBatchSettlement(endpoint: TestEndpoint): boolean { return endpoint.scheme === 'batch-settlement'; } +function authCaptureExtra(endpoint: TestEndpoint): Record | undefined { + return endpoint.schemeExtra; +} + +export function endpointAuthCaptureCaptureMode(endpoint: TestEndpoint): string | undefined { + const mode = authCaptureExtra(endpoint)?.captureMode; + return mode != null ? String(mode) : undefined; +} + +export function endpointAuthCaptureOperatorType(endpoint: TestEndpoint): string | undefined { + const operatorType = authCaptureExtra(endpoint)?.operatorType; + return operatorType != null ? String(operatorType) : undefined; +} + +/** Deferred delegated escrow: harness must POST capture after the client authorize GET. */ +export function endpointAuthCaptureNeedsDeferredCapture(endpoint: TestEndpoint): boolean { + return ( + endpointPaymentScheme(endpoint) === 'auth-capture' && + endpointAuthCaptureCaptureMode(endpoint) === 'deferred' && + endpointAuthCaptureOperatorType(endpoint) === 'delegated' + ); +} + +/** + * Auth-capture branch id for coverage / minimization (not the HTTP path). + * Used by `--min` to require every catalog branch at least once; EIP-3009 paths + * also share one endpoint slot per server (`auth-capture~eip3009`). + */ +export function endpointAuthCaptureCoverageBranch(endpoint: TestEndpoint): string | undefined { + if (endpointPaymentScheme(endpoint) !== 'auth-capture') { + return undefined; + } + const method = endpointAssetTransferMethod(endpoint) ?? 'eip3009'; + if (method === 'permit2') { + return 'self-sync-permit2'; + } + const path = endpoint.path; + if (path.startsWith('/auth-capture-custom-forwarding/')) { + return 'custom-forwarding'; + } + if (path.startsWith('/auth-capture-deferred/')) { + return 'deferred-delegated'; + } + if (path.startsWith('/auth-capture-facilitator-authorizer/')) { + return 'facilitator-sync'; + } + if (path.startsWith('/auth-capture/')) { + return 'self-sync-eip3009'; + } + return 'other'; +} + +/** + * Endpoint path used for coverage-based minimization. Auth-capture collapses to + * one slot per server per asset method; branch coverage is tracked separately + * ({@link endpointAuthCaptureCoverageBranch}). + */ +export function endpointPathForCoverageMinimization(endpoint: TestEndpoint): string { + if (endpointPaymentScheme(endpoint) === 'auth-capture') { + const method = endpointAssetTransferMethod(endpoint) ?? 'eip3009'; + return `auth-capture~${method}`; + } + return endpoint.path; +} + export interface ClientResult { success: boolean; data?: any; @@ -117,6 +182,8 @@ export interface TestEndpoint { paymentFlow?: PaymentFlow; schemeOptions?: SchemeOptions; extensions?: string[]; + /** Merged catalog `schemeExtra` for harness branching (auth-capture modes, etc.). */ + schemeExtra?: Record; /** For MCP tools: the tool name used in tools/call. Defaults to path if not specified. */ toolName?: string; /** For MCP tools: expected MCP wire transport for discovery metadata. */ diff --git a/e2e/test.ts b/e2e/test.ts index a878c4e40c..5a465fe53e 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -6,7 +6,8 @@ import { createWalletClient, createPublicClient, http, parseEther, formatEther, import { privateKeyToAccount } from 'viem/accounts'; import { base, baseSepolia } from 'viem/chains'; import { TestDiscovery } from './src/discovery'; -import { ClientConfig, ScenarioResult, ServerConfig, TestScenario, endpointAssetTransferMethod, endpointPaymentFlow, endpointPaymentScheme, endpointUsesBatchSettlement } from './src/types'; +import { ClientConfig, ScenarioResult, ServerConfig, TestScenario, endpointAssetTransferMethod, endpointAuthCaptureCoverageBranch, endpointAuthCaptureNeedsDeferredCapture, endpointPaymentFlow, endpointPaymentScheme, endpointUsesBatchSettlement } from './src/types'; +import { AUTH_CAPTURE_E2E_CAPTURE_PATH } from './src/mechanisms'; import { config as loggerConfig, log, verboseLog, errorLog, close as closeLogger, createComboLogger } from './src/logger'; import { handleDiscoveryValidation, shouldRunDiscoveryValidation, type TestedDiscoveryScenario } from './extensions/bazaar'; import { parseArgs, printHelp } from './src/cli/args'; @@ -1056,6 +1057,21 @@ async function runTest() { log(''); } + const authCaptureScenarios = filteredScenarios.filter( + s => endpointPaymentScheme(s.endpoint) === 'auth-capture', + ); + if (authCaptureScenarios.length > 0) { + const branchSelected = (branch: string) => + authCaptureScenarios.some(s => endpointAuthCaptureCoverageBranch(s.endpoint) === branch); + log('🔍 Auth-capture branch coverage (--min requires each branch once; one permit2 globally):'); + log(` Self-managed sync EIP-3009: ${branchSelected('self-sync-eip3009') ? '✅' : '⚠️ not in selected set'}`); + log(` Self-managed sync Permit2: ${branchSelected('self-sync-permit2') ? '✅' : '⚠️ not in selected set'}`); + log(` Facilitator-authorizer sync: ${branchSelected('facilitator-sync') ? '✅' : '⚠️ not in selected set'}`); + log(` Deferred delegated: ${branchSelected('deferred-delegated') ? '✅' : '⚠️ not in selected set'}`); + log(` Custom forwarding (collect): ${branchSelected('custom-forwarding') ? '✅' : '⚠️ not in selected set'}`); + log(''); + } + // Auto-detect Permit2 scenarios (upto uses Permit2 under the hood) const hasPermit2Scenarios = filteredScenarios.some(s => endpointAssetTransferMethod(s.endpoint) === 'permit2'); @@ -1550,6 +1566,43 @@ async function runTest() { const result = await runClientTest(scenario.client.proxy, baseClientConfig); + let deferredCaptureError: string | undefined; + let captureTransaction: string | undefined; + if (result.success && endpointAuthCaptureNeedsDeferredCapture(scenario.endpoint)) { + try { + const captureResponse = await fetch( + `http://localhost:${port}${AUTH_CAPTURE_E2E_CAPTURE_PATH}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: scenario.endpoint.path }), + }, + ); + const captureBody = (await captureResponse.json()) as { + success?: boolean; + transaction?: string; + error?: string; + errorReason?: string; + }; + if (!captureResponse.ok) { + deferredCaptureError = + captureBody.error || `Deferred capture HTTP ${captureResponse.status}`; + } else if (!captureBody.success) { + deferredCaptureError = + captureBody.errorReason || captureBody.error || 'Deferred capture settle failed'; + } else if (!captureBody.transaction) { + deferredCaptureError = 'Deferred capture succeeded but no transaction hash returned'; + } else { + captureTransaction = captureBody.transaction; + cLog.verboseLog(` 🔗 Deferred capture transaction: ${captureTransaction}`); + } + } catch (error) { + deferredCaptureError = + error instanceof Error ? error.message : 'Deferred capture request failed'; + } + } + + const passed = result.success && !deferredCaptureError; const detailedResult: DetailedTestResult = { testNumber: localTestNumber, client: scenario.client.name, @@ -1558,16 +1611,16 @@ async function runTest() { facilitator: scenario.facilitator?.name || 'none', protocolFamily: scenario.protocolFamily, ...scenarioDimensions(scenario), - passed: result.success, - error: result.error, - transaction: result.payment_response?.transaction, + passed, + error: deferredCaptureError || result.error, + transaction: captureTransaction || result.payment_response?.transaction, network: result.payment_response?.network, }; - if (result.success) { + if (passed) { cLog.log(` ✅ Test passed`); } else { - cLog.log(` ❌ Test failed: ${result.error}`); + cLog.log(` ❌ Test failed: ${detailedResult.error}`); if (result.verboseLogs && result.verboseLogs.length > 0) { cLog.log(` 🔍 Verbose logs:`); result.verboseLogs.forEach(logLine => cLog.log(logLine)); diff --git a/examples/go/clients/http/README.md b/examples/go/clients/http/README.md index 48d8e789aa..57921c479d 100644 --- a/examples/go/clients/http/README.md +++ b/examples/go/clients/http/README.md @@ -5,7 +5,7 @@ Example client demonstrating how to use the x402 Go HTTP client to make requests ## Prerequisites - Go 1.24 or higher -- A running x402 server (see [gin server example](../../servers/gin)) +- A running x402 server (see [gin server example](../../servers/gin) or [auth-capture server example](../../servers/auth-capture)) - Valid EVM and/or SVM private keys for making payments ## Setup diff --git a/examples/go/facilitator/auth-capture/.env-example b/examples/go/facilitator/auth-capture/.env-example new file mode 100644 index 0000000000..fb6af58a31 --- /dev/null +++ b/examples/go/facilitator/auth-capture/.env-example @@ -0,0 +1,19 @@ +EVM_PRIVATE_KEY=0x... +EVM_RPC_URL=https://sepolia.base.org + +# Optional dedicated receiver authorizer (recommended: separate from the relayer). +# When set, advertised in GET /supported as extra.receiverAuthorizer; resource servers can omit +# EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY and delegate lifecycle signing to this facilitator. +EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= + +# Optional fee terms advertised to servers/clients (omit for no fee) +FEE_RECIPIENT= +MIN_FEE_BPS=0 +MAX_FEE_BPS=0 + +# Comma-separated custom operator contracts this facilitator will relay collect for +# (advertised in GET /supported as extra.operators). Leave empty to admit none. +CUSTOM_OPERATOR_ALLOWLIST= + +# Optional listen port (default 4022) +PORT= diff --git a/examples/go/facilitator/auth-capture/.gitignore b/examples/go/facilitator/auth-capture/.gitignore new file mode 100644 index 0000000000..8998a9c654 --- /dev/null +++ b/examples/go/facilitator/auth-capture/.gitignore @@ -0,0 +1,2 @@ +.env +auth-capture diff --git a/examples/go/facilitator/auth-capture/README.md b/examples/go/facilitator/auth-capture/README.md new file mode 100644 index 0000000000..3e9a7a1536 --- /dev/null +++ b/examples/go/facilitator/auth-capture/README.md @@ -0,0 +1,97 @@ +# Auth-Capture Facilitator (Go) + +Standalone HTTP facilitator with the auth-capture EVM scheme registered for +Base Sepolia. Exposes the standard x402 endpoints: + +- `GET /supported` +- `POST /verify` +- `POST /settle` + +This facilitator is the escrow operator (`captureAuthorizer`): it verifies and +settles the client's initial authorize (collect) onchain, then later relays +the resource server's `capture`/`void` lifecycle calls. + +| Env var | Role | Onchain effect | +| --- | --- | --- | +| `EVM_PRIVATE_KEY` | **Relayer** — submits transactions | Pays gas; for `"delegated"` routes this address is `extra.captureAuthorizer` | +| `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` | **Receiver authorizer** (optional) | When set, advertised in `/supported` as `extra.receiverAuthorizer`. Resource servers that omit their own authorizer key send unsigned payloads; this facilitator signs after `ResolveCallerIdentity` authenticates the caller | + +Without `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY`, `/supported` advertises only `extra.captureAuthorizer`. Resource servers must configure `ReceiverAuthorizerSigner` locally. + +## Receiver authorizer delegation (optional) + +To let resource servers delegate capture/void/refund signing to this facilitator, set `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` (recommended: a key separate from the relayer). `GET /supported` then includes `extra.receiverAuthorizer`: + +```json +{ + "kinds": [ + { + "scheme": "auth-capture", + "network": "eip155:84532", + "extra": { + "captureAuthorizer": "...", + "receiverAuthorizer": "..." + } + } + ] +} +``` + +This example wires the facilitator SDK opt-in (all four fields required, otherwise `NewAuthCaptureEvmScheme` panics): + +- `AuthorizerSigner` from `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` +- `ResolveCallerIdentity` — invoked by the SDK during delegated settles with a `DelegatedSettleContext` (`Step`, `PaymentInfoHash`, `Payload`, `Requirements`, optional `FacilitatorContext`) +- `DelegatedAuthStorage` — this example uses `InMemoryAuthCaptureDelegatedAuthStorage` for local testing only +- `OnStorageError` — reports binding revert/delete failures + +`POST /settle` is a thin wrapper around `facilitator.Settle()`; it does not implement custom identity plumbing. Caller authentication belongs in `ResolveCallerIdentity` (see the [scheme README](../../../../go/mechanisms/evm/auth-capture/README.md#receiver-authorizer-modes)). + +```go +config.AuthorizerSigner = authorizerSigner +config.DelegatedAuthStorage = authcapturefac.NewInMemoryAuthCaptureDelegatedAuthStorage() +config.OnStorageError = func(err error, network x402.Network, paymentInfoHash string) { + /* ... */ +} +config.ResolveCallerIdentity = func(ctx context.Context, settle authcapturefac.DelegatedSettleContext) (string, error) { + // Authenticate the resource server (API key, mTLS, JWT, etc.) using settle and/or + // settle.FacilitatorContext. Return a stable merchant id, or an empty string to + // reject. Local testing only: + return "example-local-caller", nil +} +``` + +The first authenticated caller to settle a payment is bound to its `paymentInfoHash`; later lifecycle settles must resolve to the same identity. For production, replace the in-memory store with a durable, atomic implementation (Redis, SQL, etc.) when more than one facilitator process serves the same payments. + +> ⚠️ **Local testing only:** this example returns a fixed identity from `ResolveCallerIdentity`. Do not advertise `receiverAuthorizer` without real authentication. + +Pair with the [auth-capture server example](../../servers/auth-capture/): run this facilitator with `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` set and omit `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` on the resource server for facilitator-delegated sync flows. + +## Custom-operator allowlist + +`CUSTOM_OPERATOR_ALLOWLIST` is a comma-separated list of operator addresses the facilitator will relay collect for, advertised in `/supported` as `extra.operators` when the signer implements `SimulateCalls` and `WriteContractWithGas` (this example wires both via `eth_simulateV1`). Leave it empty to admit no custom operator; set it to the operator address the [custom-escrow server example](../../servers/auth-capture/) deploys so that flow can relay collect-only `authorize` through it. + +Custom collect verification uses `eth_simulateV1` with a gas cap (`CustomOperatorGasLimit`, default `1_000_000`) and outcome checks before broadcast. + +## Run + +```bash +cp .env-example .env +# fill in EVM_PRIVATE_KEY (and optionally EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY, +# CUSTOM_OPERATOR_ALLOWLIST, EVM_RPC_URL, PORT) + +go run . +``` + +Listens on `http://localhost:4022` by default (`PORT` overrides). + +## Environment + +| Variable | Description | +|-----------------------|-------------| +| `EVM_PRIVATE_KEY` (required) | Facilitator/operator wallet — signs and submits onchain transactions | +| `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` | Optional dedicated receiver authorizer; enables facilitator-delegated signing | +| `EVM_RPC_URL` | Default `https://sepolia.base.org` | +| `FEE_RECIPIENT` | Optional advertised fee recipient | +| `MIN_FEE_BPS`/`MAX_FEE_BPS` | Optional advertised fee bounds | +| `CUSTOM_OPERATOR_ALLOWLIST` | Comma-separated custom operator addresses to admit (empty admits none) | +| `PORT` | Listen port (default `4022`) | diff --git a/examples/go/facilitator/auth-capture/evm_signer.go b/examples/go/facilitator/auth-capture/evm_signer.go new file mode 100644 index 0000000000..056d756037 --- /dev/null +++ b/examples/go/facilitator/auth-capture/evm_signer.go @@ -0,0 +1,429 @@ +package main + +import ( + "bytes" + "context" + "crypto/ecdsa" + "fmt" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/signer/core/apitypes" + evmmech "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapturefac "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/facilitator" +) + +// facilitatorEvmSigner implements evmmech.FacilitatorEvmSigner. +type facilitatorEvmSigner struct { + privateKey *ecdsa.PrivateKey + address common.Address + client *ethclient.Client + chainID *big.Int +} + +func newFacilitatorEvmSigner(privateKeyHex string, rpcURL string) (*facilitatorEvmSigner, error) { + pk, err := crypto.HexToECDSA(strings.TrimPrefix(privateKeyHex, "0x")) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + client, err := ethclient.Dial(rpcURL) + if err != nil { + return nil, fmt.Errorf("dial RPC: %w", err) + } + chainID, err := client.ChainID(context.Background()) + if err != nil { + return nil, fmt.Errorf("get chain ID: %w", err) + } + return &facilitatorEvmSigner{ + privateKey: pk, + address: crypto.PubkeyToAddress(pk.PublicKey), + client: client, + chainID: chainID, + }, nil +} + +func (s *facilitatorEvmSigner) GetAddresses() []string { + return []string{s.address.Hex()} +} + +func (s *facilitatorEvmSigner) GetChainID(_ context.Context) (*big.Int, error) { + return s.chainID, nil +} + +func (s *facilitatorEvmSigner) VerifyTypedData( + _ context.Context, + address string, + domain evmmech.TypedDataDomain, + types map[string][]evmmech.TypedDataField, + primaryType string, + message map[string]interface{}, + signature []byte, +) (bool, error) { + td := buildTypedData(domain, types, primaryType, message) + + dataHash, err := td.HashStruct(td.PrimaryType, td.Message) + if err != nil { + return false, fmt.Errorf("hash struct: %w", err) + } + domainSep, err := td.HashStruct("EIP712Domain", td.Domain.Map()) + if err != nil { + return false, fmt.Errorf("hash domain: %w", err) + } + digest := crypto.Keccak256(append([]byte{0x19, 0x01}, append(domainSep, dataHash...)...)) + + if len(signature) != 65 { + return false, fmt.Errorf("invalid signature length: %d", len(signature)) + } + sig := make([]byte, 65) + copy(sig, signature) + if sig[64] >= 27 { + sig[64] -= 27 + } + + pub, err := crypto.SigToPub(digest, sig) + if err != nil { + return false, fmt.Errorf("recover pubkey: %w", err) + } + return bytes.Equal(crypto.PubkeyToAddress(*pub).Bytes(), common.HexToAddress(address).Bytes()), nil +} + +func (s *facilitatorEvmSigner) ReadContract( + ctx context.Context, + contractAddress string, + abiJSON []byte, + method string, + args ...interface{}, +) (interface{}, error) { + return s.ReadContractFrom(ctx, s.address.Hex(), contractAddress, abiJSON, method, args...) +} + +// ReadContractFrom implements the auth-capture facilitator's SenderReader: the escrow gates +// capture and void on msg.sender, so simulations must call as the operator. +func (s *facilitatorEvmSigner) ReadContractFrom( + ctx context.Context, + from string, + contractAddress string, + abiJSON []byte, + method string, + args ...interface{}, +) (interface{}, error) { + parsedABI, err := abi.JSON(strings.NewReader(string(abiJSON))) + if err != nil { + return nil, fmt.Errorf("parse ABI: %w", err) + } + methodObj, ok := parsedABI.Methods[method] + if !ok { + return nil, fmt.Errorf("method %s not found", method) + } + data, err := parsedABI.Pack(method, args...) + if err != nil { + return nil, fmt.Errorf("pack call: %w", err) + } + to := common.HexToAddress(contractAddress) + out, err := s.client.CallContract(ctx, ethereum.CallMsg{From: common.HexToAddress(from), To: &to, Data: data}, nil) + if err != nil { + return nil, fmt.Errorf("call contract: %w", err) + } + if len(methodObj.Outputs) == 0 { + return nil, nil + } + results, err := methodObj.Outputs.Unpack(out) + if err != nil { + return nil, fmt.Errorf("unpack: %w", err) + } + if len(results) > 0 { + return results[0], nil + } + return nil, nil +} + +func (s *facilitatorEvmSigner) WriteContract( + ctx context.Context, + contractAddress string, + abiJSON []byte, + method string, + dataSuffix []byte, + args ...interface{}, +) (string, error) { + return s.WriteContractWithGas(ctx, contractAddress, abiJSON, method, dataSuffix, 500_000, args...) +} + +func (s *facilitatorEvmSigner) WriteContractWithGas( + ctx context.Context, + contractAddress string, + abiJSON []byte, + method string, + dataSuffix []byte, + gas uint64, + args ...interface{}, +) (string, error) { + parsedABI, err := abi.JSON(strings.NewReader(string(abiJSON))) + if err != nil { + return "", fmt.Errorf("parse ABI: %w", err) + } + data, err := parsedABI.Pack(method, args...) + if err != nil { + return "", fmt.Errorf("pack call: %w", err) + } + data = evmmech.AppendDataSuffix(data, dataSuffix) + if gas == 0 { + gas = 500_000 + } + return s.sendTransactionWithGas(ctx, contractAddress, data, gas) +} + +func (s *facilitatorEvmSigner) SendTransaction(ctx context.Context, to string, data []byte) (string, error) { + return s.sendTransactionWithGas(ctx, to, data, 500_000) +} + +func (s *facilitatorEvmSigner) sendTransactionWithGas(ctx context.Context, to string, data []byte, gas uint64) (string, error) { + nonce, err := s.client.PendingNonceAt(ctx, s.address) + if err != nil { + return "", fmt.Errorf("get nonce: %w", err) + } + gasPrice, err := s.client.SuggestGasPrice(ctx) + if err != nil { + return "", fmt.Errorf("suggest gas price: %w", err) + } + toAddr := common.HexToAddress(to) + tx := types.NewTransaction(nonce, toAddr, big.NewInt(0), gas, gasPrice, data) + signedTx, err := types.SignTx(tx, types.LatestSignerForChainID(s.chainID), s.privateKey) + if err != nil { + return "", fmt.Errorf("sign tx: %w", err) + } + if err := s.client.SendTransaction(ctx, signedTx); err != nil { + return "", fmt.Errorf("send tx: %w", err) + } + return signedTx.Hash().Hex(), nil +} + +type rpcSimulateCall struct { + From string `json:"from"` + To string `json:"to"` + Data string `json:"data"` + Gas string `json:"gas,omitempty"` +} + +type rpcSimulateLog struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` +} + +type rpcSimulateCallOutcome struct { + Status string `json:"status"` + ReturnData string `json:"returnData"` + GasUsed string `json:"gasUsed"` + Logs []rpcSimulateLog `json:"logs"` + Error *struct { + Data string `json:"data"` + } `json:"error,omitempty"` +} + +type rpcSimulatedBlock struct { + Calls []rpcSimulateCallOutcome `json:"calls"` +} + +func (s *facilitatorEvmSigner) SimulateCalls( + ctx context.Context, + from string, + calls []authcapturefac.SimulatedCall, +) ([]authcapturefac.SimulatedCallResult, error) { + rpcCalls := make([]rpcSimulateCall, len(calls)) + for i, call := range calls { + data := call.Data + if len(data) == 0 { + data = []byte{} + } + entry := rpcSimulateCall{ + From: common.HexToAddress(from).Hex(), + To: common.HexToAddress(call.To).Hex(), + Data: hexutil.Encode(data), + } + if call.Gas > 0 { + entry.Gas = hexutil.EncodeUint64(call.Gas) + } + rpcCalls[i] = entry + } + + var blocks []rpcSimulatedBlock + err := s.client.Client().CallContext(ctx, &blocks, "eth_simulateV1", []interface{}{ + map[string]interface{}{ + "blockStateCalls": []map[string]interface{}{ + {"calls": rpcCalls}, + }, + }, + "latest", + }...) + if err != nil { + return nil, fmt.Errorf("eth_simulateV1 failed: %w", err) + } + if len(blocks) == 0 || len(blocks[0].Calls) != len(calls) { + return nil, fmt.Errorf("eth_simulateV1 returned unexpected call count") + } + + results := make([]authcapturefac.SimulatedCallResult, len(calls)) + for i, outcome := range blocks[0].Calls { + returnData := outcome.ReturnData + if outcome.Error != nil && outcome.Error.Data != "" { + returnData = outcome.Error.Data + } + if returnData == "" { + returnData = "0x" + } + data, err := hexutil.Decode(returnData) + if err != nil { + return nil, fmt.Errorf("invalid simulate return data: %w", err) + } + var gasUsed uint64 + if outcome.GasUsed != "" { + gasUsed, err = hexutil.DecodeUint64(outcome.GasUsed) + if err != nil { + return nil, fmt.Errorf("invalid simulate gasUsed: %w", err) + } + } + results[i] = authcapturefac.SimulatedCallResult{ + Success: outcome.Status == "0x1", + ReturnData: data, + GasUsed: gasUsed, + Logs: parseSimulateLogs(outcome.Logs), + } + } + return results, nil +} + +func parseSimulateLogs(logs []rpcSimulateLog) []*types.Log { + parsed := make([]*types.Log, 0, len(logs)) + for _, log := range logs { + topics := make([]common.Hash, len(log.Topics)) + for i, topic := range log.Topics { + topics[i] = common.HexToHash(topic) + } + data, err := hexutil.Decode(log.Data) + if err != nil { + data = []byte{} + } + parsed = append(parsed, &types.Log{ + Address: common.HexToAddress(log.Address), + Topics: topics, + Data: data, + }) + } + return parsed +} + +func (s *facilitatorEvmSigner) WaitForTransactionReceipt(ctx context.Context, txHash string) (*evmmech.TransactionReceipt, error) { + hash := common.HexToHash(txHash) + for i := 0; i < 60; i++ { + receipt, err := s.client.TransactionReceipt(ctx, hash) + if err == nil && receipt != nil { + return &evmmech.TransactionReceipt{ + Status: uint64(receipt.Status), + BlockNumber: receipt.BlockNumber.Uint64(), + TxHash: receipt.TxHash.Hex(), + Logs: receipt.Logs, + }, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Second): + } + } + return nil, fmt.Errorf("transaction receipt not found") +} + +func (s *facilitatorEvmSigner) GetBalance(ctx context.Context, address string, tokenAddress string) (*big.Int, error) { + if tokenAddress == "" || tokenAddress == "0x0000000000000000000000000000000000000000" { + return s.client.BalanceAt(ctx, common.HexToAddress(address), nil) + } + const erc20ABI = `[{"constant":true,"inputs":[{"name":"account","type":"address"}],"name":"balanceOf","outputs":[{"name":"","type":"uint256"}],"type":"function"}]` + out, err := s.ReadContract(ctx, tokenAddress, []byte(erc20ABI), "balanceOf", common.HexToAddress(address)) + if err != nil { + return nil, err + } + if balance, ok := out.(*big.Int); ok { + return balance, nil + } + return nil, fmt.Errorf("unexpected balance type: %T", out) +} + +func (s *facilitatorEvmSigner) GetCode(ctx context.Context, address string) ([]byte, error) { + return s.client.CodeAt(ctx, common.HexToAddress(address), nil) +} + +func buildTypedData( + domain evmmech.TypedDataDomain, + in map[string][]evmmech.TypedDataField, + primaryType string, + message map[string]interface{}, +) apitypes.TypedData { + td := apitypes.TypedData{ + Types: apitypes.Types{}, + PrimaryType: primaryType, + Domain: apitypes.TypedDataDomain{ + Name: toString(domain.Name), + Version: toString(domain.Version), + ChainId: toHexBigInt(domain.ChainID), + VerifyingContract: toString(domain.VerifyingContract), + }, + Message: message, + } + for name, fields := range in { + conv := make([]apitypes.Type, len(fields)) + for i, f := range fields { + conv[i] = apitypes.Type{Name: f.Name, Type: f.Type} + } + td.Types[name] = conv + } + if _, ok := td.Types["EIP712Domain"]; !ok { + td.Types["EIP712Domain"] = []apitypes.Type{ + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + } + } + return td +} + +func toString(v interface{}) string { + switch s := v.(type) { + case string: + return s + case *string: + if s != nil { + return *s + } + } + return "" +} + +func toHexBigInt(v interface{}) *math.HexOrDecimal256 { + switch n := v.(type) { + case *big.Int: + return (*math.HexOrDecimal256)(n) + case int64: + return (*math.HexOrDecimal256)(big.NewInt(n)) + case string: + b, ok := new(big.Int).SetString(n, 10) + if ok { + return (*math.HexOrDecimal256)(b) + } + } + return (*math.HexOrDecimal256)(big.NewInt(0)) +} + +// WriteContractFrom ignores from: this signer holds a single address. +func (r *facilitatorEvmSigner) WriteContractFrom(ctx context.Context, _, address string, abiJSON []byte, functionName string, dataSuffix []byte, args ...interface{}) (string, error) { + return r.WriteContract(ctx, address, abiJSON, functionName, dataSuffix, args...) +} diff --git a/examples/go/facilitator/auth-capture/go.mod b/examples/go/facilitator/auth-capture/go.mod new file mode 100644 index 0000000000..4c2d766b56 --- /dev/null +++ b/examples/go/facilitator/auth-capture/go.mod @@ -0,0 +1,41 @@ +module github.com/x402-foundation/x402/examples/go/facilitator/auth-capture + +go 1.24.0 + +toolchain go1.24.1 + +replace github.com/x402-foundation/x402/go/v2 => ../../../../go + +require ( + github.com/ethereum/go-ethereum v1.17.2 + github.com/joho/godotenv v1.5.1 + github.com/x402-foundation/x402/go/v2 v2.14.0 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/StackExchange/wmi v1.2.1 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/supranational/blst v0.3.16 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/examples/go/facilitator/auth-capture/go.sum b/examples/go/facilitator/auth-capture/go.sum new file mode 100644 index 0000000000..dcfc5b2968 --- /dev/null +++ b/examples/go/facilitator/auth-capture/go.sum @@ -0,0 +1,202 @@ +github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= +github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= +github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= +github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI= +github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o= +github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= +github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= +github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= +github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +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/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= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/go/facilitator/auth-capture/main.go b/examples/go/facilitator/auth-capture/main.go new file mode 100644 index 0000000000..2b836536ca --- /dev/null +++ b/examples/go/facilitator/auth-capture/main.go @@ -0,0 +1,228 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/joho/godotenv" + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + authcapturefac "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/facilitator" + evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm" +) + +const defaultPort = "4022" + +// Auth-capture facilitator demo: acts as the escrow operator (captureAuthorizer), +// authorizing holds and relaying the server's capture or void. When +// EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY is set, it also advertises extra.receiverAuthorizer +// and signs delegated charge and lifecycle payloads with an explicit +// InMemoryAuthCaptureDelegatedAuthStorage and OnStorageError (all four delegation fields +// are required to opt in). +func main() { + _ = godotenv.Load() + + port := envOr("PORT", defaultPort) + + evmPrivateKey := os.Getenv("EVM_PRIVATE_KEY") + if evmPrivateKey == "" { + fmt.Println("EVM_PRIVATE_KEY environment variable is required") + os.Exit(1) + } + + rpcURL := envOr("EVM_RPC_URL", "https://sepolia.base.org") + + evmSigner, err := newFacilitatorEvmSigner(evmPrivateKey, rpcURL) + if err != nil { + fmt.Printf("Failed to create EVM signer: %v\n", err) + os.Exit(1) + } + + feeRecipient := os.Getenv("FEE_RECIPIENT") + minFeeBps := atoiOr("MIN_FEE_BPS", 0) + maxFeeBps := atoiOr("MAX_FEE_BPS", 0) + + config := authcapturefac.AuthCaptureEvmSchemeConfig{ + // /supported advertises one of the signer's addresses per response. + CaptureAuthorizers: evmSigner.GetAddresses(), + FeeRecipient: feeRecipient, + MinFeeBps: uint16(minFeeBps), + MaxFeeBps: uint16(maxFeeBps), + } + + // Custom operators are admitted per address. An empty list admits none, leaving + // only "delegated" routes through the relayer. + customOperators := parseCommaSeparatedList(os.Getenv("CUSTOM_OPERATOR_ALLOWLIST")) + for _, addr := range customOperators { + if !evm.IsValidAddress(addr) { + fmt.Printf( + "Invalid CUSTOM_OPERATOR_ALLOWLIST entry \"%s\" (comma-separated 20-byte hex addresses, 0x-prefixed)\n", + addr, + ) + os.Exit(1) + } + } + if len(customOperators) > 0 { + config.Operators = make([]authcapturefac.OperatorAllowlistEntry, len(customOperators)) + for i, addr := range customOperators { + config.Operators[i] = authcapturefac.OperatorAllowlistEntry{ + Address: evm.NormalizeAddress(addr), + OperatorType: authcapture.OperatorTypeCustom, + } + } + config.CustomOperatorGasLimit = authcapture.DefaultCustomOperatorGasLimit + } + + // Optional dedicated receiver authorizer (recommended: separate from the relayer). + var receiverAuthorizer evm.ClientEvmSigner + if receiverAuthorizerKey := os.Getenv("EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY"); receiverAuthorizerKey != "" { + receiverAuthorizer, err = evmsigners.NewClientSignerFromPrivateKey(receiverAuthorizerKey) + if err != nil { + fmt.Printf("Invalid EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY: %v\n", err) + os.Exit(1) + } + config.AuthorizerSigner = receiverAuthorizer + config.DelegatedAuthStorage = authcapturefac.NewInMemoryAuthCaptureDelegatedAuthStorage() + // SDK hook: called during delegated /settle (see DelegatedSettleContext). + // Authenticate the resource server (API key, mTLS, JWT, etc.) using the context and + // return a stable merchant id, or an empty string to reject. Local testing only: + config.ResolveCallerIdentity = func(_ context.Context, _ authcapturefac.DelegatedSettleContext) (string, error) { + return "example-local-caller", nil + } + config.OnStorageError = func(err error, network x402.Network, paymentInfoHash string) { + fmt.Printf("[delegated-auth-storage] network=%s paymentInfoHash=%s error=%v\n", network, paymentInfoHash, err) + } + } + + scheme, err := authcapturefac.NewAuthCaptureEvmSchemeWithError(evmSigner, config) + if err != nil { + fmt.Printf("Invalid auth-capture facilitator config: %v\n", err) + os.Exit(1) + } + + facilitator := x402.Newx402Facilitator() + facilitator.Register([]x402.Network{"eip155:84532"}, scheme) + + facilitator.OnAfterVerify(func(ctx x402.FacilitatorVerifyResultContext) error { + fmt.Printf("Payment verified\n") + return nil + }) + facilitator.OnAfterSettle(func(ctx x402.FacilitatorSettleResultContext) error { + fmt.Printf("Payment settled: %s\n", ctx.Result.Transaction) + return nil + }) + + mux := http.NewServeMux() + + mux.HandleFunc("GET /supported", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, facilitator.GetSupported()) + }) + + mux.HandleFunc("POST /verify", func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + payload, requirements, err := readVerifyBody(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + result, err := facilitator.Verify(ctx, payload, requirements) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, result) + }) + + mux.HandleFunc("POST /settle", func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) + defer cancel() + + payload, requirements, err := readVerifyBody(r) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + result, err := facilitator.Settle(ctx, payload, requirements) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, result) + }) + + fmt.Printf("Auth-capture facilitator listening on http://localhost:%s\n", port) + fmt.Printf(" Capture authorizer pool (operator): %s\n", strings.Join(config.CaptureAuthorizers, ", ")) + if receiverAuthorizer != nil { + fmt.Printf(" Receiver authorizer: %s\n", receiverAuthorizer.Address()) + fmt.Println(" Delegated auth bindings: InMemoryAuthCaptureDelegatedAuthStorage (process-local)") + fmt.Println(" Delegated settles: ResolveCallerIdentity returns example-local-caller (local only)") + } else { + fmt.Println(" Receiver authorizer: not configured (resource servers must self-sign)") + } + if len(customOperators) > 0 { + fmt.Printf(" Custom operators admitted: %s\n", strings.Join(customOperators, ", ")) + } else { + fmt.Println(" Custom operators: none admitted") + } + if err := http.ListenAndServe(":"+port, mux); err != nil { + fmt.Printf("Server error: %v\n", err) + os.Exit(1) + } +} + +func parseCommaSeparatedList(raw string) []string { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +func atoiOr(key string, def int) int { + if v := os.Getenv(key); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func readVerifyBody(r *http.Request) (json.RawMessage, json.RawMessage, error) { + var body struct { + PaymentPayload json.RawMessage `json:"paymentPayload"` + PaymentRequirements json.RawMessage `json:"paymentRequirements"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + return nil, nil, fmt.Errorf("invalid JSON body: %w", err) + } + if len(body.PaymentPayload) == 0 || len(body.PaymentRequirements) == 0 { + return nil, nil, fmt.Errorf("missing paymentPayload or paymentRequirements") + } + return body.PaymentPayload, body.PaymentRequirements, nil +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/examples/go/servers/auth-capture/.env-example b/examples/go/servers/auth-capture/.env-example new file mode 100644 index 0000000000..2f9aed9b7d --- /dev/null +++ b/examples/go/servers/auth-capture/.env-example @@ -0,0 +1,13 @@ +EVM_PAYEE_ADDRESS=0x... +FACILITATOR_URL=http://localhost:4022 + +# Optional: self-managed receiver authorizer that signs the Capture/Void EIP-712 messages. +# Omit to delegate lifecycle signing to the facilitator (extra.receiverAuthorizer on /supported). +EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=0x... + +# Custom-escrow flow only (`go run . custom-escrow`). +# Must match an entry in the facilitator's CUSTOM_OPERATOR_ALLOWLIST. +# CUSTOM_OPERATOR_ADDRESS=0x8FE415CdB559fBF5B235B81CC4F7a69684A274bb + +# Optional listen port (default 4021) +# PORT= diff --git a/examples/go/servers/auth-capture/.gitignore b/examples/go/servers/auth-capture/.gitignore new file mode 100644 index 0000000000..8998a9c654 --- /dev/null +++ b/examples/go/servers/auth-capture/.gitignore @@ -0,0 +1,2 @@ +.env +auth-capture diff --git a/examples/go/servers/auth-capture/README.md b/examples/go/servers/auth-capture/README.md new file mode 100644 index 0000000000..39ae2040cc --- /dev/null +++ b/examples/go/servers/auth-capture/README.md @@ -0,0 +1,54 @@ +# Auth-Capture Server (Go) + +Demo resource server using the auth-capture scheme's escrow payment flow: the +facilitator authorizes funds into escrow *before* the handler runs, the +handler serves the request, and then this server signs a Capture (success) +or Void (failure/cancel) message that lets the facilitator release the +escrowed funds. + +The receiver authorizer that signs Capture/Void is either: + +- **Self:** set `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY`; its address is published as `extra.receiverAuthorizer` +- **Delegated:** omit the key; `extra.receiverAuthorizer` is taken from the facilitator's `/supported`, this server sends unsigned payloads, and the facilitator signs them after authenticating the caller + +## Flows + +| Command | `operatorType` | `captureMode` | Behavior | +| --- | --- | --- | --- | +| `go run .` | `"delegated"` (default) | `"sync"` (default) | Escrow authorize, then capture/void after the handler | +| `go run . custom-escrow` | `"custom"` | `"deferred"` | Collect-only `authorize` through a custom operator; lifecycle is out of band | + +### Custom escrow (collect-only) + +`extra.captureAuthorizer` is a deployed custom operator contract. The facilitator relays only the collect `authorize`; capture/void/refund happen on the operator outside x402. + +1. Facilitator: set `CUSTOM_OPERATOR_ALLOWLIST` to the operator address (see [facilitator example](../../facilitator/auth-capture/)). +2. Server: set `CUSTOM_OPERATOR_ADDRESS` to the same address and run `go run . custom-escrow`. + +Collect-only routes use `extra.receiverAuthorizer` of the zero address and `CollectOnlyRoutes` on the scheme so startup does not require a facilitator `receiverAuthorizer`. + +## Run + +```bash +cp .env-example .env +# fill in EVM_PAYEE_ADDRESS, FACILITATOR_URL, and optionally EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY + +go run . + +# or custom-operator collect-only: +# CUSTOM_OPERATOR_ADDRESS=0x8FE415CdB559fBF5B235B81CC4F7a69684A274bb +go run . custom-escrow +``` + +The server listens on `http://localhost:4021` by default (`PORT` overrides). Pair +with `examples/go/clients/http` and `examples/go/facilitator/auth-capture`. + +## Environment + +| Variable | Required | Description | +|----------------------------------------|----------|-------------| +| `EVM_PAYEE_ADDRESS` | yes | `payTo` address (escrow receiver) | +| `FACILITATOR_URL` | yes | Auth-capture facilitator endpoint (e.g. `http://localhost:4022`) | +| `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` | no | Self-managed receiver authorizer (default `go run .` flow only) | +| `CUSTOM_OPERATOR_ADDRESS` | custom-escrow | Custom operator contract; must be allowlisted on the facilitator | +| `PORT` | no | Listen port (default `4021`) | diff --git a/examples/go/servers/auth-capture/custom_escrow.go b/examples/go/servers/auth-capture/custom_escrow.go new file mode 100644 index 0000000000..0d41b2c096 --- /dev/null +++ b/examples/go/servers/auth-capture/custom_escrow.go @@ -0,0 +1,118 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + x402http "github.com/x402-foundation/x402/go/v2/http" + nethttpmw "github.com/x402-foundation/x402/go/v2/http/nethttp" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + authcaptureserver "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/server" +) + +func runCustomEscrow() { + evmAddress := requireEnv("EVM_PAYEE_ADDRESS") + facilitatorURL := requireEnv("FACILITATOR_URL") + + customOperator := os.Getenv("CUSTOM_OPERATOR_ADDRESS") + if !evm.IsValidAddress(customOperator) { + fmt.Println( + "Missing or invalid CUSTOM_OPERATOR_ADDRESS " + + "(deploy a custom operator and allowlist it on the facilitator)", + ) + os.Exit(1) + } + customOperator = evm.NormalizeAddress(customOperator) + + var minFeeBps, maxFeeBps uint16 + captureDeadlineSeconds := uint64(3600) + refundDeadlineSeconds := uint64(7200) + routeExtra := authcaptureserver.AuthCaptureRouteExtra{ + FeeRecipient: authcapture.ZeroAddress, + MinFeeBps: &minFeeBps, + MaxFeeBps: &maxFeeBps, + CaptureDeadlineSeconds: &captureDeadlineSeconds, + RefundDeadlineSeconds: &refundDeadlineSeconds, + CaptureAuthorizer: customOperator, + ReceiverAuthorizer: authcapture.ZeroAddress, + OperatorType: authcapture.OperatorTypeCustom, + PaymentFlow: authcapture.PaymentFlowEscrow, + CaptureMode: authcapture.CaptureModeDeferred, + } + + scheme := authcaptureserver.NewAuthCaptureEvmScheme(&authcaptureserver.Config{ + CollectOnlyRoutes: true, + }) + facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{ + URL: facilitatorURL, + }) + + routes := x402http.RoutesConfig{ + "GET /weather": { + Accepts: x402http.PaymentOptions{ + { + Scheme: "auth-capture", + Price: price, + Network: network, + PayTo: evm.NormalizeAddress(evmAddress), + MaxTimeoutSeconds: 300, + Extra: routeExtra.Map(), + }, + }, + Description: "Weather data", + MimeType: "application/json", + }, + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /weather", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "report": map[string]any{ + "weather": "sunny", + "temperature": 70, + "flow": "custom-escrow", + }, + }) + }) + + handler := nethttpmw.X402Payment(nethttpmw.Config{ + Routes: routes, + Facilitator: facilitator, + Schemes: []nethttpmw.SchemeConfig{ + {Network: network, Server: scheme}, + }, + Timeout: 30 * time.Second, + })(mux) + + port := envOr("PORT", defaultPort) + fmt.Printf("Auth-capture server (custom-escrow) listening on http://localhost:%s\n", port) + fmt.Println(" GET /weather") + fmt.Printf(" Custom operator: %s\n", customOperator) + fmt.Println(" Collect-only: capture/void/refund run out of band on the operator contract.") + + if err := http.ListenAndServe(":"+port, handler); err != nil { + fmt.Printf("Server error: %v\n", err) + os.Exit(1) + } +} + +func requireEnv(key string) string { + v := os.Getenv(key) + if v == "" { + fmt.Printf("%s environment variable is required\n", key) + os.Exit(1) + } + return v +} + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/examples/go/servers/auth-capture/go.mod b/examples/go/servers/auth-capture/go.mod new file mode 100644 index 0000000000..16d374a3aa --- /dev/null +++ b/examples/go/servers/auth-capture/go.mod @@ -0,0 +1,46 @@ +module github.com/x402-foundation/x402/examples/go/servers/auth-capture + +go 1.24.0 + +toolchain go1.24.1 + +replace github.com/x402-foundation/x402/go/v2 => ../../../../go + +require ( + github.com/joho/godotenv v1.5.1 + github.com/x402-foundation/x402/go/v2 v2.14.0 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect + github.com/StackExchange/wmi v1.2.1 // indirect + github.com/bits-and-blooms/bitset v1.20.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/consensys/gnark-crypto v0.18.1 // indirect + github.com/crate-crypto/go-eth-kzg v1.5.0 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 // indirect + github.com/ethereum/c-kzg-4844/v2 v2.1.6 // indirect + github.com/ethereum/go-ethereum v1.17.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/holiman/uint256 v1.3.2 // indirect + github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/supranational/blst v0.3.16 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.32.0 // indirect +) diff --git a/examples/go/servers/auth-capture/go.sum b/examples/go/servers/auth-capture/go.sum new file mode 100644 index 0000000000..456927d2bf --- /dev/null +++ b/examples/go/servers/auth-capture/go.sum @@ -0,0 +1,213 @@ +github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= +github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= +github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= +github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.20.0 h1:2F+rfL86jE2d/bmw7OhqUg2Sj/1rURkBn3MdfoPyRVU= +github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/gnark-crypto v0.18.1 h1:RyLV6UhPRoYYzaFnPQA4qK3DyuDgkTgskDdoGqFt3fI= +github.com/consensys/gnark-crypto v0.18.1/go.mod h1:L3mXGFTe1ZN+RSJ+CLjUt9x7PNdx8ubaYfDROyp2Z8c= +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/crate-crypto/go-eth-kzg v1.5.0 h1:FYRiJMJG2iv+2Dy3fi14SVGjcPteZ5HAAUe4YWlJygc= +github.com/crate-crypto/go-eth-kzg v1.5.0/go.mod h1:J9/u5sWfznSObptgfa92Jq8rTswn6ahQWEuiLHOjCUI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= +github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0 h1:HbphB4TFFXpv7MNrT52FGrrgVXF1owhMVTHFZIlnvd4= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0/go.mod h1:DZGJHZMqrU4JJqFAWUS2UO1+lbSKsdiOoYi9Zzey7Fc= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/ethereum/c-kzg-4844/v2 v2.1.6 h1:xQymkKCT5E2Jiaoqf3v4wsNgjZLY0lRSkZn27fRjSls= +github.com/ethereum/c-kzg-4844/v2 v2.1.6/go.mod h1:8HMkUZ5JRv4hpw/XUrYWSQNAUzhHMg2UDb/U+5m+XNw= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab h1:rvv6MJhy07IMfEKuARQ9TKojGqLVNxQajaXEp/BoqSk= +github.com/ethereum/go-bigmodexpfix v0.0.0-20250911101455-f9e208c548ab/go.mod h1:IuLm4IsPipXKF7CW5Lzf68PIbZ5yl7FFd74l/E0o9A8= +github.com/ethereum/go-ethereum v1.17.2 h1:ag6geu0kn8Hv5FLKTpH+Hm2DHD+iuFtuqKxEuwUsDOI= +github.com/ethereum/go-ethereum v1.17.2/go.mod h1:KHcRXfGOUfUmKg51IhQ0IowiqZ6PqZf08CMtk0g5K1o= +github.com/ferranbt/fastssz v0.1.4 h1:OCDB+dYDEQDvAgtAGnTSidK1Pe2tW3nFV40XyMkTeDY= +github.com/ferranbt/fastssz v0.1.4/go.mod h1:Ea3+oeoRGGLGm5shYAeDgu6PGUlcvQhE2fILyD9+tGg= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac= +github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db h1:IZUYC/xb3giYwBLMnr8d0TGTzPKFGNTCGgGLoyeX330= +github.com/holiman/billy v0.0.0-20250707135307-f2f9b9aae7db/go.mod h1:xTEYN9KCHxuYHs+NmrmzFcnvHMzLLNiGFafCb1n3Mfg= +github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= +github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= +github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= +github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= +github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= +github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE= +github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +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= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/go/servers/auth-capture/main.go b/examples/go/servers/auth-capture/main.go new file mode 100644 index 0000000000..79e62e970e --- /dev/null +++ b/examples/go/servers/auth-capture/main.go @@ -0,0 +1,112 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + "github.com/joho/godotenv" + x402 "github.com/x402-foundation/x402/go/v2" + x402http "github.com/x402-foundation/x402/go/v2/http" + nethttpmw "github.com/x402-foundation/x402/go/v2/http/nethttp" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcaptureserver "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/server" + evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm" +) + +const ( + defaultPort = "4021" + network = x402.Network("eip155:84532") + price = "$0.01" +) + +// Auth-capture resource server demo: after the handler runs, the facilitator captures +// (on success) or voids (on failure). The receiver authorizer's signature comes either from +// this server (EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY set) or, when that key is omitted, from +// the facilitator, which advertises its own receiverAuthorizer in GET /supported. +// +// Run `go run . custom-escrow` for the collect-only custom-operator flow. +func main() { + _ = godotenv.Load() + + if len(os.Args) > 1 && os.Args[1] == "custom-escrow" { + runCustomEscrow() + return + } + + evmAddress := requireEnv("EVM_PAYEE_ADDRESS") + facilitatorURL := requireEnv("FACILITATOR_URL") + + // Optional: omit to delegate capture/void signing to the facilitator. + config := &authcaptureserver.Config{ + // CaptureAuthorizer/FeeRecipient/MinFeeBps/MaxFeeBps are left empty here so + // this server falls back to whatever the facilitator advertises; set them + // explicitly to pin your own escrow terms instead. + } + var receiverAuthorizer evm.ClientEvmSigner + if receiverAuthKey := os.Getenv("EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY"); receiverAuthKey != "" { + signer, err := evmsigners.NewClientSignerFromPrivateKey(receiverAuthKey) + if err != nil { + fmt.Printf("Invalid EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY: %v\n", err) + os.Exit(1) + } + receiverAuthorizer = signer + config.ReceiverAuthorizerSigner = signer + } + + scheme := authcaptureserver.NewAuthCaptureEvmScheme(config) + + facilitator := x402http.NewHTTPFacilitatorClient(&x402http.FacilitatorConfig{ + URL: facilitatorURL, + }) + + routes := x402http.RoutesConfig{ + "GET /weather": { + Accepts: x402http.PaymentOptions{ + { + Scheme: "auth-capture", + Price: price, + Network: network, + PayTo: evmAddress, + MaxTimeoutSeconds: 300, + }, + }, + Description: "Weather data, settled via escrow authorize/capture", + MimeType: "application/json", + }, + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /weather", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "weather": "sunny", + "temperature": 70, + }) + }) + + handler := nethttpmw.X402Payment(nethttpmw.Config{ + Routes: routes, + Facilitator: facilitator, + Schemes: []nethttpmw.SchemeConfig{ + {Network: network, Server: scheme}, + }, + Timeout: 30 * time.Second, + })(mux) + + port := envOr("PORT", defaultPort) + fmt.Printf("Auth-capture server listening on http://localhost:%s\n", port) + fmt.Printf(" GET /weather\n") + if receiverAuthorizer != nil { + fmt.Printf(" Receiver authorizer (self): %s\n", receiverAuthorizer.Address()) + } else { + fmt.Println(" Receiver authorizer: delegated to facilitator (from /supported extra.receiverAuthorizer)") + } + + if err := http.ListenAndServe(":"+port, handler); err != nil { + fmt.Printf("Server error: %v\n", err) + os.Exit(1) + } +} diff --git a/examples/typescript/README.md b/examples/typescript/README.md index 013b9cf9b9..23134e3eca 100644 --- a/examples/typescript/README.md +++ b/examples/typescript/README.md @@ -22,11 +22,10 @@ Clients that pay for x402-protected endpoints. See [`clients/`](./clients/). | Directory | Description | | --- | --- | -| [`fetch/`](./clients/fetch/) | `@x402/fetch` wrapper around the native Fetch API | -| [`axios/`](./clients/axios/) | `@x402/axios` payment interceptor | +| [`fetch/`](./clients/fetch/) | `@x402/fetch` wrapper around the native Fetch API (exact, upto, auth-capture, and SVM schemes) | +| [`axios/`](./clients/axios/) | `@x402/axios` payment interceptor (exact, upto, auth-capture, and SVM schemes) | | [`advanced/`](./clients/advanced/) | Builder-pattern registration, payment lifecycle hooks, network preferences | | [`custom/`](./clients/custom/) | Manual payment handling without `@x402/fetch` or `@x402/axios` | -| [`auth-capture/`](./clients/auth-capture/) | Pays an auth-capture endpoint by signing an ERC-3009 `ReceiveWithAuthorization` | | [`batch-settlement/`](./clients/batch-settlement/) | Pays a sequence of requests over one payment channel using cumulative vouchers | | [`builder-code/`](./clients/builder-code/) | Verifies ERC-8021 builder-code attribution on the settlement transaction | | [`erc7702/`](./clients/erc7702/) | Paying from an ERC-7702 delegated EOA | @@ -51,6 +50,7 @@ Servers that put a paywall in front of a resource. See [`servers/`](./servers/). | [`upfront/`](./servers/upfront/) | `exact` scheme with `upfront` payment flow (settle before handler) | | [`upto/`](./servers/upto/) | `upto` scheme: authorize a ceiling, settle only actual usage | | [`batch-settlement/`](./servers/batch-settlement/) | Off-chain vouchers claimed and settled in batches by a `ChannelManager` | +| [`auth-capture/`](./servers/auth-capture/) | Refundable escrow: delegated sync/deferred and custom-operator collect | | [`bazaar/`](./servers/bazaar/) | Makes a paid API discoverable via the Bazaar extension | | [`builder-code/`](./servers/builder-code/) | ERC-8021 builder-code attribution on paid endpoints | | [`offer-receipt/`](./servers/offer-receipt/) | Signed offers (payment terms) and receipts (proof of delivery) | @@ -77,6 +77,7 @@ Services that verify and settle payments on-chain. | [`advanced/`](./facilitator/advanced/) | All-networks support, Bazaar discovery, gas-sponsoring extensions, hooks | | [`upto/`](./facilitator/upto/) | `upto` scheme with SVM rent cleanup manager (EVM + SVM) | | [`batch-settlement/`](./facilitator/batch-settlement/) | Submits batch-settlement contract calls | +| [`auth-capture/`](./facilitator/auth-capture/) | Submits auth-capture collect and lifecycle calls | | [`builder-code/`](./facilitator/builder-code/) | Appends ERC-8021 wallet attribution at settlement | ## Running Examples diff --git a/examples/typescript/clients/auth-capture/.env-local b/examples/typescript/clients/auth-capture/.env-local deleted file mode 100644 index 7c9957da69..0000000000 --- a/examples/typescript/clients/auth-capture/.env-local +++ /dev/null @@ -1,4 +0,0 @@ -EVM_PRIVATE_KEY=0x... - -RESOURCE_SERVER_URL=http://localhost:4021 -ENDPOINT_PATH=/weather diff --git a/examples/typescript/clients/auth-capture/.prettierignore b/examples/typescript/clients/auth-capture/.prettierignore deleted file mode 100644 index 5bd240ba90..0000000000 --- a/examples/typescript/clients/auth-capture/.prettierignore +++ /dev/null @@ -1,8 +0,0 @@ -docs/ -dist/ -node_modules/ -coverage/ -.github/ -src/client -**/**/*.json -*.md \ No newline at end of file diff --git a/examples/typescript/clients/auth-capture/README.md b/examples/typescript/clients/auth-capture/README.md deleted file mode 100644 index 3362e786eb..0000000000 --- a/examples/typescript/clients/auth-capture/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# auth-capture Client Example - -Fetch-based client that pays for a single request to an [auth-capture](../../../../specs/schemes/auth-capture/scheme_auth_capture_evm.md)-protected endpoint. Signs an ERC-3009 `ReceiveWithAuthorization` whose `nonce` is the payer-agnostic PaymentInfo hash (per the [scheme spec](../../../../specs/schemes/auth-capture/scheme_auth_capture_evm.md#payment-identity)). - -## Prerequisites - -- Node.js v20+, pnpm v10 -- A running auth-capture server to pay against (the server/facilitator SDK lands separately; point `RESOURCE_SERVER_URL` at any auth-capture endpoint) -- A funded EVM key holding the requested asset (USDC on Base Sepolia by default) - -## Setup - -```bash -cp .env-local .env -# Fill EVM_PRIVATE_KEY (and override RESOURCE_SERVER_URL if needed) - -cd ../../.. -pnpm install && pnpm build -cd examples/clients/auth-capture - -pnpm start -``` - -## Environment - -| Variable | Required | Default | -| :-------------------- | :------- | :---------------------- | -| `EVM_PRIVATE_KEY` | Yes | (none) | -| `RESOURCE_SERVER_URL` | No | `http://localhost:4021` | -| `ENDPOINT_PATH` | No | `/weather` | - -## What happens - -1. Client builds and signs an ERC-3009 payload with the `Eip3009Payload` shape. -2. `wrapFetchWithPayment` retries the request with the `PAYMENT-SIGNATURE` header on first `402`. -3. Server verifies, then asks the facilitator to settle. -4. Facilitator submits `AuthCaptureEscrow.authorize(...)` (two-phase); funds are locked in escrow under the captureAuthorizer's control. -5. Server returns the resource; the example prints the body and the payment response. - -Capture, void, and refund are performed by whoever holds the `captureAuthorizer` role and are out of scope for the client. diff --git a/examples/typescript/clients/auth-capture/index.ts b/examples/typescript/clients/auth-capture/index.ts deleted file mode 100644 index 288a5597ba..0000000000 --- a/examples/typescript/clients/auth-capture/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; -import { x402Client, wrapFetchWithPayment, x402HTTPClient } from "@x402/fetch"; -import { config } from "dotenv"; -import { privateKeyToAccount } from "viem/accounts"; - -config(); - -const evmPrivateKeyRaw = process.env.EVM_PRIVATE_KEY?.trim(); -const baseURL = process.env.RESOURCE_SERVER_URL?.trim() || "http://localhost:4021"; -const endpointPath = process.env.ENDPOINT_PATH?.trim() || "/weather"; -const url = `${baseURL}${endpointPath}`; - -if (!evmPrivateKeyRaw) { - console.error("EVM_PRIVATE_KEY environment variable is required"); - process.exit(1); -} -const evmPrivateKey = evmPrivateKeyRaw as `0x${string}`; - -/** - * Runs a single paid request against an auth-capture-protected endpoint. - * - * The scheme signs a payer-agnostic PaymentInfo hash (as the ERC-3009 nonce by - * default; Permit2 is also supported). The facilitator submits the resulting - * authorization to the AuthCaptureEscrow contract; funds are locked there until - * the captureAuthorizer captures, voids, or the authorization expires. - * - * @returns Resolves after the request completes and the payment response is logged. - */ -async function main(): Promise { - const evmAccount = privateKeyToAccount(evmPrivateKey); - - const client = new x402Client(); - client.register("eip155:*", new AuthCaptureEvmScheme(evmAccount)); - - const fetchWithPayment = wrapFetchWithPayment(fetch, client); - - console.log(`Payer: ${evmAccount.address}`); - console.log(`Making request to: ${url}\n`); - - const response = await fetchWithPayment(url, { method: "GET" }); - const contentType = response.headers.get("content-type") ?? ""; - const body = contentType.includes("application/json") - ? await response.json() - : await response.text(); - console.log("Response body:", body); - - const paymentResponse = new x402HTTPClient(client).getPaymentSettleResponse(name => - response.headers.get(name), - ); - console.log("\nPayment response:", JSON.stringify(paymentResponse, null, 2)); -} - -main().catch(error => { - console.error(error?.response?.data?.error ?? error); - process.exit(1); -}); diff --git a/examples/typescript/clients/axios/README.md b/examples/typescript/clients/axios/README.md index a21bbad923..c3fa0e4136 100644 --- a/examples/typescript/clients/axios/README.md +++ b/examples/typescript/clients/axios/README.md @@ -28,7 +28,7 @@ console.log(response.data); - Node.js v20+ (install via [nvm](https://github.com/nvm-sh/nvm)) - pnpm v10 (install via [pnpm.io/installation](https://pnpm.io/installation)) -- A running x402 server (see [express server example](../../servers/express)) +- A running x402 server (see [express server example](../../servers/express) or [auth-capture server example](../../servers/auth-capture)) - Valid EVM and/or SVM private keys for making payments ## Setup diff --git a/examples/typescript/clients/fetch/README.md b/examples/typescript/clients/fetch/README.md index 63d94c98ca..ce90fa9b65 100644 --- a/examples/typescript/clients/fetch/README.md +++ b/examples/typescript/clients/fetch/README.md @@ -31,7 +31,7 @@ console.log(await response.json()); - Node.js v20+ (install via [nvm](https://github.com/nvm-sh/nvm)) - pnpm v10 (install via [pnpm.io/installation](https://pnpm.io/installation)) -- A running x402 server (see [express server example](../../servers/express)) +- A running x402 server (see [express server example](../../servers/express) or [auth-capture server example](../../servers/auth-capture)) - Valid EVM and/or SVM private keys for making payments ## Setup diff --git a/examples/typescript/facilitator/README.md b/examples/typescript/facilitator/README.md index 08532be338..6cf3a391ad 100644 --- a/examples/typescript/facilitator/README.md +++ b/examples/typescript/facilitator/README.md @@ -9,6 +9,7 @@ This directory contains TypeScript facilitator examples. A facilitator verifies | [`basic/`](./basic/) | Minimal facilitator exposing `/verify` and `/settle` | | [`advanced/`](./advanced/) | All-networks support, Bazaar discovery, gas-sponsoring extensions, lifecycle hooks | | [`batch-settlement/`](./batch-settlement/) | Submits batch-settlement contract calls | +| [`auth-capture/`](./auth-capture/) | Submits auth-capture collect and lifecycle calls to AuthCaptureEscrow | | [`builder-code/`](./builder-code/) | Appends ERC-8021 wallet attribution at settlement | ## Getting Started diff --git a/examples/typescript/facilitator/auth-capture/.env-local b/examples/typescript/facilitator/auth-capture/.env-local new file mode 100644 index 0000000000..4aafb3aa4f --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/.env-local @@ -0,0 +1,18 @@ +EVM_PRIVATE_KEY=0x... +EVM_RPC_URL=https://sepolia.base.org + +# Optional dedicated receiver authorizer (recommended: separate from relayer). +# When set, advertised in GET /supported as extra.receiverAuthorizer; resource servers can omit +# EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY and delegate lifecycle signing to this facilitator. +EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= + +# Custom-operator allowlist: comma-separated operator addresses to relay collect for. +# Empty admits no custom operator +CUSTOM_OPERATOR_ALLOWLIST= + +# Optional facilitator fee bounds (defaults: zeroAddress / 0 bps). +FEE_RECIPIENT= +MIN_FEE_BPS=0 +MAX_FEE_BPS=0 + +PORT= diff --git a/examples/typescript/facilitator/auth-capture/.prettierignore b/examples/typescript/facilitator/auth-capture/.prettierignore new file mode 100644 index 0000000000..f06235c460 --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/.prettierignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/examples/typescript/clients/auth-capture/.prettierrc b/examples/typescript/facilitator/auth-capture/.prettierrc similarity index 100% rename from examples/typescript/clients/auth-capture/.prettierrc rename to examples/typescript/facilitator/auth-capture/.prettierrc diff --git a/examples/typescript/facilitator/auth-capture/README.md b/examples/typescript/facilitator/auth-capture/README.md new file mode 100644 index 0000000000..f96397babb --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/README.md @@ -0,0 +1,111 @@ +# Auth-Capture Facilitator Example + +Express.js facilitator for the **auth-capture** EVM scheme (v1.1) on Base Sepolia. It exposes standard x402 facilitator endpoints and submits collect (`authorize` / `charge`) and lifecycle (`capture` / `void` / `refund`) calls to `AuthCaptureEscrow` or an allowlisted custom operator. + +See the [v1.1 proposed specification](../../../../specs/proposed/scheme_auth_capture_evm.md) and the [scheme README](../../../../typescript/packages/mechanisms/evm/src/auth-capture/README.md). + +## Two Signer Roles + +| Env var | Role | Onchain effect | +| --- | --- | --- | +| `EVM_PRIVATE_KEY` | **Relayer** — submits transactions | Pays gas; for `"delegated"` routes this address is `extra.captureAuthorizer` | +| `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` | **Receiver authorizer** (optional) | When set, advertised in `/supported` as `extra.receiverAuthorizer`. Resource servers that omit their own authorizer key send unsigned payloads; this facilitator signs after `resolveCallerIdentity` authenticates the caller | + +Without `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY`, `/supported` advertises only `extra.captureAuthorizer`. Resource servers must configure `receiverAuthorizerSigner` locally. + +## Receiver authorizer delegation (optional) + +To let resource servers delegate capture/void/refund signing to this facilitator, set `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` (recommended: a key separate from the relayer). `GET /supported` then includes `extra.receiverAuthorizer`: + +```json +{ + "kinds": [ + { + "scheme": "auth-capture", + "network": "eip155:84532", + "extra": { + "captureAuthorizer": "...", + "receiverAuthorizer": "..." + } + } + ] +} +``` + +This example wires the facilitator SDK opt-in (all four fields required): + +- `authorizerSigner` from `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` +- `resolveCallerIdentity` — implemented on `AuthCaptureEvmScheme` config; invoked by the SDK during delegated settles with a `DelegatedSettleContext` (`step`, `paymentInfoHash`, `payload`, `requirements`, optional `facilitatorContext`) +- `delegatedAuthStorage` — this example uses `InMemoryAuthCaptureDelegatedAuthStorage` for local testing only +- `onStorageError` — reports binding revert/delete failures + +`POST /settle` is a thin wrapper around `facilitator.settle()`; it does not implement custom identity plumbing. Caller authentication belongs in `resolveCallerIdentity` (see the [scheme README](../../../../typescript/packages/mechanisms/evm/src/auth-capture/README.md#receiver-authorizer-modes)). + +```typescript +import type { DelegatedSettleContext } from "@x402/evm/auth-capture/facilitator"; + +new AuthCaptureEvmScheme(evmSigner, { + authorizerSigner, + delegatedAuthStorage, + onStorageError: (error, network, paymentInfoHash) => { + /* ... */ + }, + resolveCallerIdentity: async (ctx: DelegatedSettleContext) => { + // Authenticate the resource server (API key, mTLS, JWT, etc.) using ctx and/or + // ctx.facilitatorContext?.getExtension(...). Return a stable merchant id, or + // undefined to reject. Local testing only: + return "example-local-caller"; + }, +}); +``` + +The first authenticated caller to settle a payment is bound to its `paymentInfoHash`; later lifecycle settles must resolve to the same identity. For production, replace the in-memory store with a durable, atomic implementation (Redis, SQL, etc.) when more than one facilitator process serves the same payments. + +> ⚠️ **Local testing only:** this example returns a fixed identity from `resolveCallerIdentity`. Do not advertise `receiverAuthorizer` without real authentication. + +Pair with the [auth-capture server example](../../servers/auth-capture/): run this facilitator with `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` set and omit `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` on the resource server for facilitator-delegated sync/deferred flows. + +## Custom-operator allowlist + +`CUSTOM_OPERATOR_ALLOWLIST` is a comma-separated list of operator addresses the facilitator will relay collect for, advertised in `/supported` as `extra.operators` when `simulateCalls` is wired on the signer. Leave it empty to admit no custom operator; set it to the operator address the [custom-escrow server example](../../servers/auth-capture/) deploys so that flow can relay collect-only `authorize` through it. + +Custom collect verification uses `eth_simulateV1` with a gas cap (`customOperatorAuthorizeGasLimit`, default `1_000_000`) and outcome checks before broadcast. + +## Prerequisites + +- Node.js v20+, pnpm v10 +- Base Sepolia ETH on the **relayer** address (gas) +- USDC on Base Sepolia for client payments + +## Setup + +```bash +cp .env-local .env +# fill EVM_PRIVATE_KEY (and optionally EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY, +# CUSTOM_OPERATOR_ALLOWLIST, EVM_RPC_URL, PORT) + +cd ../../ +pnpm install && pnpm build +cd facilitator/auth-capture + +pnpm dev +``` + +The facilitator listens on `http://localhost:4022` by default. Delegated server flows pick up the relayer address from `GET /supported` extra.captureAuthorizer. + +## API Surface + +Standard x402 facilitator endpoints: `POST /verify`, `POST /settle`, `GET /supported`. + +| Collect settle (no `payload.type`) | `extra.paymentFlow` | Contract call | +| ---------------------------------- | -------------------- | ---------------- | +| Escrow hold | `"escrow"` (default) | `authorize(...)` | +| Terminal charge | `"authorization"` | `charge(...)` | + +| Lifecycle settle (`payload.type`) | Contract call | +| --- | --- | +| `"capture"` | `capture(...)` (+ optional void when `voidAuthorizerSignature` present) | +| `"void"` | `void(...)` | +| `"refund"` | `refund(...)` | + +Settle target is the canonical escrow for `"delegated"` and `extra.captureAuthorizer` for `"custom"`. diff --git a/examples/typescript/clients/auth-capture/eslint.config.js b/examples/typescript/facilitator/auth-capture/eslint.config.js similarity index 98% rename from examples/typescript/clients/auth-capture/eslint.config.js rename to examples/typescript/facilitator/auth-capture/eslint.config.js index ca28b5c47f..e2fde7b3b8 100644 --- a/examples/typescript/clients/auth-capture/eslint.config.js +++ b/examples/typescript/facilitator/auth-capture/eslint.config.js @@ -21,6 +21,7 @@ export default [ module: "readonly", require: "readonly", Buffer: "readonly", + console: "readonly", exports: "readonly", setTimeout: "readonly", clearTimeout: "readonly", diff --git a/examples/typescript/facilitator/auth-capture/index.ts b/examples/typescript/facilitator/auth-capture/index.ts new file mode 100644 index 0000000000..f2ce92d8d2 --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/index.ts @@ -0,0 +1,257 @@ +/** + * Auth-capture facilitator example (EVM) + * + * Registers {@link AuthCaptureEvmScheme} on Base Sepolia. When + * `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` is set, advertises `extra.receiverAuthorizer` + * and signs delegated lifecycle payloads with explicit {@link InMemoryAuthCaptureDelegatedAuthStorage} + * and {@link AuthCaptureFacilitatorConfig.onStorageError} (required to opt in). + */ + +import { x402Facilitator } from "@x402/core/facilitator"; +import { + PaymentPayload, + PaymentRequirements, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import { type AuthorizerSigner, toFacilitatorEvmSigner } from "@x402/evm"; +import { + AuthCaptureEvmScheme, + InMemoryAuthCaptureDelegatedAuthStorage, +} from "@x402/evm/auth-capture/facilitator"; +import dotenv from "dotenv"; +import express from "express"; +import { createWalletClient, getAddress, http, nonceManager, publicActions } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; +import { zeroAddress } from "viem"; + +dotenv.config(); + +const PORT = process.env.PORT || "4022"; + +if (!process.env.EVM_PRIVATE_KEY) { + console.error("EVM_PRIVATE_KEY environment variable is required"); + process.exit(1); +} + +const evmRpcUrl = process.env.EVM_RPC_URL?.trim() || "https://sepolia.base.org"; + +const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY?.trim(); + +const evmAccount = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`, { + nonceManager, +}); + +let authorizerSigner: AuthorizerSigner | undefined; +if (receiverAuthorizerPrivateKey) { + const authorizerAccount = privateKeyToAccount(receiverAuthorizerPrivateKey as `0x${string}`); + authorizerSigner = { + address: authorizerAccount.address, + signTypedData: params => + authorizerAccount.signTypedData( + params as Parameters[0], + ), + }; +} + +const feeRecipient = (process.env.FEE_RECIPIENT?.trim() || zeroAddress) as `0x${string}`; +const minFeeBps = Number(process.env.MIN_FEE_BPS ?? "0"); +const maxFeeBps = Number(process.env.MAX_FEE_BPS ?? "0"); + +// Custom operators are admitted per address. An empty list admits none, leaving +// only "delegated" routes through the relayer. +const customOperators = (process.env.CUSTOM_OPERATOR_ALLOWLIST ?? "") + .split(",") + .map(entry => entry.trim()) + .filter(entry => entry.length > 0); + +const malformedOperator = customOperators.find(entry => !/^0x[0-9a-fA-F]{40}$/.test(entry)); +if (malformedOperator) { + console.error( + `Invalid CUSTOM_OPERATOR_ALLOWLIST entry "${malformedOperator}" ` + + "(comma-separated 20-byte hex addresses, 0x-prefixed)", + ); + process.exit(1); +} + +console.info(`EVM Facilitator relayer: ${evmAccount.address}`); +if (authorizerSigner) { + console.info(`EVM Receiver authorizer: ${authorizerSigner.address}`); + console.info("Delegated auth bindings: InMemoryAuthCaptureDelegatedAuthStorage (process-local)"); +} else { + console.info("EVM Receiver authorizer: not configured (resource servers must self-sign)"); +} +console.info( + customOperators.length > 0 + ? `EVM Custom operators admitted: ${customOperators.join(", ")}` + : "EVM Custom operators: none admitted", +); + +const viemClient = createWalletClient({ + account: evmAccount, + chain: baseSepolia, + transport: http(evmRpcUrl), +}).extend(publicActions); + +const evmSigner = toFacilitatorEvmSigner({ + address: evmAccount.address, + getCode: args => viemClient.getCode(args), + readContract: args => + viemClient.readContract({ ...args, args: args.args ?? [] } as Parameters< + typeof viemClient.readContract + >[0]), + simulateCalls: args => + viemClient.simulateCalls(args as Parameters[0]), + verifyTypedData: args => + viemClient.verifyTypedData(args as Parameters[0]), + writeContract: args => + viemClient.writeContract(args as Parameters[0]), + sendTransaction: args => + viemClient.sendTransaction(args as Parameters[0]), + waitForTransactionReceipt: args => viemClient.waitForTransactionReceipt(args), +}); + +const facilitator = new x402Facilitator(); + +const authCaptureConfig = { + ...(authorizerSigner + ? { + authorizerSigner, + delegatedAuthStorage: new InMemoryAuthCaptureDelegatedAuthStorage(), + // SDK hook: called during delegated /settle (see DelegatedSettleContext in @x402/evm/auth-capture/facilitator). + resolveCallerIdentity: async () => "example-local-caller", + onStorageError: (error: unknown, network: string, paymentInfoHash: string) => { + console.warn("[delegated-auth-storage]", { + network, + paymentInfoHash, + error: error instanceof Error ? error.message : error, + }); + }, + } + : {}), + ...(minFeeBps > 0 || maxFeeBps > 0 || feeRecipient !== zeroAddress + ? { feeTerms: { feeRecipient, minFeeBps, maxFeeBps } } + : {}), + ...(customOperators.length > 0 + ? { + operators: customOperators.map(address => ({ + address: getAddress(address), + operatorType: "custom" as const, + })), + } + : {}), + customOperatorAuthorizeGasLimit: 1_000_000n, + refundFunding: false, +}; + +facilitator.register("eip155:84532", new AuthCaptureEvmScheme(evmSigner, authCaptureConfig)); + +// Multi-signer rotation: pass an array of toFacilitatorEvmSigner results to one +// scheme. Do not register the scheme twice on the same network — getSupported +// and settle both keep the first kind. Every address in the rotation must stay +// funded (and approved, if refundFunding is true) until payments that used it +// pass refundDeadline. +// +// const evmSignerB = toFacilitatorEvmSigner({ address: accountB.address, ...clientB }); +// facilitator.register("eip155:84532", new AuthCaptureEvmScheme([evmSigner, evmSignerB], authCaptureConfig)); + +const app = express(); +app.use(express.json()); + +/** + * POST /verify + * Verify a payment against requirements. + */ +app.post("/verify", async (req, res) => { + try { + const { paymentPayload, paymentRequirements } = req.body as { + paymentPayload: PaymentPayload; + paymentRequirements: PaymentRequirements; + }; + + if (!paymentPayload || !paymentRequirements) { + return res.status(400).json({ + error: "Missing paymentPayload or paymentRequirements", + }); + } + + const response: VerifyResponse = await facilitator.verify(paymentPayload, paymentRequirements); + + res.json(response); + } catch (error) { + console.error("Verify error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * POST /settle + * Settle a payment onchain. + */ +app.post("/settle", async (req, res) => { + try { + const { paymentPayload, paymentRequirements } = req.body; + + if (!paymentPayload || !paymentRequirements) { + return res.status(400).json({ + error: "Missing paymentPayload or paymentRequirements", + }); + } + + const payload = paymentPayload as PaymentPayload; + const requirements = paymentRequirements as PaymentRequirements; + const payloadType = (payload.payload as { type?: string }).type ?? "collect"; + + const response: SettleResponse = await facilitator.settle(payload, requirements); + + console.info( + `[settle] type=${payloadType} requirements.amount=${requirements.amount}`, + JSON.stringify(response, null, 2), + ); + + res.json(response); + } catch (error) { + console.error("Settle error:", error); + + if (error instanceof Error && error.message.includes("Settlement aborted:")) { + return res.json({ + success: false, + errorReason: error.message.replace("Settlement aborted: ", ""), + network: req.body?.paymentPayload?.network || "unknown", + } as SettleResponse); + } + + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * GET /supported + * Get supported payment kinds and extensions. + */ +app.get("/supported", async (_req, res) => { + try { + const response = facilitator.getSupported(); + res.json(response); + } catch (error) { + console.error("Supported error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +app.listen(parseInt(PORT), () => { + console.log(`Auth-capture facilitator listening on http://localhost:${PORT}`); + console.log(` Relayer (extra.captureAuthorizer for delegated): ${evmAccount.address}`); + if (authorizerSigner) { + console.log( + " Delegated settles: resolveCallerIdentity returns example-local-caller (local only)", + ); + } +}); diff --git a/examples/typescript/clients/auth-capture/package.json b/examples/typescript/facilitator/auth-capture/package.json similarity index 83% rename from examples/typescript/clients/auth-capture/package.json rename to examples/typescript/facilitator/auth-capture/package.json index 4f24fc1f08..98114175c1 100644 --- a/examples/typescript/clients/auth-capture/package.json +++ b/examples/typescript/facilitator/auth-capture/package.json @@ -1,10 +1,11 @@ { - "name": "@x402/auth-capture-client-example", - "private": true, + "name": "@x402/auth-capture-facilitator-example", + "version": "2.0.0", "type": "module", + "private": true, "scripts": { "start": "tsx index.ts", - "dev": "tsx index.ts", + "dev": "tsx watch index.ts", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", "lint": "eslint . --ext .ts --fix", @@ -13,12 +14,13 @@ "dependencies": { "@x402/core": "workspace:*", "@x402/evm": "workspace:*", - "@x402/fetch": "workspace:*", "dotenv": "^16.4.7", + "express": "^4.19.2", "viem": "^2.48.11" }, "devDependencies": { "@eslint/js": "^9.24.0", + "@types/express": "^4.17.21", "@types/node": "^22.13.4", "@typescript-eslint/eslint-plugin": "^8.29.1", "@typescript-eslint/parser": "^8.29.1", diff --git a/examples/typescript/clients/auth-capture/tsconfig.json b/examples/typescript/facilitator/auth-capture/tsconfig.json similarity index 100% rename from examples/typescript/clients/auth-capture/tsconfig.json rename to examples/typescript/facilitator/auth-capture/tsconfig.json diff --git a/examples/typescript/facilitator/upto/README.md b/examples/typescript/facilitator/upto/README.md index fc522c3149..332d1199b3 100644 --- a/examples/typescript/facilitator/upto/README.md +++ b/examples/typescript/facilitator/upto/README.md @@ -84,7 +84,8 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { createKeyPairSignerFromBytes } from "@solana/kit"; import { UptoSvmScheme } from "@x402/svm/upto/facilitator"; -const authorizerSigner = await createKeyPairSignerFromBytes(/* dedicated Ed25519 key */); +const authorizerSigner = + await createKeyPairSignerFromBytes(/* dedicated Ed25519 key */); // Carry authenticated server identity into resolveCallerIdentity (JWT/SIWX/mTLS/etc.). const callerIdentity = new AsyncLocalStorage(); diff --git a/examples/typescript/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index a0559b5a04..c3d3688130 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -1733,58 +1733,6 @@ importers: specifier: ^5.7.3 version: 5.9.3 - clients/auth-capture: - dependencies: - '@x402/core': - specifier: workspace:* - version: link:../../../../typescript/packages/core - '@x402/evm': - specifier: workspace:* - version: link:../../../../typescript/packages/mechanisms/evm - '@x402/fetch': - specifier: workspace:* - version: link:../../../../typescript/packages/http/fetch - dotenv: - specifier: ^16.4.7 - version: 16.6.1 - viem: - specifier: ^2.48.11 - version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) - devDependencies: - '@eslint/js': - specifier: ^9.24.0 - version: 9.33.0 - '@types/node': - specifier: ^22.13.4 - version: 22.17.2 - '@typescript-eslint/eslint-plugin': - specifier: ^8.29.1 - version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: ^8.29.1 - version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) - eslint: - specifier: ^9.24.0 - version: 9.33.0(jiti@2.6.1) - eslint-plugin-import: - specifier: ^2.31.0 - version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) - eslint-plugin-jsdoc: - specifier: ^50.6.9 - version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) - eslint-plugin-prettier: - specifier: ^5.2.6 - version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) - prettier: - specifier: 3.5.2 - version: 3.5.2 - tsx: - specifier: ^4.21.0 - version: 4.21.0 - typescript: - specifier: ^5.7.3 - version: 5.9.3 - clients/axios: dependencies: '@scure/base': @@ -2496,6 +2444,61 @@ importers: specifier: ^5.7.3 version: 5.9.3 + facilitator/auth-capture: + dependencies: + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.19.2 + version: 4.21.2 + viem: + specifier: ^2.48.11 + version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^22.13.4 + version: 22.17.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + facilitator/basic: dependencies: '@scure/base': @@ -2783,7 +2786,7 @@ importers: version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) wagmi: specifier: ^2.14.11 - version: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) + version: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(zod@4.1.13) devDependencies: '@eslint/js': specifier: ^9.24.0 @@ -3080,6 +3083,64 @@ importers: specifier: ^5.7.3 version: 5.9.3 + servers/auth-capture: + dependencies: + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + '@x402/express': + specifier: workspace:* + version: link:../../../../typescript/packages/http/express + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.21.2 + viem: + specifier: ^2.48.11 + version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/express': + specifier: ^5.0.1 + version: 5.0.3 + '@types/node': + specifier: ^22.13.4 + version: 22.17.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + servers/batch-settlement: dependencies: '@scure/base': @@ -3275,6 +3336,76 @@ importers: specifier: ^5.7.3 version: 5.9.3 + servers/cdp-dev: + dependencies: + '@coinbase/cdp-sdk': + specifier: ^1.39.0 + version: 1.39.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@scure/base': + specifier: ^1.2.6 + version: 1.2.6 + '@solana/kit': + specifier: ^5.1.0 + version: 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + '@x402/express': + specifier: workspace:* + version: link:../../../../typescript/packages/http/express + '@x402/extensions': + specifier: workspace:* + version: link:../../../../typescript/packages/extensions + '@x402/svm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/svm + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.21.2 + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/express': + specifier: ^5.0.1 + version: 5.0.3 + '@types/node': + specifier: ^22.13.4 + version: 22.20.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + servers/cloudfront-lambda-edge: {} servers/cloudfront-lambda-edge/cdk: @@ -8934,6 +9065,7 @@ packages: eslint@9.33.0: resolution: {integrity: sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -12255,18 +12387,6 @@ packages: utf-8-validate: optional: true - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.21.0: resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} @@ -12505,7 +12625,7 @@ snapshots: '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 convert-source-map: 2.0.0 - debug: 4.4.1 + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -12577,7 +12697,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -12632,9 +12752,9 @@ snapshots: - ws - zod - '@base-org/account@2.4.0(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13)': + '@base-org/account@2.4.0(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@4.1.13)': dependencies: - '@coinbase/cdp-sdk': 1.39.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@coinbase/cdp-sdk': 1.39.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) '@noble/hashes': 1.4.0 clsx: 1.2.1 eventemitter3: 5.0.1 @@ -12689,8 +12809,8 @@ snapshots: '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10) abitype: 1.0.6(typescript@5.9.2)(zod@3.25.76) - axios: 1.18.0 - axios-retry: 4.5.0(axios@1.18.0) + axios: 1.20.0 + axios-retry: 4.5.0(axios@1.20.0) jose: 6.0.12 md5: 2.3.0 uncrypto: 0.1.3 @@ -12713,8 +12833,8 @@ snapshots: '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) abitype: 1.0.6(typescript@5.9.3)(zod@3.25.76) - axios: 1.18.0 - axios-retry: 4.5.0(axios@1.18.0) + axios: 1.20.0 + axios-retry: 4.5.0(axios@1.20.0) jose: 6.0.12 md5: 2.3.0 uncrypto: 0.1.3 @@ -12730,18 +12850,42 @@ snapshots: - utf-8-validate - ws - '@coinbase/wallet-sdk@3.9.3': + '@coinbase/cdp-sdk@1.39.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)': dependencies: - bn.js: 5.2.5 - buffer: 6.0.3 - clsx: 1.2.1 - eth-block-tracker: 7.1.0 - eth-json-rpc-filters: 6.0.1 - eventemitter3: 5.0.4 - keccak: 3.0.4 - preact: 10.29.8 - sha.js: 2.4.12 - transitivePeerDependencies: + '@solana-program/system': 0.8.1(@solana/kit@3.0.3(typescript@5.9.3)) + '@solana-program/token': 0.6.0(@solana/kit@3.0.3(typescript@5.9.3)) + '@solana/kit': 3.0.3(typescript@5.9.3) + '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) + abitype: 1.0.6(typescript@5.9.3)(zod@3.25.76) + axios: 1.20.0 + axios-retry: 4.5.0(axios@1.20.0) + jose: 6.0.12 + md5: 2.3.0 + uncrypto: 0.1.3 + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - bufferutil + - debug + - encoding + - fastestsmallesttextencoderdecoder + - supports-color + - typescript + - utf-8-validate + - ws + + '@coinbase/wallet-sdk@3.9.3': + dependencies: + bn.js: 5.2.5 + buffer: 6.0.3 + clsx: 1.2.1 + eth-block-tracker: 7.1.0 + eth-json-rpc-filters: 6.0.1 + eventemitter3: 5.0.4 + keccak: 3.0.4 + preact: 10.29.8 + sha.js: 2.4.12 + transitivePeerDependencies: - preact-render-to-string - supports-color @@ -13109,7 +13253,7 @@ snapshots: '@eslint/config-array@0.21.0': dependencies: '@eslint/object-schema': 2.1.6 - debug: 4.4.1 + debug: 4.4.3 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -13123,7 +13267,7 @@ snapshots: '@eslint/eslintrc@3.3.1': dependencies: ajv: 6.12.6 - debug: 4.4.1 + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -13740,7 +13884,7 @@ snapshots: bufferutil: 4.0.9 cross-fetch: 4.1.0 date-fns: 2.30.0 - debug: 4.4.1 + debug: 4.4.3 eciesjs: 0.4.15 eventemitter2: 6.4.9 readable-stream: 3.6.2 @@ -13784,7 +13928,7 @@ snapshots: '@paulmillr/qr': 0.2.1 bowser: 2.12.0 cross-fetch: 4.1.0 - debug: 4.4.1 + debug: 4.4.3 eciesjs: 0.4.15 eth-rpc-errors: 4.0.3 eventemitter2: 6.4.9 @@ -13838,10 +13982,10 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@types/debug': 4.1.12 - debug: 4.4.1 + debug: 4.4.3 lodash.memoize: 4.1.2 pony-cause: 2.1.11 - semver: 7.7.3 + semver: 7.8.5 uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -13863,9 +14007,9 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@types/debug': 4.1.12 - debug: 4.4.1 + debug: 4.4.3 pony-cause: 2.1.11 - semver: 7.7.3 + semver: 7.8.5 uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -13877,9 +14021,9 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 '@types/debug': 4.1.12 - debug: 4.4.1 + debug: 4.4.3 pony-cause: 2.1.11 - semver: 7.7.3 + semver: 7.8.5 uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -15007,6 +15151,10 @@ snapshots: dependencies: '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/system@0.8.1(@solana/kit@3.0.3(typescript@5.9.3))': + dependencies: + '@solana/kit': 3.0.3(typescript@5.9.3) + '@solana-program/token-2022@0.4.2(@solana/kit@6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10))(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2))': dependencies: '@solana/kit': 6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10) @@ -15034,6 +15182,10 @@ snapshots: dependencies: '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/token@0.6.0(@solana/kit@3.0.3(typescript@5.9.3))': + dependencies: + '@solana/kit': 3.0.3(typescript@5.9.3) + '@solana-program/token@0.9.0(@solana/kit@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: '@solana/kit': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) @@ -15078,6 +15230,18 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/accounts@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 3.0.3(typescript@5.9.3) + '@solana/codecs-strings': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/rpc-spec': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/accounts@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/addresses': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -15515,6 +15679,17 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/codecs@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/codecs-core': 3.0.3(typescript@5.9.3) + '@solana/codecs-data-structures': 3.0.3(typescript@5.9.3) + '@solana/codecs-numbers': 3.0.3(typescript@5.9.3) + '@solana/codecs-strings': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/options': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/codecs@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/codecs-core': 5.1.0(typescript@5.9.2) @@ -15697,6 +15872,17 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/instruction-plans@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/instructions': 3.0.3(typescript@5.9.3) + '@solana/promises': 3.0.3(typescript@5.9.3) + '@solana/transaction-messages': 3.0.3(typescript@5.9.3) + '@solana/transactions': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/instruction-plans@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/errors': 5.1.0(typescript@5.9.2) @@ -15964,6 +16150,32 @@ snapshots: - fastestsmallesttextencoderdecoder - ws + '@solana/kit@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/accounts': 3.0.3(typescript@5.9.3) + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs': 3.0.3(typescript@5.9.3) + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/functional': 3.0.3(typescript@5.9.3) + '@solana/instruction-plans': 3.0.3(typescript@5.9.3) + '@solana/instructions': 3.0.3(typescript@5.9.3) + '@solana/keys': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/programs': 3.0.3(typescript@5.9.3) + '@solana/rpc': 3.0.3(typescript@5.9.3) + '@solana/rpc-parsed-types': 3.0.3(typescript@5.9.3) + '@solana/rpc-spec-types': 3.0.3(typescript@5.9.3) + '@solana/rpc-subscriptions': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + '@solana/signers': 3.0.3(typescript@5.9.3) + '@solana/sysvars': 3.0.3(typescript@5.9.3) + '@solana/transaction-confirmation': 3.0.3(typescript@5.9.3) + '@solana/transaction-messages': 3.0.3(typescript@5.9.3) + '@solana/transactions': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - ws + '@solana/kit@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -16345,6 +16557,14 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/programs@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/programs@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/addresses': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -16479,6 +16699,23 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/rpc-api@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 3.0.3(typescript@5.9.3) + '@solana/codecs-strings': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/keys': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-parsed-types': 3.0.3(typescript@5.9.3) + '@solana/rpc-spec': 3.0.3(typescript@5.9.3) + '@solana/rpc-transformers': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + '@solana/transaction-messages': 3.0.3(typescript@5.9.3) + '@solana/transactions': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/rpc-api@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/addresses': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -16715,6 +16952,19 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/rpc-subscriptions-api@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/keys': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/rpc-subscriptions-spec': 3.0.3(typescript@5.9.3) + '@solana/rpc-transformers': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + '@solana/transaction-messages': 3.0.3(typescript@5.9.3) + '@solana/transactions': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/rpc-subscriptions-api@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/addresses': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -16831,7 +17081,7 @@ snapshots: '@solana/functional': 6.1.0(typescript@5.9.2) '@solana/rpc-subscriptions-spec': 6.1.0(typescript@5.9.2) '@solana/subscribable': 6.1.0(typescript@5.9.2) - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: @@ -16844,7 +17094,7 @@ snapshots: '@solana/functional': 6.1.0(typescript@5.9.3) '@solana/rpc-subscriptions-spec': 6.1.0(typescript@5.9.3) '@solana/subscribable': 6.1.0(typescript@5.9.3) - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -16989,6 +17239,24 @@ snapshots: - fastestsmallesttextencoderdecoder - ws + '@solana/rpc-subscriptions@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/fast-stable-stringify': 3.0.3(typescript@5.9.3) + '@solana/functional': 3.0.3(typescript@5.9.3) + '@solana/promises': 3.0.3(typescript@5.9.3) + '@solana/rpc-spec-types': 3.0.3(typescript@5.9.3) + '@solana/rpc-subscriptions-api': 3.0.3(typescript@5.9.3) + '@solana/rpc-subscriptions-channel-websocket': 3.0.3(typescript@5.9.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-spec': 3.0.3(typescript@5.9.3) + '@solana/rpc-transformers': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + '@solana/subscribable': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - ws + '@solana/rpc-subscriptions@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 5.1.0(typescript@5.9.2) @@ -17109,6 +17377,17 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/rpc-transformers@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/functional': 3.0.3(typescript@5.9.3) + '@solana/nominal-types': 3.0.3(typescript@5.9.3) + '@solana/rpc-spec-types': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/rpc-transformers@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/errors': 5.1.0(typescript@5.9.2) @@ -17269,6 +17548,18 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/rpc-types@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 3.0.3(typescript@5.9.3) + '@solana/codecs-numbers': 3.0.3(typescript@5.9.3) + '@solana/codecs-strings': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/nominal-types': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/rpc-types@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/addresses': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -17379,6 +17670,21 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/rpc@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/fast-stable-stringify': 3.0.3(typescript@5.9.3) + '@solana/functional': 3.0.3(typescript@5.9.3) + '@solana/rpc-api': 3.0.3(typescript@5.9.3) + '@solana/rpc-spec': 3.0.3(typescript@5.9.3) + '@solana/rpc-spec-types': 3.0.3(typescript@5.9.3) + '@solana/rpc-transformers': 3.0.3(typescript@5.9.3) + '@solana/rpc-transport-http': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/rpc@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/errors': 5.1.0(typescript@5.9.2) @@ -17483,6 +17789,20 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/signers@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 3.0.3(typescript@5.9.3) + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/instructions': 3.0.3(typescript@5.9.3) + '@solana/keys': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/nominal-types': 3.0.3(typescript@5.9.3) + '@solana/transaction-messages': 3.0.3(typescript@5.9.3) + '@solana/transactions': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/signers@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/addresses': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -17617,6 +17937,16 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/sysvars@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/accounts': 3.0.3(typescript@5.9.3) + '@solana/codecs': 3.0.3(typescript@5.9.3) + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/sysvars@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/accounts': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -17727,6 +18057,23 @@ snapshots: - fastestsmallesttextencoderdecoder - ws + '@solana/transaction-confirmation@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-strings': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/keys': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/promises': 3.0.3(typescript@5.9.3) + '@solana/rpc': 3.0.3(typescript@5.9.3) + '@solana/rpc-subscriptions': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + '@solana/transaction-messages': 3.0.3(typescript@5.9.3) + '@solana/transactions': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - ws + '@solana/transaction-confirmation@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -17859,6 +18206,21 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/transaction-messages@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 3.0.3(typescript@5.9.3) + '@solana/codecs-data-structures': 3.0.3(typescript@5.9.3) + '@solana/codecs-numbers': 3.0.3(typescript@5.9.3) + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/functional': 3.0.3(typescript@5.9.3) + '@solana/instructions': 3.0.3(typescript@5.9.3) + '@solana/nominal-types': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/transaction-messages@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/addresses': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -17993,6 +18355,24 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder + '@solana/transactions@3.0.3(typescript@5.9.3)': + dependencies: + '@solana/addresses': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/codecs-core': 3.0.3(typescript@5.9.3) + '@solana/codecs-data-structures': 3.0.3(typescript@5.9.3) + '@solana/codecs-numbers': 3.0.3(typescript@5.9.3) + '@solana/codecs-strings': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/errors': 3.0.3(typescript@5.9.3) + '@solana/functional': 3.0.3(typescript@5.9.3) + '@solana/instructions': 3.0.3(typescript@5.9.3) + '@solana/keys': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3) + '@solana/nominal-types': 3.0.3(typescript@5.9.3) + '@solana/rpc-types': 3.0.3(typescript@5.9.3) + '@solana/transaction-messages': 3.0.3(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + '@solana/transactions@5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)': dependencies: '@solana/addresses': 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -18563,7 +18943,7 @@ snapshots: '@typescript-eslint/types': 8.48.0 '@typescript-eslint/typescript-estree': 8.48.0(typescript@5.9.2) '@typescript-eslint/visitor-keys': 8.48.0 - debug: 4.4.1 + debug: 4.4.3 eslint: 9.33.0(jiti@2.6.1) typescript: 5.9.2 transitivePeerDependencies: @@ -18575,7 +18955,7 @@ snapshots: '@typescript-eslint/types': 8.48.0 '@typescript-eslint/typescript-estree': 8.48.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.48.0 - debug: 4.4.1 + debug: 4.4.3 eslint: 9.33.0(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -18585,7 +18965,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.40.0(typescript@5.9.2) '@typescript-eslint/types': 8.48.0 - debug: 4.4.1 + debug: 4.4.3 typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -18594,7 +18974,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.40.0(typescript@5.9.3) '@typescript-eslint/types': 8.48.0 - debug: 4.4.1 + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -18603,7 +18983,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.9.2) '@typescript-eslint/types': 8.48.0 - debug: 4.4.1 + debug: 4.4.3 typescript: 5.9.2 transitivePeerDependencies: - supports-color @@ -18612,7 +18992,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.9.3) '@typescript-eslint/types': 8.48.0 - debug: 4.4.1 + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -18733,9 +19113,9 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.9.2) '@typescript-eslint/types': 8.48.0 '@typescript-eslint/visitor-keys': 8.48.0 - debug: 4.4.1 + debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.8.5 tinyglobby: 0.2.15 ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 @@ -18748,9 +19128,9 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.48.0(typescript@5.9.3) '@typescript-eslint/types': 8.48.0 '@typescript-eslint/visitor-keys': 8.48.0 - debug: 4.4.1 + debug: 4.4.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.8.5 tinyglobby: 0.2.15 ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 @@ -18975,21 +19355,21 @@ snapshots: - utf-8-validate - zod - '@wagmi/connectors@6.2.0(82714061ce2fc89c4f961c6dbad81f8f)': + '@wagmi/connectors@6.2.0(4da75f6670434e809e39cad7499274ab)': dependencies: - '@base-org/account': 2.4.0(@types/react@19.1.10)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) - '@coinbase/wallet-sdk': 4.3.6(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) - '@gemini-wallet/core': 0.3.2(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@base-org/account': 2.4.0(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@4.1.13) + '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@4.1.13) + '@gemini-wallet/core': 0.3.2(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) '@metamask/sdk': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) - '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.1.10)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76)) - '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) + '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.35(5b6007fc9b4119a22ae7e95981ad7a36) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) + porto: 0.2.35(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(zod@4.1.13)) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) optionalDependencies: - typescript: 5.9.2 + typescript: 5.9.3 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -19029,21 +19409,21 @@ snapshots: - ws - zod - '@wagmi/connectors@6.2.0(d27b5019f10ead2ad2c8a66fcf3e72ab)': + '@wagmi/connectors@6.2.0(82714061ce2fc89c4f961c6dbad81f8f)': dependencies: - '@base-org/account': 2.4.0(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) - '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@4.1.13) - '@gemini-wallet/core': 0.3.2(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) + '@base-org/account': 2.4.0(@types/react@19.1.10)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + '@coinbase/wallet-sdk': 4.3.6(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76) + '@gemini-wallet/core': 0.3.2(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76)) '@metamask/sdk': 0.33.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) - '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) - '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) - '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + '@safe-global/safe-apps-provider': 0.18.6(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) + '@safe-global/safe-apps-sdk': 9.1.0(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) + '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.1.10)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76)) + '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.35(6efb213377217713e5f239ae3971a013) - viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + porto: 0.2.35(5b6007fc9b4119a22ae7e95981ad7a36) + viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) optionalDependencies: - typescript: 5.9.3 + typescript: 5.9.2 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -20382,7 +20762,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -20589,9 +20969,9 @@ snapshots: axe-core@4.10.3: {} - axios-retry@4.5.0(axios@1.18.0): + axios-retry@4.5.0(axios@1.20.0): dependencies: - axios: 1.18.0 + axios: 1.20.0 is-retry-allowed: 2.2.0 axios@1.13.2: @@ -20699,7 +21079,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.1 + debug: 4.4.3 http-errors: 2.0.0 iconv-lite: 0.6.3 on-finished: 2.4.1 @@ -21506,7 +21886,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.33.0(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.1 + debug: 4.4.3 eslint: 9.33.0(jiti@2.6.1) get-tsconfig: 4.10.1 is-bun-module: 2.0.0 @@ -21681,13 +22061,13 @@ snapshots: '@es-joy/jsdoccomment': 0.50.2 are-docs-informative: 0.0.2 comment-parser: 1.4.1 - debug: 4.4.1 + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint: 9.33.0(jiti@2.6.1) espree: 10.4.0 esquery: 1.6.0 parse-imports-exports: 0.2.4 - semver: 7.7.3 + semver: 7.8.5 spdx-expression-parse: 4.0.0 transitivePeerDependencies: - supports-color @@ -21777,7 +22157,7 @@ snapshots: eslint@9.33.0(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.33.0(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.1 + '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.0 '@eslint/config-helpers': 0.3.1 '@eslint/core': 0.15.2 @@ -21792,7 +22172,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.1 + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -21968,7 +22348,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.1 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -22135,7 +22515,7 @@ snapshots: finalhandler@2.1.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -22497,7 +22877,7 @@ snapshots: dependencies: '@tootallnate/once': 2.0.1 agent-base: 6.0.2 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color optional: true @@ -22505,7 +22885,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -22517,14 +22897,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.1 + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -22637,7 +23017,7 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.8.5 is-callable@1.2.7: {} @@ -22781,7 +23161,7 @@ snapshots: istanbul-lib-source-maps@5.0.6: dependencies: '@jridgewell/trace-mapping': 0.3.30 - debug: 4.4.1 + debug: 4.4.3 istanbul-lib-coverage: 3.2.2 transitivePeerDependencies: - supports-color @@ -22905,7 +23285,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -23143,7 +23523,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.7.3 + semver: 7.8.5 make-error@1.3.6: {} @@ -24066,7 +24446,7 @@ snapshots: - immer - use-sync-external-store - porto@0.2.35(6efb213377217713e5f239ae3971a013): + porto@0.2.35(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(zod@4.1.13)): dependencies: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) hono: 4.10.7 @@ -24081,7 +24461,7 @@ snapshots: react: 19.2.6 react-native: 0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10) typescript: 5.9.3 - wagmi: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) + wagmi: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(zod@4.1.13) transitivePeerDependencies: - '@types/react' - immer @@ -24453,7 +24833,7 @@ snapshots: refa@0.12.1: dependencies: - '@eslint-community/regexpp': 4.12.1 + '@eslint-community/regexpp': 4.12.2 reflect.getprototypeof@1.0.10: dependencies: @@ -24470,7 +24850,7 @@ snapshots: regexp-ast-analysis@0.7.1: dependencies: - '@eslint-community/regexpp': 4.12.1 + '@eslint-community/regexpp': 4.12.2 refa: 0.12.1 regexp.prototype.flags@1.5.4: @@ -24591,7 +24971,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -24607,7 +24987,7 @@ snapshots: buffer: 6.0.3 eventemitter3: 5.0.4 uuid: 8.3.2 - ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: bufferutil: 4.0.9 utf-8-validate: 5.0.10 @@ -24665,7 +25045,7 @@ snapshots: scslre@0.3.0: dependencies: - '@eslint-community/regexpp': 4.12.1 + '@eslint-community/regexpp': 4.12.2 refa: 0.12.1 regexp-ast-analysis: 0.7.1 @@ -24707,7 +25087,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.1 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -25678,7 +26058,7 @@ snapshots: vite-node@3.2.4(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1): dependencies: cac: 6.7.14 - debug: 4.4.1 + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) @@ -25832,10 +26212,10 @@ snapshots: - ws - zod - wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13): + wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(zod@4.1.13): dependencies: '@tanstack/react-query': 5.90.11(react@19.2.6) - '@wagmi/connectors': 6.2.0(d27b5019f10ead2ad2c8a66fcf3e72ab) + '@wagmi/connectors': 6.2.0(4da75f6670434e809e39cad7499274ab) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) react: 19.2.6 use-sync-external-store: 1.4.0(react@19.2.6) @@ -26019,11 +26399,6 @@ snapshots: bufferutil: 4.0.9 utf-8-validate: 5.0.10 - ws@8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10): - optionalDependencies: - bufferutil: 4.0.9 - utf-8-validate: 5.0.10 - ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10): optionalDependencies: bufferutil: 4.0.9 diff --git a/examples/typescript/servers/README.md b/examples/typescript/servers/README.md index f76d239dbf..9b90f66e30 100644 --- a/examples/typescript/servers/README.md +++ b/examples/typescript/servers/README.md @@ -15,6 +15,7 @@ This directory contains TypeScript server examples demonstrating how to protect | [`upfront/`](./upfront/) | `exact` scheme with `upfront` payment flow (settle before handler) | | [`upto/`](./upto/) | `upto` scheme: authorize a ceiling, settle only actual usage | | [`batch-settlement/`](./batch-settlement/) | Off-chain vouchers claimed and settled in batches by a `ChannelManager` | +| [`auth-capture/`](./auth-capture/) | Refundable escrow payments: delegated sync/deferred and custom-operator collect | | [`bazaar/`](./bazaar/) | Makes a paid API discoverable via the Bazaar extension | | [`builder-code/`](./builder-code/) | ERC-8021 builder-code attribution on paid endpoints | | [`offer-receipt/`](./offer-receipt/) | Signed offers (payment terms) and receipts (proof of delivery) | diff --git a/examples/typescript/servers/auth-capture/.env-local b/examples/typescript/servers/auth-capture/.env-local new file mode 100644 index 0000000000..b69ba43ebb --- /dev/null +++ b/examples/typescript/servers/auth-capture/.env-local @@ -0,0 +1,9 @@ +EVM_ADDRESS=0x... +FACILITATOR_URL=http://localhost:4022 + +# Optional for delegated-sync and delegated-deferred (self-managed receiver authorizer). +# Omit to delegate capture/void/refund signing to the facilitator (extra.receiverAuthorizer on /supported). +EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=0x... + +# Custom-escrow flow only: permissionless collect wrapper on the facilitator allowlist. +CUSTOM_OPERATOR_ADDRESS= diff --git a/examples/typescript/servers/auth-capture/.prettierignore b/examples/typescript/servers/auth-capture/.prettierignore new file mode 100644 index 0000000000..f06235c460 --- /dev/null +++ b/examples/typescript/servers/auth-capture/.prettierignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/examples/typescript/servers/auth-capture/.prettierrc b/examples/typescript/servers/auth-capture/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/servers/auth-capture/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "avoid", + "printWidth": 100, + "proseWrap": "never" +} diff --git a/examples/typescript/servers/auth-capture/README.md b/examples/typescript/servers/auth-capture/README.md new file mode 100644 index 0000000000..8e0c79c751 --- /dev/null +++ b/examples/typescript/servers/auth-capture/README.md @@ -0,0 +1,84 @@ +# Auth-Capture Server Example + +Express server that protects a resource with the **auth-capture** EVM scheme (v1.1). Three entry scripts demonstrate the main operator and capture-mode combinations. + +See the [v1.1 proposed specification](../../../../specs/proposed/scheme_auth_capture_evm.md) and the [scheme README](../../../../typescript/packages/mechanisms/evm/src/auth-capture/README.md). + +## Flows + +Pick **one** script at a time (all listen on port `4021`): + +| Script | `operatorType` | `captureMode` | Behavior | +| --- | --- | --- | --- | +| `pnpm delegated-sync` | `"delegated"` | `"sync"` | Authorize before the handler, signed capture after (supports partial billing via `setSettlementOverrides`) | +| `pnpm delegated-deferred` | `"delegated"` | `"deferred"` | Authorize during the request; skip after-handler settle; capture later via admin routes or `pnpm capture-pending` | +| `pnpm custom-escrow` | `"custom"` | `"deferred"` | Collect-only `authorize` through a custom operator contract; lifecycle is out of band | + +### Delegated sync + +The facilitator relays `authorize`, the resource handler runs, then the server relays `capture` (or `void` on handler failure). Receiver-authorizer signatures are either: + +- **Self:** set `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` (must match the published `extra.receiverAuthorizer`) +- **Delegated:** omit the key; `extra.receiverAuthorizer` is taken from the facilitator's `/supported` and the facilitator signs lifecycle payloads + +`extra.captureAuthorizer` for delegated flows is copied from the facilitator's `/supported` extra.captureAuthorizer (the advertised relayer). + +The `/weather` route bills a random fraction of the authorized `$0.01` ceiling to demonstrate usage-based capture. + +### Delegated deferred (async capture) + +Funds are held in escrow during the request, but the after-handler facilitator settle is skipped. Lifecycle signing uses the same self vs facilitator-delegated receiver authorizer options as delegated sync. Capture runs **asynchronously** through: + +- `GET /admin/payments` — list in-memory authorized payments +- `POST /admin/capture` — `{ "paymentInfoHash": "0x...", "voidRemainder": true }` +- `POST /admin/void` — release the hold without capturing +- `pnpm capture-pending` — CLI that captures (and voids remainder) on the latest payment + +**In-memory storage:** the default `InMemoryAuthorizedPaymentStorage` only survives within one Node process. Deferred captures must be triggered before restarting the server; otherwise the payer can `reclaim` after the capture deadline. Production deployments need durable, atomically updatable storage (Redis, SQL, etc.). + +### Custom escrow (collect-only) + +`extra.captureAuthorizer` is a deployed custom operator. The facilitator relays only the collect `authorize`; capture/void/refund happen on the operator contract outside x402. + +Set `CUSTOM_OPERATOR_ADDRESS` to that operator. The example facilitator allowlists all custom operators for local testing. + +## Prerequisites + +- Node.js v20+, pnpm v10 +- A running [auth-capture facilitator](../../facilitator/auth-capture) +- An EVM `payTo` address (`EVM_ADDRESS`) +- For delegated flows: optional local receiver authorizer key, or a facilitator that advertises `extra.receiverAuthorizer` on `/supported` (capture authorizer comes from `extra.captureAuthorizer`) +- For custom escrow: deployed custom operator on Base Sepolia + +## Setup + +```bash +cp .env-local .env +# fill EVM_ADDRESS, FACILITATOR_URL, and flow-specific vars (see .env-local) + +cd ../../ +pnpm install && pnpm build +cd servers/auth-capture + +# Terminal 1 — pick a flow +pnpm delegated-sync +# or: pnpm delegated-deferred +# or: pnpm custom-escrow + +# Terminal 2 — pay the endpoint (TypeScript) +cd ../../clients/fetch && pnpm start +# or: cd ../../clients/axios && pnpm start +# or (Go): cd ../../../go/clients/http && go run . + +# Terminal 3 (deferred only) — capture after the client pays +cd ../../servers/auth-capture && pnpm capture-pending +``` + +## Environment + +| Variable | Required | Description | +| --- | --- | --- | +| `EVM_ADDRESS` | yes | `payTo` receiver address | +| `FACILITATOR_URL` | yes | Auth-capture facilitator endpoint | +| `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` | delegated flows (optional) | Self-managed receiver authorizer; omit to delegate lifecycle signing to the facilitator | +| `CUSTOM_OPERATOR_ADDRESS` | custom-escrow | Allowlisted custom operator contract | diff --git a/examples/typescript/servers/auth-capture/custom-escrow.ts b/examples/typescript/servers/auth-capture/custom-escrow.ts new file mode 100644 index 0000000000..56a1118f44 --- /dev/null +++ b/examples/typescript/servers/auth-capture/custom-escrow.ts @@ -0,0 +1,102 @@ +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/server"; +import { + paymentMiddlewareFromHTTPServer, + x402HTTPResourceServer, + x402ResourceServer, +} from "@x402/express"; +import { config } from "dotenv"; +import express from "express"; +import { getAddress, zeroAddress } from "viem"; + +config(); + +const NETWORK = "eip155:84532" as const; +const PORT = 4021; + +const payTo = process.env.EVM_ADDRESS?.trim(); +const facilitatorUrl = process.env.FACILITATOR_URL?.trim(); +const customOperatorAddress = process.env.CUSTOM_OPERATOR_ADDRESS?.trim() as + | `0x${string}` + | undefined; + +if (!payTo || !/^0x[0-9a-fA-F]{40}$/.test(payTo)) { + console.error("Missing or invalid EVM_ADDRESS (checksummed 20-byte hex, 0x-prefixed)"); + process.exit(1); +} + +if (!facilitatorUrl) { + console.error("Missing required FACILITATOR_URL environment variable"); + process.exit(1); +} + +if (!customOperatorAddress || !/^0x[0-9a-fA-F]{40}$/.test(customOperatorAddress)) { + console.error( + "Missing or invalid CUSTOM_OPERATOR_ADDRESS (deploy a custom operator and allowlist it on the facilitator)", + ); + process.exit(1); +} + +const payToAddress = getAddress(payTo) as `0x${string}`; +const captureAuthorizer = getAddress(customOperatorAddress) as `0x${string}`; + +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); +const scheme = new AuthCaptureEvmScheme(); + +const resourceServer = new x402ResourceServer(facilitatorClient).register(NETWORK, scheme); +const httpServer = new x402HTTPResourceServer(resourceServer, { + "GET /weather": { + accepts: { + scheme: "auth-capture", + price: "$0.01", + network: NETWORK, + payTo: payToAddress, + extra: { + feeRecipient: zeroAddress, + minFeeBps: 0, + maxFeeBps: 0, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + captureAuthorizer, + operatorType: "custom", + paymentFlow: "escrow", + captureMode: "deferred", + }, + }, + description: "Weather data", + mimeType: "application/json", + }, +}); + +const app = express(); + +/** + * Starts the resource server for the custom escrow, deferred capture flow. + */ +async function main(): Promise { + await httpServer.initialize(); + + app.use(paymentMiddlewareFromHTTPServer(httpServer, undefined, undefined, false)); + + app.get("/weather", (_req, res) => { + res.send({ + report: { + weather: "sunny", + temperature: 70, + flow: "custom-escrow", + }, + }); + }); + + app.listen(PORT, () => { + console.log(`Auth-capture server (custom-escrow) listening at http://localhost:${PORT}`); + console.log(" GET /weather"); + console.log(` Custom operator: ${captureAuthorizer}`); + console.log(" Collect-only: capture/void/refund run out of band on the operator contract."); + }); +} + +main().catch(err => { + console.error("Startup failed:", err); + process.exit(1); +}); diff --git a/examples/typescript/servers/auth-capture/delegated-deferred.ts b/examples/typescript/servers/auth-capture/delegated-deferred.ts new file mode 100644 index 0000000000..bc5c1c2177 --- /dev/null +++ b/examples/typescript/servers/auth-capture/delegated-deferred.ts @@ -0,0 +1,168 @@ +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/server"; +import { + paymentMiddlewareFromHTTPServer, + x402HTTPResourceServer, + x402ResourceServer, +} from "@x402/express"; +import { config } from "dotenv"; +import express from "express"; +import { getAddress, zeroAddress } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +config(); + +const NETWORK = "eip155:84532" as const; +const PORT = 4021; + +const payTo = process.env.EVM_ADDRESS?.trim(); +const facilitatorUrl = process.env.FACILITATOR_URL?.trim(); +const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY?.trim() as + | `0x${string}` + | undefined; + +if (!payTo || !/^0x[0-9a-fA-F]{40}$/.test(payTo)) { + console.error("Missing or invalid EVM_ADDRESS (checksummed 20-byte hex, 0x-prefixed)"); + process.exit(1); +} + +if (!facilitatorUrl) { + console.error("Missing required FACILITATOR_URL environment variable"); + process.exit(1); +} + +const receiverAuthorizerSigner = receiverAuthorizerPrivateKey + ? privateKeyToAccount(receiverAuthorizerPrivateKey) + : undefined; +const payToAddress = getAddress(payTo) as `0x${string}`; + +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); +const scheme = new AuthCaptureEvmScheme( + receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}, +); +const lifecycle = scheme.createLifecycleManager(facilitatorClient); + +const resourceServer = new x402ResourceServer(facilitatorClient).register(NETWORK, scheme); +const httpServer = new x402HTTPResourceServer(resourceServer, { + "GET /weather": { + accepts: { + scheme: "auth-capture", + price: "$0.01", + network: NETWORK, + payTo: payToAddress, + extra: { + feeRecipient: zeroAddress, + minFeeBps: 0, + maxFeeBps: 0, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + operatorType: "delegated", + paymentFlow: "escrow", + captureMode: "deferred", + }, + }, + description: "Weather data", + mimeType: "application/json", + }, +}); + +const app = express(); + +/** + * Starts the resource server for the delegated escrow, deferred capture flow. + */ +async function main(): Promise { + await httpServer.initialize(); + + app.use(paymentMiddlewareFromHTTPServer(httpServer, undefined, undefined, false)); + app.use(express.json()); + + app.get("/admin/payments", async (_req, res) => { + try { + const payments = await lifecycle.listAuthorizedPayments(); + res.json( + payments.map(payment => ({ + paymentInfoHash: payment.paymentInfoHash, + capturableAmount: payment.capturableAmount, + refundableAmount: payment.refundableAmount, + collectTransaction: payment.collectTransaction, + createdAt: payment.createdAt, + })), + ); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + app.post("/admin/capture", async (req, res) => { + try { + const paymentInfoHash = req.body?.paymentInfoHash as `0x${string}` | undefined; + if (!paymentInfoHash) { + return res.status(400).json({ error: "paymentInfoHash is required" }); + } + + const amount = req.body?.amount as string | undefined; + const voidRemainder = Boolean(req.body?.voidRemainder); + + const response = await lifecycle.capture( + paymentInfoHash, + amount ? { amount, voidRemainder } : undefined, + ); + res.json(response); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + app.post("/admin/void", async (req, res) => { + try { + const paymentInfoHash = req.body?.paymentInfoHash as `0x${string}` | undefined; + if (!paymentInfoHash) { + return res.status(400).json({ error: "paymentInfoHash is required" }); + } + + const response = await lifecycle.voidPayment(paymentInfoHash); + res.json(response); + } catch (error) { + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } + }); + + app.get("/weather", (_req, res) => { + res.send({ + report: { + weather: "sunny", + temperature: 70, + flow: "delegated-deferred", + }, + }); + }); + + app.listen(PORT, () => { + console.log(`Auth-capture server (delegated-deferred) listening at http://localhost:${PORT}`); + console.log(" GET /weather"); + console.log(" GET /admin/payments"); + console.log(" POST /admin/capture { paymentInfoHash, amount?, voidRemainder? }"); + console.log(" POST /admin/void { paymentInfoHash }"); + console.log(" Deferred captures use in-memory storage — call admin routes before restart."); + if (receiverAuthorizerSigner) { + console.log(` Receiver authorizer (self): ${receiverAuthorizerSigner.address}`); + } else { + console.log( + " Receiver authorizer: delegated to facilitator (from /supported extra.receiverAuthorizer)", + ); + } + console.log(" Capture authorizer: copied from facilitator /supported extra.captureAuthorizer"); + }); +} + +main().catch(err => { + console.error("Startup failed:", err); + process.exit(1); +}); diff --git a/examples/typescript/servers/auth-capture/delegated-sync.ts b/examples/typescript/servers/auth-capture/delegated-sync.ts new file mode 100644 index 0000000000..8f71f7928c --- /dev/null +++ b/examples/typescript/servers/auth-capture/delegated-sync.ts @@ -0,0 +1,109 @@ +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/server"; +import { + paymentMiddlewareFromHTTPServer, + setSettlementOverrides, + x402HTTPResourceServer, + x402ResourceServer, +} from "@x402/express"; +import { config } from "dotenv"; +import express from "express"; +import { getAddress, zeroAddress } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; + +config(); + +const NETWORK = "eip155:84532" as const; +const PORT = 4021; + +const payTo = process.env.EVM_ADDRESS?.trim(); +const facilitatorUrl = process.env.FACILITATOR_URL?.trim(); +const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY?.trim() as + | `0x${string}` + | undefined; + +if (!payTo || !/^0x[0-9a-fA-F]{40}$/.test(payTo)) { + console.error("Missing or invalid EVM_ADDRESS (checksummed 20-byte hex, 0x-prefixed)"); + process.exit(1); +} + +if (!facilitatorUrl) { + console.error("Missing required FACILITATOR_URL environment variable"); + process.exit(1); +} + +const receiverAuthorizerSigner = receiverAuthorizerPrivateKey + ? privateKeyToAccount(receiverAuthorizerPrivateKey) + : undefined; +const payToAddress = getAddress(payTo) as `0x${string}`; + +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); +const scheme = new AuthCaptureEvmScheme( + receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}, +); + +const resourceServer = new x402ResourceServer(facilitatorClient).register(NETWORK, scheme); +const httpServer = new x402HTTPResourceServer(resourceServer, { + "GET /weather": { + accepts: { + scheme: "auth-capture", + price: "$0.01", + network: NETWORK, + payTo: payToAddress, + extra: { + feeRecipient: zeroAddress, + minFeeBps: 0, + maxFeeBps: 0, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + operatorType: "delegated", + paymentFlow: "escrow", + captureMode: "sync", + }, + }, + description: "Weather data", + mimeType: "application/json", + }, +}); + +const app = express(); + +/** + * Starts the resource server for the delegated escrow, sync capture flow. + */ +async function main(): Promise { + await httpServer.initialize(); + + app.use(paymentMiddlewareFromHTTPServer(httpServer, undefined, undefined, false)); + + app.get("/weather", (_req, res) => { + const chargedPercent = 1 + Math.floor(Math.random() * 100); + setSettlementOverrides(res, { amount: `${chargedPercent}%` }); + + res.send({ + report: { + weather: "sunny", + temperature: 70, + flow: "delegated-sync", + }, + }); + }); + + app.listen(PORT, () => { + console.log(`Auth-capture server (delegated-sync) listening at http://localhost:${PORT}`); + console.log(" GET /weather"); + if (receiverAuthorizerSigner) { + console.log(` Receiver authorizer (self): ${receiverAuthorizerSigner.address}`); + } else { + console.log( + " Receiver authorizer: delegated to facilitator (from /supported extra.receiverAuthorizer)", + ); + } + console.log(" Capture authorizer: copied from facilitator /supported extra.captureAuthorizer"); + }); +} + +main().catch(err => { + console.error("Startup failed:", err); + process.exit(1); +}); diff --git a/examples/typescript/servers/auth-capture/eslint.config.js b/examples/typescript/servers/auth-capture/eslint.config.js new file mode 100644 index 0000000000..e2fde7b3b8 --- /dev/null +++ b/examples/typescript/servers/auth-capture/eslint.config.js @@ -0,0 +1,73 @@ +import js from "@eslint/js"; +import ts from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import prettier from "eslint-plugin-prettier"; +import jsdoc from "eslint-plugin-jsdoc"; +import importPlugin from "eslint-plugin-import"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2020, + globals: { + process: "readonly", + __dirname: "readonly", + module: "readonly", + require: "readonly", + Buffer: "readonly", + console: "readonly", + exports: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + }, + }, + plugins: { + "@typescript-eslint": ts, + prettier: prettier, + jsdoc: jsdoc, + import: importPlugin, + }, + rules: { + ...ts.configs.recommended.rules, + "import/first": "error", + "prettier/prettier": "error", + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_$" }], + "jsdoc/tag-lines": ["error", "any", { startLines: 1 }], + "jsdoc/check-alignment": "error", + "jsdoc/no-undefined-types": "off", + "jsdoc/check-param-names": "error", + "jsdoc/check-tag-names": "error", + "jsdoc/check-types": "error", + "jsdoc/implements-on-classes": "error", + "jsdoc/require-description": "error", + "jsdoc/require-jsdoc": [ + "error", + { + require: { + FunctionDeclaration: true, + MethodDefinition: true, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + }, + ], + "jsdoc/require-param": "error", + "jsdoc/require-param-description": "error", + "jsdoc/require-param-type": "off", + "jsdoc/require-returns": "error", + "jsdoc/require-returns-description": "error", + "jsdoc/require-returns-type": "off", + "jsdoc/require-hyphen-before-param-description": ["error", "always"], + }, + }, +]; diff --git a/examples/typescript/servers/auth-capture/package.json b/examples/typescript/servers/auth-capture/package.json new file mode 100644 index 0000000000..675e078c94 --- /dev/null +++ b/examples/typescript/servers/auth-capture/package.json @@ -0,0 +1,38 @@ +{ + "name": "@x402/auth-capture-server-example", + "private": true, + "type": "module", + "scripts": { + "delegated-sync": "tsx delegated-sync.ts", + "delegated-deferred": "tsx delegated-deferred.ts", + "custom-escrow": "tsx custom-escrow.ts", + "capture-pending": "tsx scripts/capture-pending.ts", + "void-pending": "tsx scripts/capture-pending.ts --void", + "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", + "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", + "lint": "eslint . --ext .ts --fix", + "lint:check": "eslint . --ext .ts" + }, + "dependencies": { + "@x402/core": "workspace:*", + "@x402/evm": "workspace:*", + "@x402/express": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.18.2", + "viem": "^2.48.11" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/express": "^5.0.1", + "@types/node": "^22.13.4", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-prettier": "^5.2.6", + "prettier": "3.5.2", + "tsx": "^4.21.0", + "typescript": "^5.7.3" + } +} diff --git a/examples/typescript/servers/auth-capture/scripts/capture-pending.ts b/examples/typescript/servers/auth-capture/scripts/capture-pending.ts new file mode 100644 index 0000000000..9f6c5b035c --- /dev/null +++ b/examples/typescript/servers/auth-capture/scripts/capture-pending.ts @@ -0,0 +1,60 @@ +import { config } from "dotenv"; + +config(); + +const baseUrl = process.env.RESOURCE_SERVER_URL?.trim() || "http://localhost:4021"; +const voidRemainder = process.argv.includes("--void"); + +/** + * Capture the most recent authorized payment via the deferred server's admin API. + * + * @returns Resolves after the capture (or void) request completes. + */ +async function main(): Promise { + const listResponse = await fetch(`${baseUrl}/admin/payments`); + if (!listResponse.ok) { + throw new Error(`Failed to list payments: ${listResponse.status} ${await listResponse.text()}`); + } + + const payments = (await listResponse.json()) as Array<{ + paymentInfoHash: `0x${string}`; + capturableAmount: string; + }>; + + if (payments.length === 0) { + console.log( + "No authorized payments in storage. Pay GET /weather first, then rerun this script.", + ); + return; + } + + const latest = payments[payments.length - 1]!; + console.log(`Latest payment: ${latest.paymentInfoHash} (capturable: ${latest.capturableAmount})`); + + if (voidRemainder) { + const voidResponse = await fetch(`${baseUrl}/admin/void`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ paymentInfoHash: latest.paymentInfoHash }), + }); + const voidBody = await voidResponse.json(); + console.log("Void response:", JSON.stringify(voidBody, null, 2)); + return; + } + + const captureResponse = await fetch(`${baseUrl}/admin/capture`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + paymentInfoHash: latest.paymentInfoHash, + voidRemainder: true, + }), + }); + const captureBody = await captureResponse.json(); + console.log("Capture response:", JSON.stringify(captureBody, null, 2)); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/examples/typescript/servers/auth-capture/tsconfig.json b/examples/typescript/servers/auth-capture/tsconfig.json new file mode 100644 index 0000000000..a2f5456d67 --- /dev/null +++ b/examples/typescript/servers/auth-capture/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "baseUrl": ".", + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/go/.changes/unreleased/added-20260929-auth-capture-server-facilitator.yaml b/go/.changes/unreleased/added-20260929-auth-capture-server-facilitator.yaml new file mode 100644 index 0000000000..b70fb34b08 --- /dev/null +++ b/go/.changes/unreleased/added-20260929-auth-capture-server-facilitator.yaml @@ -0,0 +1,3 @@ +kind: added +body: Add the auth-capture EVM scheme server and facilitator (delegated and custom operators, escrow and authorization flows, sync and deferred capture, partial capture, refunds, lifecycle manager and storage, merchant-configurable route extras, minute-bucketed dynamic deadlines), typed escrow revert mapping, and Go examples alongside the existing client. +time: 2026-09-29T12:00:00Z diff --git a/go/.changes/unreleased/added-20261002-150000.yaml b/go/.changes/unreleased/added-20261002-150000.yaml new file mode 100644 index 0000000000..52cbafd7fa --- /dev/null +++ b/go/.changes/unreleased/added-20261002-150000.yaml @@ -0,0 +1,3 @@ +kind: added +body: 'Add facilitator-authored settlement metadata (`m`) to the builder-code ERC-8021 Schema 2 suffix: `evm.DataSuffixContext.Metadata`, `BuilderCodeSuffixData`, and `m` support in `EncodeBuilderCodeSuffix` and `ParseBuilderCodeSuffixFromCalldata`. `EncodeBuilderCodeSuffix` now takes `BuilderCodeSuffixData` and returns an error when the CBOR exceeds 65,535 bytes.' +time: 2026-10-02T15:00:00.000000+02:00 diff --git a/go/.changes/unreleased/added-20261005-auth-capture-delegated-auth.yaml b/go/.changes/unreleased/added-20261005-auth-capture-delegated-auth.yaml new file mode 100644 index 0000000000..1dddb7067c --- /dev/null +++ b/go/.changes/unreleased/added-20261005-auth-capture-delegated-auth.yaml @@ -0,0 +1,3 @@ +kind: added +body: 'Add facilitator-delegated receiver authorization to the auth-capture EVM scheme. A server without a ReceiverAuthorizerSigner sends unsigned charge, capture, void and refund payloads (voidRemainder replaces voidAuthorizerSignature) and the facilitator signs them after ResolveCallerIdentity authenticates the caller, binding the identity to the paymentInfoHash in a DelegatedAuthStorage (InMemoryAuthCaptureDelegatedAuthStorage for local use). The server resolves self, delegated or collect-only per route, with Config.CollectOnlyRoutes and ValidateFacilitatorSupport checks. An omitted route receiverAuthorizer no longer implies collect-only: set it to the zero address explicitly. Replace the dedicated auth-capture client example with the base HTTP client example.' +time: 2026-10-05T14:30:00.000000+02:00 diff --git a/go/extensions/buildercode/cbor.go b/go/extensions/buildercode/cbor.go index 06cf84434f..48e6ad7b27 100644 --- a/go/extensions/buildercode/cbor.go +++ b/go/extensions/buildercode/cbor.go @@ -1,8 +1,11 @@ package buildercode import ( + "encoding/binary" "encoding/hex" "fmt" + "math" + "sort" "strconv" "strings" ) @@ -12,7 +15,9 @@ import ( // [cbor_data (variable)] [suffix_data_length (2 bytes)] [schema_id = 0x02 (1 byte)] [ERC-8021 marker (16 bytes)] // // The CBOR payload uses single-letter keys: "a" (app code, string), "w" -// (wallet code, string), and "s" (service codes, string array). +// (wallet code, string), "s" (service codes, string array), and "m" +// (facilitator-authored settlement metadata: a map of unsigned integers, +// strings, arrays, and maps). // encodeCborMajorType encodes a CBOR major type with an argument value. // @@ -20,24 +25,28 @@ import ( // - 0-23: single byte (major type << 5 | value) // - 24-255: two bytes (major type << 5 | 24, value) // - 256-65535: three bytes (major type << 5 | 25, value high, value low) -func encodeCborMajorType(majorType, value int) ([]byte, error) { +// - 65536-2^32-1: five bytes (major type << 5 | 26, 4-byte big-endian value) +// - 2^32-2^64-1: nine bytes (major type << 5 | 27, 8-byte big-endian value) +func encodeCborMajorType(majorType int, value uint64) ([]byte, error) { mt := byte(majorType << 5) switch { case value <= 23: return []byte{mt | byte(value)}, nil - case value <= 0xff: + case value <= math.MaxUint8: return []byte{mt | 24, byte(value)}, nil - case value <= 0xffff: - return []byte{mt | 25, byte(value >> 8), byte(value)}, nil + case value <= math.MaxUint16: + return binary.BigEndian.AppendUint16([]byte{mt | 25}, uint16(value)), nil + case value <= math.MaxUint32: + return binary.BigEndian.AppendUint32([]byte{mt | 26}, uint32(value)), nil default: - return nil, fmt.Errorf("CBOR value too large: %d", value) + return binary.BigEndian.AppendUint64([]byte{mt | 27}, value), nil } } // encodeCborString encodes a CBOR text string (major type 3). func encodeCborString(value string) ([]byte, error) { - header, err := encodeCborMajorType(3, len(value)) + header, err := encodeCborMajorType(3, uint64(len(value))) if err != nil { return nil, err } @@ -46,7 +55,7 @@ func encodeCborString(value string) ([]byte, error) { // encodeCborArray encodes a CBOR array of text strings (major type 4). func encodeCborArray(values []string) ([]byte, error) { - result, err := encodeCborMajorType(4, len(values)) + result, err := encodeCborMajorType(4, uint64(len(values))) if err != nil { return nil, err } @@ -61,8 +70,8 @@ func encodeCborArray(values []string) ([]byte, error) { } // encodeCborMap encodes a minimal CBOR map (major type 5) from builder code data, -// emitting only the fields that are set in field order a, w, s. -func encodeCborMap(data BuilderCodeExtensionData) ([]byte, error) { +// emitting only the fields that are set in field order a, w, s, m. +func encodeCborMap(data BuilderCodeSuffixData) ([]byte, error) { var entries []byte mapSize := 0 @@ -100,22 +109,123 @@ func encodeCborMap(data BuilderCodeExtensionData) ([]byte, error) { entries = append(entries, value...) } - header, err := encodeCborMajorType(5, mapSize) + if len(data.M) > 0 { + mapSize++ + key, err := encodeCborString("m") + if err != nil { + return nil, err + } + value, err := encodeCborValue(data.M) + if err != nil { + return nil, err + } + entries = append(entries, key...) + entries = append(entries, value...) + } + + header, err := encodeCborMajorType(5, uint64(mapSize)) if err != nil { return nil, err } return append(header, entries...), nil } +// encodeCborValue encodes a settlement metadata value: an unsigned integer, +// text string, array, or map with text keys. Anything else is rejected. +func encodeCborValue(value any) ([]byte, error) { + switch v := value.(type) { + case string: + return encodeCborString(v) + case uint64: + return encodeCborMajorType(0, v) + case uint: + return encodeCborMajorType(0, uint64(v)) + case int: + if v < 0 { + return nil, fmt.Errorf("CBOR metadata integer must not be negative: %d", v) + } + return encodeCborMajorType(0, uint64(v)) + case []any: + return encodeCborItems(v) + case []string: + return encodeCborArray(v) + case []uint64: + items := make([]any, len(v)) + for i, item := range v { + items[i] = item + } + return encodeCborItems(items) + case map[string]any: + return encodeCborValueMap(v) + case map[string]uint64: + entries := make(map[string]any, len(v)) + for key, item := range v { + entries[key] = item + } + return encodeCborValueMap(entries) + default: + return nil, fmt.Errorf("unsupported CBOR metadata type %T", value) + } +} + +// encodeCborItems encodes a CBOR array (major type 4) of metadata values. +func encodeCborItems(items []any) ([]byte, error) { + result, err := encodeCborMajorType(4, uint64(len(items))) + if err != nil { + return nil, err + } + for _, item := range items { + encoded, err := encodeCborValue(item) + if err != nil { + return nil, err + } + result = append(result, encoded...) + } + return result, nil +} + +// encodeCborValueMap encodes a CBOR map (major type 5) with text keys sorted +// bytewise by their encoded form (RFC 8949 section 4.2.1). +func encodeCborValueMap(entries map[string]any) ([]byte, error) { + type encodedEntry struct{ key, pair []byte } + + encoded := make([]encodedEntry, 0, len(entries)) + for k, v := range entries { + key, err := encodeCborString(k) + if err != nil { + return nil, err + } + value, err := encodeCborValue(v) + if err != nil { + return nil, err + } + encoded = append(encoded, encodedEntry{key: key, pair: append(append([]byte{}, key...), value...)}) + } + sort.Slice(encoded, func(i, j int) bool { return string(encoded[i].key) < string(encoded[j].key) }) + + result, err := encodeCborMajorType(5, uint64(len(entries))) + if err != nil { + return nil, err + } + for _, entry := range encoded { + result = append(result, entry.pair...) + } + return result, nil +} + // EncodeBuilderCodeSuffix builds a complete ERC-8021 Schema 2 data suffix from // builder code data. The returned bytes are ready to append to settlement // calldata. Format: [cbor_data][suffix_data_length (2 bytes)][schema_id (1 byte)][marker (16 bytes)]. -func EncodeBuilderCodeSuffix(data BuilderCodeExtensionData) ([]byte, error) { +// It returns an error when the CBOR data exceeds the 2-byte suffix_data_length. +func EncodeBuilderCodeSuffix(data BuilderCodeSuffixData) ([]byte, error) { cborBytes, err := encodeCborMap(data) if err != nil { return nil, err } cborLength := len(cborBytes) + if cborLength > math.MaxUint16 { + return nil, fmt.Errorf("builder code CBOR data is %d bytes, maximum is %d", cborLength, math.MaxUint16) + } markerBytes, err := hex.DecodeString(ERC_8021_MARKER) if err != nil { @@ -133,7 +243,7 @@ func EncodeBuilderCodeSuffix(data BuilderCodeExtensionData) ([]byte, error) { // ParseBuilderCodeSuffixFromCalldata parses ERC-8021 Schema 2 builder code // attribution from settlement calldata (hex, with or without a 0x prefix). // The second return value reports whether a valid suffix was found. -func ParseBuilderCodeSuffixFromCalldata(calldata string) (*BuilderCodeExtensionData, bool) { +func ParseBuilderCodeSuffixFromCalldata(calldata string) (*BuilderCodeSuffixData, bool) { h := strings.TrimPrefix(calldata, "0x") markerPos := strings.LastIndex(h, ERC_8021_MARKER) if markerPos < 6 { @@ -167,8 +277,8 @@ func parseHexInt(s string) int { } // parseCborMap decodes the CBOR map portion of a builder-code suffix into -// BuilderCodeExtensionData -func parseCborMap(bytes []byte) (*BuilderCodeExtensionData, bool) { +// BuilderCodeSuffixData +func parseCborMap(bytes []byte) (*BuilderCodeSuffixData, bool) { o := 0 if len(bytes) == 0 || bytes[o]>>5 != 5 { @@ -180,7 +290,7 @@ func parseCborMap(bytes []byte) (*BuilderCodeExtensionData, bool) { return nil, false } - result := &BuilderCodeExtensionData{} + result := &BuilderCodeSuffixData{} for entry := 0; entry < mapSize; entry++ { if o >= len(bytes) || bytes[o]>>5 != 3 { return nil, false @@ -223,6 +333,12 @@ func parseCborMap(bytes []byte) (*BuilderCodeExtensionData, bool) { if len(codes) > 0 { result.S = codes } + case "m": + metadata, ok := readCborMap(bytes, &o) + if !ok { + return nil, false + } + result.M = metadata default: return nil, false } @@ -231,26 +347,115 @@ func parseCborMap(bytes []byte) (*BuilderCodeExtensionData, bool) { return result, true } -// readCborLength reads a CBOR length/size argument (inline <=23 or one extra -// byte for 24), advancing o. Returns false for unsupported encodings. -func readCborLength(bytes []byte, o *int) (int, bool) { +// readCborArgument reads a CBOR item header argument (inline <=23 or 1, 2, 4, +// or 8 extra bytes for info 24 through 27), advancing o. Returns false for +// unsupported encodings. +func readCborArgument(bytes []byte, o *int) (uint64, bool) { if *o >= len(bytes) { return 0, false } info := int(bytes[*o] & 0x1f) *o++ if info <= 23 { - return info, true + return uint64(info), true + } + if info > 27 { + return 0, false + } + + width := 1 << (info - 24) + if *o+width > len(bytes) { + return 0, false + } + var v uint64 + for _, b := range bytes[*o : *o+width] { + v = v<<8 | uint64(b) + } + *o += width + return v, true +} + +// readCborLength reads a CBOR length/size argument, advancing o. Returns false +// for unsupported encodings or a length larger than the remaining bytes, since +// every string byte or array/map element takes at least one byte. +func readCborLength(bytes []byte, o *int) (int, bool) { + v, ok := readCborArgument(bytes, o) + if !ok || v > uint64(len(bytes)-*o) { + return 0, false + } + return int(v), true +} + +// readCborText reads a CBOR text string (major type 3), advancing o. +func readCborText(bytes []byte, o *int) (string, bool) { + if *o >= len(bytes) || bytes[*o]>>5 != 3 { + return "", false + } + length, ok := readCborLength(bytes, o) + if !ok { + return "", false + } + text := string(bytes[*o : *o+length]) + *o += length + return text, true +} + +// readCborValue reads a settlement metadata value, advancing o. Unsigned +// integers are returned as uint64, arrays as []any, and maps as map[string]any. +func readCborValue(bytes []byte, o *int) (any, bool) { + if *o >= len(bytes) { + return nil, false + } + + switch bytes[*o] >> 5 { + case 0: + return readCborArgument(bytes, o) + case 3: + return readCborText(bytes, o) + case 4: + size, ok := readCborLength(bytes, o) + if !ok { + return nil, false + } + items := make([]any, 0, size) + for i := 0; i < size; i++ { + item, ok := readCborValue(bytes, o) + if !ok { + return nil, false + } + items = append(items, item) + } + return items, true + case 5: + return readCborMap(bytes, o) + default: + return nil, false } - if info == 24 { - if *o >= len(bytes) { - return 0, false +} + +// readCborMap reads a CBOR map (major type 5) with text keys, advancing o. +func readCborMap(bytes []byte, o *int) (map[string]any, bool) { + if *o >= len(bytes) || bytes[*o]>>5 != 5 { + return nil, false + } + size, ok := readCborLength(bytes, o) + if !ok { + return nil, false + } + + entries := make(map[string]any, size) + for i := 0; i < size; i++ { + key, ok := readCborText(bytes, o) + if !ok { + return nil, false + } + value, ok := readCborValue(bytes, o) + if !ok { + return nil, false } - v := int(bytes[*o]) - *o++ - return v, true + entries[key] = value } - return 0, false + return entries, true } // readServiceCodeArray reads arraySize CBOR text strings, returning every diff --git a/go/extensions/buildercode/cbor_test.go b/go/extensions/buildercode/cbor_test.go index 3e048781ad..1f516c715f 100644 --- a/go/extensions/buildercode/cbor_test.go +++ b/go/extensions/buildercode/cbor_test.go @@ -3,7 +3,9 @@ package buildercode import ( "encoding/hex" "reflect" + "strings" "testing" + "time" ) const ( @@ -16,19 +18,27 @@ func TestEncodeBuilderCodeSuffixSpecVectors(t *testing.T) { // Vectors from specs/extensions/builder_code.md. tests := []struct { name string - data BuilderCodeExtensionData + data BuilderCodeSuffixData want string }{ { name: "app only", - data: BuilderCodeExtensionData{A: "bc_myapp"}, + data: BuilderCodeSuffixData{BuilderCodeExtensionData: BuilderCodeExtensionData{A: "bc_myapp"}}, want: "a161616862635f6d79617070000c0280218021802180218021802180218021", }, { name: "app and facilitator", - data: BuilderCodeExtensionData{A: "bc_myapp", W: "bc_myfacilitator"}, + data: BuilderCodeSuffixData{BuilderCodeExtensionData: BuilderCodeExtensionData{A: "bc_myapp", W: "bc_myfacilitator"}}, want: "a261616862635f6d7961707061777062635f6d79666163696c697461746f72001f0280218021802180218021802180218021", }, + { + name: "app, facilitator and metadata", + data: BuilderCodeSuffixData{ + BuilderCodeExtensionData: BuilderCodeExtensionData{A: "bc_myapp", W: "bc_myfacilitator"}, + M: map[string]any{"x402Example": uint64(7)}, + }, + want: "a361616862635f6d7961707061777062635f6d79666163696c697461746f72616da16b783430324578616d706c6507002f0280218021802180218021802180218021", + }, } for _, tt := range tests { @@ -45,7 +55,7 @@ func TestEncodeBuilderCodeSuffixSpecVectors(t *testing.T) { } func TestSuffixRoundTrip(t *testing.T) { - suffix, err := EncodeBuilderCodeSuffix(BuilderCodeExtensionData{A: appCode, W: walletCode, S: []string{serviceCode}}) + suffix, err := EncodeBuilderCodeSuffix(BuilderCodeSuffixData{BuilderCodeExtensionData: BuilderCodeExtensionData{A: appCode, W: walletCode, S: []string{serviceCode}}}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -62,7 +72,7 @@ func TestSuffixRoundTrip(t *testing.T) { func TestSuffixRoundTripMultipleServiceCodes(t *testing.T) { want := []string{serviceCode, "bc_other"} - suffix, err := EncodeBuilderCodeSuffix(BuilderCodeExtensionData{A: appCode, W: walletCode, S: want}) + suffix, err := EncodeBuilderCodeSuffix(BuilderCodeSuffixData{BuilderCodeExtensionData: BuilderCodeExtensionData{A: appCode, W: walletCode, S: want}}) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -93,3 +103,142 @@ func TestParseSpecAppOnlyVector(t *testing.T) { t.Fatalf("parse mismatch: %+v", parsed) } } + +func TestMetadataRoundTrip(t *testing.T) { + metadata := map[string]any{ + "zero": uint64(0), + "inline": uint64(23), + "oneByte": uint64(24), + "twoBytes": uint64(1 << 16), + "fourBytes": uint64(1 << 32), + "maxUint64": ^uint64(0), + "text": "hello", + "list": []any{uint64(1), "two", []any{uint64(3)}, map[string]any{"four": uint64(4)}}, + "nested": map[string]any{"inner": map[string]any{"deep": uint64(1)}}, + } + suffix, err := EncodeBuilderCodeSuffix(BuilderCodeSuffixData{ + BuilderCodeExtensionData: BuilderCodeExtensionData{A: appCode, W: walletCode, S: []string{serviceCode}}, + M: metadata, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + parsed, ok := ParseBuilderCodeSuffixFromCalldata("0xdeadbeef" + hex.EncodeToString(suffix)) + if !ok { + t.Fatal("expected a valid suffix") + } + if !reflect.DeepEqual(parsed.M, metadata) { + t.Fatalf("metadata mismatch\n got: %#v\nwant: %#v", parsed.M, metadata) + } +} + +func TestMetadataEncodingIsDeterministic(t *testing.T) { + // Shorter encoded keys sort first: b, c, then aa. + want := "a3616202616303626161" + "01" + + for _, m := range []map[string]any{ + {"aa": uint64(1), "b": uint64(2), "c": uint64(3)}, + {"c": 3, "b": 2, "aa": 1}, + {"c": uint64(3), "b": uint64(2), "aa": uint64(1)}, + } { + suffix, err := EncodeBuilderCodeSuffix(BuilderCodeSuffixData{M: m}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(hex.EncodeToString(suffix), want) { + t.Fatalf("expected sorted keys %s in %x", want, suffix) + } + } +} + +func TestMetadataTypedMapsMatchGenericMaps(t *testing.T) { + typed, err := EncodeBuilderCodeSuffix(BuilderCodeSuffixData{M: map[string]any{ + "counts": map[string]uint64{"a": 1, "b": 2}, + "list": []uint64{1, 2}, + "names": []string{"x"}, + }}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + generic, err := EncodeBuilderCodeSuffix(BuilderCodeSuffixData{M: map[string]any{ + "counts": map[string]any{"a": uint64(1), "b": uint64(2)}, + "list": []any{uint64(1), uint64(2)}, + "names": []any{"x"}, + }}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(typed, generic) { + t.Fatalf("typed and generic metadata encode differently\n typed: %x\ngeneric: %x", typed, generic) + } +} + +func TestEncodeRejectsOversizedCbor(t *testing.T) { + _, err := EncodeBuilderCodeSuffix(BuilderCodeSuffixData{M: map[string]any{"big": strings.Repeat("x", 0x10000)}}) + if err == nil { + t.Fatal("expected an error when the CBOR exceeds 65535 bytes") + } +} + +func TestEncodeRejectsUnsupportedMetadataValues(t *testing.T) { + type customStruct struct{ A uint64 } + one := uint64(1) + + for name, value := range map[string]any{ + "negative": -1, + "float": 1.5, + "bool": true, + "false": false, + "bytes": []byte{1}, + "byte array": [2]byte{1, 2}, + "nil": nil, + "time": time.Unix(0, 0), + "func": func() {}, + "struct": customStruct{A: 1}, + "pointer": &one, + "int8": int8(1), + "uint32": uint32(1), + "bool slice": []bool{true}, + "typed map": map[string]string{"k": "v"}, + "non-text keys": map[int]uint64{1: 1}, + "channel": make(chan int), + "nested nil map": map[string]any{"k": nil}, + } { + t.Run(name, func(t *testing.T) { + wrappers := map[string]any{ + "direct": value, + "array": []any{value}, + "map": map[string]any{"nested": map[string]any{"k": value}}, + } + for position, wrapped := range wrappers { + _, err := EncodeBuilderCodeSuffix(BuilderCodeSuffixData{M: map[string]any{"k": wrapped}}) + if err == nil { + t.Fatalf("expected an error for %s (%s)", name, position) + } + } + }) + } +} + +func TestParseRejectsUnsupportedMetadata(t *testing.T) { + for name, cborHex := range map[string]string{ + "negative integer": "a1616d" + "a1616b20", + "byte string": "a1616d" + "a1616b4100", + "float": "a1616d" + "a1616bf90000", + "tag": "a1616d" + "a1616bc101", + "boolean": "a1616d" + "a1616bf5", + "indefinite-length array": "a1616d" + "a1616b9fff", + "non-text map key": "a1616d" + "a10100", + "truncated value": "a1616d" + "a1616b1b00", + "m that is not a map": "a1616d" + "01", + } { + t.Run(name, func(t *testing.T) { + length := len(cborHex) / 2 + calldata := "0xdeadbeef" + cborHex + hex.EncodeToString([]byte{byte(length >> 8), byte(length)}) + "02" + ERC_8021_MARKER + if _, ok := ParseBuilderCodeSuffixFromCalldata(calldata); ok { + t.Fatalf("expected %s to be rejected", name) + } + }) + } +} diff --git a/go/extensions/buildercode/facilitator.go b/go/extensions/buildercode/facilitator.go index fa0bee28c7..d598ce5461 100644 --- a/go/extensions/buildercode/facilitator.go +++ b/go/extensions/buildercode/facilitator.go @@ -39,12 +39,14 @@ func (e *BuilderCodeFacilitatorExtension) Key() string { // `s` is read for either version and `w` is the facilitator's own code when // configured. The facilitator's own `s` entry (ServiceCode) is appended after // the echoed client/server codes, within its own MAX_FACILITATOR_SERVICE_CODES -// reservation. Returns an error when ServiceCode is set but is not a valid -// builder code. Returns nil when no attribution is present. +// reservation. `m` is the settlement metadata supplied by the settling mechanism +// in ctx.Metadata and is never read from the client payload. Returns an error +// when ServiceCode is set but is not a valid builder code. Returns nil when no +// attribution or metadata is present. func (e *BuilderCodeFacilitatorExtension) BuildDataSuffix(ctx evm.DataSuffixContext) ([]byte, error) { clientExt := extractClientExtension(ctx.Payload.Extensions) - data := BuilderCodeExtensionData{} + data := BuilderCodeSuffixData{M: ctx.Metadata} if validateCode(e.BuilderCode) { data.W = e.BuilderCode } @@ -63,7 +65,7 @@ func (e *BuilderCodeFacilitatorExtension) BuildDataSuffix(ctx evm.DataSuffixCont } } - if data.A == "" && data.W == "" && len(data.S) == 0 { + if data.A == "" && data.W == "" && len(data.S) == 0 && len(data.M) == 0 { return nil, nil } diff --git a/go/extensions/buildercode/facilitator_test.go b/go/extensions/buildercode/facilitator_test.go index a0af73f638..ef8decf266 100644 --- a/go/extensions/buildercode/facilitator_test.go +++ b/go/extensions/buildercode/facilitator_test.go @@ -25,7 +25,7 @@ func suffixContext(info map[string]interface{}) evm.DataSuffixContext { // parsedFromFacilitator runs BuildDataSuffix with a configured wallet code and // parses attribution back out of synthetic calldata. -func parsedFromFacilitator(t *testing.T, ctx evm.DataSuffixContext) *BuilderCodeExtensionData { +func parsedFromFacilitator(t *testing.T, ctx evm.DataSuffixContext) *BuilderCodeSuffixData { t.Helper() ext := &BuilderCodeFacilitatorExtension{BuilderCode: walletCode} suffix, err := ext.BuildDataSuffix(ctx) @@ -221,3 +221,45 @@ func TestBuildDataSuffixIgnoresInvalidWalletCode(t *testing.T) { t.Fatalf("expected invalid wallet code dropped, got %+v", parsed) } } + +func TestBuildDataSuffixFromMetadataAlone(t *testing.T) { + ext := &BuilderCodeFacilitatorExtension{} + ctx := suffixContext(nil) + ctx.Metadata = map[string]any{"x402Example": uint64(7)} + + suffix, err := ext.BuildDataSuffix(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + parsed, ok := ParseBuilderCodeSuffixFromCalldata("0xdeadbeef" + hex.EncodeToString(suffix)) + if !ok { + t.Fatal("expected a valid suffix") + } + if !reflect.DeepEqual(parsed.M, ctx.Metadata) || parsed.A != "" || parsed.W != "" || len(parsed.S) != 0 { + t.Fatalf("expected metadata only, got %+v", parsed) + } +} + +func TestBuildDataSuffixNoSuffixForEmptyMetadata(t *testing.T) { + ext := &BuilderCodeFacilitatorExtension{} + ctx := suffixContext(nil) + ctx.Metadata = map[string]any{} + + suffix, err := ext.BuildDataSuffix(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if suffix != nil { + t.Fatalf("expected nil suffix, got %x", suffix) + } +} + +func TestBuildDataSuffixIgnoresMetadataInClientPayload(t *testing.T) { + parsed := parsedFromFacilitator(t, suffixContext(map[string]interface{}{ + "a": appCode, + "m": map[string]interface{}{"x402Example": uint64(1)}, + })) + if parsed.W != walletCode || parsed.A != appCode || parsed.M != nil { + t.Fatalf("expected client m to be ignored, got %+v", parsed) + } +} diff --git a/go/extensions/buildercode/types.go b/go/extensions/buildercode/types.go index 02f46c0568..0ddb2557cf 100644 --- a/go/extensions/buildercode/types.go +++ b/go/extensions/buildercode/types.go @@ -65,6 +65,15 @@ type BuilderCodeExtensionData struct { S []string `json:"s,omitempty"` } +// BuilderCodeSuffixData holds the fields present in a settlement calldata +// suffix: the extension fields plus the facilitator-authored metadata map `m`. +// Metadata values may be unsigned integers, strings, []any, and map[string]any +// (parsed unsigned integers are uint64). +type BuilderCodeSuffixData struct { + BuilderCodeExtensionData + M map[string]any `json:"m,omitempty"` +} + // validateCode reports whether code matches BUILDER_CODE_PATTERN. func validateCode(code string) bool { return BUILDER_CODE_PATTERN.MatchString(code) diff --git a/go/mechanisms/evm/auth-capture/README.md b/go/mechanisms/evm/auth-capture/README.md index 60b7c73306..9a66c6b7f9 100644 --- a/go/mechanisms/evm/auth-capture/README.md +++ b/go/mechanisms/evm/auth-capture/README.md @@ -9,6 +9,8 @@ See the [auth-capture EVM specification](https://github.com/x402-foundation/x402 | Role | Import | | ------ | --------------------------------------------------------------------------- | | Client | `github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/client` | +| Server | `github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/server` | +| Facilitator | `github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/facilitator` | ## Client Usage @@ -34,3 +36,136 @@ client.Register("eip155:*", authcaptureclient.NewAuthCaptureEvmScheme(signer)) `ClientEvmSigner` only needs `Address()` and `SignTypedData`; no RPC is required for payload construction. The client participates in the collect (`authorize` / `charge`) step only. Capture, void, and refund lifecycle payloads are server/facilitator responsibilities. + +## Server Usage + +The server publishes the escrow terms. The `Capture`, `Void`, `Charge` and `Refund` messages that let the facilitator release funds are signed by the receiver authorizer, which is either this server (a `ReceiverAuthorizerSigner`) or the facilitator; see [Receiver authorizer modes](#receiver-authorizer-modes). + +```go +import ( + authcaptureserver "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/server" +) + +receiverAuthorizer, _ := evmsigners.NewClientSignerFromPrivateKey(os.Getenv("RECEIVER_AUTHORIZER_PRIVATE_KEY")) + +scheme := authcaptureserver.NewAuthCaptureEvmScheme(&authcaptureserver.Config{ + ReceiverAuthorizerSigner: receiverAuthorizer, +}) +``` + +Each route's terms come from a route extra, then `Config`, then what the facilitator advertises in `/supported`. `AuthCaptureRouteExtra` is the typed form: pass `Map()` as the route's `Extra`. + +| Field | Values | Behaviour | +| --- | --- | --- | +| `PaymentFlow` | `escrow` (default), `authorization` | `authorization` settles a single `charge` after the handler runs. | +| `CaptureMode` | `sync` (default), `deferred` | `deferred` authorizes only and leaves capture to the `LifecycleManager`. Not allowed with `authorization`. | +| `OperatorType` | `delegated` (default), `custom` | `custom` names a contract as `CaptureAuthorizer`. The facilitator must admit it and it requires `deferred`. | +| `ReceiverAuthorizer` | address | Signer of facilitator-relayed `charge` / `capture` / `void` / `refund`, resolved per [Receiver authorizer modes](#receiver-authorizer-modes). The zero address means collect-only, valid only for `escrow` + `deferred`. It must be non-zero for every `authorization` route and for `escrow` + `sync`. | +| `CaptureAuthorizer`, `FeeRecipient`, `MinFeeBps`, `MaxFeeBps` | | Merchant-set terms. Invalid terms fail when the requirements are built. | +| `CaptureDeadline`, `RefundDeadline` | Unix seconds | Absolute deadlines. | +| `CaptureDeadlineSeconds`, `RefundDeadlineSeconds` | seconds | Offsets from issue time, added to the start of the current minute so 402s issued in the same minute match. | + +`captureDeadline` and `refundDeadline` are dynamic extra fields, so the core server ignores them when matching a payment to its requirements. Set both deadlines of one form, never a mix. `maxTimeoutSeconds` must not exceed the capture window. With no fee terms the server publishes the zero address and `0`/`0` bounds, and the facilitator rejects a zero recipient paired with a non-zero bound. + +### Settlement flow + +- `escrow` + `sync`: the handler runs after `authorize`. A success captures, a failure or cancel voids. +- `escrow` + `deferred`: the handler runs after `authorize` and nothing is captured. A missing record for the payment aborts the settle. +- `authorization`: the server completes the payload with the final amount, fee and signature after the handler runs, and the facilitator submits a single `charge`. +- Overriding the settled amount below the signed amount captures that part and signs a `Void` for the remainder. + +Fail-fast in `EnhancePaymentRequirements`: + +- an `authorization` route, or `escrow` + `sync`, with no non-zero `ReceiverAuthorizer` (none from the scheme signer, the route, or the facilitator's `/supported`; a route that sets the zero address explicitly is also rejected) +- a route `ReceiverAuthorizer` that conflicts with `ReceiverAuthorizerSigner.Address()` +- a non-zero route `ReceiverAuthorizer` that is neither the scheme signer's address nor the facilitator-advertised address (the server holds no key for it) + +Omitting `ReceiverAuthorizer` never implies collect-only: it resolves to the scheme signer or the facilitator-advertised authorizer, and fails when neither exists. + +### Receiver authorizer modes + +`ReceiverAuthorizer` is the address whose EIP-712 signature the escrow operator needs for `charge`, `capture`, `void`, and `refund`. Three modes exist, and the server picks one per route. + +| Mode | `ReceiverAuthorizer` | Who signs | Wire payloads | +| --- | --- | --- | --- | +| Self | The scheme's `ReceiverAuthorizerSigner.Address()` | The server | Signed (`authorizerSignature`, and `voidAuthorizerSignature` when a capture also voids) | +| Delegated | A non-zero address the server holds no key for, normally the one the facilitator advertises in `/supported` | The facilitator, after authenticating the caller | Unsigned: the server omits the signatures | +| Collect-only | The zero address | Nobody onchain (`escrow` + `deferred` only) | None: lifecycle runs out of band | + +The server resolves the mode in this order: + +1. A configured `ReceiverAuthorizerSigner` wins. A route value that differs from its address fails. +2. Otherwise a route value of the zero address is collect-only, and a route value equal to the facilitator-advertised address is delegated. Any other non-zero route value fails, because nobody could sign for it. +3. Otherwise the facilitator-advertised non-zero address is used (delegated). +4. Otherwise `EnhancePaymentRequirements` fails. Set a `ReceiverAuthorizerSigner`, set `ReceiverAuthorizer` to the zero address on an `escrow` + `deferred` route, or point the server at a facilitator that advertises one. + +In delegated mode the facilitator's signature no longer proves the server's intent, so the facilitator authenticates each settle out of band. See [Delegated receiver authorizer](#delegated-receiver-authorizer) for the facilitator side. + +`ValidateFacilitatorSupport` runs during `x402ResourceServer` initialization and fails fast when the facilitator omits `captureAuthorizer` or, for delegated receiver signing, `receiverAuthorizer`. Collect-only merchants whose routes always set `ReceiverAuthorizer` to the zero address may set `Config.CollectOnlyRoutes` to skip the receiver check. Route-specific mistakes (for example `escrow` + `sync` without any authorizer) still surface in `EnhancePaymentRequirements`. + +### Lifecycle manager + +`scheme.NewLifecycleManager(facilitator)` captures, voids and refunds payments recorded in `Config.Storage` (in memory by default, implement `AuthorizedPaymentStorage` for durability). `Capture` takes optional `CaptureOptions` (amount, fee, `VoidRemainder`). `Refund` needs the facilitator to run with `RefundFunding`. Payments on custom operators and `authorization` payments cannot be captured or voided through the manager. It supports self and delegated payments: delegated payloads are sent unsigned, with `voidRemainder: true` in place of `voidAuthorizerSignature`. It fails with `ErrLifecycleUnavailable` for a collect-only payment, whose lifecycle is out of band. + +## Facilitator Usage + +The facilitator is the delegated escrow operator: it verifies and settles the collect (`authorize`), then relays the `capture`, `void` or `refund` (signed by the server, or by the facilitator itself when the authorizer is delegated). It also submits the server-completed `charge` of an `authorization` flow. + +```go +import ( + authcapturefacilitator "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/facilitator" +) + +scheme, err := authcapturefacilitator.NewAuthCaptureEvmSchemeWithError(signer, authcapturefacilitator.AuthCaptureEvmSchemeConfig{ + CaptureAuthorizers: signer.GetAddresses(), +}) +``` + +`NewAuthCaptureEvmSchemeWithError` returns a misconfiguration as an error; `NewAuthCaptureEvmScheme` panics instead. + +`CaptureAuthorizers` is the pool of operator addresses, each one of the signer's addresses (`CaptureAuthorizer` is a one-element shorthand). Each `/supported` response advertises one pool member, picked at random or by `SelectCaptureAuthorizer`. That address is committed onchain as the payment's operator, so every pool member must stay usable, and funded when `RefundFunding` is on, until its payments pass `refundDeadline`. + +The escrow gates `authorize`, `capture` and `void` on `msg.sender`, so simulations and writes are sent from the operator through the signer's `ReadContractFrom` and `WriteContractFrom`. Simulation failures map to the spec's `invalid_auth_capture_evm_*` reasons. For counterfactual payers, list the wallet factories in `EIP6492AllowedFactories`; verification then simulates only the factory deployment, since the collect cannot be simulated before the wallet exists. + +### Delegated receiver authorizer + +To let servers omit their own authorizer key, configure all four fields together (a partial configuration is rejected): + +```go +scheme := authcapturefacilitator.NewAuthCaptureEvmScheme(signer, authcapturefacilitator.AuthCaptureEvmSchemeConfig{ + CaptureAuthorizers: signer.GetAddresses(), + AuthorizerSigner: authorizerSigner, // advertised in /supported as extra.receiverAuthorizer + // Return a stable identity for the caller (for example from an API key or mTLS client + // certificate). An empty string or an error rejects the settle. + ResolveCallerIdentity: func(ctx context.Context, settle authcapturefacilitator.DelegatedSettleContext) (string, error) { + return lookupMerchant(settle.Payload) + }, + DelegatedAuthStorage: store, // implements AuthCaptureDelegatedAuthStorage + OnStorageError: func(err error, network x402.Network, paymentInfoHash string) { + reportBindingFailure(err, network, paymentInfoHash) + }, + // RefundFunding: true, // needed to relay delegated refunds +}) +``` + +How the facilitator uses the identity: + +- `Verify` never resolves identity and never writes a binding. +- On `Settle` of an `authorize`, or of a `charge` with `RefundFunding`, the facilitator re-verifies, resolves the identity, binds it to the `paymentInfoHash` (with `ExpiresAt` set to `refundDeadline`), then broadcasts. If the bind fails (another identity already holds it, or the storage is down) nothing is broadcast and the settle fails with `invalid_auth_capture_evm_unauthenticated_authorizer_request` or `invalid_auth_capture_evm_delegated_auth_unavailable`. +- The binding is reverted only when this call created it and the send failed or the transaction reverted onchain. It is kept when the outcome is unknown, such as a receipt timeout. +- Each later `capture`, `void`, or `refund` must resolve to the bound identity before the facilitator signs. A missing, expired, or different binding is rejected. +- A confirmed settle that leaves nothing capturable or refundable deletes the binding early. This is best effort and failures are reported through `OnStorageError`. +- A capture that also voids the remainder carries `voidRemainder: true` instead of `voidAuthorizerSignature`; the facilitator signs both legs. + +Operational notes: + +- `InMemoryAuthCaptureDelegatedAuthStorage` is for local testing only. Use a durable store with an atomic insert-if-absent `Bind` when more than one process serves the same payments. +- Identity is first-writer-wins, so anyone who can present the same credential can drive that payment's lifecycle. Give each merchant its own credential. + +### Custom operators + +A custom operator is a contract that forwards to the escrow. The facilitator relays its collect only when listed in `Operators` (an entry with address `*` admits every custom operator) and the signer implements `CallSimulator` and `GasLimitWriter`. It simulates the call under `CustomOperatorGasLimit`, then checks the escrow event, the payment state and every balance delta before broadcasting, and again against the receipt after. It never relays `capture`, `void` or `refund` for a custom operator. When it supports custom operators, `/supported` advertises them in `extra.operators`. + +### Refund funding + +The refund collector pulls the refunded tokens from the payment's operator, any member of `CaptureAuthorizers`. Set `RefundFunding` only with an out-of-band funding agreement that keeps every advertised submitter funded and approved. Without it a delegated operator's refund is rejected with `invalid_auth_capture_evm_refund_funding_unavailable`. diff --git a/go/mechanisms/evm/auth-capture/abi_tuple.go b/go/mechanisms/evm/auth-capture/abi_tuple.go new file mode 100644 index 0000000000..480d4a9b63 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/abi_tuple.go @@ -0,0 +1,53 @@ +package authcapture + +import ( + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/common" +) + +// PaymentInfoAbiTuple is the go-ethereum abi-encodable form of the onchain +// PaymentInfo struct (field order and types must match paymentInfoTupleABI +// in constants.go exactly). +type PaymentInfoAbiTuple struct { + Operator common.Address + Payer common.Address + Receiver common.Address + Token common.Address + MaxAmount *big.Int + PreApprovalExpiry *big.Int + AuthorizationExpiry *big.Int + RefundExpiry *big.Int + MinFeeBps uint16 + MaxFeeBps uint16 + FeeReceiver common.Address + Salt *big.Int +} + +// ToAbiTuple converts a PaymentInfoStruct (wire/string form) into the +// go-ethereum abi-encodable tuple used for authorize/capture/void contract calls. +func (p PaymentInfoStruct) ToAbiTuple() (PaymentInfoAbiTuple, error) { + maxAmount, ok := new(big.Int).SetString(p.MaxAmount, 10) + if !ok { + return PaymentInfoAbiTuple{}, fmt.Errorf("invalid maxAmount: %s", p.MaxAmount) + } + saltBig, err := saltToBigInt(p.Salt) + if err != nil { + return PaymentInfoAbiTuple{}, err + } + return PaymentInfoAbiTuple{ + Operator: common.HexToAddress(p.Operator), + Payer: common.HexToAddress(p.Payer), + Receiver: common.HexToAddress(p.Receiver), + Token: common.HexToAddress(p.Token), + MaxAmount: maxAmount, + PreApprovalExpiry: new(big.Int).SetUint64(p.PreApprovalExpiry), + AuthorizationExpiry: new(big.Int).SetUint64(p.AuthorizationExpiry), + RefundExpiry: new(big.Int).SetUint64(p.RefundExpiry), + MinFeeBps: p.MinFeeBps, + MaxFeeBps: p.MaxFeeBps, + FeeReceiver: common.HexToAddress(p.FeeReceiver), + Salt: saltBig, + }, nil +} diff --git a/go/mechanisms/evm/auth-capture/client/scheme.go b/go/mechanisms/evm/auth-capture/client/scheme.go index 177f0ccf64..521c90d8d3 100644 --- a/go/mechanisms/evm/auth-capture/client/scheme.go +++ b/go/mechanisms/evm/auth-capture/client/scheme.go @@ -2,7 +2,6 @@ package client import ( "context" - "encoding/json" "fmt" "math/big" "time" @@ -46,7 +45,7 @@ func (c *AuthCaptureEvmScheme) CreatePaymentPayload( requirements types.PaymentRequirements, _ x402.PaymentPayloadContext, ) (types.PaymentPayload, error) { - extra, deployment, err := parseAuthCaptureExtra(requirements) + extra, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) if err != nil { return types.PaymentPayload{}, err } @@ -60,22 +59,14 @@ func (c *AuthCaptureEvmScheme) CreatePaymentPayload( return types.PaymentPayload{}, err } - maxAmount := requirements.Amount - nowSeconds := c.now().Unix() - preApprovalExpiry := uint64(nowSeconds + int64(requirements.MaxTimeoutSeconds)) - - assetTransferMethod := extra.AssetTransferMethod - if assetTransferMethod == "" { - assetTransferMethod = string(evm.AssetTransferMethodEIP3009) - } + preApprovalExpiry := uint64(c.now().Unix() + int64(requirements.MaxTimeoutSeconds)) bindOn := authcapture.IsSaltBindingOn(extra) saltNonce, err := authcapture.GenerateSalt() if err != nil { return types.PaymentPayload{}, err } - - var salt string + salt := saltNonce if bindOn { salt, err = authcapture.DeriveBoundSalt( authcapture.ExtraAddress(extra.ReceiverAuthorizer), @@ -85,256 +76,118 @@ func (c *AuthCaptureEvmScheme) CreatePaymentPayload( if err != nil { return types.PaymentPayload{}, err } - } else { - salt = saltNonce - } - - paymentInfo := authcapture.PaymentInfoStruct{ - Operator: extra.CaptureAuthorizer, - Payer: c.signer.Address(), - Receiver: requirements.PayTo, - Token: requirements.Asset, - MaxAmount: maxAmount, - PreApprovalExpiry: preApprovalExpiry, - AuthorizationExpiry: extra.CaptureDeadline, - RefundExpiry: extra.RefundDeadline, - MinFeeBps: extra.MinFeeBps, - MaxFeeBps: extra.MaxFeeBps, - FeeReceiver: extra.FeeRecipient, - Salt: salt, } + paymentInfo := authcapture.ReconstructPaymentInfo(c.signer.Address(), preApprovalExpiry, salt, requirements, extra) nonce, err := authcapture.ComputePayerAgnosticPaymentInfoHash(chainID, paymentInfo, deployment.Escrow) if err != nil { return types.PaymentPayload{}, err } - if assetTransferMethod == string(evm.AssetTransferMethodPermit2) { - return c.createPermit2Payload(ctx, requirements, bindOn, salt, saltNonce, nonce, preApprovalExpiry, chainID, deployment) + params := collectParams{ + requirements: requirements, + extra: extra, + deployment: deployment, + chainID: chainID, + nonce: nonce, + preApprovalExpiry: preApprovalExpiry, + salt: salt, } - - return c.createEIP3009Payload(ctx, requirements, extra, bindOn, salt, saltNonce, nonce, preApprovalExpiry, chainID, deployment) -} - -func (c *AuthCaptureEvmScheme) createEIP3009Payload( - ctx context.Context, - requirements types.PaymentRequirements, - extra authcapture.AuthCaptureExtra, - bindOn bool, - salt string, - saltNonce string, - nonce string, - preApprovalExpiry uint64, - chainID *big.Int, - deployment authcapture.AuthCaptureDeployment, -) (types.PaymentPayload, error) { - authorization := authcapture.Eip3009Authorization{ - From: c.signer.Address(), - To: deployment.EIP3009Collector, - Value: requirements.Amount, - ValidAfter: "0", - ValidBefore: fmt.Sprintf("%d", preApprovalExpiry), - Nonce: nonce, + if bindOn { + params.saltNonce = saltNonce } - signature, err := authcapture.SignERC3009(ctx, c.signer, authorization, extra, requirements.Asset, chainID) + var authorization map[string]interface{} + var signature []byte + if extra.AssetTransferMethod == string(evm.AssetTransferMethodPermit2) { + authorization, signature, err = c.signPermit2(ctx, params) + if err != nil { + return types.PaymentPayload{}, err + } + return params.payload("permit2Authorization", authorization, signature), nil + } + authorization, signature, err = c.signEIP3009(ctx, params) if err != nil { - return types.PaymentPayload{}, fmt.Errorf("failed to sign ERC-3009 authorization: %w", err) + return types.PaymentPayload{}, err } + return params.payload("authorization", authorization, signature), nil +} +// collectParams are the values shared by the EIP-3009 and Permit2 collect payloads. +// saltNonce is empty when the salt binding is off. +type collectParams struct { + requirements types.PaymentRequirements + extra authcapture.AuthCaptureExtra + deployment authcapture.AuthCaptureDeployment + chainID *big.Int + nonce string + preApprovalExpiry uint64 + salt string + saltNonce string +} + +func (p collectParams) payload(authorizationKey string, authorization map[string]interface{}, signature []byte) types.PaymentPayload { payload := map[string]interface{}{ - "authorization": map[string]interface{}{ - "from": authorization.From, - "to": authorization.To, - "value": authorization.Value, - "validAfter": authorization.ValidAfter, - "validBefore": authorization.ValidBefore, - "nonce": authorization.Nonce, - }, - "signature": evm.BytesToHex(signature), - "salt": salt, + authorizationKey: authorization, + "signature": evm.BytesToHex(signature), + "salt": p.salt, } - if bindOn { - payload["saltNonce"] = saltNonce + if p.saltNonce != "" { + payload["saltNonce"] = p.saltNonce } + return types.PaymentPayload{X402Version: 2, Payload: payload} +} - return types.PaymentPayload{ - X402Version: 2, - Payload: payload, - }, nil +func (c *AuthCaptureEvmScheme) signEIP3009(ctx context.Context, p collectParams) (map[string]interface{}, []byte, error) { + authorization := authcapture.Eip3009Authorization{ + From: c.signer.Address(), + To: p.deployment.EIP3009Collector, + Value: p.requirements.Amount, + ValidAfter: "0", + ValidBefore: fmt.Sprintf("%d", p.preApprovalExpiry), + Nonce: p.nonce, + } + signature, err := authcapture.SignERC3009(ctx, c.signer, authorization, p.extra, p.requirements.Asset, p.chainID) + if err != nil { + return nil, nil, fmt.Errorf("failed to sign ERC-3009 authorization: %w", err) + } + return map[string]interface{}{ + "from": authorization.From, + "to": authorization.To, + "value": authorization.Value, + "validAfter": authorization.ValidAfter, + "validBefore": authorization.ValidBefore, + "nonce": authorization.Nonce, + }, signature, nil } -func (c *AuthCaptureEvmScheme) createPermit2Payload( - ctx context.Context, - requirements types.PaymentRequirements, - bindOn bool, - salt string, - saltNonce string, - nonce string, - preApprovalExpiry uint64, - chainID *big.Int, - deployment authcapture.AuthCaptureDeployment, -) (types.PaymentPayload, error) { - permitNonce, err := authcapture.NonceHexToDecimalString(nonce) +func (c *AuthCaptureEvmScheme) signPermit2(ctx context.Context, p collectParams) (map[string]interface{}, []byte, error) { + permitNonce, err := authcapture.NonceHexToDecimalString(p.nonce) if err != nil { - return types.PaymentPayload{}, err + return nil, nil, err } - permit := authcapture.Permit2Authorization{ From: c.signer.Address(), Permitted: authcapture.Permit2TokenPermissions{ - Token: requirements.Asset, - Amount: requirements.Amount, + Token: p.requirements.Asset, + Amount: p.requirements.Amount, }, - Spender: deployment.Permit2Collector, + Spender: p.deployment.Permit2Collector, Nonce: permitNonce, - Deadline: fmt.Sprintf("%d", preApprovalExpiry), + Deadline: fmt.Sprintf("%d", p.preApprovalExpiry), } - - signature, err := authcapture.SignPermit2(ctx, c.signer, permit, chainID) + signature, err := authcapture.SignPermit2(ctx, c.signer, permit, p.chainID) if err != nil { - return types.PaymentPayload{}, fmt.Errorf("failed to sign Permit2 authorization: %w", err) + return nil, nil, fmt.Errorf("failed to sign Permit2 authorization: %w", err) } - - payload := map[string]interface{}{ - "permit2Authorization": map[string]interface{}{ - "from": permit.From, - "permitted": map[string]interface{}{ - "token": permit.Permitted.Token, - "amount": permit.Permitted.Amount, - }, - "spender": permit.Spender, - "nonce": permit.Nonce, - "deadline": permit.Deadline, + return map[string]interface{}{ + "from": permit.From, + "permitted": map[string]interface{}{ + "token": permit.Permitted.Token, + "amount": permit.Permitted.Amount, }, - "signature": evm.BytesToHex(signature), - "salt": salt, - } - if bindOn { - payload["saltNonce"] = saltNonce - } - - return types.PaymentPayload{ - X402Version: 2, - Payload: payload, - }, nil -} - -func parseAuthCaptureExtra(requirements types.PaymentRequirements) (authcapture.AuthCaptureExtra, authcapture.AuthCaptureDeployment, error) { - if requirements.Extra == nil { - return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'captureAuthorizer' is required in payment requirements extra") - } - ex := requirements.Extra - - name, _ := ex["name"].(string) - if name == "" { - return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("EIP-712 domain parameter 'name' is required in payment requirements for asset %s", requirements.Asset) - } - version, _ := ex["version"].(string) - if version == "" { - return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("EIP-712 domain parameter 'version' is required in payment requirements for asset %s", requirements.Asset) - } - - captureAuthorizer, _ := ex["captureAuthorizer"].(string) - if captureAuthorizer == "" { - return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'captureAuthorizer' is required in payment requirements extra") - } - feeRecipient, _ := ex["feeRecipient"].(string) - if feeRecipient == "" { - return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'feeRecipient' is required in payment requirements extra") - } - - captureDeadline, err := extraUint64(ex, "captureDeadline") - if err != nil { - return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'captureDeadline' is required in payment requirements extra") - } - refundDeadline, err := extraUint64(ex, "refundDeadline") - if err != nil { - return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'refundDeadline' is required in payment requirements extra") - } - minFeeBps, err := extraUint16(ex, "minFeeBps") - if err != nil { - return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'minFeeBps' is required in payment requirements extra") - } - maxFeeBps, err := extraUint16(ex, "maxFeeBps") - if err != nil { - return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("'maxFeeBps' is required in payment requirements extra") - } - - authCaptureEscrow := stringFromExtra(ex, "authCaptureEscrow") - deployment := authcapture.ResolveAuthCaptureDeployment(authCaptureEscrow) - if deployment == nil { - return authcapture.AuthCaptureExtra{}, authcapture.AuthCaptureDeployment{}, fmt.Errorf("invalid authCaptureEscrow in payment requirements extra") - } - - extraOut := authcapture.AuthCaptureExtra{ - CaptureAuthorizer: captureAuthorizer, - CaptureDeadline: captureDeadline, - RefundDeadline: refundDeadline, - FeeRecipient: feeRecipient, - MinFeeBps: minFeeBps, - MaxFeeBps: maxFeeBps, - Name: name, - Version: version, - ReceiverAuthorizer: stringFromExtra(ex, "receiverAuthorizer"), - Policy: stringFromExtra(ex, "policy"), - PaymentFlow: stringFromExtra(ex, "paymentFlow"), - CaptureMode: stringFromExtra(ex, "captureMode"), - OperatorType: stringFromExtra(ex, "operatorType"), - AssetTransferMethod: stringFromExtra(ex, "assetTransferMethod"), - AuthCaptureEscrow: deployment.Escrow, - } - return extraOut, *deployment, nil -} - -func stringFromExtra(ex map[string]interface{}, key string) string { - if v, ok := ex[key].(string); ok { - return v - } - return "" -} - -func extraUint64(ex map[string]interface{}, key string) (uint64, error) { - value, ok := ex[key] - if !ok { - return 0, fmt.Errorf("missing %s", key) - } - switch v := value.(type) { - case float64: - if v < 0 || v != float64(uint64(v)) { - return 0, fmt.Errorf("invalid %s", key) - } - return uint64(v), nil - case int: - if v < 0 { - return 0, fmt.Errorf("invalid %s", key) - } - return uint64(v), nil - case int64: - if v < 0 { - return 0, fmt.Errorf("invalid %s", key) - } - return uint64(v), nil - case uint64: - return v, nil - case json.Number: - n, err := v.Int64() - if err != nil || n < 0 { - return 0, fmt.Errorf("invalid %s", key) - } - return uint64(n), nil - default: - return 0, fmt.Errorf("invalid %s", key) - } -} - -func extraUint16(ex map[string]interface{}, key string) (uint16, error) { - n, err := extraUint64(ex, key) - if err != nil { - return 0, err - } - if n > 65535 { - return 0, fmt.Errorf("invalid %s", key) - } - return uint16(n), nil + "spender": permit.Spender, + "nonce": permit.Nonce, + "deadline": permit.Deadline, + }, signature, nil } diff --git a/go/mechanisms/evm/auth-capture/client/scheme_test.go b/go/mechanisms/evm/auth-capture/client/scheme_test.go index 821af4dfb5..5b4c84009e 100644 --- a/go/mechanisms/evm/auth-capture/client/scheme_test.go +++ b/go/mechanisms/evm/auth-capture/client/scheme_test.go @@ -60,13 +60,6 @@ func mockRequirements(extra map[string]interface{}) types.PaymentRequirements { } } -func TestAuthCaptureEvmScheme_Scheme(t *testing.T) { - scheme := NewAuthCaptureEvmScheme(&mockSigner{address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) - if scheme.Scheme() != authcapture.SchemeAuthCapture { - t.Fatalf("scheme = %q", scheme.Scheme()) - } -} - func TestCreatePaymentPayload_InvalidAuthCaptureEscrow(t *testing.T) { scheme := NewAuthCaptureEvmScheme(&mockSigner{address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) req := mockRequirements(map[string]interface{}{ diff --git a/go/mechanisms/evm/auth-capture/constants.go b/go/mechanisms/evm/auth-capture/constants.go index b09630788d..0762570e9d 100644 --- a/go/mechanisms/evm/auth-capture/constants.go +++ b/go/mechanisms/evm/auth-capture/constants.go @@ -1,6 +1,7 @@ package authcapture import ( + "math/big" "strings" "github.com/ethereum/go-ethereum/crypto" @@ -48,11 +49,26 @@ const ( // OperatorRefundCollectorAddress is the default operator refund collector (v1.1). OperatorRefundCollectorAddress = OperatorRefundCollectorV1_1Address + // ZeroAddress is the zero EVM address, used for an absent fee recipient or policy. + ZeroAddress = "0x0000000000000000000000000000000000000000" + saltBindingTypeString = "x402AuthCaptureSaltBinding(address receiverAuthorizer,address policy,uint256 saltNonce)" paymentInfoTypeString = "PaymentInfo(address operator,address payer,address receiver,address token,uint120 maxAmount,uint48 preApprovalExpiry,uint48 authorizationExpiry,uint48 refundExpiry,uint16 minFeeBps,uint16 maxFeeBps,address feeReceiver,uint256 salt)" ) +// Values of the extra.paymentFlow, extra.captureMode and extra.operatorType fields. +const ( + PaymentFlowEscrow = "escrow" + PaymentFlowAuthorization = "authorization" + + CaptureModeSync = "sync" + CaptureModeDeferred = "deferred" + + OperatorTypeDelegated = "delegated" + OperatorTypeCustom = "custom" +) + // AuthCaptureDeploymentVersion identifies a commerce-payments deployment set. type AuthCaptureDeploymentVersion string @@ -165,3 +181,294 @@ func GetPermit2TransferFromEIP712Types() map[string][]evm.TypedDataField { "TokenPermissions": Permit2TransferFromTypes["TokenPermissions"], } } + +// OperatorEIP712Domain is the name/version half of the operator EIP-712 domain; +// see OperatorDomain for the per-call chain and verifying contract. +var OperatorEIP712Domain = evm.TypedDataDomain{ + Name: "x402 Auth Capture Operator", + Version: "1", +} + +// CaptureTypesV1_1 defines EIP-712 types for the Capture operator signature on +// v1.1 deployments (absolute feeAmount). +var CaptureTypesV1_1 = map[string][]evm.TypedDataField{ + "Capture": { + {Name: "paymentInfoHash", Type: "bytes32"}, + {Name: "amount", Type: "uint256"}, + {Name: "feeAmount", Type: "uint256"}, + {Name: "feeReceiver", Type: "address"}, + {Name: "expectedCapturableAmount", Type: "uint256"}, + {Name: "expectedRefundableAmount", Type: "uint256"}, + }, +} + +// CaptureTypesV1_0 defines EIP-712 types for the Capture operator signature on +// v1.0 deployments (feeBps). +var CaptureTypesV1_0 = map[string][]evm.TypedDataField{ + "Capture": { + {Name: "paymentInfoHash", Type: "bytes32"}, + {Name: "amount", Type: "uint256"}, + {Name: "feeBps", Type: "uint16"}, + {Name: "feeReceiver", Type: "address"}, + {Name: "expectedCapturableAmount", Type: "uint256"}, + {Name: "expectedRefundableAmount", Type: "uint256"}, + }, +} + +// VoidTypes defines EIP-712 types for the Void operator signature (identical +// across v1.0 and v1.1 — void has no fee parameter). +var VoidTypes = map[string][]evm.TypedDataField{ + "Void": { + {Name: "paymentInfoHash", Type: "bytes32"}, + }, +} + +// RefundTypes defines EIP-712 types for the Refund operator signature (identical across +// v1.0 and v1.1; the refund collector is bound, the funding address is not). +var RefundTypes = map[string][]evm.TypedDataField{ + "Refund": { + {Name: "paymentInfoHash", Type: "bytes32"}, + {Name: "amount", Type: "uint256"}, + {Name: "tokenCollector", Type: "address"}, + {Name: "expectedCapturableAmount", Type: "uint256"}, + {Name: "expectedRefundableAmount", Type: "uint256"}, + }, +} + +// ChargeTypesV1_1 defines EIP-712 types for the Charge operator signature on v1.1 +// deployments (absolute feeAmount). collectorData is signed as its keccak256 hash. +var ChargeTypesV1_1 = map[string][]evm.TypedDataField{ + "Charge": { + {Name: "paymentInfoHash", Type: "bytes32"}, + {Name: "amount", Type: "uint256"}, + {Name: "tokenCollector", Type: "address"}, + {Name: "collectorDataHash", Type: "bytes32"}, + {Name: "feeAmount", Type: "uint256"}, + {Name: "feeReceiver", Type: "address"}, + }, +} + +// ChargeTypesV1_0 defines EIP-712 types for the Charge operator signature on v1.0 +// deployments (feeBps). +var ChargeTypesV1_0 = map[string][]evm.TypedDataField{ + "Charge": { + {Name: "paymentInfoHash", Type: "bytes32"}, + {Name: "amount", Type: "uint256"}, + {Name: "tokenCollector", Type: "address"}, + {Name: "collectorDataHash", Type: "bytes32"}, + {Name: "feeBps", Type: "uint16"}, + {Name: "feeReceiver", Type: "address"}, + }, +} + +// DefaultCustomOperatorGasLimit caps the gas of a relayed call into a custom operator. +const DefaultCustomOperatorGasLimit uint64 = 1_000_000 + +// CaptureTypesForDeployment returns the Capture EIP-712 types matching the +// deployment's fee encoding (feeBps for v1.0, feeAmount for v1.1). +func CaptureTypesForDeployment(deployment *AuthCaptureDeployment) map[string][]evm.TypedDataField { + if deployment != nil && deployment.Version == AuthCaptureDeploymentV1_0 { + return CaptureTypesV1_0 + } + return CaptureTypesV1_1 +} + +// ChargeTypesForDeployment returns the Charge EIP-712 types matching the +// deployment's fee encoding (feeBps for v1.0, feeAmount for v1.1). +func ChargeTypesForDeployment(deployment *AuthCaptureDeployment) map[string][]evm.TypedDataField { + if deployment != nil && deployment.Version == AuthCaptureDeploymentV1_0 { + return ChargeTypesV1_0 + } + return ChargeTypesV1_1 +} + +// BpsDenominator is the basis-point denominator; fee bounds are at most this value. +const BpsDenominator = 10000 + +// FeeAmountFromBps computes the absolute fee for amount at feeBps, using the +// escrow's truncating integer division. +func FeeAmountFromBps(amount *big.Int, feeBps uint16) *big.Int { + fee := new(big.Int).Mul(amount, big.NewInt(int64(feeBps))) + return fee.Div(fee, big.NewInt(BpsDenominator)) +} + +// paymentInfoTupleABI is the AuthCaptureEscrow.PaymentInfo tuple shared by the function fragments. +const paymentInfoTupleABI = `{"name": "paymentInfo", "type": "tuple", "components": [ + {"name": "operator", "type": "address"}, + {"name": "payer", "type": "address"}, + {"name": "receiver", "type": "address"}, + {"name": "token", "type": "address"}, + {"name": "maxAmount", "type": "uint120"}, + {"name": "preApprovalExpiry", "type": "uint48"}, + {"name": "authorizationExpiry", "type": "uint48"}, + {"name": "refundExpiry", "type": "uint48"}, + {"name": "minFeeBps", "type": "uint16"}, + {"name": "maxFeeBps", "type": "uint16"}, + {"name": "feeReceiver", "type": "address"}, + {"name": "salt", "type": "uint256"} +]}` + +// escrowCommonABI holds the version-independent escrow functions and custom errors. +// authorize and void are gated onchain by the operator; the receiver authorizer +// signature is checked off-chain before the call and is not a contract argument. +const escrowCommonABI = ` + { + "name": "authorize", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + ` + paymentInfoTupleABI + `, + {"name": "amount", "type": "uint256"}, + {"name": "tokenCollector", "type": "address"}, + {"name": "collectorData", "type": "bytes"} + ], + "outputs": [] + }, + { + "name": "void", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + ` + paymentInfoTupleABI + ` + ], + "outputs": [] + }, + { + "name": "refund", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + ` + paymentInfoTupleABI + `, + {"name": "amount", "type": "uint256"}, + {"name": "tokenCollector", "type": "address"}, + {"name": "collectorData", "type": "bytes"} + ], + "outputs": [] + }, + { + "name": "getTokenStore", + "type": "function", + "stateMutability": "view", + "inputs": [{"name": "operator", "type": "address"}], + "outputs": [{"name": "", "type": "address"}] + }, + { + "name": "PaymentAuthorized", + "type": "event", + "anonymous": false, + "inputs": [ + {"name": "paymentInfoHash", "type": "bytes32", "indexed": true}, + ` + paymentInfoTupleABI + `, + {"name": "amount", "type": "uint256", "indexed": false}, + {"name": "tokenCollector", "type": "address", "indexed": false} + ] + }, + { + "name": "paymentState", + "type": "function", + "stateMutability": "view", + "inputs": [ + {"name": "paymentInfoHash", "type": "bytes32"} + ], + "outputs": [ + {"name": "hasCollectedPayment", "type": "bool"}, + {"name": "capturableAmount", "type": "uint120"}, + {"name": "refundableAmount", "type": "uint120"} + ] + }, + {"name": "InvalidSender", "type": "error", "inputs": [{"name": "sender", "type": "address"}, {"name": "expected", "type": "address"}]}, + {"name": "ZeroAmount", "type": "error", "inputs": []}, + {"name": "AmountOverflow", "type": "error", "inputs": [{"name": "amount", "type": "uint256"}, {"name": "limit", "type": "uint256"}]}, + {"name": "ExceedsMaxAmount", "type": "error", "inputs": [{"name": "amount", "type": "uint256"}, {"name": "maxAmount", "type": "uint256"}]}, + {"name": "AfterPreApprovalExpiry", "type": "error", "inputs": [{"name": "timestamp", "type": "uint48"}, {"name": "expiry", "type": "uint48"}]}, + {"name": "InvalidExpiries", "type": "error", "inputs": [{"name": "preApproval", "type": "uint48"}, {"name": "authorization", "type": "uint48"}, {"name": "refund", "type": "uint48"}]}, + {"name": "FeeBpsOverflow", "type": "error", "inputs": [{"name": "feeBps", "type": "uint16"}]}, + {"name": "InvalidFeeBpsRange", "type": "error", "inputs": [{"name": "minFeeBps", "type": "uint16"}, {"name": "maxFeeBps", "type": "uint16"}]}, + {"name": "ZeroFeeReceiver", "type": "error", "inputs": []}, + {"name": "InvalidFeeReceiver", "type": "error", "inputs": [{"name": "attempted", "type": "address"}, {"name": "expected", "type": "address"}]}, + {"name": "InvalidCollectorForOperation", "type": "error", "inputs": []}, + {"name": "TokenCollectionFailed", "type": "error", "inputs": []}, + {"name": "PaymentAlreadyCollected", "type": "error", "inputs": [{"name": "paymentInfoHash", "type": "bytes32"}]}, + {"name": "AfterAuthorizationExpiry", "type": "error", "inputs": [{"name": "timestamp", "type": "uint48"}, {"name": "expiry", "type": "uint48"}]}, + {"name": "InsufficientAuthorization", "type": "error", "inputs": [{"name": "paymentInfoHash", "type": "bytes32"}, {"name": "authorizedAmount", "type": "uint256"}, {"name": "requestedAmount", "type": "uint256"}]}, + {"name": "ZeroAuthorization", "type": "error", "inputs": [{"name": "paymentInfoHash", "type": "bytes32"}]}, + {"name": "AfterRefundExpiry", "type": "error", "inputs": [{"name": "timestamp", "type": "uint48"}, {"name": "expiry", "type": "uint48"}]}, + {"name": "RefundExceedsCapture", "type": "error", "inputs": [{"name": "refund", "type": "uint256"}, {"name": "captured", "type": "uint256"}]} +` + +// chargeAndEventABI builds the version-specific charge function and PaymentCharged event; +// feeType and feeName are uint256 feeAmount on v1.1 and uint16 feeBps on v1.0. +func chargeAndEventABI(feeType, feeName string) string { + return `{ + "name": "charge", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + ` + paymentInfoTupleABI + `, + {"name": "amount", "type": "uint256"}, + {"name": "tokenCollector", "type": "address"}, + {"name": "collectorData", "type": "bytes"}, + {"name": "` + feeName + `", "type": "` + feeType + `"}, + {"name": "feeReceiver", "type": "address"} + ], + "outputs": [] + }, + { + "name": "PaymentCharged", + "type": "event", + "anonymous": false, + "inputs": [ + {"name": "paymentInfoHash", "type": "bytes32", "indexed": true}, + ` + paymentInfoTupleABI + `, + {"name": "amount", "type": "uint256", "indexed": false}, + {"name": "tokenCollector", "type": "address", "indexed": false}, + {"name": "` + feeName + `", "type": "` + feeType + `", "indexed": false}, + {"name": "feeReceiver", "type": "address", "indexed": false} + ] + }` +} + +// AuthCaptureEscrowABIV1_1 is the escrow ABI for v1.1 deployments (absolute feeAmount on charge and capture). +var AuthCaptureEscrowABIV1_1 = []byte(`[` + escrowCommonABI + `, + ` + chargeAndEventABI("uint256", "feeAmount") + `, + { + "name": "capture", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + ` + paymentInfoTupleABI + `, + {"name": "amount", "type": "uint256"}, + {"name": "feeAmount", "type": "uint256"}, + {"name": "feeReceiver", "type": "address"} + ], + "outputs": [] + }, + {"name": "FeeAmountOutOfRange", "type": "error", "inputs": [{"name": "feeAmount", "type": "uint256"}, {"name": "minFee", "type": "uint256"}, {"name": "maxFee", "type": "uint256"}]} +]`) + +// AuthCaptureEscrowABIV1_0 is the escrow ABI for v1.0 deployments (feeBps on charge and capture). +var AuthCaptureEscrowABIV1_0 = []byte(`[` + escrowCommonABI + `, + ` + chargeAndEventABI("uint16", "feeBps") + `, + { + "name": "capture", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + ` + paymentInfoTupleABI + `, + {"name": "amount", "type": "uint256"}, + {"name": "feeBps", "type": "uint16"}, + {"name": "feeReceiver", "type": "address"} + ], + "outputs": [] + }, + {"name": "FeeBpsOutOfRange", "type": "error", "inputs": [{"name": "feeBps", "type": "uint16"}, {"name": "minFeeBps", "type": "uint16"}, {"name": "maxFeeBps", "type": "uint16"}]} +]`) + +// EscrowABIForDeployment returns the AuthCaptureEscrow ABI matching the +// deployment's capture fee encoding (feeBps for v1.0, feeAmount for v1.1). +func EscrowABIForDeployment(deployment *AuthCaptureDeployment) []byte { + if deployment != nil && deployment.Version == AuthCaptureDeploymentV1_0 { + return AuthCaptureEscrowABIV1_0 + } + return AuthCaptureEscrowABIV1_1 +} diff --git a/go/mechanisms/evm/auth-capture/extra.go b/go/mechanisms/evm/auth-capture/extra.go new file mode 100644 index 0000000000..67b6cfc139 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/extra.go @@ -0,0 +1,195 @@ +package authcapture + +import ( + "encoding/json" + "fmt" + "math" + "strings" + + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + "github.com/x402-foundation/x402/go/v2/types" +) + +// ParseAuthCaptureExtra validates requirements.Extra and returns it with the resolved +// commerce-payments deployment. Shared by the client, server and facilitator. +func ParseAuthCaptureExtra(requirements types.PaymentRequirements) (AuthCaptureExtra, AuthCaptureDeployment, error) { + ex := requirements.Extra + if ex == nil { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("'captureAuthorizer' is required in payment requirements extra") + } + + assetTransferMethod := stringFromExtra(ex, "assetTransferMethod") + name, _ := ex["name"].(string) + version, _ := ex["version"].(string) + // Only the EIP-3009 signature is bound to the token's EIP-712 domain. + if assetTransferMethod != string(evm.AssetTransferMethodPermit2) { + if name == "" { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("EIP-712 domain parameter 'name' is required in payment requirements for asset %s", requirements.Asset) + } + if version == "" { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("EIP-712 domain parameter 'version' is required in payment requirements for asset %s", requirements.Asset) + } + } + + captureAuthorizer, _ := ex["captureAuthorizer"].(string) + if captureAuthorizer == "" { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("'captureAuthorizer' is required in payment requirements extra") + } + feeRecipient, _ := ex["feeRecipient"].(string) + if feeRecipient == "" { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("'feeRecipient' is required in payment requirements extra") + } + + captureDeadline, err := extraUint64(ex, "captureDeadline") + if err != nil { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("'captureDeadline' is required in payment requirements extra") + } + refundDeadline, err := extraUint64(ex, "refundDeadline") + if err != nil { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("'refundDeadline' is required in payment requirements extra") + } + minFeeBps, err := extraUint16(ex, "minFeeBps") + if err != nil { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("'minFeeBps' is required in payment requirements extra") + } + maxFeeBps, err := extraUint16(ex, "maxFeeBps") + if err != nil { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("'maxFeeBps' is required in payment requirements extra") + } + + deployment := ResolveAuthCaptureDeployment(stringFromExtra(ex, "authCaptureEscrow")) + if deployment == nil { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("invalid authCaptureEscrow in payment requirements extra") + } + + captureMode := stringFromExtra(ex, "captureMode") + if captureMode != "" && captureMode != CaptureModeSync && captureMode != CaptureModeDeferred { + return AuthCaptureExtra{}, AuthCaptureDeployment{}, fmt.Errorf("invalid captureMode %q", captureMode) + } + + autoCapture, _ := ex["autoCapture"].(bool) + return AuthCaptureExtra{ + CaptureAuthorizer: captureAuthorizer, + CaptureDeadline: captureDeadline, + RefundDeadline: refundDeadline, + FeeRecipient: feeRecipient, + MinFeeBps: minFeeBps, + MaxFeeBps: maxFeeBps, + Name: name, + Version: version, + ReceiverAuthorizer: stringFromExtra(ex, "receiverAuthorizer"), + Policy: stringFromExtra(ex, "policy"), + PaymentFlow: stringFromExtra(ex, "paymentFlow"), + CaptureMode: captureMode, + AutoCapture: autoCapture, + OperatorType: stringFromExtra(ex, "operatorType"), + AssetTransferMethod: assetTransferMethod, + AuthCaptureEscrow: deployment.Escrow, + }, *deployment, nil +} + +// ReconstructPaymentInfo rebuilds the onchain PaymentInfo from the payload-derived +// payer, preApprovalExpiry and salt plus the server-published requirements and extra. +func ReconstructPaymentInfo( + payer string, + preApprovalExpiry uint64, + salt string, + requirements types.PaymentRequirements, + extra AuthCaptureExtra, +) PaymentInfoStruct { + return PaymentInfoStruct{ + Operator: extra.CaptureAuthorizer, + Payer: payer, + Receiver: requirements.PayTo, + Token: requirements.Asset, + MaxAmount: requirements.Amount, + PreApprovalExpiry: preApprovalExpiry, + AuthorizationExpiry: extra.CaptureDeadline, + RefundExpiry: extra.RefundDeadline, + MinFeeBps: extra.MinFeeBps, + MaxFeeBps: extra.MaxFeeBps, + FeeReceiver: extra.FeeRecipient, + Salt: salt, + } +} + +// JSONNumberToUint64 converts a decoded JSON or in-process number to uint64, +// rejecting negative and fractional values. +func JSONNumberToUint64(value interface{}) (uint64, bool) { + switch v := value.(type) { + case float64: + if v < 0 || v != math.Trunc(v) || v > math.MaxUint64 { + return 0, false + } + return uint64(v), true + case int: + return nonNegative(int64(v)) + case int32: + return nonNegative(int64(v)) + case int64: + return nonNegative(v) + case uint16: + return uint64(v), true + case uint32: + return uint64(v), true + case uint64: + return v, true + case json.Number: + n, err := v.Int64() + if err != nil { + return 0, false + } + return nonNegative(n) + default: + return 0, false + } +} + +// JSONNumberToUint16 is JSONNumberToUint64 restricted to the uint16 range. +func JSONNumberToUint16(value interface{}) (uint16, bool) { + n, ok := JSONNumberToUint64(value) + if !ok || n > math.MaxUint16 { + return 0, false + } + return uint16(n), true +} + +func nonNegative(n int64) (uint64, bool) { + if n < 0 { + return 0, false + } + return uint64(n), true +} + +func stringFromExtra(ex map[string]interface{}, key string) string { + v, _ := ex[key].(string) + return v +} + +func extraUint64(ex map[string]interface{}, key string) (uint64, error) { + n, ok := JSONNumberToUint64(ex[key]) + if !ok { + return 0, fmt.Errorf("missing or invalid %s", key) + } + return n, nil +} + +func extraUint16(ex map[string]interface{}, key string) (uint16, error) { + n, ok := JSONNumberToUint16(ex[key]) + if !ok { + return 0, fmt.Errorf("missing or invalid %s", key) + } + return n, nil +} + +// AuthorizerModeFor selects the authorizer mode for a resolved receiverAuthorizer: the zero +// address is collect-only, the signer's own address is self, and any other address is delegated. +func AuthorizerModeFor(receiverAuthorizer string, signer evm.ClientEvmSigner) AuthorizerMode { + if !IsNonZeroAddress(receiverAuthorizer) { + return AuthorizerModeCollectOnly + } + if signer != nil && strings.EqualFold(evm.NormalizeAddress(signer.Address()), evm.NormalizeAddress(receiverAuthorizer)) { + return AuthorizerModeSelf + } + return AuthorizerModeDelegated +} diff --git a/go/mechanisms/evm/auth-capture/extra_test.go b/go/mechanisms/evm/auth-capture/extra_test.go new file mode 100644 index 0000000000..1482cb3eeb --- /dev/null +++ b/go/mechanisms/evm/auth-capture/extra_test.go @@ -0,0 +1,164 @@ +package authcapture + +import ( + "encoding/json" + "math" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/x402-foundation/x402/go/v2/types" +) + +func validExtra() map[string]interface{} { + return map[string]interface{}{ + "name": "USDC", + "version": "2", + "captureAuthorizer": "0x1111111111111111111111111111111111111111", + "feeRecipient": "0x4444444444444444444444444444444444444444", + "minFeeBps": float64(0), + "maxFeeBps": float64(100), + "captureDeadline": float64(4102444800), + "refundDeadline": float64(4102531200), + } +} + +func TestParseAuthCaptureExtra(t *testing.T) { + requirements := types.PaymentRequirements{Asset: "0x3333333333333333333333333333333333333333", Extra: validExtra()} + + extra, deployment, err := ParseAuthCaptureExtra(requirements) + require.NoError(t, err) + assert.Equal(t, AuthCaptureDeploymentV1_1, deployment.Version) + assert.Equal(t, deployment.Escrow, extra.AuthCaptureEscrow) + assert.Equal(t, uint16(100), extra.MaxFeeBps) + assert.Equal(t, uint64(4102444800), extra.CaptureDeadline) + assert.False(t, extra.AutoCapture) + + requirements.Extra["authCaptureEscrow"] = AuthCaptureEscrowV1_0Address + requirements.Extra["autoCapture"] = true + requirements.Extra["receiverAuthorizer"] = "0x5555555555555555555555555555555555555555" + extra, deployment, err = ParseAuthCaptureExtra(requirements) + require.NoError(t, err) + assert.Equal(t, AuthCaptureDeploymentV1_0, deployment.Version) + assert.True(t, extra.AutoCapture) + assert.Equal(t, "0x5555555555555555555555555555555555555555", extra.ReceiverAuthorizer) +} + +func TestParseAuthCaptureExtra_Permit2DoesNotNeedTokenDomain(t *testing.T) { + extra := validExtra() + delete(extra, "name") + delete(extra, "version") + + _, _, err := ParseAuthCaptureExtra(types.PaymentRequirements{Extra: extra}) + require.Error(t, err, "an EIP-3009 requirement still needs the domain") + + extra["assetTransferMethod"] = "permit2" + parsed, _, err := ParseAuthCaptureExtra(types.PaymentRequirements{Extra: extra}) + require.NoError(t, err) + assert.Equal(t, "permit2", parsed.AssetTransferMethod) + assert.Empty(t, parsed.Name) +} + +func TestParseAuthCaptureExtra_Rejects(t *testing.T) { + tests := []struct { + name string + mutate func(map[string]interface{}) + want string + }{ + {"no name", func(e map[string]interface{}) { delete(e, "name") }, "'name'"}, + {"no version", func(e map[string]interface{}) { delete(e, "version") }, "'version'"}, + {"no captureAuthorizer", func(e map[string]interface{}) { delete(e, "captureAuthorizer") }, "captureAuthorizer"}, + {"no feeRecipient", func(e map[string]interface{}) { delete(e, "feeRecipient") }, "feeRecipient"}, + {"fractional captureDeadline", func(e map[string]interface{}) { e["captureDeadline"] = 1.5 }, "captureDeadline"}, + {"negative refundDeadline", func(e map[string]interface{}) { e["refundDeadline"] = float64(-1) }, "refundDeadline"}, + {"oversized minFeeBps", func(e map[string]interface{}) { e["minFeeBps"] = float64(70000) }, "minFeeBps"}, + {"missing maxFeeBps", func(e map[string]interface{}) { delete(e, "maxFeeBps") }, "maxFeeBps"}, + {"unknown escrow", func(e map[string]interface{}) { e["authCaptureEscrow"] = "0x9999999999999999999999999999999999999999" }, "authCaptureEscrow"}, + {"unknown captureMode", func(e map[string]interface{}) { e["captureMode"] = "lazy" }, "captureMode"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + extra := validExtra() + tc.mutate(extra) + _, _, err := ParseAuthCaptureExtra(types.PaymentRequirements{Extra: extra}) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.want) + }) + } + + _, _, err := ParseAuthCaptureExtra(types.PaymentRequirements{}) + require.Error(t, err) +} + +func TestReconstructPaymentInfo(t *testing.T) { + requirements := types.PaymentRequirements{ + PayTo: "0x2222222222222222222222222222222222222222", + Asset: "0x3333333333333333333333333333333333333333", + Amount: "1000000", + Extra: validExtra(), + } + extra, _, err := ParseAuthCaptureExtra(requirements) + require.NoError(t, err) + + info := ReconstructPaymentInfo("0xpayer", 42, "0xsalt", requirements, extra) + + assert.Equal(t, PaymentInfoStruct{ + Operator: extra.CaptureAuthorizer, + Payer: "0xpayer", + Receiver: requirements.PayTo, + Token: requirements.Asset, + MaxAmount: "1000000", + PreApprovalExpiry: 42, + AuthorizationExpiry: extra.CaptureDeadline, + RefundExpiry: extra.RefundDeadline, + MinFeeBps: 0, + MaxFeeBps: 100, + FeeReceiver: extra.FeeRecipient, + Salt: "0xsalt", + }, info) +} + +func TestJSONNumberToUint64(t *testing.T) { + valid := map[string]interface{}{ + "float64": float64(7), "int": 7, "int32": int32(7), "int64": int64(7), + "uint16": uint16(7), "uint32": uint32(7), "uint64": uint64(7), "json.Number": json.Number("7"), + } + for name, value := range valid { + n, ok := JSONNumberToUint64(value) + assert.True(t, ok, name) + assert.Equal(t, uint64(7), n, name) + } + + invalid := map[string]interface{}{ + "fractional": 1.5, "negative float": float64(-1), "overflow": math.Pow(2, 70), + "negative int": -1, "negative int32": int32(-1), "negative int64": int64(-1), + "negative json.Number": json.Number("-1"), "fractional json.Number": json.Number("1.5"), + "string": "7", "nil": nil, + } + for name, value := range invalid { + _, ok := JSONNumberToUint64(value) + assert.False(t, ok, name) + } +} + +func TestJSONNumberToUint16(t *testing.T) { + n, ok := JSONNumberToUint16(float64(10000)) + assert.True(t, ok) + assert.Equal(t, uint16(10000), n) + + _, ok = JSONNumberToUint16(float64(65536)) + assert.False(t, ok) +} + +func TestParseAuthCaptureExtra_CaptureMode(t *testing.T) { + for _, mode := range []string{"", CaptureModeSync, CaptureModeDeferred} { + extra := validExtra() + if mode != "" { + extra["captureMode"] = mode + } + parsed, _, err := ParseAuthCaptureExtra(types.PaymentRequirements{Extra: extra}) + require.NoError(t, err) + assert.Equal(t, mode, parsed.CaptureMode) + } +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/capture_authorizer_test.go b/go/mechanisms/evm/auth-capture/facilitator/capture_authorizer_test.go new file mode 100644 index 0000000000..79f2ea1d49 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/capture_authorizer_test.go @@ -0,0 +1,326 @@ +package facilitator + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" +) + +var ( + poolAuthorizerA = "0x" + strings.Repeat("a1", 20) + poolAuthorizerB = "0x" + strings.Repeat("b2", 20) + poolAuthorizerC = "0x" + strings.Repeat("c3", 20) +) + +func TestWriteIsPinnedToTheOperator(t *testing.T) { + t.Run("a multi-address signer writes as the operator", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + signer.addresses = []string{poolAuthorizerA, facCaptureAuthorizer} + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + + resp, err := scheme.Settle(context.Background(), fx.payload(fx.buildCapture(t, captureOpts{})), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, []string{facCaptureAuthorizer}, signer.writeFroms) + assert.Equal(t, []string{"capture"}, signer.writtenFunctions) + }) + + t.Run("a capture with a void pins both writes", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + signer.addresses = []string{poolAuthorizerA, facCaptureAuthorizer} + signer.afterWrite = func(function string) { + if function == "capture" { + signer.paymentStateCapturable.SetInt64(400000) + } + } + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + + _, err := scheme.Settle(context.Background(), fx.payload(fx.buildCapture(t, captureOpts{amount: "600000", withVoid: true})), fx.requirements, nil) + require.NoError(t, err) + assert.Equal(t, []string{facCaptureAuthorizer, facCaptureAuthorizer}, signer.writeFroms) + }) + + t.Run("a collect is pinned too", func(t *testing.T) { + requirements := facBaseRequirements(facCaptureAuthorizer, nil) + payload := buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}) + signer := newMockFacSigner(poolAuthorizerA, facCaptureAuthorizer) + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + + resp, err := scheme.Settle(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, []string{facCaptureAuthorizer}, signer.writeFroms) + }) +} + +func TestNewAuthCaptureEvmSchemeWithError(t *testing.T) { + multi := func() *mockFacSigner { return newMockFacSigner(poolAuthorizerA, poolAuthorizerB) } + + t.Run("accepts a pool of signer addresses", func(t *testing.T) { + scheme, err := NewAuthCaptureEvmSchemeWithError(multi(), AuthCaptureEvmSchemeConfig{CaptureAuthorizers: []string{poolAuthorizerA, poolAuthorizerB}}) + require.NoError(t, err) + assert.NotNil(t, scheme) + }) + + t.Run("accepts a relay-only multi-address signer without a pool", func(t *testing.T) { + plain := &plainSigner{Signer: multi()} + _, err := NewAuthCaptureEvmSchemeWithError(plain, AuthCaptureEvmSchemeConfig{Operators: allowAllCustomOperators}) + require.NoError(t, err) + }) + + t.Run("accepts a single-address signer without the sender capabilities", func(t *testing.T) { + plain := &plainSigner{Signer: newMockFacSigner(poolAuthorizerA)} + _, err := NewAuthCaptureEvmSchemeWithError(plain, AuthCaptureEvmSchemeConfig{CaptureAuthorizer: poolAuthorizerA}) + require.NoError(t, err) + }) + + t.Run("rejects a pool member the signer does not hold", func(t *testing.T) { + _, err := NewAuthCaptureEvmSchemeWithError(multi(), AuthCaptureEvmSchemeConfig{CaptureAuthorizers: []string{poolAuthorizerA, poolAuthorizerC}}) + require.ErrorContains(t, err, "not one of the signer's addresses") + }) + + t.Run("rejects a shorthand outside the pool", func(t *testing.T) { + _, err := NewAuthCaptureEvmSchemeWithError(multi(), AuthCaptureEvmSchemeConfig{ + CaptureAuthorizers: []string{poolAuthorizerA}, CaptureAuthorizer: poolAuthorizerB, + }) + require.ErrorContains(t, err, "member of CaptureAuthorizers") + }) + + t.Run("rejects an invalid pool entry", func(t *testing.T) { + _, err := NewAuthCaptureEvmSchemeWithError(multi(), AuthCaptureEvmSchemeConfig{CaptureAuthorizers: []string{poolAuthorizerA, ""}}) + require.ErrorContains(t, err, "CaptureAuthorizers contains") + }) + + t.Run("reports partial delegation as an error and the wrapper panics with the same message", func(t *testing.T) { + config := AuthCaptureEvmSchemeConfig{ResolveCallerIdentity: func(context.Context, DelegatedSettleContext) (string, error) { return "", nil }} + _, err := NewAuthCaptureEvmSchemeWithError(multi(), config) + require.EqualError(t, err, "facilitator-delegated receiver authorization requires AuthorizerSigner") + assert.PanicsWithValue(t, err.Error(), func() { NewAuthCaptureEvmScheme(multi(), config) }) + }) +} + +func TestGetExtraCaptureAuthorizerPool(t *testing.T) { + pool := []string{poolAuthorizerA, poolAuthorizerB, poolAuthorizerC} + signer := newMockFacSigner(pool...) + advertised := func(scheme *AuthCaptureEvmScheme) string { + value, _ := scheme.GetExtra(facNetwork)["captureAuthorizer"].(string) + return value + } + + t.Run("only returns pool members and eventually every one", func(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(signer, AuthCaptureEvmSchemeConfig{CaptureAuthorizers: pool}) + seen := map[string]bool{} + for i := 0; i < 300; i++ { + got := advertised(scheme) + require.Contains(t, pool, got) + seen[got] = true + } + assert.Len(t, seen, len(pool)) + }) + + t.Run("honors the selector and passes the network and candidates", func(t *testing.T) { + var gotNetwork x402.Network + var gotCandidates []string + scheme := NewAuthCaptureEvmScheme(signer, AuthCaptureEvmSchemeConfig{ + CaptureAuthorizers: pool, + SelectCaptureAuthorizer: func(network x402.Network, candidates []string) string { + gotNetwork, gotCandidates = network, candidates + candidates[0] = "mutated" + return strings.ToUpper(poolAuthorizerB) + }, + }) + for i := 0; i < 20; i++ { + assert.Equal(t, poolAuthorizerB, advertised(scheme)) + } + assert.Equal(t, x402.Network(facNetwork), gotNetwork) + assert.Len(t, gotCandidates, len(pool)) + assert.Equal(t, poolAuthorizerA, scheme.captureAuthorizers[0], "the selector cannot mutate the pool") + }) + + t.Run("falls back to a pool member when the selector answers outside the pool", func(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(signer, AuthCaptureEvmSchemeConfig{ + CaptureAuthorizers: pool, + SelectCaptureAuthorizer: func(x402.Network, []string) string { return "0x" + strings.Repeat("ee", 20) }, + }) + for i := 0; i < 50; i++ { + assert.Contains(t, pool, advertised(scheme)) + } + }) + + t.Run("the shorthand advertises its single address", func(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(signer, AuthCaptureEvmSchemeConfig{CaptureAuthorizer: poolAuthorizerB}) + assert.Equal(t, poolAuthorizerB, advertised(scheme)) + }) + + t.Run("a relay-only facilitator advertises none", func(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(signer, AuthCaptureEvmSchemeConfig{}) + assert.Nil(t, scheme.GetExtra(facNetwork)) + }) +} + +func TestLifecyclePendingKey(t *testing.T) { + fx := newLifecycleFixture(t, nil) + delegated := newDelegatedHarness(fx.signer(), fx.receiver, delegatedOpts{}).scheme + selfSigned := newScheme(fx.signer(), AuthCaptureEvmSchemeConfig{}) + + t.Run("a delegated payload is keyed independently of the authorizer signature", func(t *testing.T) { + first := fx.buildCapture(t, captureOpts{}) + second := fx.buildCapture(t, captureOpts{}) + second["authorizerSignature"] = "0x" + strings.Repeat("11", 65) + + key := delegated.pendingKey(fx.payload(first), fx.requirements) + require.NotEmpty(t, key) + assert.Equal(t, key, delegated.pendingKey(fx.payload(second), fx.requirements)) + + delete(second, "authorizerSignature") + assert.Equal(t, key, delegated.pendingKey(fx.payload(second), fx.requirements), "an unsigned payload keys the same") + }) + + t.Run("a delegated key differs by type, amount and expected balances", func(t *testing.T) { + keys := map[string]string{ + "full capture": delegated.pendingKey(fx.payload(fx.buildCapture(t, captureOpts{})), fx.requirements), + "partial capture": delegated.pendingKey(fx.payload(fx.buildCapture(t, captureOpts{amount: "600000"})), fx.requirements), + "void": delegated.pendingKey(fx.payload(fx.buildVoid(t)), fx.requirements), + "refund": delegated.pendingKey(fx.payload(fx.buildRefund(t, refundOpts{})), fx.requirements), + "other refund": delegated.pendingKey(fx.payload(fx.buildRefund(t, refundOpts{amount: "100000"})), fx.requirements), + "stale refund": delegated.pendingKey(fx.payload(fx.buildRefund(t, refundOpts{expectedRefundable: "5"})), fx.requirements), + } + unique := map[string]bool{} + for name, key := range keys { + require.NotEmpty(t, key, name) + unique[key] = true + } + assert.Len(t, unique, len(keys)) + }) + + t.Run("a delegated key includes the network and payment", func(t *testing.T) { + key := delegated.pendingKey(fx.payload(fx.buildVoid(t)), fx.requirements) + assert.True(t, strings.HasPrefix(key, facNetwork+"|"+strings.ToLower(fx.paymentHash)+"|void"), key) + }) + + t.Run("an unreadable delegated payload has no key", func(t *testing.T) { + assert.Empty(t, delegated.pendingKey(fx.payload(map[string]interface{}{"type": "capture"}), fx.requirements)) + }) + + t.Run("a server-signed payload keeps its authorizer signature as the key", func(t *testing.T) { + wire := fx.buildCapture(t, captureOpts{}) + assert.Equal(t, wire["authorizerSignature"], selfSigned.pendingKey(fx.payload(wire), fx.requirements)) + }) + + t.Run("a collect keeps the client signature", func(t *testing.T) { + payload := fx.payload(map[string]interface{}{"signature": "0xabc"}) + assert.Equal(t, "0xabc", delegated.pendingKey(payload, fx.requirements)) + assert.Equal(t, "0xabc", selfSigned.pendingKey(payload, fx.requirements)) + }) + + t.Run("a forged signature does not resume a server-signed settlement", func(t *testing.T) { + signer := fx.signer() + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + wire := fx.buildCapture(t, captureOpts{}) + require.NoError(t, scheme.pendingStore.Set(context.Background(), scheme.pendingKey(fx.payload(wire), fx.requirements), signer.writeTx)) + + forged := fx.buildCapture(t, captureOpts{}) + forged["authorizerSignature"] = "0x" + strings.Repeat("22", 65) + _, err := scheme.Settle(context.Background(), fx.payload(forged), fx.requirements, nil) + assertSettleReason(t, err, ErrAuthorizerSignature) + _, stillPending, _ := scheme.pendingStore.Get(context.Background(), scheme.pendingKey(fx.payload(wire), fx.requirements)) + assert.True(t, stillPending) + }) + + t.Run("a delegated retry resumes the broadcast transaction", func(t *testing.T) { + h := newDelegatedHarness(fx.signer(), fx.receiver, delegatedOpts{}) + h.bindRow(t, fx.paymentHash, delegatedCaller, fx.extra.RefundDeadline) + payload := unsignedCapture(t, fx, captureOpts{}, false) + require.NoError(t, h.scheme.pendingStore.Set(context.Background(), h.scheme.pendingKey(payload, fx.requirements), h.signer.writeTx)) + + resp, err := h.scheme.Settle(context.Background(), payload, fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Empty(t, h.signer.writtenFunctions) + }) +} + +// ctxRecordingStorage records the state of the context each cleanup call receives. +type ctxRecordingStorage struct { + AuthCaptureDelegatedAuthStorage + mu sync.Mutex + ctxErr error + hadDeadline bool + called bool +} + +func (s *ctxRecordingStorage) record(ctx context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + s.ctxErr = ctx.Err() + _, s.hadDeadline = ctx.Deadline() + s.called = true +} + +func (s *ctxRecordingStorage) RevertBind(ctx context.Context, write AuthCaptureDelegatedAuthWrite) error { + s.record(ctx) + return s.AuthCaptureDelegatedAuthStorage.RevertBind(ctx, write) +} + +func (s *ctxRecordingStorage) Delete(ctx context.Context, network x402.Network, paymentInfoHash string) error { + s.record(ctx) + return s.AuthCaptureDelegatedAuthStorage.Delete(ctx, network, paymentInfoHash) +} + +func TestBindingCleanupSurvivesRequestCancellation(t *testing.T) { + t.Run("RevertBind runs on a live bounded context after the request is cancelled", func(t *testing.T) { + inner := NewInMemoryAuthCaptureDelegatedAuthStorage() + storage := &ctxRecordingStorage{AuthCaptureDelegatedAuthStorage: inner} + delegated := delegatedWith(storage, &recordedStorageErrors{}) + record := delegatedRecord(func(r *AuthCaptureDelegatedAuthRecord) { r.ExpiresAt = uint64(time.Now().Unix() + 3600) }) + + ctx, cancel := context.WithCancel(context.Background()) + result := bindThenBroadcast(ctx, delegated, record, func() (string, bindDisposition) { + cancel() + return "done", bindRevert + }) + require.True(t, result.OK) + + assert.True(t, storage.called) + assert.NoError(t, storage.ctxErr, "the cleanup context must not inherit the cancellation") + assert.True(t, storage.hadDeadline, "the cleanup context must be bounded") + stored, err := inner.Get(context.Background(), record.Network, record.PaymentInfoHash) + require.NoError(t, err) + assert.Nil(t, stored, "the binding is gone") + }) + + t.Run("the terminal binding is deleted after the request is cancelled", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + inner := NewInMemoryAuthCaptureDelegatedAuthStorage() + storage := &ctxRecordingStorage{AuthCaptureDelegatedAuthStorage: inner} + delegated := delegatedWith(storage, &recordedStorageErrors{}) + network := x402.Network(fx.requirements.Network) + _, err := inner.Bind(context.Background(), AuthCaptureDelegatedAuthRecord{ + Network: network, PaymentInfoHash: fx.paymentHash, CallerIdentity: "caller", ExpiresAt: fx.extra.RefundDeadline, + }) + require.NoError(t, err) + + scheme := newScheme(fx.signer(), AuthCaptureEvmSchemeConfig{}) + lc := &lifecyclePreconditions{deployment: fx.deployment, extra: fx.extra, chainID: fx.chainID, paymentInfo: fx.paymentInfo, paymentInfoHash: fx.paymentHash} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + scheme.releaseTerminalBinding(ctx, delegated, lc, fx.requirements, fx.buildRefund(t, refundOpts{amount: fx.paymentInfo.MaxAmount})) + + assert.True(t, storage.called) + assert.NoError(t, storage.ctxErr) + assert.True(t, storage.hadDeadline) + stored, err := inner.Get(context.Background(), network, fx.paymentHash) + require.NoError(t, err) + assert.Nil(t, stored) + }) +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/charge_test.go b/go/mechanisms/evm/auth-capture/facilitator/charge_test.go new file mode 100644 index 0000000000..5c222a8eca --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/charge_test.go @@ -0,0 +1,242 @@ +package facilitator + +import ( + "context" + "math/big" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +type chargeFixture struct { + requirements types.PaymentRequirements + payload types.PaymentPayload + receiver evm.ClientEvmSigner + extra authcapture.AuthCaptureExtra + deployment authcapture.AuthCaptureDeployment +} + +func newChargeFixture(t *testing.T, extraOverrides map[string]interface{}, collect collectOpts) chargeFixture { + t.Helper() + receiver := newKeySigner(t) + overrides := map[string]interface{}{"paymentFlow": "authorization", "receiverAuthorizer": receiver.Address()} + for k, v := range extraOverrides { + overrides[k] = v + } + requirements := facBaseRequirements(facCaptureAuthorizer, overrides) + extra, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) + require.NoError(t, err) + return chargeFixture{ + requirements: requirements, + payload: buildCollectPayload(t, requirements, newKeySigner(t), collect), + receiver: receiver, + extra: extra, + deployment: deployment, + } +} + +type chargeOpts struct { + amount string + fee *authcapture.CaptureFee + feeReceiver string +} + +// complete adds the server's charge fields, signed by the receiver authorizer over the same +// values the escrow will charge. +func (fx chargeFixture) complete(t *testing.T, opts chargeOpts) types.PaymentPayload { + t.Helper() + if opts.amount == "" { + opts.amount = fx.requirements.Amount + } + if opts.feeReceiver == "" { + opts.feeReceiver = fx.extra.FeeRecipient + } + amount, _ := new(big.Int).SetString(opts.amount, 10) + fee := authcapture.DefaultCaptureFee(&fx.deployment, amount, fx.extra.MinFeeBps) + if opts.fee != nil { + fee = *opts.fee + } + + wire := map[string]interface{}{} + for k, v := range fx.payload.Payload { + wire[k] = v + } + validBefore, err := strconv.ParseUint(wire["authorization"].(map[string]interface{})["validBefore"].(string), 10, 64) + require.NoError(t, err) + payer := wire["authorization"].(map[string]interface{})["from"].(string) + + chainID, err := evm.GetEvmChainId(fx.requirements.Network) + require.NoError(t, err) + paymentInfo := authcapture.ReconstructPaymentInfo(payer, validBefore, wire["salt"].(string), fx.requirements, fx.extra) + hash, err := authcapture.ComputePaymentInfoHash(chainID, paymentInfo, payer, fx.deployment.Escrow) + require.NoError(t, err) + collectorData, err := evm.HexToBytes(wire["signature"].(string)) + require.NoError(t, err) + + signature, err := authcapture.SignCharge(context.Background(), fx.receiver, &fx.deployment, fx.extra.CaptureAuthorizer, chainID, + authcapture.ChargeParams{ + PaymentInfoHash: hash, + Amount: amount, + TokenCollector: fx.deployment.EIP3009Collector, + CollectorData: collectorData, + Fee: fee, + FeeReceiver: opts.feeReceiver, + }) + require.NoError(t, err) + + wire["amount"] = opts.amount + wire["feeReceiver"] = opts.feeReceiver + wire["authorizerSignature"] = evm.BytesToHex(signature) + fee.AddToWire(wire) + payload := fx.payload + payload.Payload = wire + return payload +} + +func TestVerifyCharge_HappyPath(t *testing.T) { + bps := uint16(50) + tests := []struct { + name string + extra map[string]interface{} + opts chargeOpts + }{ + {name: "v1.1 full amount"}, + {name: "v1.1 partial amount", opts: chargeOpts{amount: "600000"}}, + {name: "v1.1 fee at the upper bound", opts: chargeOpts{fee: &authcapture.CaptureFee{Amount: big.NewInt(10000)}}}, + {name: "v1.0 fee in bps", extra: map[string]interface{}{"authCaptureEscrow": authcapture.AuthCaptureEscrowV1_0Address}, opts: chargeOpts{fee: &authcapture.CaptureFee{Bps: &bps}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fx := newChargeFixture(t, test.extra, collectOpts{}) + signer := newMockFacSigner(facCaptureAuthorizer) + + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.complete(t, test.opts), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + assert.Contains(t, signer.readFunctions, "charge") + }) + } +} + +func TestVerifyCharge_UncompletedPayloadSimulatesAFullCharge(t *testing.T) { + fx := newChargeFixture(t, nil, collectOpts{}) + signer := newMockFacSigner(facCaptureAuthorizer) + + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload, fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + assert.Equal(t, []string{"charge"}, signer.readFunctions) +} + +func TestVerifyCharge_Rejections(t *testing.T) { + tests := []struct { + name string + opts chargeOpts + signer evm.ClientEvmSigner + mutate func(wire map[string]interface{}) + reason string + }{ + {name: "amount above the signed amount", opts: chargeOpts{amount: "1000001"}, reason: ErrAmountMismatch}, + {name: "zero amount", opts: chargeOpts{amount: "0"}, reason: ErrAmountMismatch}, + {name: "fee above the bound", opts: chargeOpts{fee: &authcapture.CaptureFee{Amount: big.NewInt(10001)}}, reason: ErrFeeBpsOutOfRange}, + {name: "fee receiver differs from extra", opts: chargeOpts{feeReceiver: "0x" + "8888888888888888888888888888888888888888"}, reason: ErrFeeReceiver}, + {name: "signature from another key", signer: newKeySigner(t), reason: ErrAuthorizerSignature}, + { + name: "charge amount changed after signing", + mutate: func(wire map[string]interface{}) { wire["amount"] = "999999" }, + reason: ErrAuthorizerSignature, + }, + { + name: "fee field of the other deployment", + mutate: func(wire map[string]interface{}) { + delete(wire, "feeAmount") + wire["feeBps"] = uint16(1) + }, + reason: ErrPayloadFormat, + }, + { + name: "partial charge fields", + mutate: func(wire map[string]interface{}) { delete(wire, "feeReceiver") }, + reason: ErrPayloadFormat, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fx := newChargeFixture(t, nil, collectOpts{}) + if test.signer != nil { + fx.receiver = test.signer + } + payload := fx.complete(t, test.opts) + if test.mutate != nil { + test.mutate(payload.Payload) + } + + _, err := newScheme(newMockFacSigner(facCaptureAuthorizer), AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), payload, fx.requirements, nil) + assertVerifyReason(t, err, test.reason) + }) + } +} + +func TestVerifyCharge_CompletionOnEscrowPayload(t *testing.T) { + fx := newChargeFixture(t, nil, collectOpts{}) + payload := fx.complete(t, chargeOpts{}) + escrow := fx.requirements + escrow.Extra = map[string]interface{}{} + for k, v := range fx.requirements.Extra { + escrow.Extra[k] = v + } + escrow.Extra["paymentFlow"] = "escrow" + payload.Accepted = escrow + + _, err := newScheme(newMockFacSigner(facCaptureAuthorizer), AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), payload, escrow, nil) + assertVerifyReason(t, err, ErrPayloadFormat) +} + +func TestSettleCharge(t *testing.T) { + t.Run("a completed charge settles in one transaction", func(t *testing.T) { + fx := newChargeFixture(t, nil, collectOpts{}) + signer := newMockFacSigner(facCaptureAuthorizer) + + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.complete(t, chargeOpts{}), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, []string{"charge"}, signer.writtenFunctions) + assert.Equal(t, []string{"charge"}, signer.readFunctions, "a charge is always re-simulated before it moves funds") + }) + + t.Run("a partial charge settles against the overridden amount", func(t *testing.T) { + fx := newChargeFixture(t, nil, collectOpts{}) + settleRequirements := fx.requirements + settleRequirements.Amount = "600000" + + signer := newMockFacSigner(facCaptureAuthorizer) + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.complete(t, chargeOpts{amount: "600000"}), settleRequirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + }) + + t.Run("an uncompleted payload is rejected", func(t *testing.T) { + fx := newChargeFixture(t, nil, collectOpts{}) + signer := newMockFacSigner(facCaptureAuthorizer) + + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload, fx.requirements, nil) + assertSettleReason(t, err, ErrPayloadFormat) + assert.Empty(t, signer.writtenFunctions) + }) + + t.Run("a revert at the final simulation is typed", func(t *testing.T) { + fx := newChargeFixture(t, nil, collectOpts{}) + signer := newMockFacSigner(facCaptureAuthorizer) + signer.simulateErr["charge"] = escrowRevert(t, "FeeAmountOutOfRange", big.NewInt(1), big.NewInt(0), big.NewInt(0)) + + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.complete(t, chargeOpts{}), fx.requirements, nil) + assertSettleReason(t, err, ErrFeeBpsOutOfRange) + assert.Empty(t, signer.writtenFunctions) + }) +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/collect.go b/go/mechanisms/evm/auth-capture/facilitator/collect.go new file mode 100644 index 0000000000..b80375e1da --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/collect.go @@ -0,0 +1,748 @@ +package facilitator + +import ( + "context" + "errors" + "fmt" + "math/big" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +const ( + methodEip3009 = "eip3009" + methodPermit2 = "permit2" +) + +// collectAuth is the method-independent view of an EIP-3009 or Permit2 collect payload. +type collectAuth struct { + method string + payer string + collector string + expectedCollector string + token string // Permit2 only; EIP-3009 binds the token through its domain + amount string + validAfter uint64 + validBefore uint64 + nonce string + digest [32]byte + signature string + salt string + saltNonce string + charge *authcapture.ChargeCompletion +} + +// collectOperation is the escrow call a collect payload settles as: authorize for the whole hold, +// or charge of the amount and fee the server's completion names (the provisional full amount and +// default fee until the payload is completed). +type collectOperation struct { + function string + amount *big.Int + fee authcapture.CaptureFee + feeReceiver string + completed bool +} + +// collectOutcome is what a collect call must leave on chain, reconstructed from the payload +// without verifying it. A resumed settlement checks a custom operator against it. +type collectOutcome struct { + collectOperation + deployment authcapture.AuthCaptureDeployment + extra authcapture.AuthCaptureExtra + paymentInfo authcapture.PaymentInfoStruct + paymentInfoHash string + payer string + tokenCollector string + network x402.Network +} + +// collectPreconditions is the verified state settleCollect reuses from verifyCollect. +type collectPreconditions struct { + collectOutcome + auth *collectAuth + sigData *evm.ERC6492SignatureData + rawSignature []byte +} + +func parseEip3009Auth(payload map[string]interface{}, rc *requestContext, asset string) (*collectAuth, error) { + p, err := authcapture.Eip3009CollectPayloadFromMap(payload) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, "", err.Error()) + } + auth := p.Authorization + validAfter, err := strconv.ParseUint(auth.ValidAfter, 10, 64) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, auth.From, "invalid validAfter") + } + validBefore, err := strconv.ParseUint(auth.ValidBefore, 10, 64) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, auth.From, "invalid validBefore") + } + digest, err := authcapture.HashERC3009Authorization(auth, rc.extra, asset, rc.chainID) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, auth.From, err.Error()) + } + return &collectAuth{ + method: methodEip3009, + payer: auth.From, + collector: auth.To, + expectedCollector: rc.deployment.EIP3009Collector, + amount: auth.Value, + validAfter: validAfter, + validBefore: validBefore, + nonce: auth.Nonce, + digest: digest, + signature: p.Signature, + salt: p.Salt, + saltNonce: p.SaltNonce, + charge: p.Charge, + }, nil +} + +func parsePermit2Auth(payload map[string]interface{}, rc *requestContext) (*collectAuth, error) { + p, err := authcapture.Permit2CollectPayloadFromMap(payload) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, "", err.Error()) + } + auth := p.Permit2Authorization + deadline, err := strconv.ParseUint(auth.Deadline, 10, 64) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, auth.From, "invalid deadline") + } + nonce, ok := new(big.Int).SetString(auth.Nonce, 10) + if !ok { + return nil, x402.NewVerifyError(ErrPayloadFormat, auth.From, "invalid nonce") + } + digest, err := authcapture.HashPermit2Authorization(auth, rc.chainID) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, auth.From, err.Error()) + } + return &collectAuth{ + method: methodPermit2, + payer: auth.From, + collector: auth.Spender, + expectedCollector: rc.deployment.Permit2Collector, + token: auth.Permitted.Token, + amount: auth.Permitted.Amount, + validBefore: deadline, + nonce: evm.BytesToHex(common.LeftPadBytes(nonce.Bytes(), 32)), + digest: digest, + signature: p.Signature, + salt: p.Salt, + saltNonce: p.SaltNonce, + charge: p.Charge, + }, nil +} + +// checkOperator applies the operator and policy checks. A lifecycle payload is relayed only for +// a delegated operator with a receiver authorizer; a custom operator runs lifecycle out of band. +func (f *AuthCaptureEvmScheme) checkOperator(extra authcapture.AuthCaptureExtra, payer string, lifecycle bool) error { + if authcapture.IsNonZeroAddress(extra.Policy) { + return x402.NewVerifyError(ErrPolicy, payer, "policy operator type is not supported") + } + switch extra.OperatorType { + case authcapture.OperatorTypeCustom: + if !f.operatorAdmitted(extra.CaptureAuthorizer) { + return x402.NewVerifyError(ErrOperatorNotAdmitted, payer, fmt.Sprintf("captureAuthorizer %s is not an admitted custom operator", extra.CaptureAuthorizer)) + } + if lifecycle { + return x402.NewVerifyError(ErrLifecycleNotRelayed, payer, "lifecycle payloads are not relayed for custom operators") + } + case "", authcapture.OperatorTypeDelegated: + if !f.controlsAddress(extra.CaptureAuthorizer) { + return x402.NewVerifyError(ErrOperatorNotAdmitted, payer, fmt.Sprintf("captureAuthorizer %s is not controlled by this facilitator", extra.CaptureAuthorizer)) + } + if lifecycle && !authcapture.IsNonZeroAddress(extra.ReceiverAuthorizer) { + return x402.NewVerifyError(ErrLifecycleNotRelayed, payer, "lifecycle payloads require a non-zero receiverAuthorizer") + } + default: + return x402.NewVerifyError(ErrUnsupportedOperatorType, payer, fmt.Sprintf("unsupported operatorType: %s", extra.OperatorType)) + } + return nil +} + +// checkMethodRouting requires the payload shape to match extra.assetTransferMethod. +func checkMethodRouting(extra authcapture.AuthCaptureExtra, auth *collectAuth) error { + expected := extra.AssetTransferMethod + if expected == "" { + expected = methodEip3009 + } + if expected != methodEip3009 && expected != methodPermit2 { + return x402.NewVerifyError(ErrUnsupportedAssetTransferMethod, auth.payer, fmt.Sprintf("unsupported assetTransferMethod: %s", expected)) + } + if expected != auth.method { + return x402.NewVerifyError(ErrPayloadMethodMismatch, auth.payer, fmt.Sprintf("%s payload for assetTransferMethod %s", auth.method, expected)) + } + return nil +} + +// checkTimes applies the deadline ordering and time window steps. +func checkTimes(extra authcapture.AuthCaptureExtra, requirements types.PaymentRequirements, auth *collectAuth) error { + now := uint64(time.Now().Unix()) + floor := now + timeSkewSeconds + timeout := uint64(max(requirements.MaxTimeoutSeconds, 0)) + + if extra.CaptureDeadline <= floor { + return x402.NewVerifyError(ErrCaptureDeadlineExpired, auth.payer, "captureDeadline is too close or in the past") + } + if extra.RefundDeadline < extra.CaptureDeadline || now+timeout > extra.CaptureDeadline || auth.validBefore > extra.CaptureDeadline { + return x402.NewVerifyError(ErrDeadlineOrdering, auth.payer, "now + maxTimeoutSeconds <= captureDeadline <= refundDeadline violated") + } + if auth.validBefore <= floor { + return x402.NewVerifyError(ErrAuthorizationExpired, auth.payer, "authorization already expired") + } + if auth.validAfter > now { + return x402.NewVerifyError(ErrAuthorizationNotYetValid, auth.payer, "authorization not yet valid") + } + return nil +} + +// checkBindings requires the collector, token and amount to match the requirements. The amount +// is the one the payer was offered: on a charge settle requirements.Amount carries the charge. +func checkBindings(acceptedAmount string, requirements types.PaymentRequirements, auth *collectAuth) error { + if !strings.EqualFold(auth.collector, auth.expectedCollector) { + return x402.NewVerifyError(ErrTokenCollectorMismatch, auth.payer, fmt.Sprintf("collector mismatch: %s != %s", auth.collector, auth.expectedCollector)) + } + if auth.token != "" && !strings.EqualFold(auth.token, requirements.Asset) { + return x402.NewVerifyError(ErrTokenMismatch, auth.payer, "permitted token mismatch") + } + if auth.amount != acceptedAmount { + return x402.NewVerifyError(ErrAmountMismatch, auth.payer, fmt.Sprintf("amount mismatch: %s != %s", auth.amount, acceptedAmount)) + } + return nil +} + +// checkSalt requires saltNonce exactly when the bind is on, and the derived salt to match. +func checkSalt(extra authcapture.AuthCaptureExtra, auth *collectAuth) error { + bindOn := authcapture.IsSaltBindingOn(extra) + if bindOn != (auth.saltNonce != "") { + return x402.NewVerifyError(ErrPayloadFormat, auth.payer, "saltNonce must be present if and only if salt binding is on") + } + if !bindOn { + return nil + } + expectedSalt, err := authcapture.DeriveBoundSalt( + authcapture.ExtraAddress(extra.ReceiverAuthorizer), + authcapture.ExtraAddress(extra.Policy), + auth.saltNonce, + ) + if err != nil { + return x402.NewVerifyError(ErrSaltBindingMismatch, auth.payer, err.Error()) + } + if !strings.EqualFold(expectedSalt, auth.salt) { + return x402.NewVerifyError(ErrSaltBindingMismatch, auth.payer, "salt does not match derived bound salt") + } + return nil +} + +// verifyPayerSignature checks the client signature. Only a counterfactual smart-wallet payer +// passes on an allowlisted factory, with the deploy simulation vouching for it. A deployed +// wallet must always present a signature its own ERC-1271 check accepts, wrapped or not. +func (f *AuthCaptureEvmScheme) verifyPayerSignature(ctx context.Context, auth *collectAuth) (*evm.ERC6492SignatureData, error) { + signatureBytes, err := evm.HexToBytes(auth.signature) + if err != nil { + return nil, x402.NewVerifyError(ErrSignature, auth.payer, err.Error()) + } + valid, sigData, err := evm.VerifyUniversalSignature(ctx, f.signer, auth.payer, auth.digest, signatureBytes, true) + if err != nil { + return nil, x402.NewVerifyError(ErrSignature, auth.payer, err.Error()) + } + if sigData == nil { + sigData = &evm.ERC6492SignatureData{InnerSignature: signatureBytes} + } + if valid { + return sigData, nil + } + switch { + case sigData.CodeDeployed: + return nil, x402.NewVerifyError(ErrSignature, auth.payer, "deployed wallet signature failed ERC-1271 verification") + case evm.HasEIP6492Deployment(sigData): + if !evm.IsFactoryAllowed(sigData.Factory, f.config.EIP6492AllowedFactories) { + return nil, x402.NewVerifyError(ErrErc6492FactoryNotAllowed, auth.payer, "factory not in EIP6492AllowedFactories allowlist") + } + return sigData, nil + case len(sigData.InnerSignature) != 65: + return nil, x402.NewVerifyError(ErrUndeployedSmartWallet, auth.payer, "smart wallet signature could not be verified") + default: + return nil, x402.NewVerifyError(ErrSignature, auth.payer, "invalid signature") + } +} + +// resolveCollectOperation decides the escrow call: authorize for an escrow payload, charge for an +// authorization payload. A charge is completed by the server's amount, fee and signature; before +// that (a raw /verify) it is simulated as a full-amount charge at the minimum fee, and /settle +// rejects it with requireCompletion. +func resolveCollectOperation(rc *requestContext, auth *collectAuth, requireCompletion bool) (collectOperation, error) { + signed, ok := parseUint(auth.amount) + if !ok { + return collectOperation{}, x402.NewVerifyError(ErrPayloadFormat, auth.payer, "invalid amount") + } + charging := rc.extra.PaymentFlow == authcapture.PaymentFlowAuthorization + switch { + case auth.charge != nil && !charging: + return collectOperation{}, x402.NewVerifyError(ErrPayloadFormat, auth.payer, "charge completion requires paymentFlow authorization") + case auth.charge == nil && !charging: + return collectOperation{function: "authorize", amount: signed}, nil + case auth.charge == nil && requireCompletion: + return collectOperation{}, x402.NewVerifyError(ErrPayloadFormat, auth.payer, "settle requires a completed charge payload") + case auth.charge == nil: + return collectOperation{ + function: "charge", + amount: signed, + fee: authcapture.DefaultCaptureFee(&rc.deployment, signed, rc.extra.MinFeeBps), + feeReceiver: rc.extra.FeeRecipient, + }, nil + } + + charge := auth.charge + amount, ok := parseUint(charge.Amount) + if !ok { + return collectOperation{}, x402.NewVerifyError(ErrPayloadFormat, auth.payer, "invalid charge amount") + } + if amount.Sign() == 0 || amount.Cmp(signed) > 0 { + return collectOperation{}, x402.NewVerifyError(ErrAmountMismatch, auth.payer, "charge amount must be > 0 and <= the signed amount") + } + fee, ok := parseSubmittedFee(charge.FeeBps, charge.FeeAmount, &rc.deployment) + if !ok { + return collectOperation{}, x402.NewVerifyError(ErrPayloadFormat, auth.payer, "charge fee field does not match the escrow deployment") + } + if reason := validateSubmittedFee(rc.extra, amount, fee, charge.FeeReceiver); reason != "" { + return collectOperation{}, x402.NewVerifyError(reason, auth.payer, "submitted fee or feeReceiver does not satisfy extra") + } + return collectOperation{function: "charge", amount: amount, fee: fee, feeReceiver: charge.FeeReceiver, completed: true}, nil +} + +func parseCollectAuth(payload types.PaymentPayload, rc *requestContext, requirements types.PaymentRequirements) (*collectAuth, error) { + if authcapture.IsPermit2Payload(payload.Payload) { + return parsePermit2Auth(payload.Payload, rc) + } + return parseEip3009Auth(payload.Payload, rc, requirements.Asset) +} + +// newCollectOutcome reconstructs the PaymentInfo the payer signed, which is bound to the amount +// it was offered (payload.Accepted), not a charge settle's overridden requirements.Amount. +func newCollectOutcome( + rc *requestContext, + auth *collectAuth, + op collectOperation, + payload types.PaymentPayload, + requirements types.PaymentRequirements, +) (*collectOutcome, error) { + accepted := requirements + accepted.Amount = payload.Accepted.Amount + paymentInfo := authcapture.ReconstructPaymentInfo(auth.payer, auth.validBefore, auth.salt, accepted, rc.extra) + hash, err := authcapture.ComputePaymentInfoHash(rc.chainID, paymentInfo, auth.payer, rc.deployment.Escrow) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, auth.payer, err.Error()) + } + return &collectOutcome{ + collectOperation: op, + deployment: rc.deployment, + extra: rc.extra, + paymentInfo: paymentInfo, + paymentInfoHash: hash, + payer: auth.payer, + tokenCollector: auth.expectedCollector, + network: x402.Network(payload.Accepted.Network), + }, nil +} + +// reconstructCollectOutcome rebuilds a collect's expected outcome without verifying it, for a +// settlement resumed after its transaction was already broadcast. +func reconstructCollectOutcome(payload types.PaymentPayload, requirements types.PaymentRequirements) (*collectOutcome, error) { + payer := payloadPayer(payload.Payload) + rc, err := checkRequest(payload, requirements, payer) + if err != nil { + return nil, err + } + auth, err := parseCollectAuth(payload, rc, requirements) + if err != nil { + return nil, err + } + op, err := resolveCollectOperation(rc, auth, true) + if err != nil { + return nil, err + } + return newCollectOutcome(rc, auth, op, payload, requirements) +} + +// chargeParams are the values the receiver authorizer signs for a completed charge. The +// collector data is the client signature exactly as it arrived on the wire. +func chargeParams(auth *collectAuth, out *collectOutcome) (authcapture.ChargeParams, error) { + collectorData, err := evm.HexToBytes(auth.signature) + if err != nil { + return authcapture.ChargeParams{}, x402.NewVerifyError(ErrSignature, auth.payer, err.Error()) + } + return authcapture.ChargeParams{ + PaymentInfoHash: out.paymentInfoHash, + Amount: out.amount, + TokenCollector: out.tokenCollector, + CollectorData: collectorData, + Fee: out.fee, + FeeReceiver: out.feeReceiver, + }, nil +} + +// checkChargeSignature verifies the receiver authorizer's signature over a completed charge. +func (f *AuthCaptureEvmScheme) checkChargeSignature(ctx context.Context, rc *requestContext, auth *collectAuth, out *collectOutcome) error { + signature, err := evm.HexToBytes(auth.charge.AuthorizerSignature) + if err != nil { + return x402.NewVerifyError(ErrAuthorizerSignature, auth.payer, err.Error()) + } + params, err := chargeParams(auth, out) + if err != nil { + return err + } + valid, err := authcapture.VerifyCharge(ctx, f.signer, rc.extra.ReceiverAuthorizer, &rc.deployment, rc.extra.CaptureAuthorizer, rc.chainID, params, signature) + if err != nil { + return x402.NewVerifyError(ErrAuthorizerSignature, auth.payer, err.Error()) + } + if !valid { + return x402.NewVerifyError(ErrAuthorizerSignature, auth.payer, "authorizer signature invalid") + } + return nil +} + +// checkCollectPreconditions runs every collect verification step short of simulation. +func (f *AuthCaptureEvmScheme) checkCollectPreconditions( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + requireCompletion bool, +) (*collectPreconditions, error) { + payer := payloadPayer(payload.Payload) + rc, err := checkRequest(payload, requirements, payer) + if err != nil { + return nil, err + } + if err := f.checkOperator(rc.extra, payer, false); err != nil { + return nil, err + } + + auth, err := parseCollectAuth(payload, rc, requirements) + if err != nil { + return nil, err + } + if err := checkMethodRouting(rc.extra, auth); err != nil { + return nil, err + } + if err := checkTimes(rc.extra, requirements, auth); err != nil { + return nil, err + } + if err := checkBindings(payload.Accepted.Amount, requirements, auth); err != nil { + return nil, err + } + if err := checkSalt(rc.extra, auth); err != nil { + return nil, err + } + + op, err := resolveCollectOperation(rc, auth, requireCompletion) + if err != nil { + return nil, err + } + out, err := newCollectOutcome(rc, auth, op, payload, requirements) + if err != nil { + return nil, err + } + expectedNonce, err := authcapture.ComputePayerAgnosticPaymentInfoHash(rc.chainID, out.paymentInfo, rc.deployment.Escrow) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, auth.payer, err.Error()) + } + if !strings.EqualFold(expectedNonce, auth.nonce) { + return nil, x402.NewVerifyError(ErrNonceMismatch, auth.payer, fmt.Sprintf("nonce mismatch: %s != %s", auth.nonce, expectedNonce)) + } + if op.completed { + if auth.charge.AuthorizerSignature != "" { + if err := f.checkChargeSignature(ctx, rc, auth, out); err != nil { + return nil, err + } + } else if getDelegatedAuthorizer(f.config, rc.extra.ReceiverAuthorizer) == nil { + // A delegated authorizer signs at settle time, after authenticating the caller. + return nil, x402.NewVerifyError(ErrAuthorizerSignature, auth.payer, "authorizerSignature is required unless the authorizer is delegated to this facilitator") + } + } + + sigData, err := f.verifyPayerSignature(ctx, auth) + if err != nil { + return nil, err + } + if rc.extra.OperatorType == authcapture.OperatorTypeCustom && needsFactoryDeploy(sigData) { + return nil, x402.NewVerifyError(ErrUndeployedSmartWallet, auth.payer, "custom operators do not support counterfactual wallets") + } + return &collectPreconditions{collectOutcome: *out, auth: auth, sigData: sigData, rawSignature: sigData.InnerSignature}, nil +} + +// escrowArgs are the AuthCaptureEscrow authorize or charge call arguments. +func (pre *collectPreconditions) escrowArgs() ([]interface{}, error) { + tuple, err := pre.paymentInfo.ToAbiTuple() + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, pre.payer, err.Error()) + } + args := []interface{}{tuple, pre.amount, common.HexToAddress(pre.tokenCollector), pre.rawSignature} + if pre.function == "charge" { + args = append(args, pre.fee.Arg(), common.HexToAddress(pre.feeReceiver)) + } + return args, nil +} + +// simulateCollect simulates the collect call, or only the factory deploy for a counterfactual payer. +// A failed simulation is reported as insufficient_balance when the payer holds less than it signed. +func (f *AuthCaptureEvmScheme) simulateCollect(ctx context.Context, pre *collectPreconditions) error { + err := f.simulateCollectCall(ctx, pre) + if err == nil { + return nil + } + var verifyErr *x402.VerifyError + if !errors.As(err, &verifyErr) { + return err + } + balance := readTokenBalance(ctx, f.signer, pre.paymentInfo.Token, pre.payer) + signed, ok := parseUint(pre.paymentInfo.MaxAmount) + if balance == nil || !ok || balance.Cmp(signed) >= 0 { + return err + } + return x402.NewVerifyError(ErrInsufficientBalance, pre.payer, "payer balance is below the signed amount") +} + +func (f *AuthCaptureEvmScheme) simulateCollectCall(ctx context.Context, pre *collectPreconditions) error { + if pre.extra.OperatorType == authcapture.OperatorTypeCustom { + return f.simulateCustomCollect(ctx, pre) + } + if needsFactoryDeploy(pre.sigData) { + return simulateFactoryDeploy(ctx, f.signer, pre.sigData, pre.payer) + } + args, err := pre.escrowArgs() + if err != nil { + return err + } + return simulateEscrowCall(ctx, f.signer, &pre.deployment, pre.paymentInfo.Operator, pre.payer, pre.function, args...) +} + +// verifyCollect validates an EIP-3009 or Permit2 collect payload and simulates its authorize or charge. +func (f *AuthCaptureEvmScheme) verifyCollect( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, +) (*x402.VerifyResponse, error) { + pre, err := f.checkCollectPreconditions(ctx, payload, requirements, false) + if err != nil { + return nil, err + } + if err := f.simulateCollect(ctx, pre); err != nil { + return nil, err + } + return &x402.VerifyResponse{IsValid: true, Payer: pre.payer}, nil +} + +// settleCollect submits the authorize or charge call for a collect payload. A charge and a custom +// operator are always re-simulated: the first moves the funds for good, the second is untrusted code. +func (f *AuthCaptureEvmScheme) settleCollect( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*x402.SettleResponse, error) { + network := x402.Network(payload.Accepted.Network) + if resp, err := f.resumePending(ctx, payload, requirements, f.resumedCollectCheck(payload, requirements)); resp != nil || err != nil { + return resp, err + } + + pre, err := f.checkCollectPreconditions(ctx, payload, requirements, true) + if err != nil { + return nil, toSettleError(err, network, "") + } + custom := pre.extra.OperatorType == authcapture.OperatorTypeCustom + if f.config.SimulateInSettle || custom || pre.completed { + if err := f.simulateCollect(ctx, pre); err != nil { + return nil, toSettleError(err, network, pre.payer) + } + } + + delegated := getDelegatedAuthorizer(f.config, pre.extra.ReceiverAuthorizer) + if delegated == nil { + return f.broadcastCollect(ctx, fctx, payload, requirements, pre).response() + } + + bindRecord, err := f.authenticateDelegatedCollect(ctx, delegated, fctx, payload, requirements, pre) + if err != nil { + return nil, err + } + if bindRecord == nil { + return f.broadcastCollect(ctx, fctx, payload, requirements, pre).response() + } + bound := bindThenBroadcast(ctx, delegated, *bindRecord, func() (settleOutcome, bindDisposition) { + outcome := f.broadcastCollect(ctx, fctx, payload, requirements, pre) + return outcome, outcome.disposition + }) + if !bound.OK { + if bound.Reason == bindConflict { + return nil, x402.NewSettleError(ErrUnauthenticatedAuthorizerRequest, pre.payer, network, "", "") + } + return nil, x402.NewSettleError(ErrDelegatedAuthUnavailable, pre.payer, network, "", + "failed to bind delegated caller: "+evm.TruncateErrorMessage(bound.Err.Error())) + } + return bound.Value.response() +} + +// settleOutcome is a settle result, which is either a response or an error, and whether a caller +// binding written for it should be kept: it is reverted only when no transaction can have landed. +type settleOutcome struct { + resp *x402.SettleResponse + err error + disposition bindDisposition +} + +func (o settleOutcome) response() (*x402.SettleResponse, error) { + return o.resp, o.err +} + +// notBroadcast is the outcome of a settle that failed before any transaction was sent. +func notBroadcast(err error) settleOutcome { + return settleOutcome{err: err, disposition: bindRevert} +} + +// awaited is the outcome of a settle whose transaction was broadcast. A pending receipt or any +// other post-broadcast outcome may still have landed onchain; only a revert is final. +func awaited(resp *x402.SettleResponse, err error) settleOutcome { + var settleErr *x402.SettleError + if errors.As(err, &settleErr) && settleErr.ErrorReason == ErrTransactionReverted { + return settleOutcome{resp: resp, err: err, disposition: bindRevert} + } + return settleOutcome{resp: resp, err: err, disposition: bindKeep} +} + +// authenticateDelegatedCollect authenticates a collect whose authorizer is delegated to this +// facilitator, produces the Charge signature the server omitted, and returns the binding to write +// before broadcast. It returns a nil record when the payment needs no binding. +func (f *AuthCaptureEvmScheme) authenticateDelegatedCollect( + ctx context.Context, + delegated *delegatedAuthorizer, + fctx *x402.FacilitatorContext, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + pre *collectPreconditions, +) (*AuthCaptureDelegatedAuthRecord, error) { + network := pre.network + fail := func(reason, message string) error { + return x402.NewSettleError(reason, pre.payer, network, "", message) + } + + identity := resolveDelegatedCallerIdentity(ctx, delegated, DelegatedSettleContext{ + Step: DelegatedStep(pre.function), + PaymentInfoHash: pre.paymentInfoHash, + Network: network, + Payer: pre.payer, + Payload: payload, + Requirements: requirements, + FacilitatorContext: fctx, + }) + if identity == "" { + return nil, fail(ErrUnauthenticatedAuthorizerRequest, "") + } + + if pre.function == "charge" && pre.auth.charge.AuthorizerSignature == "" { + if err := f.signDelegatedCharge(ctx, delegated, pre); err != nil { + return nil, toSettleError(err, network, pre.payer) + } + } + + // Only payments whose later steps the facilitator can still relay need a binding. + // A custom operator's lifecycle is never relayed, and a charge is only refundable + // through the facilitator when refunds are funded. + relayable := pre.extra.OperatorType != authcapture.OperatorTypeCustom && + (pre.function == "authorize" || f.config.RefundFunding) + if !relayable { + return nil, nil + } + return &AuthCaptureDelegatedAuthRecord{ + Network: network, + PaymentInfoHash: pre.paymentInfoHash, + CallerIdentity: identity, + ExpiresAt: pre.extra.RefundDeadline, + }, nil +} + +// signDelegatedCharge signs the Charge digest the server omitted and checks that the signature +// verifies as the receiver authorizer's. +func (f *AuthCaptureEvmScheme) signDelegatedCharge(ctx context.Context, delegated *delegatedAuthorizer, pre *collectPreconditions) error { + chainID, err := evm.GetEvmChainId(string(pre.network)) + if err != nil { + return x402.NewVerifyError(ErrInvalidNetwork, pre.payer, err.Error()) + } + params, err := chargeParams(pre.auth, &pre.collectOutcome) + if err != nil { + return err + } + signature, err := authcapture.SignCharge(ctx, delegated.signer, &pre.deployment, pre.extra.CaptureAuthorizer, chainID, params) + if err != nil { + return x402.NewVerifyError(ErrAuthorizerSignature, pre.payer, err.Error()) + } + valid, err := authcapture.VerifyCharge(ctx, f.signer, pre.extra.ReceiverAuthorizer, &pre.deployment, pre.extra.CaptureAuthorizer, chainID, params, signature) + if err != nil { + return x402.NewVerifyError(ErrAuthorizerSignature, pre.payer, err.Error()) + } + if !valid { + return x402.NewVerifyError(ErrAuthorizerSignature, pre.payer, "authorizer signature invalid") + } + return nil +} + +// broadcastCollect submits the authorize or charge call and awaits its receipt. +func (f *AuthCaptureEvmScheme) broadcastCollect( + ctx context.Context, + fctx *x402.FacilitatorContext, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + pre *collectPreconditions, +) settleOutcome { + network := pre.network + custom := pre.extra.OperatorType == authcapture.OperatorTypeCustom + if needsFactoryDeploy(pre.sigData) { + if err := evm.SendFactoryDeployTransaction(ctx, f.signer, pre.sigData); err != nil { + return notBroadcast(x402.NewSettleError(ErrSmartWalletDeploymentFailed, pre.payer, network, "", err.Error())) + } + } + + args, err := pre.escrowArgs() + if err != nil { + return notBroadcast(toSettleError(err, network, pre.payer)) + } + if !custom { + txHash, err := f.writeEscrow(ctx, fctx, payload, requirements, &pre.deployment, pre.paymentInfo.Operator, pre.payer, pre.function, args...) + if err != nil { + return notBroadcast(err) + } + return awaited(f.awaitSettlement(ctx, payload, requirements, pre.payer, txHash, nil)) + } + + before, err := f.snapshotCustomBalances(ctx, &pre.collectOutcome) + if err != nil { + return notBroadcast(toSettleError(err, network, pre.payer)) + } + txHash, err := f.submitEscrowCall(ctx, fctx, payload, requirements, &pre.deployment, "", pre.payer, + pre.extra.CaptureAuthorizer, f.customGasLimit(), pre.function, args...) + if err != nil { + return notBroadcast(err) + } + return awaited(f.awaitSettlement(ctx, payload, requirements, pre.payer, txHash, f.customReceiptCheck(&pre.collectOutcome, before))) +} + +// resumedCollectCheck is the receipt check for a collect whose transaction was broadcast by an +// earlier settle attempt. Only a custom operator needs one. +func (f *AuthCaptureEvmScheme) resumedCollectCheck(payload types.PaymentPayload, requirements types.PaymentRequirements) receiptCheck { + return func(ctx context.Context, receipt *evm.TransactionReceipt) (*x402.SettleResponse, error) { + out, err := reconstructCollectOutcome(payload, requirements) + if err != nil || out.extra.OperatorType != authcapture.OperatorTypeCustom { + return nil, nil //nolint:nilerr // an unreadable outcome is reported by the settle that resumes it + } + return f.customReceiptCheck(out, nil)(ctx, receipt) + } +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/custom.go b/go/mechanisms/evm/auth-capture/facilitator/custom.go new file mode 100644 index 0000000000..396c051c21 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/custom.go @@ -0,0 +1,479 @@ +package facilitator + +import ( + "bytes" + "context" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" +) + +// A custom operator is untrusted contract code that forwards to the escrow, so the facilitator +// does not take its word for what the call did. It simulates the call and, after it confirms, +// checks the escrow's event, the payment state and the balance deltas against what the collect +// must produce. + +// outcomeViolation is a custom operator call that did not do what the collect requires. +type outcomeViolation struct { + reason string + message string +} + +func (v *outcomeViolation) Error() string { return v.message } + +func violation(reason, format string, args ...interface{}) *outcomeViolation { + return &outcomeViolation{reason: reason, message: fmt.Sprintf(format, args...)} +} + +// customSnapshot is a point-in-time read of the payment state and the balances a collect moves. +type customSnapshot struct { + collected bool + capturable *big.Int + refundable *big.Int + balances map[string]*big.Int +} + +func (f *AuthCaptureEvmScheme) customGasLimit() uint64 { + if f.config.CustomOperatorGasLimit > 0 { + return f.config.CustomOperatorGasLimit + } + return authcapture.DefaultCustomOperatorGasLimit +} + +func (f *AuthCaptureEvmScheme) submitterAddress() string { + return f.signer.GetAddresses()[0] +} + +func packCall(contractABI []byte, function string, args ...interface{}) ([]byte, error) { + parsed, err := abi.JSON(bytes.NewReader(contractABI)) + if err != nil { + return nil, err + } + return parsed.Pack(function, args...) +} + +func addressKey(address string) string { return strings.ToLower(address) } + +// watchedAddresses are the accounts a collect moves tokens between, deduplicated. +func (f *AuthCaptureEvmScheme) watchedAddresses(out *collectOutcome, tokenStore string) []string { + candidates := []string{out.payer, tokenStore, f.submitterAddress()} + if out.function == "charge" { + candidates = append(candidates, out.paymentInfo.Receiver, out.feeReceiver) + } + seen := map[string]bool{} + var unique []string + for _, address := range candidates { + if key := addressKey(address); !seen[key] { + seen[key] = true + unique = append(unique, key) + } + } + return unique +} + +// expectedDeltas are the exact balance changes the collect must cause, per address. +func (f *AuthCaptureEvmScheme) expectedDeltas(out *collectOutcome, tokenStore string) map[string]*big.Int { + deltas := map[string]*big.Int{} + add := func(address string, amount *big.Int) { + key := addressKey(address) + if deltas[key] == nil { + deltas[key] = new(big.Int) + } + deltas[key].Add(deltas[key], amount) + } + add(out.payer, new(big.Int).Neg(out.amount)) + if out.function == "authorize" { + add(tokenStore, out.amount) + return deltas + } + fee := feeAmountOf(out.fee, out.amount) + add(out.paymentInfo.Receiver, new(big.Int).Sub(out.amount, fee)) + add(out.feeReceiver, fee) + return deltas +} + +// readCalls are the state reads of a snapshot: the payment state, then each watched balance. +func (f *AuthCaptureEvmScheme) readCalls(out *collectOutcome, addresses []string) ([]SimulatedCall, error) { + hashBytes, err := evm.HexToBytes(out.paymentInfoHash) + if err != nil { + return nil, err + } + stateData, err := packCall(authcapture.EscrowABIForDeployment(&out.deployment), "paymentState", common.BytesToHash(hashBytes)) + if err != nil { + return nil, err + } + calls := []SimulatedCall{{To: out.deployment.Escrow, Data: stateData}} + for _, address := range addresses { + data, err := packCall(evm.ERC20BalanceOfABI, "balanceOf", common.HexToAddress(address)) + if err != nil { + return nil, err + } + calls = append(calls, SimulatedCall{To: out.paymentInfo.Token, Data: data}) + } + return calls, nil +} + +// newSnapshot builds a snapshot from a decoded paymentState result and the watched balances. +func newSnapshot(addresses []string, state []interface{}, balances []*big.Int) (*customSnapshot, error) { + if len(state) != 3 { + return nil, violation(ErrSimulationFailed, "unexpected paymentState result") + } + snapshot := &customSnapshot{capturable: asBigInt(state[1]), refundable: asBigInt(state[2]), balances: map[string]*big.Int{}} + snapshot.collected, _ = state[0].(bool) + if snapshot.capturable == nil || snapshot.refundable == nil { + return nil, violation(ErrSimulationFailed, "unexpected paymentState amount types") + } + for i, address := range addresses { + if balances[i] == nil { + return nil, violation(ErrSimulationFailed, "unexpected balanceOf result") + } + snapshot.balances[address] = balances[i] + } + return snapshot, nil +} + +// decodeSnapshot turns the raw results of readCalls into a snapshot. +func decodeSnapshot(out *collectOutcome, addresses []string, results []SimulatedCallResult) (*customSnapshot, error) { + for _, result := range results { + if !result.Success { + return nil, violation(ErrSimulationFailed, "state read reverted") + } + } + escrowABI, err := abi.JSON(bytes.NewReader(authcapture.EscrowABIForDeployment(&out.deployment))) + if err != nil { + return nil, err + } + state, err := escrowABI.Unpack("paymentState", results[0].ReturnData) + if err != nil { + return nil, violation(ErrSimulationFailed, "undecodable paymentState result") + } + erc20ABI, err := abi.JSON(bytes.NewReader(evm.ERC20BalanceOfABI)) + if err != nil { + return nil, err + } + balances := make([]*big.Int, len(addresses)) + for i := range addresses { + decoded, err := erc20ABI.Unpack("balanceOf", results[i+1].ReturnData) + if err != nil || len(decoded) != 1 { + return nil, violation(ErrSimulationFailed, "undecodable balanceOf result") + } + balances[i] = asBigInt(decoded[0]) + } + return newSnapshot(addresses, state, balances) +} + +// checkEvents requires the escrow to have emitted exactly the PaymentAuthorized or PaymentCharged +// this collect names. +func (out *collectOutcome) checkEvents(logs []*gethtypes.Log) *outcomeViolation { + escrowABI, err := abi.JSON(bytes.NewReader(authcapture.EscrowABIForDeployment(&out.deployment))) + if err != nil { + return violation(ErrSimulationFailed, "escrow ABI: %v", err) + } + eventName := "PaymentAuthorized" + if out.function == "charge" { + eventName = "PaymentCharged" + } + event := escrowABI.Events[eventName] + hashBytes, err := evm.HexToBytes(out.paymentInfoHash) + if err != nil { + return violation(ErrSimulationFailed, "paymentInfoHash: %v", err) + } + for _, log := range logs { + if !strings.EqualFold(log.Address.Hex(), out.deployment.Escrow) || len(log.Topics) < 2 || + log.Topics[0] != event.ID || log.Topics[1] != common.BytesToHash(hashBytes) { + continue + } + fields, err := event.Inputs.NonIndexed().Unpack(log.Data) + if err != nil { + return violation(ErrSimulationFailed, "undecodable %s event", eventName) + } + if out.eventMatches(fields) { + return nil + } + } + return violation(ErrSimulationFailed, "custom operator did not emit the expected %s event", eventName) +} + +// eventMatches compares the event's non-indexed fields: paymentInfo, amount, tokenCollector and, +// for a charge, the fee and feeReceiver. +func (out *collectOutcome) eventMatches(fields []interface{}) bool { + want := 3 + if out.function == "charge" { + want = 5 + } + if len(fields) != want { + return false + } + amount, collector := asBigInt(fields[1]), fields[2] + if amount == nil || amount.Cmp(out.amount) != 0 { + return false + } + if collectorAddress, ok := collector.(common.Address); !ok || !strings.EqualFold(collectorAddress.Hex(), out.tokenCollector) { + return false + } + if out.function != "charge" { + return true + } + receiver, ok := fields[4].(common.Address) + if !ok || !strings.EqualFold(receiver.Hex(), out.feeReceiver) { + return false + } + if out.fee.Bps != nil { + bps, ok := fields[3].(uint16) + return ok && bps == *out.fee.Bps + } + fee := asBigInt(fields[3]) + return fee != nil && fee.Cmp(out.fee.Amount) == 0 +} + +// checkState requires the payment to be in the authorize or charge state. +func (out *collectOutcome) checkState(after *customSnapshot) *outcomeViolation { + wantCapturable, wantRefundable := new(big.Int).Set(out.amount), new(big.Int) + if out.function == "charge" { + wantCapturable, wantRefundable = wantRefundable, wantCapturable + } + if !after.collected || after.capturable.Cmp(wantCapturable) != 0 || after.refundable.Cmp(wantRefundable) != 0 { + return violation(ErrUnexpectedPaymentState, "payment state after the custom operator call is not the %s state", out.function) + } + return nil +} + +// checkTransition requires the authorize or charge state and every watched balance to move by +// exactly the expected delta. The submitter may only gain. +func (f *AuthCaptureEvmScheme) checkTransition(out *collectOutcome, expected map[string]*big.Int, before, after *customSnapshot) *outcomeViolation { + if v := out.checkState(after); v != nil { + return v + } + submitter := addressKey(f.submitterAddress()) + for address, balanceBefore := range before.balances { + delta := new(big.Int).Sub(after.balances[address], balanceBefore) + want := expected[address] + if want == nil { + want = new(big.Int) + } + if address == submitter { + if delta.Cmp(want) < 0 { + return violation(ErrUnexpectedPaymentState, "custom operator call reduced the facilitator's token balance") + } + } else if delta.Cmp(want) != 0 { + return violation(ErrUnexpectedPaymentState, "token balance of %s moved by %s, expected %s", address, delta, want) + } + } + return nil +} + +func (f *AuthCaptureEvmScheme) customTokenStore(ctx context.Context, out *collectOutcome) (string, error) { + result, err := f.signer.ReadContract(ctx, out.deployment.Escrow, authcapture.EscrowABIForDeployment(&out.deployment), + "getTokenStore", common.HexToAddress(out.paymentInfo.Operator)) + if err != nil { + return "", x402.NewVerifyError(ErrSimulationFailed, out.payer, "token store lookup failed: "+err.Error()) + } + switch store := result.(type) { + case common.Address: + return store.Hex(), nil + case string: + return store, nil + } + return "", x402.NewVerifyError(ErrSimulationFailed, out.payer, "unexpected token store result") +} + +// simulateCustomCollect runs the collect through the custom operator against one simulated state: +// reads, the gas-capped forwarded call, and the same reads again. +func (f *AuthCaptureEvmScheme) simulateCustomCollect(ctx context.Context, pre *collectPreconditions) error { + out := &pre.collectOutcome + simulator, ok := f.signer.(CallSimulator) + if !ok { + return x402.NewVerifyError(ErrOperatorNotAdmitted, out.payer, "signer cannot simulate custom operator calls") + } + tokenStore, err := f.customTokenStore(ctx, out) + if err != nil { + return err + } + addresses := f.watchedAddresses(out, tokenStore) + reads, err := f.readCalls(out, addresses) + if err != nil { + return x402.NewVerifyError(ErrSimulationFailed, out.payer, err.Error()) + } + args, err := pre.escrowArgs() + if err != nil { + return err + } + forwarded, err := packCall(authcapture.EscrowABIForDeployment(&out.deployment), out.function, args...) + if err != nil { + return x402.NewVerifyError(ErrPayloadFormat, out.payer, err.Error()) + } + + gasLimit := f.customGasLimit() + calls := append(append(append([]SimulatedCall{}, reads...), SimulatedCall{To: out.extra.CaptureAuthorizer, Data: forwarded, Gas: gasLimit}), reads...) + results, err := simulator.SimulateCalls(ctx, f.submitterAddress(), calls) + if err != nil { + return x402.NewVerifyError(ErrSimulationFailed, out.payer, err.Error()) + } + if len(results) != len(calls) { + return x402.NewVerifyError(ErrSimulationFailed, out.payer, "simulator returned an unexpected number of results") + } + + n := len(reads) + call := results[n] + if !call.Success { + return x402.NewVerifyError(revertReason(&out.deployment, call.ReturnData), out.payer, "custom operator call reverted") + } + if call.GasUsed > gasLimit { + return x402.NewVerifyError(ErrSimulationFailed, out.payer, "custom operator call exceeded its gas limit") + } + if v := out.checkEvents(call.Logs); v != nil { + return x402.NewVerifyError(v.reason, out.payer, v.message) + } + before, err := decodeSnapshot(out, addresses, results[:n]) + if err != nil { + return toVerifyViolation(err, out.payer) + } + after, err := decodeSnapshot(out, addresses, results[n+1:]) + if err != nil { + return toVerifyViolation(err, out.payer) + } + if before.collected { + return x402.NewVerifyError(ErrPaymentAlreadyCollected, out.payer, "payment was already collected") + } + if v := f.checkTransition(out, f.expectedDeltas(out, tokenStore), before, after); v != nil { + return x402.NewVerifyError(v.reason, out.payer, v.message) + } + return nil +} + +func toVerifyViolation(err error, payer string) error { + var v *outcomeViolation + if errors.As(err, &v) { + return x402.NewVerifyError(v.reason, payer, v.message) + } + return x402.NewVerifyError(ErrSimulationFailed, payer, err.Error()) +} + +// customSnapshotFor reads a snapshot from the chain, outside any simulation. +func (f *AuthCaptureEvmScheme) customSnapshotFor(ctx context.Context, out *collectOutcome, addresses []string) (*customSnapshot, error) { + hashBytes, err := evm.HexToBytes(out.paymentInfoHash) + if err != nil { + return nil, err + } + calls := []evm.MulticallCall{{ + Address: out.deployment.Escrow, + ABI: authcapture.EscrowABIForDeployment(&out.deployment), + FunctionName: "paymentState", + Args: []interface{}{common.BytesToHash(hashBytes)}, + }} + for _, address := range addresses { + calls = append(calls, evm.MulticallCall{ + Address: out.paymentInfo.Token, + ABI: evm.ERC20BalanceOfABI, + FunctionName: "balanceOf", + Args: []interface{}{common.HexToAddress(address)}, + }) + } + results, err := evm.Multicall(ctx, f.signer, calls) + if err != nil { + return nil, err + } + if len(results) != len(calls) { + return nil, violation(ErrSimulationFailed, "unexpected multicall result count") + } + for _, result := range results { + if !result.Success() { + return nil, violation(ErrSimulationFailed, "state read reverted") + } + } + state, _ := results[0].Result.([]interface{}) + balances := make([]*big.Int, len(addresses)) + for i := range addresses { + balances[i] = asBigInt(results[i+1].Result) + } + return newSnapshot(addresses, state, balances) +} + +// staleUncollected is the empty paymentState a node returns before it has the collect, +// even after it has already served the receipt. +func staleUncollected(snap *customSnapshot) bool { + return snap != nil && !snap.collected && snap.capturable.Sign() == 0 && snap.refundable.Sign() == 0 +} + +// snapshotAfterCollect reads the confirmed snapshot, retrying while paymentState is still +// the empty pre-collect struct. The node that served the receipt may not show the collect yet. +func (f *AuthCaptureEvmScheme) snapshotAfterCollect(ctx context.Context, out *collectOutcome, addresses []string) (*customSnapshot, error) { + var snap *customSnapshot + var err error + for attempt := 1; ; attempt++ { + snap, err = f.customSnapshotFor(ctx, out, addresses) + if err != nil || !staleUncollected(snap) || attempt == collectedReadAttempts { + return snap, err + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(collectedReadDelay): + } + } +} + +// snapshotCustomBalances reads the pre-broadcast state the receipt check compares against. +func (f *AuthCaptureEvmScheme) snapshotCustomBalances(ctx context.Context, out *collectOutcome) (*customSnapshot, error) { + tokenStore, err := f.customTokenStore(ctx, out) + if err != nil { + return nil, err + } + snapshot, err := f.customSnapshotFor(ctx, out, f.watchedAddresses(out, tokenStore)) + if err != nil { + return nil, x402.NewVerifyError(ErrSimulationFailed, out.payer, err.Error()) + } + return snapshot, nil +} + +// customReceiptCheck re-checks a confirmed custom operator collect: the escrow's event, the payment +// state and, when the pre-broadcast snapshot is known, the balance deltas. It is nil for any other +// operator type. +func (f *AuthCaptureEvmScheme) customReceiptCheck(out *collectOutcome, before *customSnapshot) receiptCheck { + if out.extra.OperatorType != authcapture.OperatorTypeCustom { + return nil + } + return func(ctx context.Context, receipt *evm.TransactionReceipt) (*x402.SettleResponse, error) { + rejected := func(v *outcomeViolation) (*x402.SettleResponse, error) { + return &x402.SettleResponse{ + Success: false, + ErrorReason: v.reason, + ErrorMessage: v.message, + Transaction: receipt.TxHash, + Network: out.network, + Payer: out.payer, + }, nil + } + if v := out.checkEvents(receipt.Logs); v != nil { + return rejected(v) + } + tokenStore, err := f.customTokenStore(ctx, out) + if err != nil { + return nil, toSettleError(err, out.network, out.payer) + } + after, err := f.snapshotAfterCollect(ctx, out, f.watchedAddresses(out, tokenStore)) + if err != nil { + return nil, toSettleError(toVerifyViolation(err, out.payer), out.network, out.payer) + } + var v *outcomeViolation + if before == nil { + v = out.checkState(after) + } else { + v = f.checkTransition(out, f.expectedDeltas(out, tokenStore), before, after) + } + if v != nil { + return rejected(v) + } + return nil, nil + } +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/custom_test.go b/go/mechanisms/evm/auth-capture/facilitator/custom_test.go new file mode 100644 index 0000000000..7d38da4e81 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/custom_test.go @@ -0,0 +1,472 @@ +package facilitator + +import ( + "bytes" + "context" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +var ( + customSubmitter = "0x" + strings.Repeat("b", 40) + customTokenStore = "0x" + strings.Repeat("5", 40) + startingBalance = big.NewInt(5_000_000) +) + +// customRun scripts one custom operator collect against a simulating mock signer. +type customRun struct { + t *testing.T + scheme *AuthCaptureEvmScheme + signer *mockFacSigner + payload types.PaymentPayload + requirements types.PaymentRequirements + pre *collectPreconditions + addresses []string + deltas map[string]*big.Int +} + +func newCustomRun(t *testing.T, charge bool) *customRun { + t.Helper() + var payload types.PaymentPayload + var requirements types.PaymentRequirements + if charge { + fx := newChargeFixture(t, map[string]interface{}{"operatorType": "custom"}, collectOpts{}) + payload, requirements = fx.complete(t, chargeOpts{}), fx.requirements + } else { + requirements = facBaseRequirements(facCaptureAuthorizer, map[string]interface{}{"operatorType": "custom"}) + payload = buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}) + } + + signer := newMockFacSigner(customSubmitter) + signer.tokenStore = customTokenStore + // The custom operator is not a signer address, so the scheme is relay-only. + scheme := NewAuthCaptureEvmScheme(signer, AuthCaptureEvmSchemeConfig{Operators: allowAllCustomOperators}) + pre, err := scheme.checkCollectPreconditions(context.Background(), payload, requirements, charge) + require.NoError(t, err) + + addresses := scheme.watchedAddresses(&pre.collectOutcome, customTokenStore) + return &customRun{ + t: t, scheme: scheme, signer: signer, payload: payload, requirements: requirements, pre: pre, + addresses: addresses, deltas: scheme.expectedDeltas(&pre.collectOutcome, customTokenStore), + } +} + +func (r *customRun) delta(address string) *big.Int { + if d := r.deltas[strings.ToLower(address)]; d != nil { + return d + } + return new(big.Int) +} + +func (r *customRun) state(collected bool, capturable, refundable *big.Int) []byte { + escrow, err := abi.JSON(bytes.NewReader(authcapture.EscrowABIForDeployment(&r.pre.deployment))) + require.NoError(r.t, err) + data, err := escrow.Methods["paymentState"].Outputs.Pack(collected, capturable, refundable) + require.NoError(r.t, err) + return data +} + +func (r *customRun) balance(amount *big.Int) []byte { + erc20, err := abi.JSON(bytes.NewReader(evm.ERC20BalanceOfABI)) + require.NoError(r.t, err) + data, err := erc20.Methods["balanceOf"].Outputs.Pack(amount) + require.NoError(r.t, err) + return data +} + +// finalState is the payment state the escrow reaches after the collect. +func (r *customRun) finalState() (capturable, refundable *big.Int) { + if r.pre.function == "charge" { + return big.NewInt(0), new(big.Int).Set(r.pre.amount) + } + return new(big.Int).Set(r.pre.amount), big.NewInt(0) +} + +// reads is one snapshot: the payment state, then each watched balance, moved by moved(address). +func (r *customRun) reads(collected bool, capturable, refundable *big.Int, moved func(address string) *big.Int) []SimulatedCallResult { + results := []SimulatedCallResult{{Success: true, ReturnData: r.state(collected, capturable, refundable)}} + for _, address := range r.addresses { + balance := new(big.Int).Set(startingBalance) + if moved != nil { + balance.Add(balance, moved(address)) + } + results = append(results, SimulatedCallResult{Success: true, ReturnData: r.balance(balance)}) + } + return results +} + +func (r *customRun) eventLog(tweak func(fields []interface{})) *gethtypes.Log { + out := &r.pre.collectOutcome + escrow, err := abi.JSON(bytes.NewReader(authcapture.EscrowABIForDeployment(&out.deployment))) + require.NoError(r.t, err) + name := "PaymentAuthorized" + tuple, err := out.paymentInfo.ToAbiTuple() + require.NoError(r.t, err) + fields := []interface{}{tuple, out.amount, common.HexToAddress(out.tokenCollector)} + if out.function == "charge" { + name = "PaymentCharged" + fields = append(fields, out.fee.Arg(), common.HexToAddress(out.feeReceiver)) + } + if tweak != nil { + tweak(fields) + } + data, err := escrow.Events[name].Inputs.NonIndexed().Pack(fields...) + require.NoError(r.t, err) + hash, err := evm.HexToBytes(out.paymentInfoHash) + require.NoError(r.t, err) + return &gethtypes.Log{ + Address: common.HexToAddress(out.deployment.Escrow), + Topics: []common.Hash{escrow.Events[name].ID, common.BytesToHash(hash)}, + Data: data, + } +} + +// simulation is the scripted eth_simulateV1 outcome; tests overwrite the part they break. +type simulation struct { + before, after []SimulatedCallResult + call SimulatedCallResult +} + +func (r *customRun) happy() *simulation { + capturable, refundable := r.finalState() + return &simulation{ + before: r.reads(false, big.NewInt(0), big.NewInt(0), nil), + after: r.reads(true, capturable, refundable, func(address string) *big.Int { return r.delta(address) }), + call: SimulatedCallResult{Success: true, GasUsed: 100_000, Logs: []*gethtypes.Log{r.eventLog(nil)}}, + } +} + +func (r *customRun) script(s *simulation) { + r.signer.simulateCalls = func(from string, calls []SimulatedCall) ([]SimulatedCallResult, error) { + assert.Equal(r.t, customSubmitter, from) + results := append(append(append([]SimulatedCallResult{}, s.before...), s.call), s.after...) + return results, nil + } +} + +func (r *customRun) verify() error { + _, err := r.scheme.Verify(context.Background(), r.payload, r.requirements, nil) + return err +} + +func TestVerifyCustomCollect_HappyPath(t *testing.T) { + for _, charge := range []bool{false, true} { + run := newCustomRun(t, charge) + run.script(run.happy()) + + assert.NoError(t, run.verify(), "charge=%v", charge) + } +} + +func TestVerifyCustomCollect_ForwardsThroughTheOperatorWithAGasCap(t *testing.T) { + run := newCustomRun(t, false) + s := run.happy() + run.signer.simulateCalls = func(_ string, calls []SimulatedCall) ([]SimulatedCallResult, error) { + call := calls[len(s.before)] + assert.True(t, strings.EqualFold(facCaptureAuthorizer, call.To), "the call goes to the custom operator") + assert.Equal(t, authcapture.DefaultCustomOperatorGasLimit, call.Gas) + return append(append(append([]SimulatedCallResult{}, s.before...), s.call), s.after...), nil + } + + require.NoError(t, run.verify()) +} + +func TestVerifyCustomCollect_Rejections(t *testing.T) { + zero := big.NewInt(0) + tests := []struct { + name string + charge bool + mutate func(r *customRun, s *simulation) + reason string + }{ + { + name: "the operator call reverts", + mutate: func(_ *customRun, s *simulation) { s.call = SimulatedCallResult{Success: false} }, + reason: ErrSimulationFailed, + }, + { + name: "the operator call exceeds its gas limit", + mutate: func(_ *customRun, s *simulation) { s.call.GasUsed = authcapture.DefaultCustomOperatorGasLimit + 1 }, + reason: ErrSimulationFailed, + }, + { + name: "no escrow event is emitted", + mutate: func(_ *customRun, s *simulation) { s.call.Logs = nil }, + reason: ErrSimulationFailed, + }, + { + name: "the event is for another amount", + mutate: func(r *customRun, s *simulation) { + s.call.Logs = []*gethtypes.Log{r.eventLog(func(f []interface{}) { f[1] = big.NewInt(1) })} + }, + reason: ErrSimulationFailed, + }, + { + name: "the event names another token collector", + mutate: func(r *customRun, s *simulation) { + s.call.Logs = []*gethtypes.Log{r.eventLog(func(f []interface{}) { f[2] = common.HexToAddress("0x" + strings.Repeat("9", 40)) })} + }, + reason: ErrSimulationFailed, + }, + { + name: "the charge event names another fee receiver", + charge: true, + mutate: func(r *customRun, s *simulation) { + s.call.Logs = []*gethtypes.Log{r.eventLog(func(f []interface{}) { f[4] = common.HexToAddress("0x" + strings.Repeat("9", 40)) })} + }, + reason: ErrSimulationFailed, + }, + { + name: "the charge event reports another fee", + charge: true, + mutate: func(r *customRun, s *simulation) { + s.call.Logs = []*gethtypes.Log{r.eventLog(func(f []interface{}) { f[3] = big.NewInt(7) })} + }, + reason: ErrSimulationFailed, + }, + { + name: "the event is emitted by another contract", + mutate: func(_ *customRun, s *simulation) { + s.call.Logs[0].Address = common.HexToAddress("0x" + strings.Repeat("9", 40)) + }, + reason: ErrSimulationFailed, + }, + { + name: "the payment was already collected", + mutate: func(r *customRun, s *simulation) { + s.before = r.reads(true, zero, zero, nil) + }, + reason: ErrPaymentAlreadyCollected, + }, + { + name: "the payment never reaches the collected state", + mutate: func(r *customRun, s *simulation) { + s.after = r.reads(false, zero, zero, func(a string) *big.Int { return r.delta(a) }) + }, + reason: ErrUnexpectedPaymentState, + }, + { + name: "the payer is debited less than the amount", + mutate: func(r *customRun, s *simulation) { + capturable, refundable := r.finalState() + s.after = r.reads(true, capturable, refundable, func(a string) *big.Int { + if strings.EqualFold(a, r.pre.payer) { + return new(big.Int).Neg(big.NewInt(1)) + } + return r.delta(a) + }) + }, + reason: ErrUnexpectedPaymentState, + }, + { + name: "the operator skims the token store", + mutate: func(r *customRun, s *simulation) { + capturable, refundable := r.finalState() + s.after = r.reads(true, capturable, refundable, func(a string) *big.Int { + if strings.EqualFold(a, customTokenStore) { + return new(big.Int).Sub(r.delta(a), big.NewInt(1)) + } + return r.delta(a) + }) + }, + reason: ErrUnexpectedPaymentState, + }, + { + name: "the operator drains the facilitator", + mutate: func(r *customRun, s *simulation) { + capturable, refundable := r.finalState() + s.after = r.reads(true, capturable, refundable, func(a string) *big.Int { + if strings.EqualFold(a, customSubmitter) { + return big.NewInt(-1) + } + return r.delta(a) + }) + }, + reason: ErrUnexpectedPaymentState, + }, + { + name: "a charge that leaves the hold capturable", + charge: true, + mutate: func(r *customRun, s *simulation) { + s.after = r.reads(true, r.pre.amount, zero, func(a string) *big.Int { return r.delta(a) }) + }, + reason: ErrUnexpectedPaymentState, + }, + { + name: "an unreadable state", + mutate: func(_ *customRun, s *simulation) { s.before[0].ReturnData = []byte{1} }, + reason: ErrSimulationFailed, + }, + { + name: "a reverted state read", + mutate: func(_ *customRun, s *simulation) { s.after[1].Success = false }, + reason: ErrSimulationFailed, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + run := newCustomRun(t, test.charge) + s := run.happy() + test.mutate(run, s) + run.script(s) + + assertVerifyReason(t, run.verify(), test.reason) + }) + } +} + +func TestVerifyCustomCollect_SimulatorFailures(t *testing.T) { + t.Run("the simulator errors", func(t *testing.T) { + run := newCustomRun(t, false) + run.signer.simulateCalls = func(string, []SimulatedCall) ([]SimulatedCallResult, error) { return nil, assert.AnError } + assertVerifyReason(t, run.verify(), ErrSimulationFailed) + }) + + t.Run("the simulator drops results", func(t *testing.T) { + run := newCustomRun(t, false) + run.signer.simulateCalls = func(string, []SimulatedCall) ([]SimulatedCallResult, error) { return nil, nil } + assertVerifyReason(t, run.verify(), ErrSimulationFailed) + }) +} + +func TestSettleCustomCollect(t *testing.T) { + // Settle re-reads the state around the broadcast, so the mock mirrors the happy simulation + // in its own state: balances move and the payment is collected once the write lands. + arm := func(run *customRun) { + run.script(run.happy()) + for _, address := range run.addresses { + run.signer.balances[address] = new(big.Int).Set(startingBalance) + } + run.signer.paymentStateCapturable, run.signer.paymentStateRefundable = big.NewInt(0), big.NewInt(0) + run.signer.afterWrite = func(string) { + capturable, refundable := run.finalState() + run.signer.paymentStateHasCollected = true + run.signer.paymentStateCapturable, run.signer.paymentStateRefundable = capturable, refundable + for _, address := range run.addresses { + run.signer.balances[address] = new(big.Int).Add(startingBalance, run.delta(address)) + } + } + run.signer.receipt = &evm.TransactionReceipt{ + Status: evm.TxStatusSuccess, TxHash: run.signer.writeTx, Logs: []*gethtypes.Log{run.eventLog(nil)}, + } + } + + for _, charge := range []bool{false, true} { + t.Run("a confirmed collect that matches settles", func(t *testing.T) { + run := newCustomRun(t, charge) + arm(run) + + resp, err := run.scheme.Settle(context.Background(), run.payload, run.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, uint64(authcapture.DefaultCustomOperatorGasLimit), run.signer.writeGas) + assert.Equal(t, strings.ToLower(facCaptureAuthorizer), strings.ToLower(run.signer.writeTarget)) + }) + } + + t.Run("a receipt without the escrow event is rejected", func(t *testing.T) { + run := newCustomRun(t, false) + arm(run) + run.signer.receipt.Logs = nil + + resp, err := run.scheme.Settle(context.Background(), run.payload, run.requirements, nil) + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Equal(t, ErrSimulationFailed, resp.ErrorReason) + assert.Equal(t, run.signer.writeTx, resp.Transaction) + }) + + t.Run("balances that moved differently on chain are rejected", func(t *testing.T) { + run := newCustomRun(t, false) + arm(run) + write := run.signer.afterWrite + run.signer.afterWrite = func(function string) { + write(function) + run.signer.balances[strings.ToLower(customTokenStore)] = new(big.Int).Set(startingBalance) + } + + resp, err := run.scheme.Settle(context.Background(), run.payload, run.requirements, nil) + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Equal(t, ErrUnexpectedPaymentState, resp.ErrorReason) + }) + + t.Run("a failing pre-broadcast simulation never broadcasts", func(t *testing.T) { + run := newCustomRun(t, false) + arm(run) + s := run.happy() + s.call.Logs = nil + run.script(s) + + _, err := run.scheme.Settle(context.Background(), run.payload, run.requirements, nil) + assertSettleReason(t, err, ErrSimulationFailed) + assert.Empty(t, run.signer.writtenFunctions) + }) + + t.Run("a lagging paymentState read is retried until the collect is visible", func(t *testing.T) { + run := newCustomRun(t, false) + arm(run) + write := run.signer.afterWrite + run.signer.afterWrite = func(function string) { + write(function) + run.signer.stalePaymentStateReads = 2 + } + + resp, err := run.scheme.Settle(context.Background(), run.payload, run.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, 0, run.signer.stalePaymentStateReads) + }) + + t.Run("a payment that stays uncollected is rejected", func(t *testing.T) { + run := newCustomRun(t, false) + arm(run) + write := run.signer.afterWrite + run.signer.afterWrite = func(function string) { + write(function) + run.signer.paymentStateHasCollected = false + run.signer.paymentStateCapturable = big.NewInt(0) + run.signer.paymentStateRefundable = big.NewInt(0) + } + + resp, err := run.scheme.Settle(context.Background(), run.payload, run.requirements, nil) + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Equal(t, ErrUnexpectedPaymentState, resp.ErrorReason) + }) + + t.Run("a resumed settlement is still checked against the receipt", func(t *testing.T) { + run := newCustomRun(t, true) + arm(run) + run.signer.receipt.Logs = nil + require.NoError(t, run.scheme.pendingStore.Set(context.Background(), settlementKey(run.payload.Payload), run.signer.writeTx)) + + resp, err := run.scheme.Settle(context.Background(), run.payload, run.requirements, nil) + require.NoError(t, err) + assert.False(t, resp.Success) + assert.Empty(t, run.signer.writtenFunctions) + }) +} + +func TestCustomOperatorRequiresASimulatingSigner(t *testing.T) { + run := newCustomRun(t, false) + plain := &plainSigner{Signer: run.signer} + scheme := NewAuthCaptureEvmScheme(plain, AuthCaptureEvmSchemeConfig{Operators: allowAllCustomOperators}) + + err := scheme.simulateCustomCollect(context.Background(), run.pre) + assertVerifyReason(t, err, ErrOperatorNotAdmitted) +} + +// plainSigner hides the optional simulator and gas-limit capabilities. +type plainSigner struct{ Signer } diff --git a/go/mechanisms/evm/auth-capture/facilitator/delegated_auth.go b/go/mechanisms/evm/auth-capture/facilitator/delegated_auth.go new file mode 100644 index 0000000000..59982dfc69 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/delegated_auth.go @@ -0,0 +1,330 @@ +package facilitator + +// Facilitator-delegated receiver authorizer: caller-identity binding. +// +// When the facilitator holds the receiverAuthorizer key, the authorizerSignature it produces is +// no longer evidence of the server's intent, so the request is authenticated out of band +// instead. The first authenticated caller to settle a payment is bound to its paymentInfoHash; +// every later facilitator-signed step must come from the same identity. The binding is written +// after re-verify and before broadcast, and fails closed. + +import ( + "context" + "errors" + "strconv" + "strings" + "sync" + "time" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + "github.com/x402-foundation/x402/go/v2/types" +) + +// DelegatedStep names the settle step a delegated request performs. +type DelegatedStep string + +// Delegated settle steps. +const ( + DelegatedStepAuthorize DelegatedStep = "authorize" + DelegatedStepCharge DelegatedStep = "charge" + DelegatedStepCapture DelegatedStep = "capture" + DelegatedStepVoid DelegatedStep = "void" + DelegatedStepRefund DelegatedStep = "refund" +) + +// DelegatedSettleContext is passed to AuthCaptureEvmSchemeConfig.ResolveCallerIdentity. +type DelegatedSettleContext struct { + Step DelegatedStep + PaymentInfoHash string + Network x402.Network + Payer string + Payload types.PaymentPayload + Requirements types.PaymentRequirements + FacilitatorContext *x402.FacilitatorContext +} + +// ResolveCallerIdentity authenticates the caller of a delegated settle out of band and returns a +// stable identity. Returning an empty string or an error rejects the settle. The identity must +// be the same across the authorize and the later lifecycle settles of one payment. +type ResolveCallerIdentity func(ctx context.Context, settle DelegatedSettleContext) (string, error) + +// AuthCaptureDelegatedAuthRecord is the caller binding for one payment. +type AuthCaptureDelegatedAuthRecord struct { + Network x402.Network + PaymentInfoHash string + // CallerIdentity is the stable identity returned by ResolveCallerIdentity. + CallerIdentity string + // ExpiresAt is unix seconds (refundDeadline). An expired row is treated as absent. + ExpiresAt uint64 +} + +// AuthCaptureDelegatedAuthWrite is the result of AuthCaptureDelegatedAuthStorage.Bind. +// RevertToken is empty when the row already existed, so a later revert leaves that row alone. +type AuthCaptureDelegatedAuthWrite struct { + // Record is the row as stored after the write. + Record AuthCaptureDelegatedAuthRecord + // RevertToken is an opaque host-defined token. Empty when this call did not create the row. + RevertToken string +} + +// AuthCaptureDelegatedAuthStorage is the pluggable storage for facilitator-delegated caller bindings. +type AuthCaptureDelegatedAuthStorage interface { + // Bind inserts the row when it is absent (an expired row counts as absent). The check and the + // insert MUST be one atomic operation under concurrent callers, for example a unique key on + // Network and PaymentInfoHash with insert-on-conflict. + // + // When a live row exists, leave its CallerIdentity and ExpiresAt untouched and return it with + // an empty RevertToken. A different identity is rejected by the SDK through bindThenBroadcast. + // A matching identity is an idempotent success; hosts SHOULD rotate the stored token so a + // concurrent creator's revert no longer matches. + Bind(ctx context.Context, record AuthCaptureDelegatedAuthRecord) (AuthCaptureDelegatedAuthWrite, error) + // RevertBind deletes the row only when write.RevertToken is non-empty and still matches. + RevertBind(ctx context.Context, write AuthCaptureDelegatedAuthWrite) error + // Get reads one live row. It returns nil when the row is absent or expired. + Get(ctx context.Context, network x402.Network, paymentInfoHash string) (*AuthCaptureDelegatedAuthRecord, error) + // Delete removes a row. Hosts SHOULD also purge expired rows (for example a TTL index on ExpiresAt). + Delete(ctx context.Context, network x402.Network, paymentInfoHash string) error +} + +// OnDelegatedAuthStorageError is reported when a binding revert or early delete fails. +// It must not replace the settle result. +type OnDelegatedAuthStorageError func(err error, network x402.Network, paymentInfoHash string) + +// ErrCallerIdentityConflict means the first writer already bound this payment to a different +// caller identity. +var ErrCallerIdentityConflict = errors.New("delegated auth binding already exists for a different identity") + +// delegatedAuthorizer is the resolved delegated-authorizer wiring for one request. +type delegatedAuthorizer struct { + signer evm.ClientEvmSigner + resolveCallerIdentity ResolveCallerIdentity + storage AuthCaptureDelegatedAuthStorage + onStorageError OnDelegatedAuthStorageError +} + +// getDelegatedAuthorizer returns the delegated authorizer when receiverAuthorizer is the key this +// facilitator holds and delegation is fully wired, and nil otherwise. +func getDelegatedAuthorizer(config AuthCaptureEvmSchemeConfig, receiverAuthorizer string) *delegatedAuthorizer { + if config.AuthorizerSigner == nil || config.ResolveCallerIdentity == nil || + config.DelegatedAuthStorage == nil || config.OnStorageError == nil { + return nil + } + if !strings.EqualFold(evm.NormalizeAddress(config.AuthorizerSigner.Address()), evm.NormalizeAddress(receiverAuthorizer)) { + return nil + } + return &delegatedAuthorizer{ + signer: config.AuthorizerSigner, + resolveCallerIdentity: config.ResolveCallerIdentity, + storage: config.DelegatedAuthStorage, + onStorageError: config.OnStorageError, + } +} + +// resolveDelegatedCallerIdentity resolves a delegated settle's caller identity. An error and an +// empty result are both treated as unauthenticated and return an empty string. +func resolveDelegatedCallerIdentity(ctx context.Context, delegated *delegatedAuthorizer, settle DelegatedSettleContext) string { + identity, err := delegated.resolveCallerIdentity(ctx, settle) + if err != nil { + return "" + } + return identity +} + +// reportDelegatedStorageError reports a storage failure that must not replace the settle result. +func reportDelegatedStorageError(delegated *delegatedAuthorizer, err error, network x402.Network, paymentInfoHash string) { + delegated.onStorageError(err, network, paymentInfoHash) +} + +// bindDisposition says how a broadcast ended, for bindThenBroadcast. +type bindDisposition string + +const ( + bindKeep bindDisposition = "keep" + bindRevert bindDisposition = "revert" +) + +// bindFailure says why a bind failed closed. +type bindFailure string + +const ( + bindConflict bindFailure = "conflict" + bindUnavailable bindFailure = "unavailable" +) + +// bindThenBroadcastResult is the outcome of bindThenBroadcast: the broadcast value when OK, or +// the reason the bind failed closed. +type bindThenBroadcastResult[T any] struct { + OK bool + Value T + Reason bindFailure + Err error +} + +// bindingCleanupTimeout bounds a binding revert or delete. +const bindingCleanupTimeout = 5 * time.Second + +// detachedCleanupContext keeps ctx's values but not its cancellation, so a client disconnect +// cannot leave a stale binding behind. +func detachedCleanupContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(ctx), bindingCleanupTimeout) +} + +// bindThenBroadcast binds the caller to the payment, then runs broadcast. +// +// The bind is fail-closed: a storage error or an identity conflict returns without calling +// broadcast. A binding this call created is reverted when broadcast returns bindRevert; bindKeep +// leaves it, because the outcome is then unknown. A revert error goes to OnStorageError and +// never replaces the broadcast result. +func bindThenBroadcast[T any]( + ctx context.Context, + delegated *delegatedAuthorizer, + record AuthCaptureDelegatedAuthRecord, + broadcast func() (T, bindDisposition), +) bindThenBroadcastResult[T] { + write, err := delegated.storage.Bind(ctx, record) + if err != nil { + return bindThenBroadcastResult[T]{Reason: bindUnavailable, Err: err} + } + if write.Record.CallerIdentity != record.CallerIdentity { + return bindThenBroadcastResult[T]{Reason: bindConflict, Err: ErrCallerIdentityConflict} + } + + value, disposition := broadcast() + if disposition == bindRevert { + cleanupCtx, cancel := detachedCleanupContext(ctx) + defer cancel() + if err := delegated.storage.RevertBind(cleanupCtx, write); err != nil { + reportDelegatedStorageError(delegated, err, record.Network, record.PaymentInfoHash) + } + } + return bindThenBroadcastResult[T]{OK: true, Value: value} +} + +// validateDelegatedReceiverAuthorizerConfig rejects a partial delegated-authorizer config. +func validateDelegatedReceiverAuthorizerConfig(config AuthCaptureEvmSchemeConfig) error { + anyDelegation := config.AuthorizerSigner != nil || config.ResolveCallerIdentity != nil || + config.DelegatedAuthStorage != nil || config.OnStorageError != nil + if !anyDelegation { + return nil + } + switch { + case config.AuthorizerSigner == nil: + return errors.New("facilitator-delegated receiver authorization requires AuthorizerSigner") + case config.ResolveCallerIdentity == nil: + return errors.New("AuthorizerSigner requires ResolveCallerIdentity") + case config.DelegatedAuthStorage == nil: + return errors.New("AuthorizerSigner requires DelegatedAuthStorage") + case config.OnStorageError == nil: + return errors.New("AuthorizerSigner requires OnStorageError") + } + return nil +} + +// delegatedAuthRow is one stored binding and the token that reverts it. +type delegatedAuthRow struct { + record AuthCaptureDelegatedAuthRecord + revertToken string +} + +// InMemoryAuthCaptureDelegatedAuthStorage is an in-memory AuthCaptureDelegatedAuthStorage. A +// per-row counter is the revert token: a matching-identity Bind on a live row rotates it and +// returns an empty token, so the creator's revert no longer matches. Expired rows are purged +// lazily on Bind and Get. Bindings are lost on restart, which fails closed. +type InMemoryAuthCaptureDelegatedAuthStorage struct { + mu sync.Mutex + rows map[string]*delegatedAuthRow + nextToken uint64 + now func() time.Time +} + +// NewInMemoryAuthCaptureDelegatedAuthStorage creates an empty in-memory binding store. +func NewInMemoryAuthCaptureDelegatedAuthStorage() *InMemoryAuthCaptureDelegatedAuthStorage { + return &InMemoryAuthCaptureDelegatedAuthStorage{ + rows: make(map[string]*delegatedAuthRow), + now: time.Now, + } +} + +// Bind implements AuthCaptureDelegatedAuthStorage. +func (s *InMemoryAuthCaptureDelegatedAuthStorage) Bind(_ context.Context, record AuthCaptureDelegatedAuthRecord) (AuthCaptureDelegatedAuthWrite, error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.purgeExpired() + key := delegatedAuthRowKey(record.Network, record.PaymentInfoHash) + s.nextToken++ + revertToken := strconv.FormatUint(s.nextToken, 10) + existing, ok := s.rows[key] + if !ok { + s.rows[key] = &delegatedAuthRow{record: record, revertToken: revertToken} + return AuthCaptureDelegatedAuthWrite{Record: record, RevertToken: revertToken}, nil + } + if existing.record.CallerIdentity == record.CallerIdentity { + existing.revertToken = revertToken + } + return AuthCaptureDelegatedAuthWrite{Record: existing.record}, nil +} + +// RevertBind implements AuthCaptureDelegatedAuthStorage. +func (s *InMemoryAuthCaptureDelegatedAuthStorage) RevertBind(_ context.Context, write AuthCaptureDelegatedAuthWrite) error { + if write.RevertToken == "" { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + + key := delegatedAuthRowKey(write.Record.Network, write.Record.PaymentInfoHash) + row, ok := s.rows[key] + if !ok || row.revertToken != write.RevertToken { + return nil + } + delete(s.rows, key) + return nil +} + +// Get implements AuthCaptureDelegatedAuthStorage. +func (s *InMemoryAuthCaptureDelegatedAuthStorage) Get(_ context.Context, network x402.Network, paymentInfoHash string) (*AuthCaptureDelegatedAuthRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + + key := delegatedAuthRowKey(network, paymentInfoHash) + row, ok := s.rows[key] + if !ok { + return nil, nil + } + if s.isExpired(row.record) { + delete(s.rows, key) + return nil, nil + } + record := row.record + return &record, nil +} + +// Delete implements AuthCaptureDelegatedAuthStorage. +func (s *InMemoryAuthCaptureDelegatedAuthStorage) Delete(_ context.Context, network x402.Network, paymentInfoHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + + delete(s.rows, delegatedAuthRowKey(network, paymentInfoHash)) + return nil +} + +// purgeExpired drops every expired row. The caller holds the lock. +func (s *InMemoryAuthCaptureDelegatedAuthStorage) purgeExpired() { + for key, row := range s.rows { + if s.isExpired(row.record) { + delete(s.rows, key) + } + } +} + +// isExpired reports whether a binding is past ExpiresAt. +func (s *InMemoryAuthCaptureDelegatedAuthStorage) isExpired(record AuthCaptureDelegatedAuthRecord) bool { + return record.ExpiresAt <= uint64(s.now().Unix()) +} + +// delegatedAuthRowKey is a composite key so the same hash on two networks cannot collide. +func delegatedAuthRowKey(network x402.Network, paymentInfoHash string) string { + return string(network) + "\x00" + strings.ToLower(paymentInfoHash) +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/delegated_auth_test.go b/go/mechanisms/evm/auth-capture/facilitator/delegated_auth_test.go new file mode 100644 index 0000000000..492f629099 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/delegated_auth_test.go @@ -0,0 +1,321 @@ +package facilitator + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" +) + +const ( + delegatedTestAuthorizer = "0x1111111111111111111111111111111111111111" + delegatedTestNetwork = x402.Network("eip155:84532") + delegatedTestNow = int64(1_800_000_000) +) + +var delegatedTestHash = "0x" + strings.Repeat("ab", 32) + +func delegatedRecord(mutate func(*AuthCaptureDelegatedAuthRecord)) AuthCaptureDelegatedAuthRecord { + record := AuthCaptureDelegatedAuthRecord{ + Network: delegatedTestNetwork, + PaymentInfoHash: delegatedTestHash, + CallerIdentity: "caller-1", + ExpiresAt: uint64(delegatedTestNow + 3600), + } + if mutate != nil { + mutate(&record) + } + return record +} + +// fakeClockStorage is an in-memory store whose clock the test controls. +func fakeClockStorage() (*InMemoryAuthCaptureDelegatedAuthStorage, func(seconds int64)) { + storage := NewInMemoryAuthCaptureDelegatedAuthStorage() + now := delegatedTestNow + storage.now = func() time.Time { return time.Unix(now, 0) } + return storage, func(seconds int64) { now = seconds } +} + +// stubAuthorizerSigner is a ClientEvmSigner that only has an address. +type stubAuthorizerSigner struct{ address string } + +func (s stubAuthorizerSigner) Address() string { return s.address } + +func (stubAuthorizerSigner) SignTypedData(context.Context, evm.TypedDataDomain, map[string][]evm.TypedDataField, string, map[string]interface{}) ([]byte, error) { + return nil, errors.New("not used") +} + +// recordedStorageErrors collects OnStorageError reports. +type recordedStorageErrors struct { + mu sync.Mutex + errs []error + network []x402.Network + hashes []string +} + +func (r *recordedStorageErrors) report(err error, network x402.Network, paymentInfoHash string) { + r.mu.Lock() + defer r.mu.Unlock() + r.errs = append(r.errs, err) + r.network = append(r.network, network) + r.hashes = append(r.hashes, paymentInfoHash) +} + +func delegatedWith(storage AuthCaptureDelegatedAuthStorage, reported *recordedStorageErrors) *delegatedAuthorizer { + return &delegatedAuthorizer{ + signer: stubAuthorizerSigner{address: delegatedTestAuthorizer}, + resolveCallerIdentity: func(context.Context, DelegatedSettleContext) (string, error) { return "", nil }, + storage: storage, + onStorageError: reported.report, + } +} + +// failingBindStorage fails every Bind. +type failingBindStorage struct { + AuthCaptureDelegatedAuthStorage +} + +func (failingBindStorage) Bind(context.Context, AuthCaptureDelegatedAuthRecord) (AuthCaptureDelegatedAuthWrite, error) { + return AuthCaptureDelegatedAuthWrite{}, errors.New("db down") +} + +// failingRevertStorage fails every RevertBind. +type failingRevertStorage struct { + AuthCaptureDelegatedAuthStorage +} + +func (failingRevertStorage) RevertBind(context.Context, AuthCaptureDelegatedAuthWrite) error { + return errors.New("db down") +} + +func TestInMemoryAuthCaptureDelegatedAuthStorage(t *testing.T) { + ctx := context.Background() + + t.Run("stores the first writer and returns a revert token", func(t *testing.T) { + storage, _ := fakeClockStorage() + write, err := storage.Bind(ctx, delegatedRecord(nil)) + require.NoError(t, err) + assert.Equal(t, "caller-1", write.Record.CallerIdentity) + assert.NotEmpty(t, write.RevertToken) + + stored, err := storage.Get(ctx, delegatedTestNetwork, delegatedTestHash) + require.NoError(t, err) + assert.Equal(t, ptr(delegatedRecord(nil)), stored) + }) + + t.Run("keeps the first writer and returns an empty token for a different identity", func(t *testing.T) { + storage, _ := fakeClockStorage() + _, err := storage.Bind(ctx, delegatedRecord(nil)) + require.NoError(t, err) + write, err := storage.Bind(ctx, delegatedRecord(func(r *AuthCaptureDelegatedAuthRecord) { + r.CallerIdentity = "caller-2" + r.ExpiresAt = 1 + })) + require.NoError(t, err) + assert.Equal(t, delegatedRecord(nil), write.Record) + assert.Empty(t, write.RevertToken) + + stored, err := storage.Get(ctx, delegatedTestNetwork, delegatedTestHash) + require.NoError(t, err) + assert.Equal(t, ptr(delegatedRecord(nil)), stored) + }) + + t.Run("rotates the token on a same-identity bind so the creator's revert no longer matches", func(t *testing.T) { + storage, _ := fakeClockStorage() + creator, err := storage.Bind(ctx, delegatedRecord(nil)) + require.NoError(t, err) + retry, err := storage.Bind(ctx, delegatedRecord(nil)) + require.NoError(t, err) + assert.Empty(t, retry.RevertToken) + + require.NoError(t, storage.RevertBind(ctx, creator)) + stored, err := storage.Get(ctx, delegatedTestNetwork, delegatedTestHash) + require.NoError(t, err) + assert.NotNil(t, stored) + }) + + t.Run("deletes the row on revert only with the matching non-empty token", func(t *testing.T) { + storage, _ := fakeClockStorage() + write, err := storage.Bind(ctx, delegatedRecord(nil)) + require.NoError(t, err) + + emptyToken := write + emptyToken.RevertToken = "" + require.NoError(t, storage.RevertBind(ctx, emptyToken)) + stored, _ := storage.Get(ctx, delegatedTestNetwork, delegatedTestHash) + assert.NotNil(t, stored) + + require.NoError(t, storage.RevertBind(ctx, write)) + stored, _ = storage.Get(ctx, delegatedTestNetwork, delegatedTestHash) + assert.Nil(t, stored) + }) + + t.Run("treats an expired row as absent and lets a new identity bind", func(t *testing.T) { + storage, setNow := fakeClockStorage() + _, err := storage.Bind(ctx, delegatedRecord(func(r *AuthCaptureDelegatedAuthRecord) { r.ExpiresAt = uint64(delegatedTestNow + 10) })) + require.NoError(t, err) + setNow(delegatedTestNow + 10) + stored, err := storage.Get(ctx, delegatedTestNetwork, delegatedTestHash) + require.NoError(t, err) + assert.Nil(t, stored) + + _, err = storage.Bind(ctx, delegatedRecord(func(r *AuthCaptureDelegatedAuthRecord) { r.ExpiresAt = uint64(delegatedTestNow + 20) })) + require.NoError(t, err) + setNow(delegatedTestNow + 20) + write, err := storage.Bind(ctx, delegatedRecord(func(r *AuthCaptureDelegatedAuthRecord) { + r.CallerIdentity = "caller-2" + r.ExpiresAt = uint64(delegatedTestNow + 99) + })) + require.NoError(t, err) + assert.Equal(t, "caller-2", write.Record.CallerIdentity) + assert.NotEmpty(t, write.RevertToken) + }) + + t.Run("scopes rows by network and case-insensitive hash", func(t *testing.T) { + storage, _ := fakeClockStorage() + _, err := storage.Bind(ctx, delegatedRecord(nil)) + require.NoError(t, err) + + other, err := storage.Get(ctx, "eip155:8453", delegatedTestHash) + require.NoError(t, err) + assert.Nil(t, other) + upper, err := storage.Get(ctx, delegatedTestNetwork, "0x"+strings.ToUpper(delegatedTestHash[2:])) + require.NoError(t, err) + assert.NotNil(t, upper) + + require.NoError(t, storage.Delete(ctx, delegatedTestNetwork, delegatedTestHash)) + stored, err := storage.Get(ctx, delegatedTestNetwork, delegatedTestHash) + require.NoError(t, err) + assert.Nil(t, stored) + }) +} + +func TestGetDelegatedAuthorizer(t *testing.T) { + storage := NewInMemoryAuthCaptureDelegatedAuthStorage() + config := AuthCaptureEvmSchemeConfig{ + AuthorizerSigner: stubAuthorizerSigner{address: delegatedTestAuthorizer}, + ResolveCallerIdentity: func(context.Context, DelegatedSettleContext) (string, error) { return "caller-1", nil }, + DelegatedAuthStorage: storage, + OnStorageError: func(error, x402.Network, string) {}, + } + + t.Run("returns the wiring only when the receiverAuthorizer is the configured signer", func(t *testing.T) { + delegated := getDelegatedAuthorizer(config, delegatedTestAuthorizer) + require.NotNil(t, delegated) + assert.Same(t, storage, delegated.storage) + assert.NotNil(t, getDelegatedAuthorizer(config, "0x"+strings.ToUpper(delegatedTestAuthorizer[2:]))) + assert.Nil(t, getDelegatedAuthorizer(config, "0x2222222222222222222222222222222222222222")) + }) + + t.Run("returns nil when delegation is not fully configured", func(t *testing.T) { + assert.Nil(t, getDelegatedAuthorizer(AuthCaptureEvmSchemeConfig{}, delegatedTestAuthorizer)) + + noStorage := config + noStorage.DelegatedAuthStorage = nil + assert.Nil(t, getDelegatedAuthorizer(noStorage, delegatedTestAuthorizer)) + + noOnStorageError := config + noOnStorageError.OnStorageError = nil + assert.Nil(t, getDelegatedAuthorizer(noOnStorageError, delegatedTestAuthorizer)) + }) +} + +func TestResolveDelegatedCallerIdentity(t *testing.T) { + settle := DelegatedSettleContext{Step: DelegatedStepCapture} + + resolve := func(identity string, err error) string { + delegated := delegatedWith(NewInMemoryAuthCaptureDelegatedAuthStorage(), &recordedStorageErrors{}) + delegated.resolveCallerIdentity = func(context.Context, DelegatedSettleContext) (string, error) { return identity, err } + return resolveDelegatedCallerIdentity(context.Background(), delegated, settle) + } + + t.Run("returns a non-empty identity", func(t *testing.T) { + assert.Equal(t, "caller-1", resolve("caller-1", nil)) + }) + + t.Run("treats an empty identity as unauthenticated", func(t *testing.T) { + assert.Empty(t, resolve("", nil)) + }) + + t.Run("treats a returned error as unauthenticated", func(t *testing.T) { + assert.Empty(t, resolve("caller-1", errors.New("boom"))) + }) +} + +func TestBindThenBroadcast(t *testing.T) { + ctx := context.Background() + + t.Run("broadcasts after a successful bind and keeps the row", func(t *testing.T) { + storage, _ := fakeClockStorage() + result := bindThenBroadcast(ctx, delegatedWith(storage, &recordedStorageErrors{}), delegatedRecord(nil), + func() (string, bindDisposition) { return "ok", bindKeep }) + assert.True(t, result.OK) + assert.Equal(t, "ok", result.Value) + + stored, _ := storage.Get(ctx, delegatedTestNetwork, delegatedTestHash) + assert.NotNil(t, stored) + }) + + t.Run("reverts the created row when the broadcast asks for it", func(t *testing.T) { + storage, _ := fakeClockStorage() + result := bindThenBroadcast(ctx, delegatedWith(storage, &recordedStorageErrors{}), delegatedRecord(nil), + func() (string, bindDisposition) { return "failed", bindRevert }) + assert.True(t, result.OK) + assert.Equal(t, "failed", result.Value) + + stored, _ := storage.Get(ctx, delegatedTestNetwork, delegatedTestHash) + assert.Nil(t, stored) + }) + + t.Run("reports a failing revert without replacing the broadcast result", func(t *testing.T) { + storage, _ := fakeClockStorage() + reported := &recordedStorageErrors{} + result := bindThenBroadcast(ctx, delegatedWith(failingRevertStorage{storage}, reported), delegatedRecord(nil), + func() (string, bindDisposition) { return "failed", bindRevert }) + assert.True(t, result.OK) + assert.Equal(t, "failed", result.Value) + require.Len(t, reported.errs, 1) + assert.Equal(t, delegatedTestNetwork, reported.network[0]) + assert.Equal(t, delegatedTestHash, reported.hashes[0]) + }) + + t.Run("fails closed without broadcasting on an identity conflict", func(t *testing.T) { + storage, _ := fakeClockStorage() + _, err := storage.Bind(ctx, delegatedRecord(func(r *AuthCaptureDelegatedAuthRecord) { r.CallerIdentity = "someone-else" })) + require.NoError(t, err) + + broadcast := false + result := bindThenBroadcast(ctx, delegatedWith(storage, &recordedStorageErrors{}), delegatedRecord(nil), + func() (string, bindDisposition) { + broadcast = true + return "", bindKeep + }) + assert.False(t, result.OK) + assert.Equal(t, bindConflict, result.Reason) + assert.ErrorIs(t, result.Err, ErrCallerIdentityConflict) + assert.False(t, broadcast) + }) + + t.Run("fails closed without broadcasting when the store is unavailable", func(t *testing.T) { + storage, _ := fakeClockStorage() + broadcast := false + result := bindThenBroadcast(ctx, delegatedWith(failingBindStorage{storage}, &recordedStorageErrors{}), delegatedRecord(nil), + func() (string, bindDisposition) { + broadcast = true + return "", bindKeep + }) + assert.False(t, result.OK) + assert.Equal(t, bindUnavailable, result.Reason) + assert.False(t, broadcast) + }) +} + +func ptr[T any](value T) *T { return &value } diff --git a/go/mechanisms/evm/auth-capture/facilitator/delegated_test.go b/go/mechanisms/evm/auth-capture/facilitator/delegated_test.go new file mode 100644 index 0000000000..5631cb19e5 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/delegated_test.go @@ -0,0 +1,479 @@ +package facilitator + +import ( + "context" + "errors" + "math/big" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +const delegatedCaller = "caller-1" + +// recordingAuthorizer is the delegated authorizer signer; it records what it was asked to sign. +type recordingAuthorizer struct { + evm.ClientEvmSigner + primaryTypes []string +} + +func (r *recordingAuthorizer) SignTypedData(ctx context.Context, domain evm.TypedDataDomain, fields map[string][]evm.TypedDataField, primaryType string, message map[string]interface{}) ([]byte, error) { + r.primaryTypes = append(r.primaryTypes, primaryType) + return r.ClientEvmSigner.SignTypedData(ctx, domain, fields, primaryType, message) +} + +// failingGetStorage fails every Get. +type failingGetStorage struct { + AuthCaptureDelegatedAuthStorage +} + +func (failingGetStorage) Get(context.Context, x402.Network, string) (*AuthCaptureDelegatedAuthRecord, error) { + return nil, errors.New("db down") +} + +// failingDeleteStorage fails every Delete. +type failingDeleteStorage struct { + AuthCaptureDelegatedAuthStorage +} + +func (failingDeleteStorage) Delete(context.Context, x402.Network, string) error { + return errors.New("db down") +} + +// delegatedHarness is a facilitator whose receiverAuthorizer key it holds. +type delegatedHarness struct { + scheme *AuthCaptureEvmScheme + signer *mockFacSigner + storage AuthCaptureDelegatedAuthStorage + authorizer *recordingAuthorizer + reported *recordedStorageErrors + settles []DelegatedSettleContext +} + +type delegatedOpts struct { + // identity is what the resolver returns; nil selects delegatedCaller and "" is unauthenticated. + identity *string + resolverErr error + storage AuthCaptureDelegatedAuthStorage + noRefundFunding bool +} + +func newDelegatedHarness(signer *mockFacSigner, receiver evm.ClientEvmSigner, opts delegatedOpts) *delegatedHarness { + h := &delegatedHarness{ + signer: signer, + storage: opts.storage, + authorizer: &recordingAuthorizer{ClientEvmSigner: receiver}, + reported: &recordedStorageErrors{}, + } + if h.storage == nil { + h.storage = NewInMemoryAuthCaptureDelegatedAuthStorage() + } + identity := delegatedCaller + if opts.identity != nil { + identity = *opts.identity + } + h.scheme = newScheme(signer, AuthCaptureEvmSchemeConfig{ + AuthorizerSigner: h.authorizer, + ResolveCallerIdentity: func(_ context.Context, settle DelegatedSettleContext) (string, error) { + h.settles = append(h.settles, settle) + return identity, opts.resolverErr + }, + DelegatedAuthStorage: h.storage, + OnStorageError: h.reported.report, + RefundFunding: !opts.noRefundFunding, + }) + return h +} + +func (h *delegatedHarness) binding(t *testing.T, hash string) *AuthCaptureDelegatedAuthRecord { + t.Helper() + record, err := h.storage.Get(context.Background(), facNetwork, hash) + require.NoError(t, err) + return record +} + +func (h *delegatedHarness) bindRow(t *testing.T, hash, identity string, expiresAt uint64) { + t.Helper() + _, err := h.storage.Bind(context.Background(), AuthCaptureDelegatedAuthRecord{ + Network: facNetwork, PaymentInfoHash: hash, CallerIdentity: identity, ExpiresAt: expiresAt, + }) + require.NoError(t, err) +} + +func strPtr(value string) *string { return &value } + +func unsigned(payload types.PaymentPayload) types.PaymentPayload { + wire := map[string]interface{}{} + for key, value := range payload.Payload { + wire[key] = value + } + delete(wire, "authorizerSignature") + payload.Payload = wire + return payload +} + +// collectPaymentInfoHash is the paymentInfoHash of a collect payload. +func collectPaymentInfoHash(t *testing.T, requirements types.PaymentRequirements, payload types.PaymentPayload) string { + t.Helper() + extra, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) + require.NoError(t, err) + authorization := payload.Payload["authorization"].(map[string]interface{}) + validBefore, err := strconv.ParseUint(authorization["validBefore"].(string), 10, 64) + require.NoError(t, err) + payer := authorization["from"].(string) + chainID, err := evm.GetEvmChainId(requirements.Network) + require.NoError(t, err) + paymentInfo := authcapture.ReconstructPaymentInfo(payer, validBefore, payload.Payload["salt"].(string), requirements, extra) + hash, err := authcapture.ComputePaymentInfoHash(chainID, paymentInfo, payer, deployment.Escrow) + require.NoError(t, err) + return hash +} + +func TestDelegatedConfiguration(t *testing.T) { + signer := newMockFacSigner(facCaptureAuthorizer) + authorizer := stubAuthorizerSigner{address: delegatedTestAuthorizer} + resolve := func(context.Context, DelegatedSettleContext) (string, error) { return delegatedCaller, nil } + onError := func(error, x402.Network, string) {} + storage := NewInMemoryAuthCaptureDelegatedAuthStorage() + build := func(config AuthCaptureEvmSchemeConfig) func() { return func() { newScheme(signer, config) } } + + t.Run("requires the full delegation quartet together", func(t *testing.T) { + assert.PanicsWithValue(t, "AuthorizerSigner requires ResolveCallerIdentity", + build(AuthCaptureEvmSchemeConfig{AuthorizerSigner: authorizer})) + assert.PanicsWithValue(t, "facilitator-delegated receiver authorization requires AuthorizerSigner", + build(AuthCaptureEvmSchemeConfig{ResolveCallerIdentity: resolve})) + assert.PanicsWithValue(t, "AuthorizerSigner requires DelegatedAuthStorage", + build(AuthCaptureEvmSchemeConfig{AuthorizerSigner: authorizer, ResolveCallerIdentity: resolve})) + assert.PanicsWithValue(t, "AuthorizerSigner requires OnStorageError", + build(AuthCaptureEvmSchemeConfig{AuthorizerSigner: authorizer, ResolveCallerIdentity: resolve, DelegatedAuthStorage: storage})) + assert.PanicsWithValue(t, "facilitator-delegated receiver authorization requires AuthorizerSigner", + build(AuthCaptureEvmSchemeConfig{DelegatedAuthStorage: storage})) + assert.NotPanics(t, + build(AuthCaptureEvmSchemeConfig{AuthorizerSigner: authorizer, ResolveCallerIdentity: resolve, DelegatedAuthStorage: storage, OnStorageError: onError})) + }) + + t.Run("advertises the authorizer address as receiverAuthorizer", func(t *testing.T) { + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{ + AuthorizerSigner: authorizer, ResolveCallerIdentity: resolve, DelegatedAuthStorage: storage, OnStorageError: onError, + }) + extra := scheme.GetExtra(facNetwork) + assert.Equal(t, delegatedTestAuthorizer, extra["receiverAuthorizer"]) + }) + + t.Run("does not advertise receiverAuthorizer when delegation is not configured", func(t *testing.T) { + extra := newScheme(signer, AuthCaptureEvmSchemeConfig{}).GetExtra(facNetwork) + assert.NotContains(t, extra, "receiverAuthorizer") + }) +} + +func TestDelegatedCharge(t *testing.T) { + newCharge := func(t *testing.T, opts delegatedOpts) (chargeFixture, *delegatedHarness, string) { + t.Helper() + fx := newChargeFixture(t, nil, collectOpts{}) + h := newDelegatedHarness(newMockFacSigner(facCaptureAuthorizer), fx.receiver, opts) + return fx, h, collectPaymentInfoHash(t, fx.requirements, fx.payload) + } + + t.Run("verifies an unsigned charge without resolving identity or binding", func(t *testing.T) { + fx, h, hash := newCharge(t, delegatedOpts{}) + resp, err := h.scheme.Verify(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + assert.Empty(t, h.settles) + assert.Nil(t, h.binding(t, hash)) + }) + + t.Run("rejects an unsigned charge when no delegated authorizer is configured", func(t *testing.T) { + fx := newChargeFixture(t, nil, collectOpts{}) + scheme := newScheme(newMockFacSigner(facCaptureAuthorizer), AuthCaptureEvmSchemeConfig{}) + _, err := scheme.Verify(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + assertVerifyReason(t, err, ErrAuthorizerSignature) + }) + + t.Run("signs the Charge digest, binds the caller until refundDeadline, then broadcasts", func(t *testing.T) { + fx, h, hash := newCharge(t, delegatedOpts{}) + resp, err := h.scheme.Settle(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, []string{"Charge"}, h.authorizer.primaryTypes) + + require.Len(t, h.settles, 1) + assert.Equal(t, DelegatedStepCharge, h.settles[0].Step) + assert.Equal(t, hash, h.settles[0].PaymentInfoHash) + + binding := h.binding(t, hash) + require.NotNil(t, binding) + assert.Equal(t, delegatedCaller, binding.CallerIdentity) + assert.Equal(t, fx.extra.RefundDeadline, binding.ExpiresAt) + assert.Equal(t, []string{"charge"}, h.signer.writtenFunctions) + }) + + t.Run("does not bind a charge that cannot be refunded through this facilitator", func(t *testing.T) { + fx, h, hash := newCharge(t, delegatedOpts{noRefundFunding: true}) + resp, err := h.scheme.Settle(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Nil(t, h.binding(t, hash)) + }) + + t.Run("refuses to sign or broadcast for an unauthenticated caller", func(t *testing.T) { + fx, h, _ := newCharge(t, delegatedOpts{identity: strPtr("")}) + _, err := h.scheme.Settle(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + assertSettleReason(t, err, ErrUnauthenticatedAuthorizerRequest) + assert.Empty(t, h.authorizer.primaryTypes) + assert.Empty(t, h.signer.writtenFunctions) + }) + + t.Run("treats a failing identity resolver as unauthenticated", func(t *testing.T) { + fx, h, _ := newCharge(t, delegatedOpts{resolverErr: errors.New("auth backend down")}) + _, err := h.scheme.Settle(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + assertSettleReason(t, err, ErrUnauthenticatedAuthorizerRequest) + assert.Empty(t, h.signer.writtenFunctions) + }) + + t.Run("fails closed with no broadcast when another identity already holds the binding", func(t *testing.T) { + fx, h, hash := newCharge(t, delegatedOpts{}) + h.bindRow(t, hash, "someone-else", fx.extra.RefundDeadline) + _, err := h.scheme.Settle(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + assertSettleReason(t, err, ErrUnauthenticatedAuthorizerRequest) + assert.Empty(t, h.signer.writtenFunctions) + assert.Equal(t, "someone-else", h.binding(t, hash).CallerIdentity) + }) + + t.Run("fails closed with no broadcast when the binding store is unavailable", func(t *testing.T) { + fx, h, _ := newCharge(t, delegatedOpts{storage: failingBindStorage{NewInMemoryAuthCaptureDelegatedAuthStorage()}}) + _, err := h.scheme.Settle(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + assertSettleReason(t, err, ErrDelegatedAuthUnavailable) + assert.Empty(t, h.signer.writtenFunctions) + }) + + t.Run("reverts the binding it created when the broadcast is rejected", func(t *testing.T) { + fx, h, hash := newCharge(t, delegatedOpts{}) + h.signer.writeErr["charge"] = errors.New("insufficient funds for gas") + _, err := h.scheme.Settle(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + require.Error(t, err) + assert.Nil(t, h.binding(t, hash)) + }) + + t.Run("reverts the binding it created when the transaction reverts onchain", func(t *testing.T) { + fx, h, hash := newCharge(t, delegatedOpts{}) + h.signer.receipt = &evm.TransactionReceipt{Status: evm.TxStatusFailed, TxHash: h.signer.writeTx} + _, err := h.scheme.Settle(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + assertSettleReason(t, err, ErrTransactionReverted) + assert.Nil(t, h.binding(t, hash)) + }) + + t.Run("keeps the binding when the receipt wait times out", func(t *testing.T) { + fx, h, hash := newCharge(t, delegatedOpts{}) + h.signer.receiptErr = errors.New("receipt timeout") + _, err := h.scheme.Settle(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + require.Error(t, err) + binding := h.binding(t, hash) + require.NotNil(t, binding) + assert.Equal(t, delegatedCaller, binding.CallerIdentity) + }) + + t.Run("does not delete a pre-existing binding when a same-identity retry reverts", func(t *testing.T) { + fx, h, hash := newCharge(t, delegatedOpts{}) + h.bindRow(t, hash, delegatedCaller, fx.extra.RefundDeadline) + h.signer.receipt = &evm.TransactionReceipt{Status: evm.TxStatusFailed, TxHash: h.signer.writeTx} + _, err := h.scheme.Settle(context.Background(), unsigned(fx.complete(t, chargeOpts{})), fx.requirements, nil) + require.Error(t, err) + assert.NotNil(t, h.binding(t, hash)) + }) + + t.Run("binds an authorize settle for a delegated operator", func(t *testing.T) { + receiver := newKeySigner(t) + requirements := facBaseRequirements(facCaptureAuthorizer, map[string]interface{}{"receiverAuthorizer": receiver.Address()}) + payload := buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}) + h := newDelegatedHarness(newMockFacSigner(facCaptureAuthorizer), receiver, delegatedOpts{}) + + resp, err := h.scheme.Settle(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + require.Len(t, h.settles, 1) + assert.Equal(t, DelegatedStepAuthorize, h.settles[0].Step) + + binding := h.binding(t, collectPaymentInfoHash(t, requirements, payload)) + require.NotNil(t, binding) + assert.Equal(t, delegatedCaller, binding.CallerIdentity) + }) +} + +// delegatedLifecycle is a lifecycle fixture served by a delegated harness. +func newDelegatedLifecycle(t *testing.T, opts delegatedOpts) (lifecycleFixture, *delegatedHarness) { + t.Helper() + fx := newLifecycleFixture(t, nil) + return fx, newDelegatedHarness(fx.signer(), fx.receiver, opts) +} + +// unsignedCapture is a capture the server built without a local receiver-authorizer signer. +func unsignedCapture(t *testing.T, fx lifecycleFixture, opts captureOpts, voidRemainder bool) types.PaymentPayload { + t.Helper() + wire := fx.buildCapture(t, opts) + delete(wire, "authorizerSignature") + delete(wire, "voidAuthorizerSignature") + if voidRemainder { + wire["voidRemainder"] = true + } + return fx.payload(wire) +} + +func TestDelegatedLifecycle(t *testing.T) { + + t.Run("signs and settles an unsigned capture for the bound caller", func(t *testing.T) { + fx, h := newDelegatedLifecycle(t, delegatedOpts{}) + h.bindRow(t, fx.paymentHash, delegatedCaller, fx.extra.RefundDeadline) + + resp, err := h.scheme.Settle(context.Background(), unsignedCapture(t, fx, captureOpts{}, false), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + require.Len(t, h.settles, 1) + assert.Equal(t, DelegatedStepCapture, h.settles[0].Step) + assert.Equal(t, []string{"Capture"}, h.authorizer.primaryTypes) + assert.Equal(t, []string{"capture"}, h.signer.writtenFunctions) + }) + + t.Run("verifies an unsigned capture for the bound caller and rejects everyone else", func(t *testing.T) { + fx, h := newDelegatedLifecycle(t, delegatedOpts{}) + payload := unsignedCapture(t, fx, captureOpts{}, false) + + _, err := h.scheme.Verify(context.Background(), payload, fx.requirements, nil) + assertVerifyReason(t, err, ErrUnauthenticatedAuthorizerRequest) + + h.bindRow(t, fx.paymentHash, "someone-else", fx.extra.RefundDeadline) + _, err = h.scheme.Verify(context.Background(), payload, fx.requirements, nil) + assertVerifyReason(t, err, ErrUnauthenticatedAuthorizerRequest) + + require.NoError(t, h.storage.Delete(context.Background(), facNetwork, fx.paymentHash)) + h.bindRow(t, fx.paymentHash, delegatedCaller, fx.extra.RefundDeadline) + resp, err := h.scheme.Verify(context.Background(), payload, fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + }) + + t.Run("rejects an unauthenticated caller before touching the binding store", func(t *testing.T) { + fx, h := newDelegatedLifecycle(t, delegatedOpts{identity: strPtr("")}) + h.bindRow(t, fx.paymentHash, delegatedCaller, fx.extra.RefundDeadline) + + _, err := h.scheme.Settle(context.Background(), unsignedCapture(t, fx, captureOpts{}, false), fx.requirements, nil) + assertSettleReason(t, err, ErrUnauthenticatedAuthorizerRequest) + assert.Empty(t, h.signer.writtenFunctions) + }) + + t.Run("treats an expired binding as absent", func(t *testing.T) { + fx, h := newDelegatedLifecycle(t, delegatedOpts{}) + h.bindRow(t, fx.paymentHash, delegatedCaller, 1) + + _, err := h.scheme.Settle(context.Background(), unsignedCapture(t, fx, captureOpts{}, false), fx.requirements, nil) + assertSettleReason(t, err, ErrUnauthenticatedAuthorizerRequest) + assert.Empty(t, h.signer.writtenFunctions) + }) + + t.Run("reports the binding store being unavailable instead of unauthenticated", func(t *testing.T) { + fx, h := newDelegatedLifecycle(t, delegatedOpts{storage: failingGetStorage{NewInMemoryAuthCaptureDelegatedAuthStorage()}}) + + _, err := h.scheme.Settle(context.Background(), unsignedCapture(t, fx, captureOpts{}, false), fx.requirements, nil) + assertSettleReason(t, err, ErrDelegatedAuthUnavailable) + assert.Empty(t, h.signer.writtenFunctions) + }) + + t.Run("signs both legs of a capture that voids the remainder", func(t *testing.T) { + fx, h := newDelegatedLifecycle(t, delegatedOpts{}) + h.bindRow(t, fx.paymentHash, delegatedCaller, fx.extra.RefundDeadline) + h.signer.afterWrite = func(function string) { + if function == "capture" { + h.signer.paymentStateCapturable = big.NewInt(400000) + } + } + + resp, err := h.scheme.Settle(context.Background(), unsignedCapture(t, fx, captureOpts{amount: "600000"}, true), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, []string{"Capture", "Void"}, h.authorizer.primaryTypes) + assert.Equal(t, []string{"capture", "void"}, h.signer.writtenFunctions) + }) + + t.Run("rejects voidRemainder combined with a signature as a malformed payload", func(t *testing.T) { + fx, h := newDelegatedLifecycle(t, delegatedOpts{}) + h.bindRow(t, fx.paymentHash, delegatedCaller, fx.extra.RefundDeadline) + payload := unsignedCapture(t, fx, captureOpts{amount: "600000"}, true) + payload.Payload["voidAuthorizerSignature"] = fx.voidSignature(t) + + _, err := h.scheme.Verify(context.Background(), payload, fx.requirements, nil) + assertVerifyReason(t, err, ErrPayloadFormat) + _, err = h.scheme.Settle(context.Background(), payload, fx.requirements, nil) + assertSettleReason(t, err, ErrPayloadFormat) + + signed := fx.buildCapture(t, captureOpts{amount: "600000"}) + signed["voidRemainder"] = true + _, err = h.scheme.Settle(context.Background(), fx.payload(signed), fx.requirements, nil) + assertSettleReason(t, err, ErrPayloadFormat) + assert.Empty(t, h.signer.writtenFunctions) + }) + + t.Run("rejects voidRemainder when the authorizer is not delegated", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + payload := unsignedCapture(t, fx, captureOpts{amount: "600000"}, true) + + _, err := newScheme(fx.signer(), AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), payload, fx.requirements, nil) + assertVerifyReason(t, err, ErrAuthorizerSignature) + }) + + t.Run("signs an unsigned void and releases the binding once nothing is left", func(t *testing.T) { + fx, h := newDelegatedLifecycle(t, delegatedOpts{}) + h.bindRow(t, fx.paymentHash, delegatedCaller, fx.extra.RefundDeadline) + h.signer.afterWrite = func(string) { + h.signer.paymentStateCapturable = big.NewInt(0) + h.signer.paymentStateRefundable = big.NewInt(0) + } + + resp, err := h.scheme.Settle(context.Background(), unsigned(fx.payload(fx.buildVoid(t))), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, []string{"Void"}, h.authorizer.primaryTypes) + assert.Nil(t, h.binding(t, fx.paymentHash)) + }) + + t.Run("keeps the binding after a capture that leaves a refundable balance", func(t *testing.T) { + fx, h := newDelegatedLifecycle(t, delegatedOpts{}) + h.bindRow(t, fx.paymentHash, delegatedCaller, fx.extra.RefundDeadline) + + resp, err := h.scheme.Settle(context.Background(), unsignedCapture(t, fx, captureOpts{}, false), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.NotNil(t, h.binding(t, fx.paymentHash)) + }) + + t.Run("does not release the binding when the lifecycle settle fails", func(t *testing.T) { + fx, h := newDelegatedLifecycle(t, delegatedOpts{}) + h.bindRow(t, fx.paymentHash, delegatedCaller, fx.extra.RefundDeadline) + h.signer.writeErr["capture"] = errors.New("insufficient funds for gas") + + _, err := h.scheme.Settle(context.Background(), unsignedCapture(t, fx, captureOpts{amount: "1000000"}, false), fx.requirements, nil) + require.Error(t, err) + assert.NotNil(t, h.binding(t, fx.paymentHash)) + }) + + t.Run("reports a failing early delete to OnStorageError without failing the settle", func(t *testing.T) { + storage := failingDeleteStorage{NewInMemoryAuthCaptureDelegatedAuthStorage()} + fx, h := newDelegatedLifecycle(t, delegatedOpts{storage: storage}) + h.bindRow(t, fx.paymentHash, delegatedCaller, fx.extra.RefundDeadline) + h.signer.afterWrite = func(string) { + h.signer.paymentStateCapturable = big.NewInt(0) + h.signer.paymentStateRefundable = big.NewInt(0) + } + + resp, err := h.scheme.Settle(context.Background(), unsignedCapture(t, fx, captureOpts{amount: "600000"}, true), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.NotEmpty(t, h.reported.errs) + }) +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/errors.go b/go/mechanisms/evm/auth-capture/facilitator/errors.go new file mode 100644 index 0000000000..93872ab548 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/errors.go @@ -0,0 +1,65 @@ +package facilitator + +// Error reasons for the auth-capture EVM scheme, as listed in the spec's Error Codes section. +const ( + // Verification errors. + ErrInvalidScheme = "invalid_auth_capture_evm_scheme" + ErrNetworkMismatch = "invalid_auth_capture_evm_network_mismatch" + ErrInvalidNetwork = "invalid_network" + ErrPayloadFormat = "invalid_auth_capture_evm_payload_format" + ErrPayloadType = "invalid_auth_capture_evm_payload_type" + ErrExtra = "invalid_auth_capture_evm_extra" + ErrUnsupportedOperatorType = "invalid_auth_capture_evm_unsupported_operator_type" + ErrPolicy = "invalid_auth_capture_evm_policy" + ErrMissingReceiverAuthorizer = "invalid_auth_capture_evm_missing_receiver_authorizer" + ErrLifecycleNotRelayed = "invalid_auth_capture_evm_lifecycle_not_relayed" + ErrOperatorNotAdmitted = "invalid_auth_capture_evm_operator_not_admitted" + ErrOperatorMismatch = "invalid_auth_capture_evm_operator_mismatch" + ErrSaltBindingMismatch = "invalid_auth_capture_evm_salt_binding_mismatch" + ErrAuthorizerSignature = "invalid_auth_capture_evm_authorizer_signature" + ErrUnexpectedPaymentState = "invalid_auth_capture_evm_unexpected_payment_state" + ErrRefundFundingUnavailable = "invalid_auth_capture_evm_refund_funding_unavailable" + ErrUnsupportedAssetTransferMethod = "invalid_auth_capture_evm_unsupported_asset_transfer_method" + ErrPayloadMethodMismatch = "invalid_auth_capture_evm_payload_method_mismatch" + ErrCaptureDeadlineExpired = "invalid_auth_capture_evm_capture_deadline_expired" + ErrRefundDeadlineExpired = "invalid_auth_capture_evm_refund_deadline_expired" + ErrDeadlineOrdering = "invalid_auth_capture_evm_deadline_ordering" + ErrAuthorizationExpired = "invalid_auth_capture_evm_authorization_expired" + ErrAuthorizationNotYetValid = "invalid_auth_capture_evm_authorization_not_yet_valid" + ErrSignature = "invalid_auth_capture_evm_signature" + ErrErc6492FactoryNotAllowed = "invalid_auth_capture_evm_erc6492_factory_not_allowed" + ErrAmountMismatch = "invalid_auth_capture_evm_amount_mismatch" + ErrTokenCollectorMismatch = "invalid_auth_capture_evm_token_collector_mismatch" + ErrTokenMismatch = "invalid_auth_capture_evm_token_mismatch" + ErrNonceMismatch = "invalid_auth_capture_evm_nonce_mismatch" + ErrInsufficientBalance = "invalid_auth_capture_evm_insufficient_balance" + ErrSimulationFailed = "invalid_auth_capture_evm_simulation_failed" + ErrUnsupportedPaymentFlow = "invalid_auth_capture_evm_unsupported_payment_flow" + ErrVoidAuthorizerSignature = "invalid_auth_capture_evm_void_authorizer_signature" + ErrVoidRemainderFullCapture = "invalid_auth_capture_evm_void_remainder_full_capture" + ErrPaymentInfoMismatch = "invalid_auth_capture_evm_payment_info_mismatch" + ErrUndeployedSmartWallet = "invalid_auth_capture_evm_payload_undeployed_smart_wallet" + + // Delegated receiver authorizer errors. + ErrUnauthenticatedAuthorizerRequest = "invalid_auth_capture_evm_unauthenticated_authorizer_request" + ErrDelegatedAuthUnavailable = "invalid_auth_capture_evm_delegated_auth_unavailable" + + // Typed simulation and settlement reverts. + ErrPaymentAlreadyCollected = "invalid_auth_capture_evm_payment_already_collected" + ErrTokenCollectionFailed = "invalid_auth_capture_evm_token_collection_failed" + ErrCollector = "invalid_auth_capture_evm_collector" + ErrAmountOverflow = "invalid_auth_capture_evm_amount_overflow" + ErrFeeBps = "invalid_auth_capture_evm_fee_bps" + ErrFeeBpsRange = "invalid_auth_capture_evm_fee_bps_range" + ErrFeeBpsOutOfRange = "invalid_auth_capture_evm_fee_bps_out_of_range" + ErrZeroFeeReceiver = "invalid_auth_capture_evm_zero_fee_receiver" + ErrFeeReceiver = "invalid_auth_capture_evm_fee_receiver" + ErrInsufficientAuthorization = "invalid_auth_capture_evm_insufficient_authorization" + ErrZeroAuthorization = "invalid_auth_capture_evm_zero_authorization" + ErrRefundExceedsCapture = "invalid_auth_capture_evm_refund_exceeds_capture" + + // Settlement errors. + ErrVerificationFailed = "invalid_auth_capture_evm_verification_failed" + ErrTransactionReverted = "invalid_auth_capture_evm_transaction_reverted" + ErrSmartWalletDeploymentFailed = "smart_wallet_deployment_failed" +) diff --git a/go/mechanisms/evm/auth-capture/facilitator/fee.go b/go/mechanisms/evm/auth-capture/facilitator/fee.go new file mode 100644 index 0000000000..aa26d88e64 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/fee.go @@ -0,0 +1,60 @@ +package facilitator + +import ( + "math/big" + "strings" + + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" +) + +func parseUint(value string) (*big.Int, bool) { + n, ok := new(big.Int).SetString(value, 10) + return n, ok && n.Sign() >= 0 +} + +// parseSubmittedFee reads the deployment's fee field (feeBps on v1.0, feeAmount on v1.1) and +// rejects a payload that carries the other one or none. +func parseSubmittedFee(feeBps *uint16, feeAmount string, deployment *authcapture.AuthCaptureDeployment) (authcapture.CaptureFee, bool) { + if deployment.Version == authcapture.AuthCaptureDeploymentV1_0 { + return authcapture.CaptureFee{Bps: feeBps}, feeBps != nil && feeAmount == "" + } + amount, ok := parseUint(feeAmount) + return authcapture.CaptureFee{Amount: amount}, ok && feeBps == nil +} + +// feeWithinBounds applies the escrow's fee range: feeBps in [min, max] on v1.0, and +// feeAmount in [amount*min/10000, amount*max/10000] on v1.1. +func feeWithinBounds(fee authcapture.CaptureFee, amount *big.Int, extra authcapture.AuthCaptureExtra) bool { + if fee.Bps != nil { + return *fee.Bps >= extra.MinFeeBps && *fee.Bps <= extra.MaxFeeBps + } + lowest := authcapture.FeeAmountFromBps(amount, extra.MinFeeBps) + highest := authcapture.FeeAmountFromBps(amount, extra.MaxFeeBps) + return fee.Amount.Cmp(lowest) >= 0 && fee.Amount.Cmp(highest) <= 0 +} + +// feeAmountOf is the absolute fee the escrow takes from amount for a submitted fee. +func feeAmountOf(fee authcapture.CaptureFee, amount *big.Int) *big.Int { + if fee.Bps != nil { + return authcapture.FeeAmountFromBps(amount, *fee.Bps) + } + return fee.Amount +} + +// validateSubmittedFee returns the invalidReason for a charge or capture fee the escrow would +// reject or the extra does not allow, or "" when it is valid. A zero extra.feeRecipient lets the +// submitter name any receiver, which the escrow then requires to be non-zero for a non-zero fee. +func validateSubmittedFee(extra authcapture.AuthCaptureExtra, amount *big.Int, fee authcapture.CaptureFee, feeReceiver string) string { + switch { + case !evm.IsValidAddress(feeReceiver): + return ErrFeeReceiver + case authcapture.IsNonZeroAddress(extra.FeeRecipient) && !strings.EqualFold(feeReceiver, extra.FeeRecipient): + return ErrFeeReceiver + case !feeWithinBounds(fee, amount, extra): + return ErrFeeBpsOutOfRange + case !authcapture.IsNonZeroAddress(feeReceiver) && feeAmountOf(fee, amount).Sign() != 0: + return ErrZeroFeeReceiver + } + return "" +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/fee_test.go b/go/mechanisms/evm/auth-capture/facilitator/fee_test.go new file mode 100644 index 0000000000..cb2c576af3 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/fee_test.go @@ -0,0 +1,93 @@ +package facilitator + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" +) + +func TestValidateSubmittedFee(t *testing.T) { + extra := authcapture.AuthCaptureExtra{FeeRecipient: facFeeRecipient, MinFeeBps: 10, MaxFeeBps: 100} + amount := big.NewInt(1_000_000) + bps := func(v uint16) authcapture.CaptureFee { return authcapture.CaptureFee{Bps: &v} } + flat := func(v int64) authcapture.CaptureFee { return authcapture.CaptureFee{Amount: big.NewInt(v)} } + + tests := []struct { + name string + extra authcapture.AuthCaptureExtra + fee authcapture.CaptureFee + receiver string + want string + }{ + {name: "bps at the lower bound", extra: extra, fee: bps(10), receiver: facFeeRecipient}, + {name: "bps at the upper bound", extra: extra, fee: bps(100), receiver: facFeeRecipient}, + {name: "amount at the lower bound", extra: extra, fee: flat(1000), receiver: facFeeRecipient}, + {name: "amount at the upper bound", extra: extra, fee: flat(10000), receiver: facFeeRecipient}, + {name: "receiver compared case-insensitively", extra: extra, fee: bps(50), receiver: "0x" + "3333333333333333333333333333333333333333"}, + {name: "bps below the range", extra: extra, fee: bps(9), receiver: facFeeRecipient, want: ErrFeeBpsOutOfRange}, + {name: "bps above the range", extra: extra, fee: bps(101), receiver: facFeeRecipient, want: ErrFeeBpsOutOfRange}, + {name: "amount below the range", extra: extra, fee: flat(999), receiver: facFeeRecipient, want: ErrFeeBpsOutOfRange}, + {name: "amount above the range", extra: extra, fee: flat(10001), receiver: facFeeRecipient, want: ErrFeeBpsOutOfRange}, + {name: "receiver differs from feeRecipient", extra: extra, fee: bps(50), receiver: "0x" + "8888888888888888888888888888888888888888", want: ErrFeeReceiver}, + {name: "receiver is not an address", extra: extra, fee: bps(50), receiver: "nope", want: ErrFeeReceiver}, + { + name: "an open feeRecipient accepts any receiver", + extra: authcapture.AuthCaptureExtra{FeeRecipient: authcapture.ZeroAddress, MaxFeeBps: 100}, + fee: bps(50), + receiver: "0x" + "8888888888888888888888888888888888888888", + }, + { + name: "a fee needs a receiver", + extra: authcapture.AuthCaptureExtra{FeeRecipient: authcapture.ZeroAddress, MaxFeeBps: 100}, + fee: bps(50), + receiver: authcapture.ZeroAddress, + want: ErrZeroFeeReceiver, + }, + { + name: "no fee needs no receiver", + extra: authcapture.AuthCaptureExtra{FeeRecipient: authcapture.ZeroAddress, MaxFeeBps: 100}, + fee: bps(0), + receiver: authcapture.ZeroAddress, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, validateSubmittedFee(test.extra, amount, test.fee, test.receiver)) + }) + } +} + +func TestParseSubmittedFee(t *testing.T) { + v1_0 := authcapture.ResolveAuthCaptureDeployment(authcapture.AuthCaptureEscrowV1_0Address) + v1_1 := authcapture.ResolveAuthCaptureDeployment("") + bps := uint16(25) + + fee, ok := parseSubmittedFee(&bps, "", v1_0) + assert.True(t, ok) + assert.Equal(t, bps, *fee.Bps) + + _, ok = parseSubmittedFee(&bps, "5", v1_0) + assert.False(t, ok, "v1.0 carries only feeBps") + _, ok = parseSubmittedFee(nil, "", v1_0) + assert.False(t, ok) + + fee, ok = parseSubmittedFee(nil, "5", v1_1) + assert.True(t, ok) + assert.Equal(t, int64(5), fee.Amount.Int64()) + + _, ok = parseSubmittedFee(&bps, "5", v1_1) + assert.False(t, ok, "v1.1 carries only feeAmount") + _, ok = parseSubmittedFee(nil, "-5", v1_1) + assert.False(t, ok) +} + +func TestFeeAmountOf(t *testing.T) { + amount := big.NewInt(1_000_000) + bps := uint16(50) + + assert.Equal(t, int64(5000), feeAmountOf(authcapture.CaptureFee{Bps: &bps}, amount).Int64()) + assert.Equal(t, int64(42), feeAmountOf(authcapture.CaptureFee{Amount: big.NewInt(42)}, amount).Int64()) +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/lifecycle.go b/go/mechanisms/evm/auth-capture/facilitator/lifecycle.go new file mode 100644 index 0000000000..176137c49c --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/lifecycle.go @@ -0,0 +1,788 @@ +package facilitator + +import ( + "context" + "fmt" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +// lifecyclePreconditions is the state common to capture, void and refund: the resolved request, +// the escrow's paymentInfoHash, and the on-chain paymentState balances. +type lifecyclePreconditions struct { + deployment authcapture.AuthCaptureDeployment + extra authcapture.AuthCaptureExtra + chainID *big.Int + paymentInfo authcapture.PaymentInfoStruct + paymentInfoHash string + capturableAmount *big.Int + refundableAmount *big.Int +} + +// Lifecycle operations a payload.type names. +const ( + opCapture = "capture" + opVoid = "void" + opRefund = "refund" +) + +// checkLifecycleCommon validates what capture, void and refund share without touching the chain: +// the request, the relay gates, the paymentInfo against the originally accepted requirements and +// the salt binding. The on-chain payment state is read separately, after the authorizer +// signature checks, so a forged payload cannot cost any RPC reads. +func (f *AuthCaptureEvmScheme) checkLifecycleCommon( + payload types.PaymentPayload, + requirements types.PaymentRequirements, + paymentInfo authcapture.PaymentInfoStruct, + saltNonce string, + operation string, +) (*lifecyclePreconditions, error) { + payer := paymentInfo.Payer + rc, err := checkRequest(payload, requirements, payer) + if err != nil { + return nil, err + } + extra := rc.extra + + if err := f.checkOperator(extra, payer, true); err != nil { + return nil, err + } + if operation != opRefund && extra.PaymentFlow == authcapture.PaymentFlowAuthorization { + return nil, x402.NewVerifyError(ErrPayloadType, payer, "capture and void require paymentFlow escrow, got authorization") + } + if operation == opRefund && !f.config.RefundFunding { + return nil, x402.NewVerifyError(ErrRefundFundingUnavailable, payer, "refunds for delegated operators need an out-of-band funding agreement") + } + if !strings.EqualFold(paymentInfo.Operator, extra.CaptureAuthorizer) { + return nil, x402.NewVerifyError(ErrOperatorMismatch, payer, "paymentInfo.operator does not match captureAuthorizer") + } + + expectedSalt, err := authcapture.DeriveBoundSalt( + authcapture.ExtraAddress(extra.ReceiverAuthorizer), + authcapture.ExtraAddress(extra.Policy), + saltNonce, + ) + if err != nil { + return nil, x402.NewVerifyError(ErrSaltBindingMismatch, payer, err.Error()) + } + if !strings.EqualFold(expectedSalt, paymentInfo.Salt) { + return nil, x402.NewVerifyError(ErrSaltBindingMismatch, payer, "salt does not match derived bound salt") + } + if mismatch := paymentInfoMismatch(paymentInfo, payload.Accepted.Amount, requirements, extra); mismatch != "" { + return nil, x402.NewVerifyError(ErrPaymentInfoMismatch, payer, mismatch) + } + + paymentInfoHash, err := authcapture.ComputePaymentInfoHash(rc.chainID, paymentInfo, payer, rc.deployment.Escrow) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, payer, err.Error()) + } + + return &lifecyclePreconditions{ + deployment: rc.deployment, + extra: extra, + chainID: rc.chainID, + paymentInfo: paymentInfo, + paymentInfoHash: paymentInfoHash, + }, nil +} + +// loadCollectedState fills the on-chain balances and requires the payment to be collected. +func (f *AuthCaptureEvmScheme) loadCollectedState(ctx context.Context, lc *lifecyclePreconditions) error { + payer := lc.paymentInfo.Payer + hasCollected, capturable, refundable, err := readCollectedState(ctx, f.signer, &lc.deployment, lc.paymentInfoHash) + if err != nil { + return x402.NewVerifyError(ErrUnexpectedPaymentState, payer, err.Error()) + } + if !hasCollected { + return x402.NewVerifyError(ErrUnexpectedPaymentState, payer, "payment has not been collected on-chain") + } + lc.capturableAmount = capturable + lc.refundableAmount = refundable + return nil +} + +// balancesMatch reports whether the on-chain balances are what the authorizer signed against. +func (lc *lifecyclePreconditions) balancesMatch(expectedCapturable, expectedRefundable *big.Int) bool { + return lc.capturableAmount.Cmp(expectedCapturable) == 0 && lc.refundableAmount.Cmp(expectedRefundable) == 0 +} + +// paymentInfoMismatch returns a description of the first paymentInfo field that differs +// from what the requirements dictate, or "" when they all match. maxAmount is the amount +// the payer authorized, so it is compared with the accepted amount: on the settle path +// requirements.Amount carries the capture amount, which may be a partial one. +func paymentInfoMismatch( + info authcapture.PaymentInfoStruct, + acceptedAmount string, + requirements types.PaymentRequirements, + extra authcapture.AuthCaptureExtra, +) string { + switch { + case !strings.EqualFold(info.Receiver, requirements.PayTo): + return "paymentInfo.receiver does not match requirements.payTo" + case !strings.EqualFold(info.Token, requirements.Asset): + return "paymentInfo.token does not match requirements.asset" + case info.MaxAmount != acceptedAmount: + return "paymentInfo.maxAmount does not match the accepted amount" + case !strings.EqualFold(info.FeeReceiver, extra.FeeRecipient): + return "paymentInfo.feeReceiver does not match extra.feeRecipient" + case info.MinFeeBps != extra.MinFeeBps || info.MaxFeeBps != extra.MaxFeeBps: + return "paymentInfo fee bounds do not match extra" + case info.AuthorizationExpiry != extra.CaptureDeadline || info.RefundExpiry != extra.RefundDeadline: + return "paymentInfo deadlines do not match extra" + } + return "" +} + +// tuple returns the paymentInfo as the first escrow call argument. +func (lc *lifecyclePreconditions) tuple() (authcapture.PaymentInfoAbiTuple, error) { + tuple, err := lc.paymentInfo.ToAbiTuple() + if err != nil { + return tuple, x402.NewVerifyError(ErrPayloadFormat, lc.paymentInfo.Payer, err.Error()) + } + return tuple, nil +} + +// simulateLifecycle simulates an operator-gated escrow call taking the paymentInfo first. +func (f *AuthCaptureEvmScheme) simulateLifecycle(ctx context.Context, lc *lifecyclePreconditions, function string, args ...interface{}) error { + tuple, err := lc.tuple() + if err != nil { + return err + } + return simulateEscrowCall(ctx, f.signer, &lc.deployment, lc.paymentInfo.Operator, lc.paymentInfo.Payer, function, append([]interface{}{tuple}, args...)...) +} + +// writeLifecycle submits an operator-gated escrow call taking the paymentInfo first. +func (f *AuthCaptureEvmScheme) writeLifecycle( + ctx context.Context, + fctx *x402.FacilitatorContext, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + lc *lifecyclePreconditions, + function string, + args ...interface{}, +) (string, error) { + tuple, err := lc.tuple() + if err != nil { + return "", toSettleError(err, x402.Network(payload.Accepted.Network), lc.paymentInfo.Payer) + } + return f.writeEscrow(ctx, fctx, payload, requirements, &lc.deployment, lc.paymentInfo.Operator, lc.paymentInfo.Payer, function, append([]interface{}{tuple}, args...)...) +} + +// capturePreconditions is the verified state verifyCapture derives and settleCapture reuses. +type capturePreconditions struct { + lifecycle *lifecyclePreconditions + amount *big.Int + fee authcapture.CaptureFee + feeReceiver string + withVoid bool +} + +// checkCapturePreconditions validates a capture payload per the spec's Lifecycle payloads checklist. +// Every check that needs no chain read, including both authorizer signatures, runs before the +// payment state is read. +func (f *AuthCaptureEvmScheme) checkCapturePreconditions( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*capturePreconditions, error) { + p, err := authcapture.CapturePayloadFromMap(payload.Payload) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, "", err.Error()) + } + payer := p.PaymentInfo.Payer + + lc, err := f.checkLifecycleCommon(payload, requirements, p.PaymentInfo, p.SaltNonce, opCapture) + if err != nil { + return nil, err + } + signed, err := f.signedLifecyclePayload(ctx, fctx, payload, requirements, lc) + if err != nil { + return nil, err + } + if p, err = authcapture.CapturePayloadFromMap(signed.Payload); err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, payer, err.Error()) + } + + fee, ok := parseSubmittedFee(p.FeeBps, p.FeeAmount, &lc.deployment) + if !ok { + return nil, x402.NewVerifyError(ErrPayloadFormat, payer, "fee field does not match the deployment's fee encoding") + } + amount, amountOK := parseUint(p.Amount) + expectedCapturable, capturableOK := parseUint(p.ExpectedCapturableAmount) + expectedRefundable, refundableOK := parseUint(p.ExpectedRefundableAmount) + if !amountOK || !capturableOK || !refundableOK { + return nil, x402.NewVerifyError(ErrPayloadFormat, payer, "amount, expectedCapturableAmount and expectedRefundableAmount must be unsigned integers") + } + if uint64(time.Now().Unix()) >= p.PaymentInfo.AuthorizationExpiry { + return nil, x402.NewVerifyError(ErrCaptureDeadlineExpired, payer, "authorizationExpiry has passed") + } + if amount.Sign() <= 0 { + return nil, x402.NewVerifyError(ErrInsufficientAuthorization, payer, "amount must be > 0 and <= capturableAmount") + } + if reason := validateSubmittedFee(lc.extra, amount, fee, p.FeeReceiver); reason != "" { + return nil, x402.NewVerifyError(reason, payer, "submitted fee or feeReceiver does not satisfy extra") + } + + captureSig, err := evm.HexToBytes(p.AuthorizerSignature) + if err != nil { + return nil, x402.NewVerifyError(ErrSignature, payer, err.Error()) + } + valid, err := authcapture.VerifyCapture(ctx, f.signer, lc.extra.ReceiverAuthorizer, &lc.deployment, lc.extra.CaptureAuthorizer, lc.chainID, + authcapture.CaptureParams{ + PaymentInfoHash: lc.paymentInfoHash, + Amount: amount, + Fee: fee, + FeeReceiver: p.FeeReceiver, + ExpectedCapturable: expectedCapturable, + ExpectedRefundable: expectedRefundable, + }, captureSig) + if err != nil { + return nil, x402.NewVerifyError(ErrAuthorizerSignature, payer, err.Error()) + } + if !valid { + return nil, x402.NewVerifyError(ErrAuthorizerSignature, payer, "authorizer signature invalid") + } + if p.VoidAuthorizerSignature != "" { + if err := f.checkVoidSignature(ctx, lc, p.VoidAuthorizerSignature, ErrVoidAuthorizerSignature); err != nil { + return nil, err + } + } + + if err := f.loadCollectedState(ctx, lc); err != nil { + return nil, err + } + if !lc.balancesMatch(expectedCapturable, expectedRefundable) { + return nil, x402.NewVerifyError(ErrUnexpectedPaymentState, payer, "expected capturable/refundable amount is stale") + } + if amount.Cmp(lc.capturableAmount) > 0 { + return nil, x402.NewVerifyError(ErrInsufficientAuthorization, payer, "amount must be > 0 and <= capturableAmount") + } + if p.VoidAuthorizerSignature != "" && amount.Cmp(lc.capturableAmount) >= 0 { + return nil, x402.NewVerifyError(ErrVoidRemainderFullCapture, payer, "voidAuthorizerSignature present but amount leaves no remainder to void") + } + + return &capturePreconditions{ + lifecycle: lc, + amount: amount, + fee: fee, + feeReceiver: p.FeeReceiver, + withVoid: p.VoidAuthorizerSignature != "", + }, nil +} + +// checkVoidSignature verifies the receiverAuthorizer's Void signature over the paymentInfoHash. +// invalidReason names the failure for a signature that does not verify. +func (f *AuthCaptureEvmScheme) checkVoidSignature(ctx context.Context, lc *lifecyclePreconditions, voidSignature, invalidReason string) error { + payer := lc.paymentInfo.Payer + sig, err := evm.HexToBytes(voidSignature) + if err != nil { + return x402.NewVerifyError(invalidReason, payer, err.Error()) + } + valid, err := authcapture.VerifyVoid(ctx, f.signer, lc.extra.ReceiverAuthorizer, lc.extra.CaptureAuthorizer, lc.chainID, lc.paymentInfoHash, sig) + if err != nil { + return x402.NewVerifyError(invalidReason, payer, err.Error()) + } + if !valid { + return x402.NewVerifyError(invalidReason, payer, "void authorizer signature invalid") + } + return nil +} + +func (pre *capturePreconditions) captureArgs() []interface{} { + return []interface{}{pre.amount, pre.fee.Arg(), common.HexToAddress(pre.feeReceiver)} +} + +// simulateCapture simulates the capture, and the void when a capture-and-void payload carries one. +func (f *AuthCaptureEvmScheme) simulateCapture(ctx context.Context, pre *capturePreconditions) error { + if err := f.simulateLifecycle(ctx, pre.lifecycle, "capture", pre.captureArgs()...); err != nil { + return err + } + if pre.withVoid { + return f.simulateLifecycle(ctx, pre.lifecycle, "void") + } + return nil +} + +// verifyCapture validates a capture payload and simulates it. +func (f *AuthCaptureEvmScheme) verifyCapture( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*x402.VerifyResponse, error) { + pre, err := f.checkCapturePreconditions(ctx, payload, requirements, fctx) + if err != nil { + return nil, err + } + if err := f.simulateCapture(ctx, pre); err != nil { + return nil, err + } + return &x402.VerifyResponse{IsValid: true, Payer: pre.lifecycle.paymentInfo.Payer}, nil +} + +// settleCapture submits the capture, then any voidAuthorizerSignature void of the remaining hold. +func (f *AuthCaptureEvmScheme) settleCapture( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*x402.SettleResponse, error) { + network := x402.Network(payload.Accepted.Network) + if resp, err := f.resumePending(ctx, payload, requirements, nil); resp != nil || err != nil { + return resp, err + } + + pre, err := f.checkCapturePreconditions(ctx, payload, requirements, fctx) + if err != nil { + return nil, toSettleError(err, network, "") + } + payer := pre.lifecycle.paymentInfo.Payer + if f.config.SimulateInSettle { + if err := f.simulateCapture(ctx, pre); err != nil { + return nil, toSettleError(err, network, payer) + } + } + + txHash, err := f.writeLifecycle(ctx, fctx, payload, requirements, pre.lifecycle, "capture", pre.captureArgs()...) + if err != nil { + return nil, err + } + resp, err := f.awaitSettlement(ctx, payload, requirements, payer, txHash, nil) + if err != nil || !pre.withVoid { + return resp, err + } + + // The capture is the settlement of record, so a failed void is reported in Extra. + voidTx, voidErr := f.voidRemainder(ctx, fctx, payload, requirements, pre.lifecycle) + switch { + case voidErr != nil: + resp.Extra = map[string]interface{}{"voidError": voidErr.Error()} + case voidTx != "": + resp.Extra = map[string]interface{}{"voidTransaction": voidTx} + } + return resp, nil +} + +// voidRemainder voids the hold left after a capture. It returns an empty hash when a race +// already emptied the hold, which the spec treats as capture-only success. +func (f *AuthCaptureEvmScheme) voidRemainder( + ctx context.Context, + fctx *x402.FacilitatorContext, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + lc *lifecyclePreconditions, +) (string, error) { + _, capturable, _, err := readPaymentState(ctx, f.signer, &lc.deployment, lc.paymentInfoHash) + if err != nil { + return "", err + } + if capturable.Sign() <= 0 { + return "", nil + } + txHash, err := f.writeLifecycle(ctx, fctx, payload, requirements, lc, "void") + if err != nil { + return "", err + } + receipt, err := f.signer.WaitForTransactionReceipt(ctx, txHash) + if err != nil { + return "", err + } + if receipt.Status != evm.TxStatusSuccess { + return "", fmt.Errorf("void transaction %s reverted", txHash) + } + return txHash, nil +} + +// checkVoidPreconditions validates a void payload per the spec's Lifecycle payloads checklist. +func (f *AuthCaptureEvmScheme) checkVoidPreconditions( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*lifecyclePreconditions, error) { + p, err := authcapture.VoidPayloadFromMap(payload.Payload) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, "", err.Error()) + } + payer := p.PaymentInfo.Payer + if p.VoidAuthorizerSignature != "" { + return nil, x402.NewVerifyError(ErrVoidAuthorizerSignature, payer, "voidAuthorizerSignature must not appear on a void payload") + } + + lc, err := f.checkLifecycleCommon(payload, requirements, p.PaymentInfo, p.SaltNonce, opVoid) + if err != nil { + return nil, err + } + signed, err := f.signedLifecyclePayload(ctx, fctx, payload, requirements, lc) + if err != nil { + return nil, err + } + if p, err = authcapture.VoidPayloadFromMap(signed.Payload); err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, payer, err.Error()) + } + if err := f.checkVoidSignature(ctx, lc, p.AuthorizerSignature, ErrAuthorizerSignature); err != nil { + return nil, err + } + + if err := f.loadCollectedState(ctx, lc); err != nil { + return nil, err + } + if lc.capturableAmount.Sign() <= 0 { + return nil, x402.NewVerifyError(ErrUnexpectedPaymentState, payer, "no capturable balance to void") + } + return lc, nil +} + +// verifyVoid validates a void payload and simulates it. +func (f *AuthCaptureEvmScheme) verifyVoid( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*x402.VerifyResponse, error) { + lc, err := f.checkVoidPreconditions(ctx, payload, requirements, fctx) + if err != nil { + return nil, err + } + if err := f.simulateLifecycle(ctx, lc, "void"); err != nil { + return nil, err + } + return &x402.VerifyResponse{IsValid: true, Payer: lc.paymentInfo.Payer}, nil +} + +// settleVoid submits the void call for a void payload. +func (f *AuthCaptureEvmScheme) settleVoid( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*x402.SettleResponse, error) { + network := x402.Network(payload.Accepted.Network) + if resp, err := f.resumePending(ctx, payload, requirements, nil); resp != nil || err != nil { + return resp, err + } + + lc, err := f.checkVoidPreconditions(ctx, payload, requirements, fctx) + if err != nil { + return nil, toSettleError(err, network, "") + } + payer := lc.paymentInfo.Payer + if f.config.SimulateInSettle { + if err := f.simulateLifecycle(ctx, lc, "void"); err != nil { + return nil, toSettleError(err, network, payer) + } + } + + txHash, err := f.writeLifecycle(ctx, fctx, payload, requirements, lc, "void") + if err != nil { + return nil, err + } + return f.awaitSettlement(ctx, payload, requirements, payer, txHash, nil) +} + +// hasAuthorizerSignature reports whether a lifecycle payload carries the authorizer signature. +func hasAuthorizerSignature(payload map[string]interface{}) bool { + signature, _ := payload["authorizerSignature"].(string) + return signature != "" +} + +// lifecyclePaymentInfo reads the paymentInfo and payer out of a capture, void or refund payload. +func lifecyclePaymentInfo(payload map[string]interface{}) (authcapture.PaymentInfoStruct, error) { + switch payload["type"] { + case opCapture: + p, err := authcapture.CapturePayloadFromMap(payload) + if err != nil { + return authcapture.PaymentInfoStruct{}, err + } + return p.PaymentInfo, nil + case opVoid: + p, err := authcapture.VoidPayloadFromMap(payload) + if err != nil { + return authcapture.PaymentInfoStruct{}, err + } + return p.PaymentInfo, nil + case opRefund: + p, err := authcapture.RefundPayloadFromMap(payload) + if err != nil { + return authcapture.PaymentInfoStruct{}, err + } + return p.PaymentInfo, nil + default: + return authcapture.PaymentInfoStruct{}, fmt.Errorf("unexpected lifecycle payload type %v", payload["type"]) + } +} + +// signedLifecyclePayload returns the lifecycle payload with its authorizer signature: as is when +// the server signed it, signed by this facilitator when the authorizer is delegated to it, and a +// verification failure otherwise. +func (f *AuthCaptureEvmScheme) signedLifecyclePayload( + ctx context.Context, + fctx *x402.FacilitatorContext, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + lc *lifecyclePreconditions, +) (types.PaymentPayload, error) { + if hasAuthorizerSignature(payload.Payload) { + return payload, nil + } + delegated := getDelegatedAuthorizer(f.config, lc.extra.ReceiverAuthorizer) + if delegated == nil { + return payload, x402.NewVerifyError(ErrAuthorizerSignature, lc.paymentInfo.Payer, "authorizerSignature is required unless the authorizer is delegated to this facilitator") + } + return f.signDelegatedLifecycle(ctx, fctx, delegated, payload, requirements, lc) +} + +// signDelegatedLifecycle authenticates a lifecycle request whose authorizer is delegated to this +// facilitator and signs it. The caller must resolve to the identity bound to the payment at +// authorize (or charge) time. Nothing is bound here. +func (f *AuthCaptureEvmScheme) signDelegatedLifecycle( + ctx context.Context, + fctx *x402.FacilitatorContext, + delegated *delegatedAuthorizer, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + lc *lifecyclePreconditions, +) (types.PaymentPayload, error) { + payer := lc.paymentInfo.Payer + network := x402.Network(requirements.Network) + operation, _ := payload.Payload["type"].(string) + + identity := resolveDelegatedCallerIdentity(ctx, delegated, DelegatedSettleContext{ + Step: DelegatedStep(operation), + PaymentInfoHash: lc.paymentInfoHash, + Network: network, + Payer: payer, + Payload: payload, + Requirements: requirements, + FacilitatorContext: fctx, + }) + if identity == "" { + return payload, x402.NewVerifyError(ErrUnauthenticatedAuthorizerRequest, payer, "") + } + + binding, err := delegated.storage.Get(ctx, network, lc.paymentInfoHash) + if err != nil { + return payload, x402.NewVerifyError(ErrDelegatedAuthUnavailable, payer, + "failed to read delegated auth binding: "+evm.TruncateErrorMessage(err.Error())) + } + if binding == nil || binding.ExpiresAt <= uint64(time.Now().Unix()) || binding.CallerIdentity != identity { + return payload, x402.NewVerifyError(ErrUnauthenticatedAuthorizerRequest, payer, "") + } + + signed := make(map[string]interface{}, len(payload.Payload)+2) + for key, value := range payload.Payload { + signed[key] = value + } + switch operation { + case opVoid: + signature, err := authcapture.SignVoid(ctx, delegated.signer, lc.extra.CaptureAuthorizer, lc.chainID, lc.paymentInfoHash) + if err != nil { + return payload, x402.NewVerifyError(ErrAuthorizerSignature, payer, err.Error()) + } + signed["authorizerSignature"] = evm.BytesToHex(signature) + case opRefund: + p, err := authcapture.RefundPayloadFromMap(payload.Payload) + if err != nil { + return payload, x402.NewVerifyError(ErrPayloadFormat, payer, err.Error()) + } + amount, amountOK := parseUint(p.Amount) + expectedCapturable, capturableOK := parseUint(p.ExpectedCapturableAmount) + expectedRefundable, refundableOK := parseUint(p.ExpectedRefundableAmount) + if !amountOK || !capturableOK || !refundableOK { + return payload, x402.NewVerifyError(ErrPayloadFormat, payer, "amount, expectedCapturableAmount and expectedRefundableAmount must be unsigned integers") + } + signature, err := authcapture.SignRefund(ctx, delegated.signer, lc.extra.CaptureAuthorizer, lc.chainID, + authcapture.RefundParams{ + PaymentInfoHash: lc.paymentInfoHash, + Amount: amount, + TokenCollector: lc.deployment.OperatorRefundCollector, + ExpectedCapturable: expectedCapturable, + ExpectedRefundable: expectedRefundable, + }) + if err != nil { + return payload, x402.NewVerifyError(ErrAuthorizerSignature, payer, err.Error()) + } + signed["authorizerSignature"] = evm.BytesToHex(signature) + case opCapture: + p, err := authcapture.CapturePayloadFromMap(payload.Payload) + if err != nil { + return payload, x402.NewVerifyError(ErrPayloadFormat, payer, err.Error()) + } + fee, ok := parseSubmittedFee(p.FeeBps, p.FeeAmount, &lc.deployment) + if !ok { + return payload, x402.NewVerifyError(ErrPayloadFormat, payer, "fee field does not match the deployment's fee encoding") + } + amount, amountOK := parseUint(p.Amount) + expectedCapturable, capturableOK := parseUint(p.ExpectedCapturableAmount) + expectedRefundable, refundableOK := parseUint(p.ExpectedRefundableAmount) + if !amountOK || !capturableOK || !refundableOK { + return payload, x402.NewVerifyError(ErrPayloadFormat, payer, "amount, expectedCapturableAmount and expectedRefundableAmount must be unsigned integers") + } + signature, err := authcapture.SignCapture(ctx, delegated.signer, &lc.deployment, lc.extra.CaptureAuthorizer, lc.chainID, + authcapture.CaptureParams{ + PaymentInfoHash: lc.paymentInfoHash, + Amount: amount, + Fee: fee, + FeeReceiver: p.FeeReceiver, + ExpectedCapturable: expectedCapturable, + ExpectedRefundable: expectedRefundable, + }) + if err != nil { + return payload, x402.NewVerifyError(ErrAuthorizerSignature, payer, err.Error()) + } + signed["authorizerSignature"] = evm.BytesToHex(signature) + delete(signed, "voidRemainder") + if p.VoidRemainder { + voidSignature, err := authcapture.SignVoid(ctx, delegated.signer, lc.extra.CaptureAuthorizer, lc.chainID, lc.paymentInfoHash) + if err != nil { + return payload, x402.NewVerifyError(ErrAuthorizerSignature, payer, err.Error()) + } + signed["voidAuthorizerSignature"] = evm.BytesToHex(voidSignature) + } + default: + return payload, x402.NewVerifyError(ErrPayloadType, payer, fmt.Sprintf("unexpected lifecycle payload type: %s", operation)) + } + + payload.Payload = signed + return payload, nil +} + +// delegatedLifecycle resolves the delegated authorizer and the identifiers of an unverified +// lifecycle payload. It returns a nil authorizer when the authorizer is not delegated to this +// facilitator, or when the payload or requirements are unreadable, which verification reports. +func (f *AuthCaptureEvmScheme) delegatedLifecycle( + payload types.PaymentPayload, + requirements types.PaymentRequirements, +) (*delegatedAuthorizer, *lifecyclePreconditions) { + extra, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) + if err != nil { + return nil, nil + } + delegated := getDelegatedAuthorizer(f.config, extra.ReceiverAuthorizer) + if delegated == nil { + return nil, nil + } + paymentInfo, err := lifecyclePaymentInfo(payload.Payload) + if err != nil { + return nil, nil + } + chainID, err := evm.GetEvmChainId(requirements.Network) + if err != nil { + return nil, nil + } + paymentInfoHash, err := authcapture.ComputePaymentInfoHash(chainID, paymentInfo, paymentInfo.Payer, deployment.Escrow) + if err != nil { + return nil, nil + } + return delegated, &lifecyclePreconditions{ + deployment: deployment, + extra: extra, + chainID: chainID, + paymentInfo: paymentInfo, + paymentInfoHash: paymentInfoHash, + } +} + +// lifecycleSettler settles an authorizer-signed lifecycle payload. +type lifecycleSettler func( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*x402.SettleResponse, error) + +// settleLifecycle re-verifies and settles a lifecycle payload. A delegated authorizer's signature +// is produced first, so the pending-settlement key derives from it and a retry reconciles. +func (f *AuthCaptureEvmScheme) settleLifecycle( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, + settle lifecycleSettler, +) (*x402.SettleResponse, error) { + delegated, lc := f.delegatedLifecycle(payload, requirements) + + settleable := payload + if delegated != nil && !hasAuthorizerSignature(payload.Payload) { + signed, err := f.signDelegatedLifecycle(ctx, fctx, delegated, payload, requirements, lc) + if err != nil { + return nil, toSettleError(err, x402.Network(payload.Accepted.Network), lc.paymentInfo.Payer) + } + settleable = signed + } + + resp, err := settle(ctx, settleable, requirements, fctx) + if err == nil && resp != nil && resp.Success && delegated != nil { + f.releaseTerminalBinding(ctx, delegated, lc, requirements, settleable.Payload) + } + return resp, err +} + +// releaseTerminalBinding deletes the caller binding once the payment has nothing left to relay: +// no capturable hold and nothing refundable. It is best effort, so a storage failure never fails +// a confirmed settle. Balances come from the signed expectations; a void leg is not known +// locally, so those cases read paymentState once, and a stale read just leaves the row to expire. +func (f *AuthCaptureEvmScheme) releaseTerminalBinding( + ctx context.Context, + delegated *delegatedAuthorizer, + lc *lifecyclePreconditions, + requirements types.PaymentRequirements, + settled map[string]interface{}, +) { + var capturable, refundable *big.Int + switch settled["type"] { + case opRefund: + p, err := authcapture.RefundPayloadFromMap(settled) + if err != nil { + return + } + amount, amountOK := parseUint(p.Amount) + expectedCapturable, capturableOK := parseUint(p.ExpectedCapturableAmount) + expectedRefundable, refundableOK := parseUint(p.ExpectedRefundableAmount) + if !amountOK || !capturableOK || !refundableOK { + return + } + capturable = expectedCapturable + refundable = new(big.Int).Sub(expectedRefundable, amount) + case opCapture: + p, err := authcapture.CapturePayloadFromMap(settled) + if err != nil { + return + } + if p.VoidAuthorizerSignature == "" { + amount, amountOK := parseUint(p.Amount) + expectedCapturable, capturableOK := parseUint(p.ExpectedCapturableAmount) + expectedRefundable, refundableOK := parseUint(p.ExpectedRefundableAmount) + if !amountOK || !capturableOK || !refundableOK { + return + } + capturable = new(big.Int).Sub(expectedCapturable, amount) + refundable = new(big.Int).Add(expectedRefundable, amount) + } + } + if capturable == nil { + _, readCapturable, readRefundable, err := readPaymentState(ctx, f.signer, &lc.deployment, lc.paymentInfoHash) + if err != nil { + return + } + capturable, refundable = readCapturable, readRefundable + } + if capturable.Sign() != 0 || refundable.Sign() != 0 { + return + } + + network := x402.Network(requirements.Network) + cleanupCtx, cancel := detachedCleanupContext(ctx) + defer cancel() + if err := delegated.storage.Delete(cleanupCtx, network, lc.paymentInfoHash); err != nil { + reportDelegatedStorageError(delegated, err, network, lc.paymentInfoHash) + } +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/lifecycle_test.go b/go/mechanisms/evm/auth-capture/facilitator/lifecycle_test.go new file mode 100644 index 0000000000..f51b97f89a --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/lifecycle_test.go @@ -0,0 +1,549 @@ +package facilitator + +import ( + "context" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +func TestVerifyCapture_HappyPath(t *testing.T) { + bps := uint16(50) + tests := []struct { + name string + extra map[string]interface{} + opts captureOpts + }{ + {name: "v1.1 full capture"}, + {name: "v1.1 fee at the upper bound", opts: captureOpts{feeAmount: big.NewInt(10000)}}, + {name: "v1.0 fee in bps", extra: map[string]interface{}{"authCaptureEscrow": authcapture.AuthCaptureEscrowV1_0Address}, opts: captureOpts{feeBps: &bps}}, + {name: "capture and void", opts: captureOpts{amount: "600000", withVoid: true}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fx := newLifecycleFixture(t, test.extra) + scheme := newScheme(fx.signer(), AuthCaptureEvmSchemeConfig{}) + + resp, err := scheme.Verify(context.Background(), fx.payload(fx.buildCapture(t, test.opts)), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + assert.Equal(t, strings.ToLower(fx.paymentInfo.Payer), strings.ToLower(resp.Payer)) + }) + } +} + +func TestVerifyCapture_Rejections(t *testing.T) { + over := uint16(101) + tests := []struct { + name string + extra map[string]interface{} + opts captureOpts + mutate func(fx *lifecycleFixture, wire map[string]interface{}, signer *mockFacSigner) + reason string + }{ + { + name: "stale expected amounts", + opts: captureOpts{amount: "1", expectedCapturable: "1"}, + reason: ErrUnexpectedPaymentState, + }, + { + name: "capture deadline passed", + extra: map[string]interface{}{ + "captureDeadline": float64(time.Now().Unix() - 10), + "refundDeadline": float64(time.Now().Unix() + 86400), + }, + reason: ErrCaptureDeadlineExpired, + }, + { + name: "amount above the hold", + opts: captureOpts{amount: "1000001"}, + reason: ErrInsufficientAuthorization, + }, + { + name: "fee receiver differs", + opts: captureOpts{feeReceiver: "0x" + strings.Repeat("8", 40)}, + reason: ErrFeeReceiver, + }, + { + name: "v1.1 fee above the bound", + opts: captureOpts{feeAmount: big.NewInt(10001)}, + reason: ErrFeeBpsOutOfRange, + }, + { + name: "v1.0 fee above the bound", + extra: map[string]interface{}{"authCaptureEscrow": authcapture.AuthCaptureEscrowV1_0Address}, + opts: captureOpts{feeBps: &over}, + reason: ErrFeeBpsOutOfRange, + }, + { + name: "v1.0 deployment given feeAmount", + extra: map[string]interface{}{"authCaptureEscrow": authcapture.AuthCaptureEscrowV1_0Address}, + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + delete(wire, "feeBps") + wire["feeAmount"] = "0" + }, + reason: ErrPayloadFormat, + }, + { + name: "both fee fields", + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["feeBps"] = uint16(1) + }, + reason: ErrPayloadFormat, + }, + { + name: "void signature with nothing left to void", + opts: captureOpts{withVoid: true}, + reason: ErrVoidRemainderFullCapture, + }, + { + name: "void signature over another payment", + opts: captureOpts{amount: "600000", withVoid: true}, + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + other := newKeySigner(t) + sig, err := authcapture.SignVoid(context.Background(), other, facCaptureAuthorizer, big.NewInt(84532), "0x"+strings.Repeat("1", 64)) + require.NoError(t, err) + wire["voidAuthorizerSignature"] = evm.BytesToHex(sig) + }, + reason: ErrVoidAuthorizerSignature, + }, + { + name: "capture signature from another key", + mutate: func(fx *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["authorizerSignature"] = fx.voidSignature(t) + }, + reason: ErrAuthorizerSignature, + }, + { + name: "operator differs from captureAuthorizer", + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["paymentInfo"].(map[string]interface{})["operator"] = "0x" + strings.Repeat("9", 40) + }, + reason: ErrOperatorMismatch, + }, + { + name: "maxAmount differs from the requirements", + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["paymentInfo"].(map[string]interface{})["maxAmount"] = "1" + }, + reason: ErrPaymentInfoMismatch, + }, + { + name: "salt does not match the binding", + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["saltNonce"] = "0x02" + }, + reason: ErrSaltBindingMismatch, + }, + { + name: "payment never collected", + mutate: func(_ *lifecycleFixture, _ map[string]interface{}, signer *mockFacSigner) { + signer.paymentStateHasCollected = false + }, + reason: ErrUnexpectedPaymentState, + }, + { + name: "payment state unreadable", + mutate: func(_ *lifecycleFixture, _ map[string]interface{}, signer *mockFacSigner) { + signer.paymentStateErr = assert.AnError + }, + reason: ErrUnexpectedPaymentState, + }, + { + name: "custom operator does not relay lifecycle", + extra: map[string]interface{}{"operatorType": "custom"}, + reason: ErrLifecycleNotRelayed, + }, + { + name: "no receiver authorizer", + mutate: func(fx *lifecycleFixture, _ map[string]interface{}, _ *mockFacSigner) { + fx.requirements.Extra["receiverAuthorizer"] = authcapture.ZeroAddress + }, + reason: ErrLifecycleNotRelayed, + }, + { + name: "authorization flow has nothing to capture", + extra: map[string]interface{}{"paymentFlow": "authorization"}, + reason: ErrPayloadType, + }, + { + name: "operator not controlled", + mutate: func(_ *lifecycleFixture, _ map[string]interface{}, signer *mockFacSigner) { + signer.addresses = []string{"0x" + strings.Repeat("9", 40)} + }, + reason: ErrOperatorNotAdmitted, + }, + { + name: "malformed amount", + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["amount"] = "-5" + }, + reason: ErrPayloadFormat, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fx := newLifecycleFixture(t, test.extra) + signer := fx.signer() + wire := fx.buildCapture(t, test.opts) + if test.mutate != nil { + test.mutate(&fx, wire, signer) + } + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{Operators: allowAllCustomOperators}) + + _, err := scheme.Verify(context.Background(), fx.payload(wire), fx.requirements, nil) + assertVerifyReason(t, err, test.reason) + }) + } +} + +func TestVerifyCapture_SimulatesBothLegs(t *testing.T) { + fx := newLifecycleFixture(t, nil) + wire := fx.buildCapture(t, captureOpts{amount: "600000", withVoid: true}) + + signer := fx.signer() + signer.simulateErr["void"] = escrowRevert(t, "InvalidSender", common.Address{}, common.Address{}) + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload(wire), fx.requirements, nil) + assertVerifyReason(t, err, ErrOperatorMismatch) + + signer = fx.signer() + signer.simulateErr["capture"] = escrowRevert(t, "FeeAmountOutOfRange", big.NewInt(1), big.NewInt(0), big.NewInt(0)) + _, err = newScheme(signer, AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload(wire), fx.requirements, nil) + assertVerifyReason(t, err, ErrFeeBpsOutOfRange) +} + +func TestSettleCapture(t *testing.T) { + t.Run("full capture reports the captured amount", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload(fx.buildCapture(t, captureOpts{})), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, fx.paymentInfo.MaxAmount, resp.Amount) + assert.Equal(t, []string{"capture"}, signer.writtenFunctions) + assert.Nil(t, resp.Extra) + }) + + t.Run("capture and void voids the remainder", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + signer.afterWrite = func(function string) { + if function == "capture" { + signer.paymentStateCapturable = big.NewInt(400000) + } + } + + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload(fx.buildCapture(t, captureOpts{amount: "600000", withVoid: true})), fx.requirements, nil) + require.NoError(t, err) + assert.Equal(t, "600000", resp.Amount) + assert.Equal(t, []string{"capture", "void"}, signer.writtenFunctions) + assert.Equal(t, signer.writeTx, resp.Extra["voidTransaction"]) + }) + + t.Run("a race that emptied the hold skips the void", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + signer.afterWrite = func(function string) { + if function == "capture" { + signer.paymentStateCapturable = big.NewInt(0) + } + } + + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload(fx.buildCapture(t, captureOpts{amount: "600000", withVoid: true})), fx.requirements, nil) + require.NoError(t, err) + assert.Equal(t, []string{"capture"}, signer.writtenFunctions) + assert.Nil(t, resp.Extra) + }) + + t.Run("a failed void is reported without failing the capture", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + signer.afterWrite = func(function string) { + if function == "capture" { + signer.paymentStateCapturable = big.NewInt(400000) + } + } + signer.writeErr["void"] = escrowRevert(t, "ZeroAuthorization", [32]byte{}) + + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload(fx.buildCapture(t, captureOpts{amount: "600000", withVoid: true})), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Contains(t, resp.Extra["voidError"], ErrZeroAuthorization) + }) + + t.Run("a reverted void receipt is reported", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + signer.afterWrite = func(function string) { + switch function { + case "capture": + signer.paymentStateCapturable = big.NewInt(400000) + case "void": + signer.receipt = &evm.TransactionReceipt{Status: evm.TxStatusFailed, TxHash: signer.writeTx} + } + } + + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload(fx.buildCapture(t, captureOpts{amount: "600000", withVoid: true})), fx.requirements, nil) + require.NoError(t, err) + assert.Contains(t, resp.Extra["voidError"], "reverted") + }) + + t.Run("typed revert and re-simulation", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + signer.writeErr["capture"] = escrowRevert(t, "AfterAuthorizationExpiry", big.NewInt(2), big.NewInt(1)) + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload(fx.buildCapture(t, captureOpts{})), fx.requirements, nil) + assertSettleReason(t, err, ErrCaptureDeadlineExpired) + + signer = fx.signer() + signer.simulateErr["capture"] = escrowRevert(t, "InsufficientAuthorization", [32]byte{}, big.NewInt(1), big.NewInt(2)) + _, err = newScheme(signer, AuthCaptureEvmSchemeConfig{SimulateInSettle: true}).Settle(context.Background(), fx.payload(fx.buildCapture(t, captureOpts{})), fx.requirements, nil) + assertSettleReason(t, err, ErrInsufficientAuthorization) + }) + + t.Run("verification failure", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + signer.paymentStateHasCollected = false + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload(fx.buildCapture(t, captureOpts{})), fx.requirements, nil) + assertSettleReason(t, err, ErrUnexpectedPaymentState) + }) + + t.Run("pending settlement is resumed", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + wire := fx.buildCapture(t, captureOpts{}) + require.NoError(t, scheme.pendingStore.Set(context.Background(), scheme.pendingKey(fx.payload(wire), fx.requirements), signer.writeTx)) + + resp, err := scheme.Settle(context.Background(), fx.payload(wire), fx.requirements, nil) + require.NoError(t, err) + assert.Equal(t, strings.ToLower(fx.paymentInfo.Payer), strings.ToLower(resp.Payer)) + assert.Empty(t, signer.writtenFunctions) + }) +} + +func TestVoid(t *testing.T) { + t.Run("verify", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + resp, err := newScheme(fx.signer(), AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload(fx.buildVoid(t)), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + }) + + rejections := []struct { + name string + mutate func(fx *lifecycleFixture, wire map[string]interface{}, signer *mockFacSigner) + reason string + }{ + { + name: "no capturable balance", + mutate: func(_ *lifecycleFixture, _ map[string]interface{}, s *mockFacSigner) { + s.paymentStateCapturable = big.NewInt(0) + }, + reason: ErrUnexpectedPaymentState, + }, + { + name: "voidAuthorizerSignature must not appear", + mutate: func(fx *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["voidAuthorizerSignature"] = fx.voidSignature(t) + }, + reason: ErrVoidAuthorizerSignature, + }, + { + name: "signature from another key", + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + sig, err := authcapture.SignVoid(context.Background(), newKeySigner(t), facCaptureAuthorizer, big.NewInt(84532), "0x"+strings.Repeat("1", 64)) + require.NoError(t, err) + wire["authorizerSignature"] = evm.BytesToHex(sig) + }, + reason: ErrAuthorizerSignature, + }, + } + for _, test := range rejections { + t.Run(test.name, func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + wire := fx.buildVoid(t) + test.mutate(&fx, wire, signer) + + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload(wire), fx.requirements, nil) + assertVerifyReason(t, err, test.reason) + }) + } + + t.Run("simulation revert", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + signer.simulateErr["void"] = escrowRevert(t, "ZeroAuthorization", [32]byte{}) + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload(fx.buildVoid(t)), fx.requirements, nil) + assertVerifyReason(t, err, ErrZeroAuthorization) + }) + + t.Run("settle", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{SimulateInSettle: true}).Settle(context.Background(), fx.payload(fx.buildVoid(t)), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Empty(t, resp.Amount) + assert.Equal(t, []string{"void"}, signer.writtenFunctions) + }) + + t.Run("settle failures", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + signer.paymentStateCapturable = big.NewInt(0) + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload(fx.buildVoid(t)), fx.requirements, nil) + assertSettleReason(t, err, ErrUnexpectedPaymentState) + + signer = fx.signer() + signer.simulateErr["void"] = escrowRevert(t, "ZeroAuthorization", [32]byte{}) + _, err = newScheme(signer, AuthCaptureEvmSchemeConfig{SimulateInSettle: true}).Settle(context.Background(), fx.payload(fx.buildVoid(t)), fx.requirements, nil) + assertSettleReason(t, err, ErrZeroAuthorization) + + signer = fx.signer() + signer.writeErr["void"] = escrowRevert(t, "InvalidSender", common.Address{}, common.Address{}) + _, err = newScheme(signer, AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload(fx.buildVoid(t)), fx.requirements, nil) + assertSettleReason(t, err, ErrOperatorMismatch) + }) +} + +func TestToSettleError(t *testing.T) { + err := toSettleError(x402.NewVerifyError(ErrAmountMismatch, "0xpayer", "bad"), facNetwork, "") + assertSettleReason(t, err, ErrAmountMismatch) + + err = toSettleError(assert.AnError, facNetwork, "0xpayer") + assertSettleReason(t, err, ErrVerificationFailed) +} + +func TestRevertReason(t *testing.T) { + deployment := authcapture.ResolveAuthCaptureDeployment("") + v10 := authcapture.ResolveAuthCaptureDeployment(authcapture.AuthCaptureEscrowV1_0Address) + + assert.Equal(t, ErrSimulationFailed, revertReason(deployment, nil)) + assert.Equal(t, ErrSimulationFailed, revertReason(deployment, []byte{0xde, 0xad, 0xbe, 0xef})) + + data := errorRevertData(escrowRevert(t, "FeeAmountOutOfRange", big.NewInt(1), big.NewInt(0), big.NewInt(0))) + assert.Equal(t, ErrFeeBpsOutOfRange, revertReason(deployment, data)) + + // FeeAmountOutOfRange is not declared by the v1.0 ABI. + assert.Equal(t, ErrSimulationFailed, revertReason(v10, data)) + + assert.Nil(t, errorRevertData(assert.AnError)) + assert.Nil(t, errorRevertData(revertError{data: "not hex"})) +} + +func TestLifecycle_PartialCaptureAmountOverride(t *testing.T) { + override := func(fx lifecycleFixture, amount string) types.PaymentRequirements { + requirements := fx.requirements + requirements.Amount = amount + return requirements + } + + t.Run("verify and settle accept a requirements amount below the signed hold", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + wire := fx.buildCapture(t, captureOpts{amount: "600000", withVoid: true}) + requirements := override(fx, "600000") + + verified, err := newScheme(fx.signer(), AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload(wire), requirements, nil) + require.NoError(t, err) + assert.True(t, verified.IsValid) + + settled, err := newScheme(fx.signer(), AuthCaptureEvmSchemeConfig{}).Settle(context.Background(), fx.payload(wire), requirements, nil) + require.NoError(t, err) + assert.True(t, settled.Success) + }) + + t.Run("maxAmount must equal the accepted amount, not the override", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + payload := fx.payload(fx.buildCapture(t, captureOpts{})) + payload.Accepted.Amount = "600000" + + _, err := newScheme(fx.signer(), AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), payload, override(fx, "1000000"), nil) + assertVerifyReason(t, err, ErrPaymentInfoMismatch) + }) +} + +func TestLifecycle_SignaturesAreVerifiedBeforeChainState(t *testing.T) { + tests := []struct { + name string + build func(t *testing.T, fx lifecycleFixture) map[string]interface{} + reason string + }{ + { + name: "capture signature over a different amount", + build: func(t *testing.T, fx lifecycleFixture) map[string]interface{} { + wire := fx.buildCapture(t, captureOpts{}) + wire["amount"] = "999999" + return wire + }, + reason: ErrAuthorizerSignature, + }, + { + name: "void leg signed by another key", + build: func(t *testing.T, fx lifecycleFixture) map[string]interface{} { + wire := fx.buildCapture(t, captureOpts{amount: "600000", withVoid: true}) + voidSignature, err := authcapture.SignVoid(context.Background(), newKeySigner(t), fx.extra.CaptureAuthorizer, fx.chainID, fx.paymentHash) + require.NoError(t, err) + wire["voidAuthorizerSignature"] = evm.BytesToHex(voidSignature) + return wire + }, + reason: ErrVoidAuthorizerSignature, + }, + { + name: "void payload signed by another key", + build: func(t *testing.T, fx lifecycleFixture) map[string]interface{} { + wire := fx.buildVoid(t) + voidSignature, err := authcapture.SignVoid(context.Background(), newKeySigner(t), fx.extra.CaptureAuthorizer, fx.chainID, fx.paymentHash) + require.NoError(t, err) + wire["authorizerSignature"] = evm.BytesToHex(voidSignature) + return wire + }, + reason: ErrAuthorizerSignature, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.signer() + + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload(test.build(t, fx)), fx.requirements, nil) + assertVerifyReason(t, err, test.reason) + assert.Zero(t, signer.stateReads, "no chain state may be read for an unauthenticated payload") + }) + } +} + +func TestLifecycle_SimulationsRunAsTheOperator(t *testing.T) { + fx := newLifecycleFixture(t, nil) + wire := fx.buildCapture(t, captureOpts{amount: "600000", withVoid: true}) + + signer := fx.signer() + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload(wire), fx.requirements, nil) + require.NoError(t, err) + require.Len(t, signer.readFroms, 2, "capture and void are both simulated") + for _, from := range signer.readFroms { + assert.True(t, strings.EqualFold(fx.paymentInfo.Operator, from), "simulated from %s, want the operator %s", from, fx.paymentInfo.Operator) + } +} + +func TestVerifyLifecycle_RejectsAMissingSignatureWhenNoDelegatedAuthorizerIsConfigured(t *testing.T) { + fx := newLifecycleFixture(t, nil) + wire := fx.buildCapture(t, captureOpts{}) + delete(wire, "authorizerSignature") + + _, err := newScheme(fx.signer(), AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload(wire), fx.requirements, nil) + assertVerifyReason(t, err, ErrAuthorizerSignature) +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/refund.go b/go/mechanisms/evm/auth-capture/facilitator/refund.go new file mode 100644 index 0000000000..a1e19175d8 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/refund.go @@ -0,0 +1,139 @@ +package facilitator + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/common" + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +// refundPreconditions is the verified state verifyRefund derives and settleRefund reuses. +type refundPreconditions struct { + lifecycle *lifecyclePreconditions + amount *big.Int +} + +// refundArgs are the AuthCaptureEscrow.refund arguments after the paymentInfo: the refund +// collector pulls the refunded tokens from the operator, so it takes no collector data. +func (pre *refundPreconditions) refundArgs() []interface{} { + return []interface{}{pre.amount, common.HexToAddress(pre.lifecycle.deployment.OperatorRefundCollector), []byte{}} +} + +// checkRefundPreconditions validates a refund payload per the spec's Lifecycle payloads checklist. +// The authorizer signature and deadline are checked before the payment state is read. +func (f *AuthCaptureEvmScheme) checkRefundPreconditions( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*refundPreconditions, error) { + p, err := authcapture.RefundPayloadFromMap(payload.Payload) + if err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, "", err.Error()) + } + payer := p.PaymentInfo.Payer + + lc, err := f.checkLifecycleCommon(payload, requirements, p.PaymentInfo, p.SaltNonce, opRefund) + if err != nil { + return nil, err + } + signed, err := f.signedLifecyclePayload(ctx, fctx, payload, requirements, lc) + if err != nil { + return nil, err + } + if p, err = authcapture.RefundPayloadFromMap(signed.Payload); err != nil { + return nil, x402.NewVerifyError(ErrPayloadFormat, payer, err.Error()) + } + + amount, amountOK := parseUint(p.Amount) + expectedCapturable, capturableOK := parseUint(p.ExpectedCapturableAmount) + expectedRefundable, refundableOK := parseUint(p.ExpectedRefundableAmount) + if !amountOK || !capturableOK || !refundableOK { + return nil, x402.NewVerifyError(ErrPayloadFormat, payer, "amount, expectedCapturableAmount and expectedRefundableAmount must be unsigned integers") + } + + signature, err := evm.HexToBytes(p.AuthorizerSignature) + if err != nil { + return nil, x402.NewVerifyError(ErrAuthorizerSignature, payer, err.Error()) + } + valid, err := authcapture.VerifyRefund(ctx, f.signer, lc.extra.ReceiverAuthorizer, lc.extra.CaptureAuthorizer, lc.chainID, + authcapture.RefundParams{ + PaymentInfoHash: lc.paymentInfoHash, + Amount: amount, + TokenCollector: lc.deployment.OperatorRefundCollector, + ExpectedCapturable: expectedCapturable, + ExpectedRefundable: expectedRefundable, + }, signature) + if err != nil { + return nil, x402.NewVerifyError(ErrAuthorizerSignature, payer, err.Error()) + } + if !valid { + return nil, x402.NewVerifyError(ErrAuthorizerSignature, payer, "authorizer signature invalid") + } + if uint64(time.Now().Unix()) >= p.PaymentInfo.RefundExpiry { + return nil, x402.NewVerifyError(ErrRefundDeadlineExpired, payer, "refundExpiry has passed") + } + + if err := f.loadCollectedState(ctx, lc); err != nil { + return nil, err + } + if !lc.balancesMatch(expectedCapturable, expectedRefundable) { + return nil, x402.NewVerifyError(ErrUnexpectedPaymentState, payer, "expected capturable/refundable amount is stale") + } + if amount.Sign() <= 0 || amount.Cmp(lc.refundableAmount) > 0 { + return nil, x402.NewVerifyError(ErrRefundExceedsCapture, payer, "amount must be > 0 and <= refundableAmount") + } + return &refundPreconditions{lifecycle: lc, amount: amount}, nil +} + +// verifyRefund validates a refund payload and simulates it. +func (f *AuthCaptureEvmScheme) verifyRefund( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*x402.VerifyResponse, error) { + pre, err := f.checkRefundPreconditions(ctx, payload, requirements, fctx) + if err != nil { + return nil, err + } + if err := f.simulateLifecycle(ctx, pre.lifecycle, opRefund, pre.refundArgs()...); err != nil { + return nil, err + } + return &x402.VerifyResponse{IsValid: true, Payer: pre.lifecycle.paymentInfo.Payer}, nil +} + +// settleRefund submits the refund call for a refund payload. +func (f *AuthCaptureEvmScheme) settleRefund( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*x402.SettleResponse, error) { + network := x402.Network(payload.Accepted.Network) + if resp, err := f.resumePending(ctx, payload, requirements, nil); resp != nil || err != nil { + return resp, err + } + + pre, err := f.checkRefundPreconditions(ctx, payload, requirements, fctx) + if err != nil { + return nil, toSettleError(err, network, "") + } + payer := pre.lifecycle.paymentInfo.Payer + if f.config.SimulateInSettle { + if err := f.simulateLifecycle(ctx, pre.lifecycle, opRefund, pre.refundArgs()...); err != nil { + return nil, toSettleError(err, network, payer) + } + } + + txHash, err := f.writeLifecycle(ctx, fctx, payload, requirements, pre.lifecycle, opRefund, pre.refundArgs()...) + if err != nil { + return nil, err + } + return f.awaitSettlement(ctx, payload, requirements, payer, txHash, nil) +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/refund_test.go b/go/mechanisms/evm/auth-capture/facilitator/refund_test.go new file mode 100644 index 0000000000..e59fb41034 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/refund_test.go @@ -0,0 +1,210 @@ +package facilitator + +import ( + "context" + "math/big" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" +) + +type refundOpts struct { + amount string + expectedCapturable string + expectedRefundable string +} + +func (fx lifecycleFixture) buildRefund(t *testing.T, opts refundOpts) map[string]interface{} { + t.Helper() + if opts.amount == "" { + opts.amount = "400000" + } + if opts.expectedCapturable == "" { + opts.expectedCapturable = "0" + } + if opts.expectedRefundable == "" { + opts.expectedRefundable = fx.paymentInfo.MaxAmount + } + amount, _ := new(big.Int).SetString(opts.amount, 10) + capturable, _ := new(big.Int).SetString(opts.expectedCapturable, 10) + refundable, _ := new(big.Int).SetString(opts.expectedRefundable, 10) + + signature, err := authcapture.SignRefund(context.Background(), fx.receiver, fx.extra.CaptureAuthorizer, fx.chainID, + authcapture.RefundParams{ + PaymentInfoHash: fx.paymentHash, + Amount: amount, + TokenCollector: fx.deployment.OperatorRefundCollector, + ExpectedCapturable: capturable, + ExpectedRefundable: refundable, + }) + require.NoError(t, err) + + paymentInfo, err := fx.paymentInfo.ToWireMap() + require.NoError(t, err) + return map[string]interface{}{ + "type": "refund", + "paymentInfo": paymentInfo, + "saltNonce": fx.saltNonce, + "amount": opts.amount, + "expectedCapturableAmount": opts.expectedCapturable, + "expectedRefundableAmount": opts.expectedRefundable, + "authorizerSignature": evm.BytesToHex(signature), + } +} + +func (fx lifecycleFixture) refundSigner() *mockFacSigner { + signer := fx.signer() + signer.paymentStateCapturable = big.NewInt(0) + signer.paymentStateRefundable, _ = new(big.Int).SetString(fx.paymentInfo.MaxAmount, 10) + return signer +} + +func TestVerifyRefund_HappyPath(t *testing.T) { + for _, escrow := range []string{"", authcapture.AuthCaptureEscrowV1_0Address} { + fx := newLifecycleFixture(t, map[string]interface{}{"authCaptureEscrow": escrow}) + signer := fx.refundSigner() + + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{RefundFunding: true}).Verify(context.Background(), fx.payload(fx.buildRefund(t, refundOpts{})), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + assert.Equal(t, []string{"refund"}, signer.readFunctions) + } +} + +func TestVerifyRefund_RequiresFundingOptIn(t *testing.T) { + fx := newLifecycleFixture(t, nil) + + _, err := newScheme(fx.refundSigner(), AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), fx.payload(fx.buildRefund(t, refundOpts{})), fx.requirements, nil) + assertVerifyReason(t, err, ErrRefundFundingUnavailable) +} + +func TestVerifyRefund_Rejections(t *testing.T) { + tests := []struct { + name string + extra map[string]interface{} + opts refundOpts + mutate func(fx *lifecycleFixture, wire map[string]interface{}, signer *mockFacSigner) + reason string + }{ + {name: "amount above the refundable balance", opts: refundOpts{amount: "1000001"}, reason: ErrRefundExceedsCapture}, + {name: "zero amount", opts: refundOpts{amount: "0"}, reason: ErrRefundExceedsCapture}, + {name: "stale expected balances", opts: refundOpts{expectedRefundable: "1"}, reason: ErrUnexpectedPaymentState}, + { + name: "refund deadline passed", + extra: map[string]interface{}{ + "captureDeadline": float64(time.Now().Unix() - 20), + "refundDeadline": float64(time.Now().Unix() - 10), + }, + reason: ErrRefundDeadlineExpired, + }, + { + name: "signature from another key", + mutate: func(fx *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["authorizerSignature"] = fx.voidSignature(t) + }, + reason: ErrAuthorizerSignature, + }, + { + name: "signature over a different amount", + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["amount"] = "400001" + }, + reason: ErrAuthorizerSignature, + }, + { + name: "malformed amount", + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["amount"] = "-1" + }, + reason: ErrPayloadFormat, + }, + { + name: "payment never collected", + mutate: func(_ *lifecycleFixture, _ map[string]interface{}, signer *mockFacSigner) { + signer.paymentStateHasCollected = false + }, + reason: ErrUnexpectedPaymentState, + }, + { + name: "salt does not match the binding", + mutate: func(_ *lifecycleFixture, wire map[string]interface{}, _ *mockFacSigner) { + wire["saltNonce"] = "0x02" + }, + reason: ErrSaltBindingMismatch, + }, + { + name: "custom operator does not relay lifecycle", + extra: map[string]interface{}{"operatorType": "custom"}, + reason: ErrLifecycleNotRelayed, + }, + { + name: "simulation revert", + mutate: func(_ *lifecycleFixture, _ map[string]interface{}, signer *mockFacSigner) { + signer.simulateErr["refund"] = escrowRevert(t, "RefundExceedsCapture", big.NewInt(2), big.NewInt(1)) + }, + reason: ErrRefundExceedsCapture, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fx := newLifecycleFixture(t, test.extra) + signer := fx.refundSigner() + wire := fx.buildRefund(t, test.opts) + if test.mutate != nil { + test.mutate(&fx, wire, signer) + } + + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{RefundFunding: true, Operators: allowAllCustomOperators}).Verify(context.Background(), fx.payload(wire), fx.requirements, nil) + assertVerifyReason(t, err, test.reason) + }) + } +} + +func TestSettleRefund(t *testing.T) { + t.Run("submits the refund and reports the amount", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.refundSigner() + + resp, err := newScheme(signer, AuthCaptureEvmSchemeConfig{RefundFunding: true}).Settle(context.Background(), fx.payload(fx.buildRefund(t, refundOpts{})), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, []string{"refund"}, signer.writtenFunctions) + assert.Equal(t, strings.ToLower(fx.paymentInfo.Payer), strings.ToLower(resp.Payer)) + }) + + t.Run("re-simulates when configured", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.refundSigner() + signer.simulateErr["refund"] = escrowRevert(t, "RefundExceedsCapture", big.NewInt(2), big.NewInt(1)) + + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{RefundFunding: true, SimulateInSettle: true}).Settle(context.Background(), fx.payload(fx.buildRefund(t, refundOpts{})), fx.requirements, nil) + assertSettleReason(t, err, ErrRefundExceedsCapture) + assert.Empty(t, signer.writtenFunctions) + }) + + t.Run("verification failure", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.refundSigner() + _, err := newScheme(signer, AuthCaptureEvmSchemeConfig{RefundFunding: true}).Settle(context.Background(), fx.payload(fx.buildRefund(t, refundOpts{amount: "1000001"})), fx.requirements, nil) + assertSettleReason(t, err, ErrRefundExceedsCapture) + }) + + t.Run("a pending settlement is resumed instead of resubmitted", func(t *testing.T) { + fx := newLifecycleFixture(t, nil) + signer := fx.refundSigner() + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{RefundFunding: true}) + wire := fx.buildRefund(t, refundOpts{}) + require.NoError(t, scheme.pendingStore.Set(context.Background(), scheme.pendingKey(fx.payload(wire), fx.requirements), signer.writeTx)) + + resp, err := scheme.Settle(context.Background(), fx.payload(wire), fx.requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Empty(t, signer.writtenFunctions) + }) +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/scheme.go b/go/mechanisms/evm/auth-capture/facilitator/scheme.go new file mode 100644 index 0000000000..95276fc95e --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/scheme.go @@ -0,0 +1,335 @@ +package facilitator + +import ( + "context" + "errors" + "fmt" + "math/rand/v2" + "strings" + + "github.com/ethereum/go-ethereum/common" + gethtypes "github.com/ethereum/go-ethereum/core/types" + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +// OperatorAddressWildcard admits every contract operator of an entry's type. +const OperatorAddressWildcard = "*" + +// OperatorAllowlistEntry admits a contract operator, or every one with OperatorAddressWildcard, +// under the given operator type. Only "custom" is relayed. +type OperatorAllowlistEntry struct { + Address string `json:"address"` + OperatorType string `json:"operatorType"` +} + +// AuthCaptureEvmSchemeConfig configures the facilitator for the delegated operator type +// (authorize, charge, capture, void, refund) and, with an allowlist, custom operators. +type AuthCaptureEvmSchemeConfig struct { + // CaptureAuthorizers is the pool of operator addresses /supported advertises one at a time as + // extra.captureAuthorizer. Each MUST be one of signer.GetAddresses(), and stay usable (and + // funded when RefundFunding is on) until its payments pass refundDeadline. Leave it and + // CaptureAuthorizer empty for a relay-only facilitator that serves only Operators. + CaptureAuthorizers []string + // CaptureAuthorizer is a one-element shorthand for CaptureAuthorizers. + CaptureAuthorizer string + // SelectCaptureAuthorizer picks the pool member for one /supported response. A nil selector, + // or a result outside candidates, falls back to a random pick. + SelectCaptureAuthorizer func(network x402.Network, candidates []string) string + // FeeRecipient, MinFeeBps and MaxFeeBps are the fee terms advertised in /supported; omit for no fee. + FeeRecipient string + MinFeeBps uint16 + MaxFeeBps uint16 + // EIP6492AllowedFactories allowlists factories called to deploy counterfactual wallets; empty denies all. + EIP6492AllowedFactories []string + // SimulateInSettle reruns collect/lifecycle simulation during settle. Verify always simulates, + // and settle always does for a charge and for a custom operator. + SimulateInSettle bool + // Operators is the allowlist of custom contract operators the facilitator relays collects + // for. Empty admits none. Custom operators also need a signer implementing CallSimulator + // and GasLimitWriter. + Operators []OperatorAllowlistEntry + // CustomOperatorGasLimit caps simulated and submitted calls into a custom operator; zero + // selects authcapture.DefaultCustomOperatorGasLimit. + CustomOperatorGasLimit uint64 + // RefundFunding permits relaying refunds for delegated operators. The refund collector pulls + // the refunded tokens from the captureAuthorizer, so enable it only with an out-of-band + // funding agreement that keeps every advertised submitter funded and approved. + RefundFunding bool + + // AuthorizerSigner enables facilitator-delegated receiver authorization. Its address is + // advertised as /supported extra.receiverAuthorizer, and the facilitator signs charge and + // lifecycle digests with it when the server omits authorizerSignature. It requires + // ResolveCallerIdentity, DelegatedAuthStorage and OnStorageError together; + // NewAuthCaptureEvmSchemeWithError rejects a config where only some of the four are set. + AuthorizerSigner evm.ClientEvmSigner + // ResolveCallerIdentity authenticates the caller of a delegated settle out of band and + // returns a stable identity. An empty identity or an error rejects the settle. The identity + // must be the same across the authorize and the later lifecycle settles of one payment. + ResolveCallerIdentity ResolveCallerIdentity + // DelegatedAuthStorage holds the caller bindings for delegated payments. + DelegatedAuthStorage AuthCaptureDelegatedAuthStorage + // OnStorageError is called when reverting or deleting a binding fails. It must not replace + // the settle result. + OnStorageError OnDelegatedAuthStorageError +} + +// SimulatedCall is one call of a CallSimulator batch. A zero Gas leaves the limit unset. +type SimulatedCall struct { + To string + Data []byte + Gas uint64 +} + +// SimulatedCallResult is the outcome of one SimulatedCall, with the logs of nested calls. +type SimulatedCallResult struct { + Success bool + ReturnData []byte + GasUsed uint64 + Logs []*gethtypes.Log +} + +// Signer is the facilitator signer the scheme needs. The escrow gates authorize, capture and void +// on msg.sender, so simulations and writes must be sent from a chosen operator address, which +// must be one of GetAddresses(). +type Signer interface { + evm.FacilitatorEvmSigner + + ReadContractFrom(ctx context.Context, from, address string, abi []byte, functionName string, args ...interface{}) (interface{}, error) + WriteContractFrom(ctx context.Context, from, address string, abi []byte, functionName string, dataSuffix []byte, args ...interface{}) (string, error) +} + +// CallSimulator is an optional capability of a FacilitatorEvmSigner: run an ordered batch of calls +// against one simulated state (eth_simulateV1), so each call sees the effects of the earlier ones. +// Custom operators need it to check the outcome of a relayed call before it is broadcast. +type CallSimulator interface { + SimulateCalls(ctx context.Context, from string, calls []SimulatedCall) ([]SimulatedCallResult, error) +} + +// GasLimitWriter is an optional capability of a FacilitatorEvmSigner: WriteContract with an +// explicit gas limit. Custom operators need it so a relayed call cannot drain the gas budget. +type GasLimitWriter interface { + WriteContractWithGas(ctx context.Context, address string, abi []byte, functionName string, dataSuffix []byte, gas uint64, args ...interface{}) (string, error) +} + +// AuthCaptureEvmScheme implements SchemeNetworkFacilitator for the auth-capture EVM scheme. +// Escrow simulations and writes are sent from the payment's operator. +type AuthCaptureEvmScheme struct { + signer Signer + config AuthCaptureEvmSchemeConfig + captureAuthorizers []string + pendingStore x402.PendingSettlementStore +} + +// NewAuthCaptureEvmScheme is NewAuthCaptureEvmSchemeWithError that panics on an invalid config. +func NewAuthCaptureEvmScheme(signer Signer, config AuthCaptureEvmSchemeConfig) *AuthCaptureEvmScheme { + scheme, err := NewAuthCaptureEvmSchemeWithError(signer, config) + if err != nil { + panic(err.Error()) + } + return scheme +} + +// NewAuthCaptureEvmSchemeWithError creates a new AuthCaptureEvmScheme. It rejects partial +// delegated-authorizer config and a captureAuthorizer the signer does not hold. +func NewAuthCaptureEvmSchemeWithError(signer Signer, config AuthCaptureEvmSchemeConfig) (*AuthCaptureEvmScheme, error) { + if err := validateDelegatedReceiverAuthorizerConfig(config); err != nil { + return nil, err + } + pool, err := resolveCaptureAuthorizerPool(signer, config) + if err != nil { + return nil, err + } + return &AuthCaptureEvmScheme{ + signer: signer, + config: config, + captureAuthorizers: pool, + pendingStore: x402.NewInMemoryPendingSettlementStore(), + }, nil +} + +// resolveCaptureAuthorizerPool returns the deduplicated pool, checked against the signer. +func resolveCaptureAuthorizerPool(signer Signer, config AuthCaptureEvmSchemeConfig) ([]string, error) { + pool := make([]string, 0, len(config.CaptureAuthorizers)+1) + seen := make(map[string]bool) + for _, candidate := range config.CaptureAuthorizers { + if !authcapture.IsNonZeroAddress(candidate) { + return nil, fmt.Errorf("CaptureAuthorizers contains an empty or zero address: %q", candidate) + } + if key := evm.NormalizeAddress(candidate); !seen[key] { + seen[key] = true + pool = append(pool, candidate) + } + } + if config.CaptureAuthorizer != "" { + if !authcapture.IsNonZeroAddress(config.CaptureAuthorizer) { + return nil, fmt.Errorf("CaptureAuthorizer is a zero address: %q", config.CaptureAuthorizer) + } + key := evm.NormalizeAddress(config.CaptureAuthorizer) + switch { + case len(pool) == 0: + pool = append(pool, config.CaptureAuthorizer) + case !seen[key]: + return nil, errors.New("CaptureAuthorizer must be a member of CaptureAuthorizers when both are set") + } + } + if len(pool) == 0 { + return pool, nil + } + + controlled := make(map[string]bool) + for _, address := range signer.GetAddresses() { + controlled[evm.NormalizeAddress(address)] = true + } + for _, candidate := range pool { + if !controlled[evm.NormalizeAddress(candidate)] { + return nil, fmt.Errorf("captureAuthorizer %s is not one of the signer's addresses", candidate) + } + } + return pool, nil +} + +// SetPendingSettlementStore overrides the default in-memory PendingSettlementStore. +func (f *AuthCaptureEvmScheme) SetPendingSettlementStore(store x402.PendingSettlementStore) { + if store != nil { + f.pendingStore = store + } +} + +// Scheme returns the scheme identifier. +func (f *AuthCaptureEvmScheme) Scheme() string { + return authcapture.SchemeAuthCapture +} + +// CaipFamily returns the CAIP family pattern this facilitator supports. +func (f *AuthCaptureEvmScheme) CaipFamily() string { + return "eip155:*" +} + +// supportsCustomOperators reports whether the signer has what relaying into a custom operator needs. +func (f *AuthCaptureEvmScheme) supportsCustomOperators() bool { + _, simulates := f.signer.(CallSimulator) + _, capsGas := f.signer.(GasLimitWriter) + return len(f.config.Operators) > 0 && simulates && capsGas +} + +// operatorAdmitted reports whether the allowlist admits address as a custom operator. +func (f *AuthCaptureEvmScheme) operatorAdmitted(address string) bool { + if !f.supportsCustomOperators() { + return false + } + for _, entry := range f.config.Operators { + if entry.OperatorType != authcapture.OperatorTypeCustom { + continue + } + if entry.Address == OperatorAddressWildcard || strings.EqualFold(entry.Address, address) { + return true + } + } + return false +} + +// pickCaptureAuthorizer returns the captureAuthorizer to advertise, or "" without a pool. +func (f *AuthCaptureEvmScheme) pickCaptureAuthorizer(network x402.Network) string { + switch len(f.captureAuthorizers) { + case 0: + return "" + case 1: + return f.captureAuthorizers[0] + } + if f.config.SelectCaptureAuthorizer != nil { + candidates := append([]string(nil), f.captureAuthorizers...) + selected := f.config.SelectCaptureAuthorizer(network, candidates) + for _, candidate := range f.captureAuthorizers { + if evm.NormalizeAddress(candidate) == evm.NormalizeAddress(selected) { + return candidate + } + } + } + return f.captureAuthorizers[rand.IntN(len(f.captureAuthorizers))] +} + +// GetExtra returns the facilitator's advertised auth-capture terms for /supported: the capture +// authorizer (a pool member), the receiver authorizer when this facilitator can authenticate delegated requests, +// the fee terms, and the custom-operator allowlist. +func (f *AuthCaptureEvmScheme) GetExtra(network x402.Network) map[string]interface{} { + extra := map[string]interface{}{} + if captureAuthorizer := f.pickCaptureAuthorizer(network); captureAuthorizer != "" { + extra["captureAuthorizer"] = captureAuthorizer + } + if f.config.AuthorizerSigner != nil { + extra["receiverAuthorizer"] = common.HexToAddress(f.config.AuthorizerSigner.Address()).Hex() + } + if f.config.FeeRecipient != "" { + extra["feeRecipient"] = f.config.FeeRecipient + extra["minFeeBps"] = f.config.MinFeeBps + extra["maxFeeBps"] = f.config.MaxFeeBps + } + if f.supportsCustomOperators() { + extra["operators"] = f.config.Operators + } + if len(extra) == 0 { + return nil + } + return extra +} + +// GetSigners returns signer addresses used by this facilitator. +func (f *AuthCaptureEvmScheme) GetSigners(_ x402.Network) []string { + return f.signer.GetAddresses() +} + +// Verify routes to collect or lifecycle (capture, void, refund) verification by payload shape. +func (f *AuthCaptureEvmScheme) Verify( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*x402.VerifyResponse, error) { + switch { + case authcapture.IsEip3009Payload(payload.Payload), authcapture.IsPermit2Payload(payload.Payload): + return f.verifyCollect(ctx, payload, requirements) + case authcapture.IsCapturePayload(payload.Payload): + return f.verifyCapture(ctx, payload, requirements, fctx) + case authcapture.IsVoidPayload(payload.Payload): + return f.verifyVoid(ctx, payload, requirements, fctx) + case authcapture.IsRefundPayload(payload.Payload): + return f.verifyRefund(ctx, payload, requirements, fctx) + default: + return nil, x402.NewVerifyError(unknownShapeReason(payload.Payload), "", "payload matches no known auth-capture shape") + } +} + +// Settle routes to collect or lifecycle (capture, void, refund) settlement by payload shape. +func (f *AuthCaptureEvmScheme) Settle( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + fctx *x402.FacilitatorContext, +) (*x402.SettleResponse, error) { + switch { + case authcapture.IsEip3009Payload(payload.Payload), authcapture.IsPermit2Payload(payload.Payload): + return f.settleCollect(ctx, payload, requirements, fctx) + case authcapture.IsCapturePayload(payload.Payload): + return f.settleLifecycle(ctx, payload, requirements, fctx, f.settleCapture) + case authcapture.IsVoidPayload(payload.Payload): + return f.settleLifecycle(ctx, payload, requirements, fctx, f.settleVoid) + case authcapture.IsRefundPayload(payload.Payload): + return f.settleLifecycle(ctx, payload, requirements, fctx, f.settleRefund) + default: + network := x402.Network(payload.Accepted.Network) + return nil, x402.NewSettleError(unknownShapeReason(payload.Payload), "", network, "", "payload matches no known auth-capture shape") + } +} + +// unknownShapeReason is payload_type for a payload naming a lifecycle type we do not know, and +// payload_format for a malformed payload of a known shape. +func unknownShapeReason(payload map[string]interface{}) string { + switch payload["type"] { + case nil, opCapture, opVoid, opRefund: + return ErrPayloadFormat + } + return ErrPayloadType +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/scheme_test.go b/go/mechanisms/evm/auth-capture/facilitator/scheme_test.go new file mode 100644 index 0000000000..8e1e93d2c3 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/scheme_test.go @@ -0,0 +1,431 @@ +package facilitator + +import ( + "context" + "math/big" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +func TestSchemeMetadata(t *testing.T) { + signer := newMockFacSigner(facCaptureAuthorizer) + + noFee := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + assert.Equal(t, authcapture.SchemeAuthCapture, noFee.Scheme()) + assert.Equal(t, "eip155:*", noFee.CaipFamily()) + assert.Equal(t, []string{facCaptureAuthorizer}, noFee.GetSigners(facNetwork)) + assert.Equal(t, map[string]interface{}{"captureAuthorizer": facCaptureAuthorizer}, noFee.GetExtra(facNetwork)) + + withFee := newScheme(signer, AuthCaptureEvmSchemeConfig{FeeRecipient: facFeeRecipient, MinFeeBps: 5, MaxFeeBps: 5}) + extra := withFee.GetExtra(facNetwork) + assert.Equal(t, facFeeRecipient, extra["feeRecipient"]) + assert.Equal(t, uint16(5), extra["minFeeBps"]) + + unconfigured := NewAuthCaptureEvmScheme(signer, AuthCaptureEvmSchemeConfig{}) + assert.Nil(t, unconfigured.GetExtra(facNetwork)) +} + +func TestVerifyAndSettle_UnknownPayloadShape(t *testing.T) { + scheme := newScheme(newMockFacSigner(facCaptureAuthorizer), AuthCaptureEvmSchemeConfig{}) + requirements := facBaseRequirements(facCaptureAuthorizer, nil) + payload := types.PaymentPayload{Accepted: requirements, Payload: map[string]interface{}{"foo": "bar"}} + + _, err := scheme.Verify(context.Background(), payload, requirements, nil) + assertVerifyReason(t, err, ErrPayloadFormat) + + _, err = scheme.Settle(context.Background(), payload, requirements, nil) + assertSettleReason(t, err, ErrPayloadFormat) +} + +func TestVerifyCollect_HappyPath(t *testing.T) { + tests := []struct { + name string + extra map[string]interface{} + }{ + {name: "eip3009"}, + {name: "permit2", extra: map[string]interface{}{"assetTransferMethod": "permit2"}}, + {name: "salt bound", extra: map[string]interface{}{"receiverAuthorizer": "0x" + strings.Repeat("7", 40)}}, + {name: "no fee, zero recipient", extra: map[string]interface{}{"feeRecipient": authcapture.ZeroAddress, "maxFeeBps": float64(0)}}, + {name: "v1.0 deployment", extra: map[string]interface{}{"authCaptureEscrow": authcapture.AuthCaptureEscrowV1_0Address}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scheme := newScheme(newMockFacSigner(facCaptureAuthorizer), AuthCaptureEvmSchemeConfig{}) + requirements := facBaseRequirements(facCaptureAuthorizer, test.extra) + payer := newKeySigner(t) + + resp, err := scheme.Verify(context.Background(), buildCollectPayload(t, requirements, payer, collectOpts{}), requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + assert.Equal(t, strings.ToLower(payer.Address()), strings.ToLower(resp.Payer)) + }) + } +} + +func TestVerifyCollect_Rejections(t *testing.T) { + now := time.Now().Unix() + tests := []struct { + name string + extra map[string]interface{} + opts collectOpts + mutate func(requirements *types.PaymentRequirements, payload *types.PaymentPayload) + reason string + }{ + { + name: "payload scheme mismatch", + mutate: func(_ *types.PaymentRequirements, p *types.PaymentPayload) { p.Accepted.Scheme = "exact" }, + reason: ErrInvalidScheme, + }, + { + name: "requirements scheme mismatch", + mutate: func(r *types.PaymentRequirements, _ *types.PaymentPayload) { r.Scheme = "exact" }, + reason: ErrInvalidScheme, + }, + { + name: "network mismatch", + mutate: func(r *types.PaymentRequirements, _ *types.PaymentPayload) { r.Network = "eip155:1" }, + reason: ErrNetworkMismatch, + }, + { + name: "non-eip155 network", + mutate: func(r *types.PaymentRequirements, p *types.PaymentPayload) { + r.Network = "solana:devnet" + p.Accepted.Network = "solana:devnet" + }, + reason: ErrInvalidNetwork, + }, + { + name: "missing extra", + mutate: func(r *types.PaymentRequirements, _ *types.PaymentPayload) { delete(r.Extra, "captureDeadline") }, + reason: ErrExtra, + }, + {name: "min fee above max", extra: map[string]interface{}{"minFeeBps": float64(200)}, reason: ErrExtra}, + {name: "max fee above 10000", extra: map[string]interface{}{"maxFeeBps": float64(10001)}, reason: ErrExtra}, + {name: "zero recipient with fee bounds", extra: map[string]interface{}{"feeRecipient": authcapture.ZeroAddress}, reason: ErrZeroFeeReceiver}, + {name: "legacy autoCapture", extra: map[string]interface{}{"autoCapture": true}, reason: ErrUnsupportedPaymentFlow}, + {name: "authorization flow without receiver authorizer", extra: map[string]interface{}{"paymentFlow": "authorization"}, reason: ErrMissingReceiverAuthorizer}, + {name: "unknown payment flow", extra: map[string]interface{}{"paymentFlow": "streaming"}, reason: ErrUnsupportedPaymentFlow}, + {name: "custom operator outside the allowlist", extra: map[string]interface{}{"operatorType": "custom"}, reason: ErrOperatorNotAdmitted}, + {name: "unknown operator type", extra: map[string]interface{}{"operatorType": "multisig"}, reason: ErrUnsupportedOperatorType}, + {name: "policy operator", extra: map[string]interface{}{"policy": "0x" + strings.Repeat("5", 40)}, reason: ErrPolicy}, + {name: "operator not controlled", extra: map[string]interface{}{"captureAuthorizer": "0x" + strings.Repeat("9", 40)}, reason: ErrOperatorNotAdmitted}, + { + name: "eip3009 payload for permit2 route", + mutate: func(r *types.PaymentRequirements, _ *types.PaymentPayload) { + r.Extra["assetTransferMethod"] = "permit2" + }, + reason: ErrPayloadMethodMismatch, + }, + {name: "unknown asset transfer method", extra: map[string]interface{}{"assetTransferMethod": "magic"}, reason: ErrUnsupportedAssetTransferMethod}, + { + name: "capture deadline inside the skew floor", + extra: map[string]interface{}{"captureDeadline": float64(now + 3), "refundDeadline": float64(now + 100)}, + reason: ErrCaptureDeadlineExpired, + }, + { + name: "timeout longer than the capture window", + extra: map[string]interface{}{"captureDeadline": float64(now + 1000), "refundDeadline": float64(now + 2000)}, + reason: ErrDeadlineOrdering, + }, + { + name: "refund before capture", + extra: map[string]interface{}{"captureDeadline": float64(now + 86400), "refundDeadline": float64(now + 4000)}, + reason: ErrDeadlineOrdering, + }, + {name: "validBefore inside the skew floor", opts: collectOpts{validBefore: now + 3}, reason: ErrAuthorizationExpired}, + {name: "validAfter in the future", opts: collectOpts{validAfter: uint64(now + 600)}, reason: ErrAuthorizationNotYetValid}, + { + name: "amount mismatch", + mutate: func(_ *types.PaymentRequirements, p *types.PaymentPayload) { + p.Payload["authorization"].(map[string]interface{})["value"] = "1" + }, + reason: ErrAmountMismatch, + }, + { + name: "collector mismatch", + mutate: func(_ *types.PaymentRequirements, p *types.PaymentPayload) { + p.Payload["authorization"].(map[string]interface{})["to"] = "0x" + strings.Repeat("1", 40) + }, + reason: ErrTokenCollectorMismatch, + }, + { + name: "nonce mismatch", + mutate: func(_ *types.PaymentRequirements, p *types.PaymentPayload) { + p.Payload["authorization"].(map[string]interface{})["nonce"] = "0x" + strings.Repeat("1", 64) + }, + reason: ErrNonceMismatch, + }, + { + name: "saltNonce present while unbound", + mutate: func(_ *types.PaymentRequirements, p *types.PaymentPayload) { + p.Payload["saltNonce"] = "0x01" + }, + reason: ErrPayloadFormat, + }, + { + name: "saltNonce missing while bound", + extra: map[string]interface{}{"receiverAuthorizer": "0x" + strings.Repeat("7", 40)}, + mutate: func(_ *types.PaymentRequirements, p *types.PaymentPayload) { + delete(p.Payload, "saltNonce") + }, + reason: ErrPayloadFormat, + }, + { + name: "salt does not match the binding", + extra: map[string]interface{}{"receiverAuthorizer": "0x" + strings.Repeat("7", 40)}, + mutate: func(_ *types.PaymentRequirements, p *types.PaymentPayload) { + p.Payload["salt"] = "0x" + strings.Repeat("1", 64) + }, + reason: ErrSaltBindingMismatch, + }, + { + name: "signature from another key", + mutate: func(_ *types.PaymentRequirements, p *types.PaymentPayload) { + p.Payload["authorization"].(map[string]interface{})["from"] = newKeySigner(t).Address() + }, + reason: ErrSignature, + }, + { + name: "charge completion on an escrow payload", + mutate: func(_ *types.PaymentRequirements, p *types.PaymentPayload) { + p.Payload["amount"] = "1" + p.Payload["feeAmount"] = "0" + p.Payload["feeReceiver"] = facFeeRecipient + p.Payload["authorizerSignature"] = "0x01" + p.Payload["saltNonce"] = "0x01" + }, + reason: ErrPayloadFormat, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scheme := newScheme(newMockFacSigner(facCaptureAuthorizer), AuthCaptureEvmSchemeConfig{}) + requirements := facBaseRequirements(facCaptureAuthorizer, test.extra) + payload := buildCollectPayload(t, requirements, newKeySigner(t), test.opts) + if test.mutate != nil { + test.mutate(&requirements, &payload) + } + + _, err := scheme.Verify(context.Background(), payload, requirements, nil) + assertVerifyReason(t, err, test.reason) + }) + } +} + +func TestVerifyCollect_Permit2Rejections(t *testing.T) { + tests := []struct { + name string + mutate func(auth map[string]interface{}) + reason string + }{ + {name: "spender is not the collector", mutate: func(a map[string]interface{}) { a["spender"] = "0x" + strings.Repeat("1", 40) }, reason: ErrTokenCollectorMismatch}, + { + name: "permitted token differs", + mutate: func(a map[string]interface{}) { + a["permitted"].(map[string]interface{})["token"] = "0x" + strings.Repeat("1", 40) + }, + reason: ErrTokenMismatch, + }, + { + name: "permitted amount differs", + mutate: func(a map[string]interface{}) { a["permitted"].(map[string]interface{})["amount"] = "1" }, + reason: ErrAmountMismatch, + }, + {name: "malformed nonce", mutate: func(a map[string]interface{}) { a["nonce"] = "abc" }, reason: ErrPayloadFormat}, + {name: "malformed deadline", mutate: func(a map[string]interface{}) { a["deadline"] = "soon" }, reason: ErrPayloadFormat}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scheme := newScheme(newMockFacSigner(facCaptureAuthorizer), AuthCaptureEvmSchemeConfig{}) + requirements := facBaseRequirements(facCaptureAuthorizer, map[string]interface{}{"assetTransferMethod": "permit2"}) + payload := buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}) + test.mutate(payload.Payload["permit2Authorization"].(map[string]interface{})) + + _, err := scheme.Verify(context.Background(), payload, requirements, nil) + assertVerifyReason(t, err, test.reason) + }) + } +} + +func TestVerifyCollect_SimulationRevertsAreTyped(t *testing.T) { + tests := []struct { + name string + err error + reason string + }{ + {name: "pre-approval expired", err: escrowRevert(t, "AfterPreApprovalExpiry", big.NewInt(2), big.NewInt(1)), reason: ErrAuthorizationExpired}, + {name: "wrong sender", err: escrowRevert(t, "InvalidSender", common.Address{}, common.Address{}), reason: ErrOperatorMismatch}, + {name: "already collected", err: escrowRevert(t, "PaymentAlreadyCollected", [32]byte{}), reason: ErrPaymentAlreadyCollected}, + {name: "collection failed", err: escrowRevert(t, "TokenCollectionFailed"), reason: ErrTokenCollectionFailed}, + {name: "unmapped rpc failure", err: assert.AnError, reason: ErrSimulationFailed}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + signer := newMockFacSigner(facCaptureAuthorizer) + signer.simulateErr["authorize"] = test.err + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + requirements := facBaseRequirements(facCaptureAuthorizer, nil) + + _, err := scheme.Verify(context.Background(), buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}), requirements, nil) + assertVerifyReason(t, err, test.reason) + }) + } +} + +func TestSettleCollect_HappyPath(t *testing.T) { + signer := newMockFacSigner(facCaptureAuthorizer) + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + requirements := facBaseRequirements(facCaptureAuthorizer, nil) + + resp, err := scheme.Settle(context.Background(), buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}), requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, signer.writeTx, resp.Transaction) + assert.Equal(t, facAmount, resp.Amount) + assert.Equal(t, []string{"authorize"}, signer.writtenFunctions) +} + +func TestSettleCollect_FailureReasons(t *testing.T) { + requirements := facBaseRequirements(facCaptureAuthorizer, nil) + + t.Run("verification failure", func(t *testing.T) { + scheme := newScheme(newMockFacSigner("0x"+strings.Repeat("9", 40)), AuthCaptureEvmSchemeConfig{}) + _, err := scheme.Settle(context.Background(), buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}), requirements, nil) + assertSettleReason(t, err, ErrOperatorNotAdmitted) + }) + + t.Run("typed write revert", func(t *testing.T) { + signer := newMockFacSigner(facCaptureAuthorizer) + signer.writeErr["authorize"] = escrowRevert(t, "PaymentAlreadyCollected", [32]byte{}) + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + _, err := scheme.Settle(context.Background(), buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}), requirements, nil) + assertSettleReason(t, err, ErrPaymentAlreadyCollected) + }) + + t.Run("simulation in settle", func(t *testing.T) { + signer := newMockFacSigner(facCaptureAuthorizer) + signer.simulateErr["authorize"] = escrowRevert(t, "TokenCollectionFailed") + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{SimulateInSettle: true}) + _, err := scheme.Settle(context.Background(), buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}), requirements, nil) + assertSettleReason(t, err, ErrTokenCollectionFailed) + assert.Empty(t, signer.writtenFunctions) + }) + + t.Run("reverted receipt", func(t *testing.T) { + signer := newMockFacSigner(facCaptureAuthorizer) + signer.receipt = &evm.TransactionReceipt{Status: evm.TxStatusFailed, TxHash: signer.writeTx} + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + _, err := scheme.Settle(context.Background(), buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}), requirements, nil) + assertSettleReason(t, err, ErrTransactionReverted) + }) +} + +func TestSettleCollect_PendingSettlementSkipsRebroadcast(t *testing.T) { + signer := newMockFacSigner(facCaptureAuthorizer) + scheme := newScheme(signer, AuthCaptureEvmSchemeConfig{}) + requirements := facBaseRequirements(facCaptureAuthorizer, nil) + payload := buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}) + + store := x402.NewInMemoryPendingSettlementStore() + scheme.SetPendingSettlementStore(store) + scheme.SetPendingSettlementStore(nil) // nil keeps the store + require.NoError(t, store.Set(context.Background(), payload.Payload["signature"].(string), signer.writeTx)) + + resp, err := scheme.Settle(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Empty(t, signer.writtenFunctions, "a pending settlement must not be re-broadcast") +} + +func TestSimulateFactoryDeploy(t *testing.T) { + sigData := &evm.ERC6492SignatureData{FactoryCalldata: []byte{0x01}} + sigData.Factory[0] = 0xaa + assert.True(t, needsFactoryDeploy(sigData)) + assert.False(t, needsFactoryDeploy(&evm.ERC6492SignatureData{})) + + signer := newMockFacSigner(facCaptureAuthorizer) + require.NoError(t, simulateFactoryDeploy(context.Background(), signer, sigData, "0xpayer")) + + signer.multicallSuccess = false + assertVerifyReason(t, simulateFactoryDeploy(context.Background(), signer, sigData, "0xpayer"), ErrSimulationFailed) +} + +func TestVerifyCollect_ERC6492WrappedSignature(t *testing.T) { + factory := common.HexToAddress("0x" + strings.Repeat("aa", 20)) + wrap := func(t *testing.T, payload types.PaymentPayload) { + t.Helper() + inner, err := evm.HexToBytes(payload.Payload["signature"].(string)) + require.NoError(t, err) + payload.Payload["signature"] = evm.BytesToHex(wrapERC6492ForTest(t, factory, []byte{0x01}, inner)) + } + config := AuthCaptureEvmSchemeConfig{EIP6492AllowedFactories: []string{factory.Hex()}} + requirements := facBaseRequirements(facCaptureAuthorizer, nil) + + t.Run("counterfactual wallet on an allowlisted factory passes", func(t *testing.T) { + payload := buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}) + wrap(t, payload) + + resp, err := newScheme(newMockFacSigner(facCaptureAuthorizer), config).Verify(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + }) + + t.Run("deployed wallet must pass its own ERC-1271 check", func(t *testing.T) { + payload := buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}) + wrap(t, payload) + signer := newMockFacSigner(facCaptureAuthorizer) + signer.code = []byte{0x60} + signer.isValidSignatureResult = [4]byte{0xff, 0xff, 0xff, 0xff} + + _, err := newScheme(signer, config).Verify(context.Background(), payload, requirements, nil) + assertVerifyReason(t, err, ErrSignature) + assert.Empty(t, signer.readFroms, "the authorize simulation must not be reached") + }) + + t.Run("deployed wallet whose ERC-1271 check accepts passes", func(t *testing.T) { + payload := buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}) + wrap(t, payload) + signer := newMockFacSigner(facCaptureAuthorizer) + signer.code = []byte{0x60} + signer.isValidSignatureResult = [4]byte{0x16, 0x26, 0xba, 0x7e} + + resp, err := newScheme(signer, config).Verify(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) + }) +} + +func wrapERC6492ForTest(t *testing.T, factory common.Address, factoryData, inner []byte) []byte { + t.Helper() + addressType, err := abi.NewType("address", "", nil) + require.NoError(t, err) + bytesType, err := abi.NewType("bytes", "", nil) + require.NoError(t, err) + packed, err := abi.Arguments{{Type: addressType}, {Type: bytesType}, {Type: bytesType}}.Pack(factory, factoryData, inner) + require.NoError(t, err) + magic, err := evm.HexToBytes(evm.ERC6492MagicValue) + require.NoError(t, err) + return append(packed, magic...) +} + +func TestVerifyCollect_Permit2OnlyAssetWithoutTokenDomain(t *testing.T) { + requirements := facBaseRequirements(facCaptureAuthorizer, map[string]interface{}{"assetTransferMethod": "permit2"}) + delete(requirements.Extra, "name") + delete(requirements.Extra, "version") + payload := buildCollectPayload(t, requirements, newKeySigner(t), collectOpts{}) + + resp, err := newScheme(newMockFacSigner(facCaptureAuthorizer), AuthCaptureEvmSchemeConfig{}).Verify(context.Background(), payload, requirements, nil) + require.NoError(t, err) + assert.True(t, resp.IsValid) +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/shared.go b/go/mechanisms/evm/auth-capture/facilitator/shared.go new file mode 100644 index 0000000000..4c5ad24557 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/shared.go @@ -0,0 +1,470 @@ +package facilitator + +import ( + "bytes" + "context" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +// timeSkewSeconds is the spec's floor for deadline and expiry checks. +const timeSkewSeconds = 6 + +// requestContext is what every payload shape derives from the requirements. +type requestContext struct { + extra authcapture.AuthCaptureExtra + deployment authcapture.AuthCaptureDeployment + chainID *big.Int +} + +// checkRequest runs the scheme, network and extra checks shared by collect and lifecycle payloads. +func checkRequest(payload types.PaymentPayload, requirements types.PaymentRequirements, payer string) (*requestContext, error) { + if requirements.Scheme != authcapture.SchemeAuthCapture || payload.Accepted.Scheme != authcapture.SchemeAuthCapture { + return nil, x402.NewVerifyError(ErrInvalidScheme, payer, fmt.Sprintf("invalid scheme: %s", payload.Accepted.Scheme)) + } + if payload.Accepted.Network != requirements.Network { + return nil, x402.NewVerifyError(ErrNetworkMismatch, payer, fmt.Sprintf("network mismatch: %s != %s", payload.Accepted.Network, requirements.Network)) + } + chainID, err := evm.GetEvmChainId(requirements.Network) + if err != nil { + return nil, x402.NewVerifyError(ErrInvalidNetwork, payer, err.Error()) + } + + extra, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) + if err != nil { + return nil, x402.NewVerifyError(ErrExtra, payer, err.Error()) + } + if extra.MinFeeBps > extra.MaxFeeBps || extra.MaxFeeBps > authcapture.BpsDenominator { + return nil, x402.NewVerifyError(ErrExtra, payer, "fee bounds must satisfy minFeeBps <= maxFeeBps <= 10000") + } + if !authcapture.IsNonZeroAddress(extra.FeeRecipient) && extra.MaxFeeBps != 0 { + return nil, x402.NewVerifyError(ErrZeroFeeReceiver, payer, "a zero feeRecipient requires minFeeBps and maxFeeBps of 0") + } + if extra.AutoCapture { + return nil, x402.NewVerifyError(ErrUnsupportedPaymentFlow, payer, "autoCapture is removed; use paymentFlow") + } + switch extra.PaymentFlow { + case "", authcapture.PaymentFlowEscrow: + case authcapture.PaymentFlowAuthorization: + if !authcapture.IsNonZeroAddress(extra.ReceiverAuthorizer) { + return nil, x402.NewVerifyError(ErrMissingReceiverAuthorizer, payer, "paymentFlow authorization requires a non-zero receiverAuthorizer") + } + default: + return nil, x402.NewVerifyError(ErrUnsupportedPaymentFlow, payer, fmt.Sprintf("unsupported paymentFlow: %s", extra.PaymentFlow)) + } + return &requestContext{extra: extra, deployment: deployment, chainID: chainID}, nil +} + +// controlsAddress reports whether address is one this facilitator submits transactions from. +func (f *AuthCaptureEvmScheme) controlsAddress(address string) bool { + for _, controlled := range f.signer.GetAddresses() { + if evm.NormalizeAddress(controlled) == evm.NormalizeAddress(address) { + return true + } + } + return false +} + +// toSettleError converts a verification failure into the equivalent settle failure. +func toSettleError(err error, network x402.Network, payer string) error { + ve := &x402.VerifyError{} + if errors.As(err, &ve) { + return x402.NewSettleError(ve.InvalidReason, ve.Payer, network, "", ve.InvalidMessage) + } + return x402.NewSettleError(ErrVerificationFailed, payer, network, "", err.Error()) +} + +func escrowABI(deployment *authcapture.AuthCaptureDeployment) (abi.ABI, error) { + return abi.JSON(bytes.NewReader(authcapture.EscrowABIForDeployment(deployment))) +} + +// escrowRevertReasons maps AuthCaptureEscrow custom errors to spec invalidReason codes. +var escrowRevertReasons = map[string]string{ + "AfterPreApprovalExpiry": ErrAuthorizationExpired, + "InvalidExpiries": ErrDeadlineOrdering, + "ExceedsMaxAmount": ErrAmountMismatch, + "ZeroAmount": ErrAmountMismatch, + "AmountOverflow": ErrAmountOverflow, + "PaymentAlreadyCollected": ErrPaymentAlreadyCollected, + "TokenCollectionFailed": ErrTokenCollectionFailed, + "InvalidCollectorForOperation": ErrCollector, + "InvalidSender": ErrOperatorMismatch, + "FeeBpsOverflow": ErrFeeBps, + "InvalidFeeBpsRange": ErrFeeBpsRange, + "FeeBpsOutOfRange": ErrFeeBpsOutOfRange, + "FeeAmountOutOfRange": ErrFeeBpsOutOfRange, + "ZeroFeeReceiver": ErrZeroFeeReceiver, + "InvalidFeeReceiver": ErrFeeReceiver, + "AfterAuthorizationExpiry": ErrCaptureDeadlineExpired, + "InsufficientAuthorization": ErrInsufficientAuthorization, + "ZeroAuthorization": ErrZeroAuthorization, + "AfterRefundExpiry": ErrRefundDeadlineExpired, + "RefundExceedsCapture": ErrRefundExceedsCapture, +} + +// revertReason decodes revert data against the escrow ABI's custom errors, falling +// back to ErrSimulationFailed for unknown or missing data. +func revertReason(deployment *authcapture.AuthCaptureDeployment, data []byte) string { + if len(data) < 4 { + return ErrSimulationFailed + } + contractABI, err := escrowABI(deployment) + if err != nil { + return ErrSimulationFailed + } + var selector [4]byte + copy(selector[:], data[:4]) + abiErr, err := contractABI.ErrorByID(selector) + if err != nil { + return ErrSimulationFailed + } + if reason, ok := escrowRevertReasons[abiErr.Name]; ok { + return reason + } + return ErrSimulationFailed +} + +// errorRevertData extracts hex revert data from a JSON-RPC error, if it carries any. +func errorRevertData(err error) []byte { + var withData interface{ ErrorData() interface{} } + if !errors.As(err, &withData) { + return nil + } + hexData, ok := withData.ErrorData().(string) + if !ok { + return nil + } + data, decodeErr := evm.HexToBytes(hexData) + if decodeErr != nil { + return nil + } + return data +} + +// simulateEscrowCall eth_calls an AuthCaptureEscrow function as the operator and returns a typed +// VerifyError when it reverts. The escrow gates authorize, capture and void on msg.sender. +func simulateEscrowCall( + ctx context.Context, + signer Signer, + deployment *authcapture.AuthCaptureDeployment, + operator string, + payer string, + function string, + args ...interface{}, +) error { + escrowABI := authcapture.EscrowABIForDeployment(deployment) + if _, err := signer.ReadContractFrom(ctx, operator, deployment.Escrow, escrowABI, function, args...); err != nil { + return x402.NewVerifyError(revertReason(deployment, errorRevertData(err)), payer, err.Error()) + } + return nil +} + +// needsFactoryDeploy reports whether the payer is a counterfactual smart wallet the +// facilitator must deploy before authorize. +func needsFactoryDeploy(sigData *evm.ERC6492SignatureData) bool { + return evm.HasEIP6492Deployment(sigData) && !sigData.CodeDeployed +} + +// simulateFactoryDeploy checks the ERC-6492 factory call succeeds. The escrow call itself +// cannot be simulated for an undeployed payer: Multicall3 is not the operator. +func simulateFactoryDeploy(ctx context.Context, signer evm.FacilitatorEvmSigner, sigData *evm.ERC6492SignatureData, payer string) error { + results, err := evm.Multicall(ctx, signer, []evm.MulticallCall{ + {Address: common.BytesToAddress(sigData.Factory[:]).Hex(), CallData: sigData.FactoryCalldata}, + }) + if err != nil { + return x402.NewVerifyError(ErrSimulationFailed, payer, err.Error()) + } + if len(results) != 1 || !results[0].Success() { + return x402.NewVerifyError(ErrSimulationFailed, payer, "smart wallet deployment simulation reverted") + } + return nil +} + +// readPaymentState reads paymentState(paymentInfoHash), the single-use balance the +// facilitator checks before relaying a capture or void. It goes through Multicall so the +// three return values decode identically for every signer implementation. +func readPaymentState( + ctx context.Context, + signer evm.FacilitatorEvmSigner, + deployment *authcapture.AuthCaptureDeployment, + paymentInfoHashHex string, +) (hasCollectedPayment bool, capturableAmount *big.Int, refundableAmount *big.Int, err error) { + hashBytes, err := evm.HexToBytes(paymentInfoHashHex) + if err != nil { + return false, nil, nil, fmt.Errorf("invalid paymentInfoHash: %w", err) + } + + results, err := evm.Multicall(ctx, signer, []evm.MulticallCall{{ + Address: deployment.Escrow, + ABI: authcapture.EscrowABIForDeployment(deployment), + FunctionName: "paymentState", + Args: []interface{}{common.BytesToHash(hashBytes)}, + }}) + if err != nil { + return false, nil, nil, err + } + if len(results) != 1 || !results[0].Success() { + return false, nil, nil, fmt.Errorf("paymentState call failed") + } + outputs, ok := results[0].Result.([]interface{}) + if !ok || len(outputs) != 3 { + return false, nil, nil, fmt.Errorf("unexpected paymentState result shape") + } + hasCollectedPayment, _ = outputs[0].(bool) + capturableAmount = asBigInt(outputs[1]) + refundableAmount = asBigInt(outputs[2]) + if capturableAmount == nil || refundableAmount == nil { + return false, nil, nil, fmt.Errorf("unexpected paymentState amount types") + } + return hasCollectedPayment, capturableAmount, refundableAmount, nil +} + +// A capture or void usually follows the authorize immediately, and an RPC node behind +// the one that mined it may not show the collect yet. Reads are retried before the +// payment is treated as uncollected; tests shorten the delay. +const collectedReadAttempts = 5 + +var collectedReadDelay = time.Second + +// readCollectedState is readPaymentState, retried while the payment reads as uncollected. +func readCollectedState( + ctx context.Context, + signer evm.FacilitatorEvmSigner, + deployment *authcapture.AuthCaptureDeployment, + paymentInfoHash string, +) (hasCollected bool, capturable *big.Int, refundable *big.Int, err error) { + for attempt := 1; ; attempt++ { + hasCollected, capturable, refundable, err = readPaymentState(ctx, signer, deployment, paymentInfoHash) + if err != nil || hasCollected || attempt == collectedReadAttempts { + return hasCollected, capturable, refundable, err + } + select { + case <-ctx.Done(): + return false, nil, nil, ctx.Err() + case <-time.After(collectedReadDelay): + } + } +} + +// asBigInt normalizes a go-ethereum ABI-decoded numeric result to *big.Int. +func asBigInt(value interface{}) *big.Int { + switch v := value.(type) { + case *big.Int: + return v + case big.Int: + return &v + default: + return nil + } +} + +// writeEscrow submits an AuthCaptureEscrow call from operator, mapping a revert to its typed reason. +func (f *AuthCaptureEvmScheme) writeEscrow( + ctx context.Context, + fctx *x402.FacilitatorContext, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + deployment *authcapture.AuthCaptureDeployment, + operator string, + payer string, + function string, + args ...interface{}, +) (string, error) { + return f.submitEscrowCall(ctx, fctx, payload, requirements, deployment, operator, payer, deployment.Escrow, 0, function, args...) +} + +// submitEscrowCall submits an escrow-ABI call to target, the escrow itself or a custom operator +// forwarding to it. A non-empty from is the sender. A non-zero gas caps the call, needs a +// GasLimitWriter signer and ignores from. +func (f *AuthCaptureEvmScheme) submitEscrowCall( + ctx context.Context, + fctx *x402.FacilitatorContext, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + deployment *authcapture.AuthCaptureDeployment, + from string, + payer string, + target string, + gas uint64, + function string, + args ...interface{}, +) (string, error) { + network := x402.Network(payload.Accepted.Network) + dataSuffix, err := evm.ResolveDataSuffix(fctx, evm.DataSuffixContext{Payload: payload, Requirements: requirements}) + if err != nil { + return "", x402.NewSettleError(ErrPayloadFormat, payer, network, "", err.Error()) + } + escrowABI := authcapture.EscrowABIForDeployment(deployment) + var txHash string + switch { + case gas > 0: + writer, ok := f.signer.(GasLimitWriter) + if !ok { + return "", x402.NewSettleError(ErrSimulationFailed, payer, network, "", "signer cannot cap gas for a custom operator") + } + txHash, err = writer.WriteContractWithGas(ctx, target, escrowABI, function, dataSuffix, gas, args...) + case from != "": + txHash, err = f.signer.WriteContractFrom(ctx, from, target, escrowABI, function, dataSuffix, args...) + default: + txHash, err = f.signer.WriteContract(ctx, target, escrowABI, function, dataSuffix, args...) + } + if err != nil { + return "", x402.NewSettleError(revertReason(deployment, errorRevertData(err)), payer, network, "", err.Error()) + } + return txHash, nil +} + +// pendingKey is the pending-settlement store key: the payload's signature, except for a lifecycle +// payload whose authorizer is delegated to this facilitator. The facilitator produced that +// signature, which may differ between attempts, so the key is what the payload does to which payment. +func (f *AuthCaptureEvmScheme) pendingKey(payload types.PaymentPayload, requirements types.PaymentRequirements) string { + switch payload.Payload["type"] { + case opCapture, opVoid, opRefund: + extra, _, err := authcapture.ParseAuthCaptureExtra(requirements) + if err == nil && getDelegatedAuthorizer(f.config, extra.ReceiverAuthorizer) != nil { + return lifecycleSettlementKey(payload, requirements) + } + } + return settlementKey(payload.Payload) +} + +// lifecycleSettlementKey is network, paymentInfoHash, type, amount and expected balances, or "" +// when the payload cannot be read. +func lifecycleSettlementKey(payload types.PaymentPayload, requirements types.PaymentRequirements) string { + _, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) + if err != nil { + return "" + } + chainID, err := evm.GetEvmChainId(requirements.Network) + if err != nil { + return "" + } + paymentInfo, err := lifecyclePaymentInfo(payload.Payload) + if err != nil { + return "" + } + paymentInfoHash, err := authcapture.ComputePaymentInfoHash(chainID, paymentInfo, paymentInfo.Payer, deployment.Escrow) + if err != nil { + return "" + } + operation, _ := payload.Payload["type"].(string) + amount, _ := payload.Payload["amount"].(string) + expectedCapturable, _ := payload.Payload["expectedCapturableAmount"].(string) + expectedRefundable, _ := payload.Payload["expectedRefundableAmount"].(string) + return strings.Join([]string{ + requirements.Network, strings.ToLower(paymentInfoHash), operation, amount, expectedCapturable, expectedRefundable, + }, "|") +} + +// settlementKey is the pending-settlement key of a collect payload: its signature. +func settlementKey(payload map[string]interface{}) string { + if sig, ok := payload["signature"].(string); ok { + return sig + } + sig, _ := payload["authorizerSignature"].(string) + return sig +} + +// payloadPayer reads the payer straight from the wire payload for the pending-settlement +// path, where the original attempt already verified this exact payload. +func payloadPayer(payload map[string]interface{}) string { + for _, key := range []string{"authorization", "permit2Authorization"} { + if auth, ok := payload[key].(map[string]interface{}); ok { + from, _ := auth["from"].(string) + return from + } + } + if info, ok := payload["paymentInfo"].(map[string]interface{}); ok { + payer, _ := info["payer"].(string) + return payer + } + return "" +} + +// settledAmount is the amount reported in the SettleResponse: the hold for authorize, the +// amount the payload names for charge, capture and refund, and none for void. +func settledAmount(payload map[string]interface{}, requirements types.PaymentRequirements) string { + if payload["type"] == opVoid { + return "" + } + if amount, ok := payload["amount"].(string); ok { + return amount + } + return requirements.Amount +} + +// resumePending completes a settlement whose transaction was broadcast on an earlier +// attempt. It returns (nil, nil) when nothing is pending for the payload. +func (f *AuthCaptureEvmScheme) resumePending( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + check receiptCheck, +) (*x402.SettleResponse, error) { + key := f.pendingKey(payload, requirements) + if key == "" { + return nil, nil + } + txHash, ok, _ := f.pendingStore.Get(ctx, key) + if !ok { + return nil, nil + } + _ = f.pendingStore.Delete(ctx, key) + return f.awaitSettlement(ctx, payload, requirements, payloadPayer(payload.Payload), txHash, check) +} + +// receiptCheck inspects a confirmed receipt. It returns a failed SettleResponse to reject the +// settlement, and nil, nil to accept it. +type receiptCheck func(ctx context.Context, receipt *evm.TransactionReceipt) (*x402.SettleResponse, error) + +// awaitSettlement waits for txHash to confirm, applies check if set, and builds the SettleResponse. +func (f *AuthCaptureEvmScheme) awaitSettlement( + ctx context.Context, + payload types.PaymentPayload, + requirements types.PaymentRequirements, + payer string, + txHash string, + check receiptCheck, +) (*x402.SettleResponse, error) { + network := x402.Network(payload.Accepted.Network) + receipt, err := evm.WaitForSettleReceiptWithPendingStore( + ctx, f.pendingStore, f.pendingKey(payload, requirements), f.signer, txHash, payer, network, + ErrTransactionReverted, ErrTransactionReverted, + ) + if err != nil { + return nil, err + } + if check != nil { + if rejected, err := check(ctx, receipt); rejected != nil || err != nil { + return rejected, err + } + } + return &x402.SettleResponse{ + Success: true, + Transaction: receipt.TxHash, + Network: network, + Payer: payer, + Amount: settledAmount(payload.Payload, requirements), + }, nil +} + +// readTokenBalance reads an ERC-20 balance. It returns nil when the read fails or decodes to +// an unexpected type, so callers can treat the balance as unknown. +func readTokenBalance(ctx context.Context, signer evm.FacilitatorEvmSigner, token, account string) *big.Int { + balance, err := signer.ReadContract(ctx, token, evm.ERC20BalanceOfABI, "balanceOf", common.HexToAddress(account)) + if err != nil { + return nil + } + return asBigInt(balance) +} diff --git a/go/mechanisms/evm/auth-capture/facilitator/testutil_test.go b/go/mechanisms/evm/auth-capture/facilitator/testutil_test.go new file mode 100644 index 0000000000..80ddff79b3 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/facilitator/testutil_test.go @@ -0,0 +1,545 @@ +package facilitator + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "math/big" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm" + "github.com/x402-foundation/x402/go/v2/types" +) + +// mockFacSigner is a FacilitatorEvmSigner whose chain reads and writes are scripted per test. +type mockFacSigner struct { + addresses []string + + paymentStateHasCollected bool + paymentStateCapturable *big.Int + paymentStateRefundable *big.Int + paymentStateErr error + // stalePaymentStateReads serves the empty pre-collect state this many times, then the fields above. + stalePaymentStateReads int + + simulateErr map[string]error // escrow function name -> forced simulation error + + writeTx string + writeErr map[string]error // escrow function name -> forced write error + writtenFunctions []string + afterWrite func(function string) + + receipt *evm.TransactionReceipt + receiptErr error + + multicallSuccess bool + + code []byte + isValidSignatureResult interface{} + stateReads int + readFroms []string + writeFroms []string + + readFunctions []string + tokenBalance *big.Int // balanceOf through ReadContract + balances map[string]*big.Int // balanceOf inside a multicall, by lowercase account + tokenStore string + + simulateCalls func(from string, calls []SimulatedCall) ([]SimulatedCallResult, error) + writeTarget string + writeGas uint64 +} + +func newMockFacSigner(addresses ...string) *mockFacSigner { + txHash := "0x" + strings.Repeat("de", 32) + return &mockFacSigner{ + addresses: addresses, + simulateErr: map[string]error{}, + writeErr: map[string]error{}, + writeTx: txHash, + receipt: &evm.TransactionReceipt{Status: evm.TxStatusSuccess, TxHash: txHash}, + multicallSuccess: true, + balances: map[string]*big.Int{}, + } +} + +func (m *mockFacSigner) GetAddresses() []string { return m.addresses } + +func (m *mockFacSigner) GetCode(_ context.Context, _ string) ([]byte, error) { return m.code, nil } + +func init() { collectedReadDelay = time.Millisecond } + +// packArgs ABI-encodes args as the real signer does, so a wrongly typed argument fails the test. +func packArgs(abiJSON []byte, function string, args []interface{}) error { + parsed, err := abi.JSON(bytes.NewReader(abiJSON)) + if err != nil { + return err + } + if _, err := parsed.Pack(function, args...); err != nil { + return fmt.Errorf("failed to pack method call: %w", err) + } + return nil +} + +func (m *mockFacSigner) ReadContract(_ context.Context, _ string, abiJSON []byte, functionName string, args ...interface{}) (interface{}, error) { + if err := packArgs(abiJSON, functionName, args); err != nil { + return nil, err + } + if functionName == "tryAggregate" { + return m.tryAggregate(args) + } + if functionName == "isValidSignature" { + return m.isValidSignatureResult, nil + } + m.readFunctions = append(m.readFunctions, functionName) + switch functionName { + case "balanceOf": + return m.tokenBalance, nil + case "getTokenStore": + return common.HexToAddress(m.tokenStore), nil + } + if err := m.simulateErr[functionName]; err != nil { + return nil, err + } + return nil, nil +} + +// ReadContractFrom records the simulated sender so tests can assert it is the operator. +func (m *mockFacSigner) ReadContractFrom(ctx context.Context, from, address string, abiJSON []byte, functionName string, args ...interface{}) (interface{}, error) { + m.readFroms = append(m.readFroms, from) + return m.ReadContract(ctx, address, abiJSON, functionName, args...) +} + +// WriteContractFrom records the sender so tests can assert it is the operator. +func (m *mockFacSigner) WriteContractFrom(ctx context.Context, from, target string, abiJSON []byte, function string, dataSuffix []byte, args ...interface{}) (string, error) { + m.writeFroms = append(m.writeFroms, from) + return m.WriteContract(ctx, target, abiJSON, function, dataSuffix, args...) +} + +func (m *mockFacSigner) VerifyTypedData(context.Context, string, evm.TypedDataDomain, map[string][]evm.TypedDataField, string, map[string]interface{}, []byte) (bool, error) { + return false, nil +} + +func (m *mockFacSigner) WriteContract(_ context.Context, target string, abiJSON []byte, function string, _ []byte, args ...interface{}) (string, error) { + m.writeTarget = target + if err := packArgs(abiJSON, function, args); err != nil { + return "", err + } + m.writtenFunctions = append(m.writtenFunctions, function) + if err := m.writeErr[function]; err != nil { + return "", err + } + if m.afterWrite != nil { + m.afterWrite(function) + } + return m.writeTx, nil +} + +// WriteContractWithGas makes the mock a GasLimitWriter. +func (m *mockFacSigner) WriteContractWithGas(ctx context.Context, target string, abiJSON []byte, function string, dataSuffix []byte, gas uint64, args ...interface{}) (string, error) { + m.writeGas = gas + return m.WriteContract(ctx, target, abiJSON, function, dataSuffix, args...) +} + +// SimulateCalls makes the mock a CallSimulator. +func (m *mockFacSigner) SimulateCalls(_ context.Context, from string, calls []SimulatedCall) ([]SimulatedCallResult, error) { + return m.simulateCalls(from, calls) +} + +func (m *mockFacSigner) SendTransaction(context.Context, string, []byte) (string, error) { + return m.writeTx, nil +} + +func (m *mockFacSigner) WaitForTransactionReceipt(context.Context, string) (*evm.TransactionReceipt, error) { + if m.receiptErr != nil { + return nil, m.receiptErr + } + return m.receipt, nil +} + +func (m *mockFacSigner) GetBalance(context.Context, string, string) (*big.Int, error) { + return big.NewInt(0), nil +} + +func (m *mockFacSigner) GetChainID(context.Context) (*big.Int, error) { return big.NewInt(84532), nil } + +type aggregateResult = struct { + Success bool + ReturnData []byte +} + +// tryAggregate answers a Multicall3 batch: paymentState calls return the encoded state, +// any other call (a factory deploy) succeeds or fails per multicallSuccess. +func (m *mockFacSigner) tryAggregate(args []interface{}) (interface{}, error) { + m.stateReads++ + if m.paymentStateErr != nil { + return nil, m.paymentStateErr + } + escrow, err := abi.JSON(bytes.NewReader(authcapture.EscrowABIForDeployment(authcapture.ResolveAuthCaptureDeployment("")))) + if err != nil { + return nil, err + } + erc20, err := abi.JSON(bytes.NewReader(evm.ERC20BalanceOfABI)) + if err != nil { + return nil, err + } + calls := reflect.ValueOf(args[1]) + results := make([]aggregateResult, calls.Len()) + for i := range results { + callData := calls.Index(i).FieldByName("CallData").Bytes() + if bytes.HasPrefix(callData, escrow.Methods["paymentState"].ID) { + collected := m.paymentStateHasCollected + capturable, refundable := m.paymentStateCapturable, m.paymentStateRefundable + if m.stalePaymentStateReads > 0 { + m.stalePaymentStateReads-- + collected = false + capturable, refundable = big.NewInt(0), big.NewInt(0) + } + returnData, err := escrow.Methods["paymentState"].Outputs.Pack(collected, capturable, refundable) + if err != nil { + return nil, err + } + results[i] = aggregateResult{Success: true, ReturnData: returnData} + continue + } + if bytes.HasPrefix(callData, erc20.Methods["balanceOf"].ID) { + account := strings.ToLower(common.BytesToAddress(callData[len(callData)-20:]).Hex()) + balance := m.balances[account] + if balance == nil { + balance = new(big.Int) + } + returnData, err := erc20.Methods["balanceOf"].Outputs.Pack(balance) + if err != nil { + return nil, err + } + results[i] = aggregateResult{Success: true, ReturnData: returnData} + continue + } + results[i] = aggregateResult{Success: m.multicallSuccess} + } + return results, nil +} + +// revertError mimics a JSON-RPC error that carries revert data. +type revertError struct{ data string } + +func (e revertError) Error() string { return "execution reverted" } +func (e revertError) ErrorData() interface{} { return e.data } + +// escrowRevert returns the RPC error for an AuthCaptureEscrow custom error with args. +func escrowRevert(t *testing.T, name string, args ...interface{}) error { + t.Helper() + deployment := authcapture.ResolveAuthCaptureDeployment("") + contractABI, err := escrowABI(deployment) + require.NoError(t, err) + abiErr, ok := contractABI.Errors[name] + require.True(t, ok, "escrow ABI declares %s", name) + packed, err := abiErr.Inputs.Pack(args...) + require.NoError(t, err) + return revertError{data: "0x" + hex.EncodeToString(append(abiErr.ID.Bytes()[:4], packed...))} +} + +var ( + facCaptureAuthorizer = "0x" + strings.Repeat("c", 40) + facFeeRecipient = "0x" + strings.Repeat("3", 40) + facPayTo = "0x" + strings.Repeat("2", 40) + facAsset = "0x" + strings.Repeat("a", 40) +) + +var allowAllCustomOperators = []OperatorAllowlistEntry{{Address: OperatorAddressWildcard, OperatorType: authcapture.OperatorTypeCustom}} + +const ( + facNetwork = "eip155:84532" + facAmount = "1000000" +) + +func newKeySigner(t *testing.T) evm.ClientEvmSigner { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + signer, err := evmsigners.NewClientSignerFromPrivateKey(hexutil.Encode(crypto.FromECDSA(key))) + require.NoError(t, err) + return signer +} + +func facBaseRequirements(captureAuthorizer string, extraOverrides map[string]interface{}) types.PaymentRequirements { + future := time.Now().Unix() + 86400 + extra := map[string]interface{}{ + "name": "USDC", + "version": "2", + "captureAuthorizer": captureAuthorizer, + "feeRecipient": facFeeRecipient, + "minFeeBps": float64(0), + "maxFeeBps": float64(100), + "captureDeadline": float64(future), + "refundDeadline": float64(future + 86400), + } + for k, v := range extraOverrides { + extra[k] = v + } + return types.PaymentRequirements{ + Scheme: authcapture.SchemeAuthCapture, + Network: facNetwork, + Amount: facAmount, + Asset: facAsset, + PayTo: facPayTo, + MaxTimeoutSeconds: 3600, + Extra: extra, + } +} + +func newScheme(signer *mockFacSigner, config AuthCaptureEvmSchemeConfig) *AuthCaptureEvmScheme { + // The constructor rejects a captureAuthorizer the signer does not hold. + for _, address := range signer.addresses { + if strings.EqualFold(address, facCaptureAuthorizer) { + config.CaptureAuthorizer = facCaptureAuthorizer + } + } + return NewAuthCaptureEvmScheme(signer, config) +} + +// collectOpts tunes buildCollectPayload; zero values give a valid payload. +type collectOpts struct { + validAfter uint64 + validBefore int64 // absolute unix seconds; default now+3600 +} + +// buildCollectPayload builds a real-signature collect payload for the method in requirements.extra. +func buildCollectPayload(t *testing.T, requirements types.PaymentRequirements, payer evm.ClientEvmSigner, opts collectOpts) types.PaymentPayload { + t.Helper() + extra, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) + require.NoError(t, err) + chainID, err := evm.GetEvmChainId(requirements.Network) + require.NoError(t, err) + + validBefore := opts.validBefore + if validBefore == 0 { + validBefore = time.Now().Unix() + 3600 + } + saltNonce, salt := "", "0x"+strings.Repeat("0", 64) + if authcapture.IsSaltBindingOn(extra) { + saltNonce = "0x01" + salt, err = authcapture.DeriveBoundSalt(authcapture.ExtraAddress(extra.ReceiverAuthorizer), authcapture.ExtraAddress(extra.Policy), saltNonce) + require.NoError(t, err) + } + + paymentInfo := authcapture.ReconstructPaymentInfo(payer.Address(), uint64(validBefore), salt, requirements, extra) + nonceHex, err := authcapture.ComputePayerAgnosticPaymentInfoHash(chainID, paymentInfo, deployment.Escrow) + require.NoError(t, err) + + wire := map[string]interface{}{"salt": salt} + var signature []byte + if extra.AssetTransferMethod == methodPermit2 { + nonce, err := authcapture.NonceHexToDecimalString(nonceHex) + require.NoError(t, err) + permit := authcapture.Permit2Authorization{ + From: payer.Address(), + Permitted: authcapture.Permit2TokenPermissions{Token: requirements.Asset, Amount: requirements.Amount}, + Spender: deployment.Permit2Collector, + Nonce: nonce, + Deadline: strconv.FormatInt(validBefore, 10), + } + signature, err = authcapture.SignPermit2(context.Background(), payer, permit, chainID) + require.NoError(t, err) + wire["permit2Authorization"] = map[string]interface{}{ + "from": permit.From, + "permitted": map[string]interface{}{"token": permit.Permitted.Token, "amount": permit.Permitted.Amount}, + "spender": permit.Spender, + "nonce": permit.Nonce, + "deadline": permit.Deadline, + } + } else { + authorization := authcapture.Eip3009Authorization{ + From: payer.Address(), + To: deployment.EIP3009Collector, + Value: requirements.Amount, + ValidAfter: strconv.FormatUint(opts.validAfter, 10), + ValidBefore: strconv.FormatInt(validBefore, 10), + Nonce: nonceHex, + } + signature, err = authcapture.SignERC3009(context.Background(), payer, authorization, extra, requirements.Asset, chainID) + require.NoError(t, err) + wire["authorization"] = map[string]interface{}{ + "from": authorization.From, + "to": authorization.To, + "value": authorization.Value, + "validAfter": authorization.ValidAfter, + "validBefore": authorization.ValidBefore, + "nonce": authorization.Nonce, + } + } + wire["signature"] = evm.BytesToHex(signature) + if saltNonce != "" { + wire["saltNonce"] = saltNonce + } + return types.PaymentPayload{X402Version: 2, Accepted: requirements, Payload: wire} +} + +func assertVerifyReason(t *testing.T, err error, reason string) { + t.Helper() + require.Error(t, err) + ve := &x402.VerifyError{} + require.ErrorAs(t, err, &ve) + assert.Equal(t, reason, ve.InvalidReason) +} + +func assertSettleReason(t *testing.T, err error, reason string) { + t.Helper() + require.Error(t, err) + se := &x402.SettleError{} + require.ErrorAs(t, err, &se) + assert.Equal(t, reason, se.ErrorReason) +} + +// lifecycleFixture is a capture/void test setup: requirements with a real receiverAuthorizer +// and a matching PaymentInfo the mock reports as collected. +type lifecycleFixture struct { + requirements types.PaymentRequirements + extra authcapture.AuthCaptureExtra + deployment authcapture.AuthCaptureDeployment + chainID *big.Int + paymentInfo authcapture.PaymentInfoStruct + receiver evm.ClientEvmSigner + saltNonce string + paymentHash string +} + +func newLifecycleFixture(t *testing.T, extraOverrides map[string]interface{}) lifecycleFixture { + t.Helper() + receiver := newKeySigner(t) + overrides := map[string]interface{}{"receiverAuthorizer": receiver.Address()} + for k, v := range extraOverrides { + overrides[k] = v + } + requirements := facBaseRequirements(facCaptureAuthorizer, overrides) + extra, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) + require.NoError(t, err) + + saltNonce := "0x01" + salt, err := authcapture.DeriveBoundSalt(receiver.Address(), authcapture.ExtraAddress(""), saltNonce) + require.NoError(t, err) + + paymentInfo := authcapture.ReconstructPaymentInfo(newKeySigner(t).Address(), extra.CaptureDeadline-1, salt, requirements, extra) + chainID, err := evm.GetEvmChainId(requirements.Network) + require.NoError(t, err) + paymentHash, err := authcapture.ComputePaymentInfoHash(chainID, paymentInfo, paymentInfo.Payer, deployment.Escrow) + require.NoError(t, err) + + return lifecycleFixture{ + requirements: requirements, + extra: extra, + deployment: deployment, + chainID: chainID, + paymentInfo: paymentInfo, + receiver: receiver, + saltNonce: saltNonce, + paymentHash: paymentHash, + } +} + +func (fx lifecycleFixture) signer() *mockFacSigner { + signer := newMockFacSigner(fx.extra.CaptureAuthorizer) + signer.paymentStateHasCollected = true + signer.paymentStateCapturable, _ = new(big.Int).SetString(fx.paymentInfo.MaxAmount, 10) + signer.paymentStateRefundable = big.NewInt(0) + return signer +} + +func (fx lifecycleFixture) payload(wire map[string]interface{}) types.PaymentPayload { + return types.PaymentPayload{X402Version: 2, Accepted: fx.requirements, Payload: wire} +} + +func (fx lifecycleFixture) voidSignature(t *testing.T) string { + t.Helper() + sig, err := authcapture.SignVoid(context.Background(), fx.receiver, fx.extra.CaptureAuthorizer, fx.chainID, fx.paymentHash) + require.NoError(t, err) + return evm.BytesToHex(sig) +} + +// captureOpts tunes buildCapture; zero values capture the full hold with the default fee. +type captureOpts struct { + amount string + expectedCapturable string + feeBps *uint16 // v1.0 fee; nil selects the deployment default + feeAmount *big.Int // v1.1 fee; nil selects the deployment default + withVoid bool + feeReceiver string +} + +func (fx lifecycleFixture) buildCapture(t *testing.T, opts captureOpts) map[string]interface{} { + t.Helper() + if opts.amount == "" { + opts.amount = fx.paymentInfo.MaxAmount + } + if opts.expectedCapturable == "" { + opts.expectedCapturable = fx.paymentInfo.MaxAmount + } + if opts.feeReceiver == "" { + opts.feeReceiver = fx.extra.FeeRecipient + } + amount, _ := new(big.Int).SetString(opts.amount, 10) + expectedCapturable, _ := new(big.Int).SetString(opts.expectedCapturable, 10) + + fee := authcapture.DefaultCaptureFee(&fx.deployment, amount, fx.extra.MinFeeBps) + if opts.feeBps != nil { + fee = authcapture.CaptureFee{Bps: opts.feeBps} + } + if opts.feeAmount != nil { + fee = authcapture.CaptureFee{Amount: opts.feeAmount} + } + signature, err := authcapture.SignCapture(context.Background(), fx.receiver, &fx.deployment, fx.extra.CaptureAuthorizer, fx.chainID, + authcapture.CaptureParams{ + PaymentInfoHash: fx.paymentHash, + Amount: amount, + Fee: fee, + FeeReceiver: opts.feeReceiver, + ExpectedCapturable: expectedCapturable, + ExpectedRefundable: big.NewInt(0), + }) + require.NoError(t, err) + + paymentInfo, err := fx.paymentInfo.ToWireMap() + require.NoError(t, err) + wire := map[string]interface{}{ + "type": "capture", + "paymentInfo": paymentInfo, + "saltNonce": fx.saltNonce, + "amount": opts.amount, + "feeReceiver": opts.feeReceiver, + "expectedCapturableAmount": opts.expectedCapturable, + "expectedRefundableAmount": "0", + "authorizerSignature": evm.BytesToHex(signature), + } + fee.AddToWire(wire) + if opts.withVoid { + wire["voidAuthorizerSignature"] = fx.voidSignature(t) + } + return wire +} + +func (fx lifecycleFixture) buildVoid(t *testing.T) map[string]interface{} { + t.Helper() + paymentInfo, err := fx.paymentInfo.ToWireMap() + require.NoError(t, err) + return map[string]interface{}{ + "type": "void", + "paymentInfo": paymentInfo, + "saltNonce": fx.saltNonce, + "authorizerSignature": fx.voidSignature(t), + } +} diff --git a/go/mechanisms/evm/auth-capture/nonce.go b/go/mechanisms/evm/auth-capture/nonce.go index eca016b1ec..61e219cb84 100644 --- a/go/mechanisms/evm/auth-capture/nonce.go +++ b/go/mechanisms/evm/auth-capture/nonce.go @@ -111,22 +111,30 @@ func ComputePayerAgnosticPaymentInfoHash(chainID *big.Int, paymentInfo PaymentIn return hashPaymentInfo(chainID, paymentInfo, zero, escrow) } -// SignERC3009 signs ReceiveWithAuthorization with the token EIP-712 domain from extra. -func SignERC3009( - ctx context.Context, - signer evm.ClientEvmSigner, - authorization Eip3009Authorization, - extra AuthCaptureExtra, - tokenAddress string, - chainID *big.Int, -) ([]byte, error) { - domain := evm.TypedDataDomain{ +// ComputePaymentInfoHash returns the escrow's getHash(paymentInfo): the +// real-payer PaymentInfo hash used as the lifecycle paymentInfoHash (Capture, +// Void, Refund digests) and as the paymentState lookup key. +func ComputePaymentInfoHash(chainID *big.Int, paymentInfo PaymentInfoStruct, payer string, escrowAddress ...string) (string, error) { + escrow := AuthCaptureEscrowAddress + if len(escrowAddress) > 0 && escrowAddress[0] != "" { + escrow = escrowAddress[0] + } + return hashPaymentInfo(chainID, paymentInfo, payer, escrow) +} + +// eip3009Domain returns the EIP-712 domain for ReceiveWithAuthorization (the token contract). +func eip3009Domain(extra AuthCaptureExtra, tokenAddress string, chainID *big.Int) evm.TypedDataDomain { + return evm.TypedDataDomain{ Name: extra.Name, Version: extra.Version, ChainID: chainID, VerifyingContract: evm.NormalizeAddress(tokenAddress), } +} +// eip3009Message builds the ReceiveWithAuthorization message, shared by SignERC3009 and +// HashERC3009Authorization so the signed and verified digests can never drift apart. +func eip3009Message(authorization Eip3009Authorization) (map[string]interface{}, error) { value, ok := new(big.Int).SetString(authorization.Value, 10) if !ok { return nil, fmt.Errorf("invalid authorization value: %s", authorization.Value) @@ -144,37 +152,71 @@ func SignERC3009( return nil, fmt.Errorf("invalid authorization nonce: %w", err) } - message := map[string]interface{}{ + return map[string]interface{}{ "from": evm.NormalizeAddress(authorization.From), "to": evm.NormalizeAddress(authorization.To), "value": value, "validAfter": validAfter, "validBefore": validBefore, "nonce": nonceBytes, + }, nil +} + +// SignERC3009 signs ReceiveWithAuthorization with the token EIP-712 domain from extra. +func SignERC3009( + ctx context.Context, + signer evm.ClientEvmSigner, + authorization Eip3009Authorization, + extra AuthCaptureExtra, + tokenAddress string, + chainID *big.Int, +) ([]byte, error) { + message, err := eip3009Message(authorization) + if err != nil { + return nil, err } return signer.SignTypedData( ctx, - domain, + eip3009Domain(extra, tokenAddress, chainID), GetReceiveAuthorizationEIP712Types(), "ReceiveWithAuthorization", message, ) } -// SignPermit2 signs PermitTransferFrom against the canonical Permit2 domain (no witness). -func SignPermit2( - ctx context.Context, - signer evm.ClientEvmSigner, - permit Permit2Authorization, +// HashERC3009Authorization returns the EIP-712 digest that SignERC3009 signs, for +// facilitator-side signature verification. +func HashERC3009Authorization( + authorization Eip3009Authorization, + extra AuthCaptureExtra, + tokenAddress string, chainID *big.Int, -) ([]byte, error) { - domain := evm.TypedDataDomain{ +) ([32]byte, error) { + message, err := eip3009Message(authorization) + if err != nil { + return [32]byte{}, err + } + return evm.HashEIP712TypedData( + eip3009Domain(extra, tokenAddress, chainID), + GetReceiveAuthorizationEIP712Types(), + "ReceiveWithAuthorization", + message, + ) +} + +// permit2Domain returns the canonical Permit2 EIP-712 domain (no witness, no version field). +func permit2Domain(chainID *big.Int) evm.TypedDataDomain { + return evm.TypedDataDomain{ Name: "Permit2", ChainID: chainID, VerifyingContract: evm.PERMIT2Address, } +} +// permit2Message builds the PermitTransferFrom message, shared by SignPermit2 and +// HashPermit2Authorization so the signed and verified digests can never drift apart. +func permit2Message(permit Permit2Authorization) (map[string]interface{}, error) { amount, ok := new(big.Int).SetString(permit.Permitted.Amount, 10) if !ok { return nil, fmt.Errorf("invalid permitted amount: %s", permit.Permitted.Amount) @@ -188,7 +230,7 @@ func SignPermit2( return nil, fmt.Errorf("invalid permit deadline: %s", permit.Deadline) } - message := map[string]interface{}{ + return map[string]interface{}{ "permitted": map[string]interface{}{ "token": evm.NormalizeAddress(permit.Permitted.Token), "amount": amount, @@ -196,11 +238,40 @@ func SignPermit2( "spender": evm.NormalizeAddress(permit.Spender), "nonce": nonce, "deadline": deadline, + }, nil +} + +// SignPermit2 signs PermitTransferFrom against the canonical Permit2 domain (no witness). +func SignPermit2( + ctx context.Context, + signer evm.ClientEvmSigner, + permit Permit2Authorization, + chainID *big.Int, +) ([]byte, error) { + message, err := permit2Message(permit) + if err != nil { + return nil, err } return signer.SignTypedData( ctx, - domain, + permit2Domain(chainID), + GetPermit2TransferFromEIP712Types(), + "PermitTransferFrom", + message, + ) +} + +// HashPermit2Authorization returns the EIP-712 digest that SignPermit2 signs, for +// facilitator-side signature verification. Distinct from evm.HashPermit2Authorization, +// which hashes exact's witness-bearing PermitTransferFrom variant. +func HashPermit2Authorization(permit Permit2Authorization, chainID *big.Int) ([32]byte, error) { + message, err := permit2Message(permit) + if err != nil { + return [32]byte{}, err + } + return evm.HashEIP712TypedData( + permit2Domain(chainID), GetPermit2TransferFromEIP712Types(), "PermitTransferFrom", message, @@ -216,22 +287,10 @@ func GenerateSalt() (string, error) { return evm.BytesToHex(buf), nil } -// NormalizeBytes32 zero-pads a hex integer to a full 32-byte word. -func NormalizeBytes32(value string) (string, error) { - hexPart := strings.TrimPrefix(strings.TrimPrefix(value, "0x"), "0X") - if len(hexPart) == 0 || len(hexPart) > 64 { - return "", fmt.Errorf("invalid bytes32: %s", value) - } - if _, err := hex.DecodeString(hexPart); err != nil { - return "", fmt.Errorf("invalid bytes32: %s", value) - } - return "0x" + strings.ToLower(strings.Repeat("0", 64-len(hexPart))+hexPart), nil -} - // ExtraAddress treats absent or invalid values as the zero address. func ExtraAddress(value string) string { if value == "" || !evm.IsValidAddress(value) { - return "0x0000000000000000000000000000000000000000" + return ZeroAddress } return evm.NormalizeAddress(value) } @@ -241,7 +300,7 @@ func IsNonZeroAddress(value string) bool { if value == "" || !evm.IsValidAddress(value) { return false } - return !strings.EqualFold(evm.NormalizeAddress(value), "0x0000000000000000000000000000000000000000") + return !strings.EqualFold(evm.NormalizeAddress(value), ZeroAddress) } // IsSaltBindingOn is true when receiverAuthorizer or policy is non-zero. diff --git a/go/mechanisms/evm/auth-capture/operator.go b/go/mechanisms/evm/auth-capture/operator.go new file mode 100644 index 0000000000..91a3b5a26f --- /dev/null +++ b/go/mechanisms/evm/auth-capture/operator.go @@ -0,0 +1,255 @@ +package authcapture + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" +) + +// OperatorDomain returns the EIP-712 domain for the operator signatures, scoped to the +// chain and the captureAuthorizer (PaymentInfo.operator). +func OperatorDomain(captureAuthorizer string, chainID *big.Int) evm.TypedDataDomain { + return evm.TypedDataDomain{ + Name: OperatorEIP712Domain.Name, + Version: OperatorEIP712Domain.Version, + ChainID: chainID, + VerifyingContract: evm.NormalizeAddress(captureAuthorizer), + } +} + +// CaptureFee is the fee submitted with a charge or capture: Bps on v1.0 deployments, Amount on v1.1. +type CaptureFee struct { + Bps *uint16 + Amount *big.Int +} + +// DefaultCaptureFee returns minFeeBps of amount in the deployment's fee encoding. +func DefaultCaptureFee(deployment *AuthCaptureDeployment, amount *big.Int, minFeeBps uint16) CaptureFee { + if deployment.Version == AuthCaptureDeploymentV1_0 { + return CaptureFee{Bps: &minFeeBps} + } + return CaptureFee{Amount: FeeAmountFromBps(amount, minFeeBps)} +} + +// Arg returns the fee as the AuthCaptureEscrow.charge or capture call argument. +func (f CaptureFee) Arg() interface{} { + if f.Bps != nil { + return *f.Bps + } + return f.Amount +} + +// AddToWire sets the fee field (feeBps or feeAmount) on a charge or capture wire payload. +func (f CaptureFee) AddToWire(payload map[string]interface{}) { + if f.Bps != nil { + payload["feeBps"] = *f.Bps + return + } + payload["feeAmount"] = f.Amount.String() +} + +func (f CaptureFee) addToMessage(message map[string]interface{}) { + if f.Bps != nil { + message["feeBps"] = big.NewInt(int64(*f.Bps)) + return + } + message["feeAmount"] = f.Amount +} + +// CaptureParams are the values the receiver authorizer signs for a capture. +type CaptureParams struct { + PaymentInfoHash string + Amount *big.Int + Fee CaptureFee + FeeReceiver string + ExpectedCapturable *big.Int + ExpectedRefundable *big.Int +} + +func (p CaptureParams) message() map[string]interface{} { + message := map[string]interface{}{ + "paymentInfoHash": p.PaymentInfoHash, + "amount": p.Amount, + "feeReceiver": evm.NormalizeAddress(p.FeeReceiver), + "expectedCapturableAmount": p.ExpectedCapturable, + "expectedRefundableAmount": p.ExpectedRefundable, + } + p.Fee.addToMessage(message) + return message +} + +// ChargeParams are the values the receiver authorizer signs for a terminal charge. +// CollectorData is the client signature exactly as it arrived on the wire. +type ChargeParams struct { + PaymentInfoHash string + Amount *big.Int + TokenCollector string + CollectorData []byte + Fee CaptureFee + FeeReceiver string +} + +func (p ChargeParams) message() map[string]interface{} { + message := map[string]interface{}{ + "paymentInfoHash": p.PaymentInfoHash, + "amount": p.Amount, + "tokenCollector": evm.NormalizeAddress(p.TokenCollector), + "collectorDataHash": crypto.Keccak256Hash(p.CollectorData).Hex(), + "feeReceiver": evm.NormalizeAddress(p.FeeReceiver), + } + p.Fee.addToMessage(message) + return message +} + +// RefundParams are the values the receiver authorizer signs for a refund. +type RefundParams struct { + PaymentInfoHash string + Amount *big.Int + TokenCollector string + ExpectedCapturable *big.Int + ExpectedRefundable *big.Int +} + +func (p RefundParams) message() map[string]interface{} { + return map[string]interface{}{ + "paymentInfoHash": p.PaymentInfoHash, + "amount": p.Amount, + "tokenCollector": evm.NormalizeAddress(p.TokenCollector), + "expectedCapturableAmount": p.ExpectedCapturable, + "expectedRefundableAmount": p.ExpectedRefundable, + } +} + +func signOperator( + ctx context.Context, + signer evm.ClientEvmSigner, + captureAuthorizer string, + chainID *big.Int, + types map[string][]evm.TypedDataField, + primaryType string, + message map[string]interface{}, +) ([]byte, error) { + return signer.SignTypedData(ctx, OperatorDomain(captureAuthorizer, chainID), types, primaryType, message) +} + +func verifyOperator( + ctx context.Context, + verifier evm.FacilitatorEvmSigner, + receiverAuthorizer string, + captureAuthorizer string, + chainID *big.Int, + types map[string][]evm.TypedDataField, + primaryType string, + message map[string]interface{}, + signature []byte, +) (bool, error) { + return evm.VerifyTypedDataStrict( + ctx, verifier, receiverAuthorizer, OperatorDomain(captureAuthorizer, chainID), types, primaryType, message, signature, + ) +} + +// SignCapture signs the Capture message; VerifyCapture checks it, so both sides share one digest. +func SignCapture( + ctx context.Context, + signer evm.ClientEvmSigner, + deployment *AuthCaptureDeployment, + captureAuthorizer string, + chainID *big.Int, + params CaptureParams, +) ([]byte, error) { + return signOperator(ctx, signer, captureAuthorizer, chainID, CaptureTypesForDeployment(deployment), "Capture", params.message()) +} + +// VerifyCapture reports whether signature is receiverAuthorizer's signature over the Capture params. +func VerifyCapture( + ctx context.Context, + verifier evm.FacilitatorEvmSigner, + receiverAuthorizer string, + deployment *AuthCaptureDeployment, + captureAuthorizer string, + chainID *big.Int, + params CaptureParams, + signature []byte, +) (bool, error) { + return verifyOperator(ctx, verifier, receiverAuthorizer, captureAuthorizer, chainID, CaptureTypesForDeployment(deployment), "Capture", params.message(), signature) +} + +// SignCharge signs the Charge message; VerifyCharge checks it. +func SignCharge( + ctx context.Context, + signer evm.ClientEvmSigner, + deployment *AuthCaptureDeployment, + captureAuthorizer string, + chainID *big.Int, + params ChargeParams, +) ([]byte, error) { + return signOperator(ctx, signer, captureAuthorizer, chainID, ChargeTypesForDeployment(deployment), "Charge", params.message()) +} + +// VerifyCharge reports whether signature is receiverAuthorizer's signature over the Charge params. +func VerifyCharge( + ctx context.Context, + verifier evm.FacilitatorEvmSigner, + receiverAuthorizer string, + deployment *AuthCaptureDeployment, + captureAuthorizer string, + chainID *big.Int, + params ChargeParams, + signature []byte, +) (bool, error) { + return verifyOperator(ctx, verifier, receiverAuthorizer, captureAuthorizer, chainID, ChargeTypesForDeployment(deployment), "Charge", params.message(), signature) +} + +// SignVoid signs the Void message; VerifyVoid checks it. +func SignVoid( + ctx context.Context, + signer evm.ClientEvmSigner, + captureAuthorizer string, + chainID *big.Int, + paymentInfoHash string, +) ([]byte, error) { + return signOperator(ctx, signer, captureAuthorizer, chainID, VoidTypes, "Void", voidMessage(paymentInfoHash)) +} + +// VerifyVoid reports whether signature is receiverAuthorizer's signature over the Void message. +func VerifyVoid( + ctx context.Context, + verifier evm.FacilitatorEvmSigner, + receiverAuthorizer string, + captureAuthorizer string, + chainID *big.Int, + paymentInfoHash string, + signature []byte, +) (bool, error) { + return verifyOperator(ctx, verifier, receiverAuthorizer, captureAuthorizer, chainID, VoidTypes, "Void", voidMessage(paymentInfoHash), signature) +} + +func voidMessage(paymentInfoHash string) map[string]interface{} { + return map[string]interface{}{"paymentInfoHash": paymentInfoHash} +} + +// SignRefund signs the Refund message; VerifyRefund checks it. +func SignRefund( + ctx context.Context, + signer evm.ClientEvmSigner, + captureAuthorizer string, + chainID *big.Int, + params RefundParams, +) ([]byte, error) { + return signOperator(ctx, signer, captureAuthorizer, chainID, RefundTypes, "Refund", params.message()) +} + +// VerifyRefund reports whether signature is receiverAuthorizer's signature over the Refund params. +func VerifyRefund( + ctx context.Context, + verifier evm.FacilitatorEvmSigner, + receiverAuthorizer string, + captureAuthorizer string, + chainID *big.Int, + params RefundParams, + signature []byte, +) (bool, error) { + return verifyOperator(ctx, verifier, receiverAuthorizer, captureAuthorizer, chainID, RefundTypes, "Refund", params.message(), signature) +} diff --git a/go/mechanisms/evm/auth-capture/operator_test.go b/go/mechanisms/evm/auth-capture/operator_test.go new file mode 100644 index 0000000000..be7a9df785 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/operator_test.go @@ -0,0 +1,185 @@ +package authcapture + +import ( + "bytes" + "context" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm" +) + +const ( + testCaptureAuthorizer = "0x1111111111111111111111111111111111111111" + testPaymentInfoHash = "0x00000000000000000000000000000000000000000000000000000000000000aa" +) + +func testSigner(t *testing.T) evm.ClientEvmSigner { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + signer, err := evmsigners.NewClientSignerFromPrivateKey(hexutil.Encode(crypto.FromECDSA(key))) + require.NoError(t, err) + return signer +} + +func TestOperatorDomain(t *testing.T) { + domain := OperatorDomain(testCaptureAuthorizer, big.NewInt(84532)) + + assert.Equal(t, "x402 Auth Capture Operator", domain.Name) + assert.Equal(t, "1", domain.Version) + assert.Equal(t, big.NewInt(84532), domain.ChainID) + assert.Equal(t, evm.NormalizeAddress(testCaptureAuthorizer), domain.VerifyingContract) +} + +func TestDefaultCaptureFee(t *testing.T) { + amount := big.NewInt(1_000_000) + + v10 := DefaultCaptureFee(ResolveAuthCaptureDeployment(AuthCaptureEscrowV1_0Address), amount, 25) + require.NotNil(t, v10.Bps) + assert.Equal(t, uint16(25), v10.Arg()) + wire := map[string]interface{}{} + v10.AddToWire(wire) + assert.Equal(t, map[string]interface{}{"feeBps": uint16(25)}, wire) + + v11 := DefaultCaptureFee(ResolveAuthCaptureDeployment(AuthCaptureEscrowV1_1Address), amount, 25) + assert.Equal(t, big.NewInt(2500), v11.Arg()) + wire = map[string]interface{}{} + v11.AddToWire(wire) + assert.Equal(t, map[string]interface{}{"feeAmount": "2500"}, wire) +} + +func TestCaptureSignatureRoundTrip(t *testing.T) { + ctx := context.Background() + chainID := big.NewInt(84532) + + for _, escrow := range []string{AuthCaptureEscrowV1_0Address, AuthCaptureEscrowV1_1Address} { + t.Run(escrow, func(t *testing.T) { + deployment := ResolveAuthCaptureDeployment(escrow) + signer := testSigner(t) + params := CaptureParams{ + PaymentInfoHash: testPaymentInfoHash, + Amount: big.NewInt(600000), + Fee: DefaultCaptureFee(deployment, big.NewInt(600000), 50), + FeeReceiver: "0x4444444444444444444444444444444444444444", + ExpectedCapturable: big.NewInt(400000), + ExpectedRefundable: big.NewInt(0), + } + + signature, err := SignCapture(ctx, signer, deployment, testCaptureAuthorizer, chainID, params) + require.NoError(t, err) + + verify := func(chain *big.Int, p CaptureParams) bool { + ok, err := evm.VerifyEOATypedData(signer.Address(), OperatorDomain(testCaptureAuthorizer, chain), CaptureTypesForDeployment(deployment), "Capture", p.message(), signature) + require.NoError(t, err) + return ok + } + assert.True(t, verify(chainID, params)) + assert.False(t, verify(big.NewInt(8453), params), "the signature is bound to the chain") + + tampered := params + tampered.Amount = big.NewInt(600001) + assert.False(t, verify(chainID, tampered), "the signature is bound to the amount") + }) + } +} + +func TestVoidSignatureRoundTrip(t *testing.T) { + signer := testSigner(t) + chainID := big.NewInt(84532) + + signature, err := SignVoid(context.Background(), signer, testCaptureAuthorizer, chainID, testPaymentInfoHash) + require.NoError(t, err) + + message := map[string]interface{}{"paymentInfoHash": testPaymentInfoHash} + ok, err := evm.VerifyEOATypedData(signer.Address(), OperatorDomain(testCaptureAuthorizer, chainID), VoidTypes, "Void", message, signature) + require.NoError(t, err) + assert.True(t, ok) + + other := map[string]interface{}{"paymentInfoHash": "0x00000000000000000000000000000000000000000000000000000000000000bb"} + ok, err = evm.VerifyEOATypedData(signer.Address(), OperatorDomain(testCaptureAuthorizer, chainID), VoidTypes, "Void", other, signature) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestChargeSignatureRoundTrip(t *testing.T) { + ctx := context.Background() + chainID := big.NewInt(84532) + + for _, escrow := range []string{AuthCaptureEscrowV1_0Address, AuthCaptureEscrowV1_1Address} { + t.Run(escrow, func(t *testing.T) { + deployment := ResolveAuthCaptureDeployment(escrow) + signer := testSigner(t) + params := ChargeParams{ + PaymentInfoHash: testPaymentInfoHash, + Amount: big.NewInt(750000), + TokenCollector: deployment.EIP3009Collector, + CollectorData: []byte{0x01, 0x02, 0x03}, + Fee: DefaultCaptureFee(deployment, big.NewInt(750000), 100), + FeeReceiver: "0x4444444444444444444444444444444444444444", + } + + signature, err := SignCharge(ctx, signer, deployment, testCaptureAuthorizer, chainID, params) + require.NoError(t, err) + + verify := func(p ChargeParams) bool { + ok, err := evm.VerifyEOATypedData(signer.Address(), OperatorDomain(testCaptureAuthorizer, chainID), ChargeTypesForDeployment(deployment), "Charge", p.message(), signature) + require.NoError(t, err) + return ok + } + assert.True(t, verify(params)) + + tampered := params + tampered.CollectorData = []byte{0x01, 0x02, 0x04} + assert.False(t, verify(tampered), "the signature is bound to the collector data") + tampered = params + tampered.Amount = big.NewInt(750001) + assert.False(t, verify(tampered), "the signature is bound to the amount") + }) + } +} + +func TestRefundSignatureRoundTrip(t *testing.T) { + signer := testSigner(t) + chainID := big.NewInt(84532) + params := RefundParams{ + PaymentInfoHash: testPaymentInfoHash, + Amount: big.NewInt(250000), + TokenCollector: OperatorRefundCollectorAddress, + ExpectedCapturable: big.NewInt(0), + ExpectedRefundable: big.NewInt(750000), + } + + signature, err := SignRefund(context.Background(), signer, testCaptureAuthorizer, chainID, params) + require.NoError(t, err) + + verify := func(p RefundParams) bool { + ok, err := evm.VerifyEOATypedData(signer.Address(), OperatorDomain(testCaptureAuthorizer, chainID), RefundTypes, "Refund", p.message(), signature) + require.NoError(t, err) + return ok + } + assert.True(t, verify(params)) + + tampered := params + tampered.ExpectedRefundable = big.NewInt(500000) + assert.False(t, verify(tampered), "the signature is bound to the refundable balance") +} + +func TestEscrowABIsDeclareChargeRefundAndEvents(t *testing.T) { + for _, escrow := range []string{AuthCaptureEscrowV1_0Address, AuthCaptureEscrowV1_1Address} { + parsed, err := abi.JSON(bytes.NewReader(EscrowABIForDeployment(ResolveAuthCaptureDeployment(escrow)))) + require.NoError(t, err) + for _, method := range []string{"authorize", "charge", "capture", "void", "refund", "paymentState", "getTokenStore"} { + assert.Contains(t, parsed.Methods, method, escrow) + } + assert.Contains(t, parsed.Events, "PaymentAuthorized") + assert.Contains(t, parsed.Events, "PaymentCharged") + } +} diff --git a/go/mechanisms/evm/auth-capture/server/core_test.go b/go/mechanisms/evm/auth-capture/server/core_test.go new file mode 100644 index 0000000000..f03e633a24 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/server/core_test.go @@ -0,0 +1,179 @@ +package server + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +// recordingFacilitator plays the facilitator behind the real resource server, recording every +// settle payload it is sent. +type recordingFacilitator struct { + fakeFacilitator + extra map[string]interface{} +} + +func (f *recordingFacilitator) Settle(ctx context.Context, payload, requirements []byte) (*x402.SettleResponse, error) { + f.payloads = append(f.payloads, payload) + var decoded types.PaymentPayload + if err := json.Unmarshal(payload, &decoded); err != nil { + return nil, err + } + transaction := "0xcollect" + if kind, _ := decoded.Payload["type"].(string); kind != "" { + transaction = "0x" + kind + } else if _, charged := decoded.Payload["authorizerSignature"]; charged { + transaction = "0xcharge" + } + return &x402.SettleResponse{Success: true, Transaction: transaction, Network: testNetwork, Payer: testPayer}, nil +} + +func (f *recordingFacilitator) GetSupported(context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{Kinds: []types.SupportedKind{{ + X402Version: 2, Scheme: authcapture.SchemeAuthCapture, Network: testNetwork, Extra: f.extra, + }}}, nil +} + +// coreServer wires the scheme into the real resource server and returns the requirements a +// route with the given extra would publish. +func coreServer(t *testing.T, route map[string]interface{}) (resourceServer, *AuthCaptureEvmScheme, *recordingFacilitator, types.PaymentRequirements) { + t.Helper() + facilitator := &recordingFacilitator{extra: map[string]interface{}{"captureAuthorizer": testCaptureAuthorizer}} + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + server := x402.Newx402ResourceServer(x402.WithFacilitatorClient(facilitator)) + server.Register(testNetwork, scheme) + require.NoError(t, server.Initialize(context.Background())) + + built, err := server.BuildPaymentRequirementsFromConfig(context.Background(), x402.ResourceConfig{ + Scheme: authcapture.SchemeAuthCapture, + PayTo: testPayTo, + Price: map[string]interface{}{"amount": "1000000", "asset": testAsset, "extra": map[string]interface{}{"name": "USDC", "version": "2"}}, + Network: testNetwork, + MaxTimeoutSeconds: 300, + Extra: route, + }) + require.NoError(t, err) + require.Len(t, built, 1) + return server, scheme, facilitator, built[0] +} + +// resourceServer is the part of the core resource server these tests drive. +type resourceServer interface { + SettlePaymentWithExtensions( + ctx context.Context, payload types.PaymentPayload, requirements types.PaymentRequirements, + overrides *x402.SettlementOverrides, declaredExtensions map[string]interface{}, phase x402.SettlePhase, + ) (*x402.SettleResponse, error) + FindMatchingRequirements(available []types.PaymentRequirements, payload types.PaymentPayload) *types.PaymentRequirements +} + +func paymentFor(requirements types.PaymentRequirements) types.PaymentPayload { + return types.PaymentPayload{ + X402Version: 2, + Accepted: requirements, + Payload: map[string]interface{}{ + "authorization": map[string]interface{}{ + "from": testPayer, "to": authcapture.EIP3009TokenCollectorAddress, "value": requirements.Amount, + "validAfter": "0", "validBefore": "1700003600", + "nonce": "0x1111111111111111111111111111111111111111111111111111111111111111", + }, + "signature": "0xdeadbeef", + "salt": "0x2222222222222222222222222222222222222222222222222222222222222222", + "saltNonce": "0x01", + }, + } +} + +func settleAt(t *testing.T, server resourceServer, payload types.PaymentPayload, requirements types.PaymentRequirements, phase x402.SettlePhase, overrides *x402.SettlementOverrides) *x402.SettleResponse { + t.Helper() + result, err := server.SettlePaymentWithExtensions(context.Background(), payload, requirements, overrides, nil, phase) + require.NoError(t, err) + require.True(t, result.Success) + return result +} + +func lastSent(t *testing.T, facilitator *recordingFacilitator) types.PaymentPayload { + t.Helper() + require.NotEmpty(t, facilitator.payloads) + var payload types.PaymentPayload + require.NoError(t, json.Unmarshal(facilitator.payloads[len(facilitator.payloads)-1], &payload)) + return payload +} + +func TestCoreServer_DeferredRouteCapturesLaterThroughTheManager(t *testing.T) { + server, scheme, facilitator, requirements := coreServer(t, AuthCaptureRouteExtra{CaptureMode: "deferred"}.Map()) + payload := paymentFor(requirements) + + settleAt(t, server, payload, requirements, x402.SettlePhaseBeforeHandler, nil) + require.Len(t, facilitator.payloads, 1) + + afterHandler := settleAt(t, server, payload, requirements, x402.SettlePhaseAfterHandler, nil) + assert.Equal(t, "0xcollect", afterHandler.Transaction, "the after-handler settle echoes the collect receipt") + assert.Len(t, facilitator.payloads, 1, "and never reaches the facilitator") + + records, err := scheme.Storage().List(context.Background()) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, "1000000", records[0].CapturableAmount) + + manager := scheme.NewLifecycleManager(facilitator) + response, err := manager.Capture(context.Background(), records[0].PaymentInfoHash, nil) + require.NoError(t, err) + assert.Equal(t, "0xcapture", response.Transaction) + assert.True(t, authcapture.IsCapturePayload(lastSent(t, facilitator).Payload)) +} + +func TestCoreServer_SyncRouteCapturesAfterTheHandler(t *testing.T) { + server, scheme, facilitator, requirements := coreServer(t, nil) + payload := paymentFor(requirements) + + settleAt(t, server, payload, requirements, x402.SettlePhaseBeforeHandler, nil) + settleAt(t, server, payload, requirements, x402.SettlePhaseAfterHandler, &x402.SettlementOverrides{Amount: "400000"}) + + sent := lastSent(t, facilitator) + assert.True(t, authcapture.IsCapturePayload(sent.Payload)) + assert.Equal(t, "400000", sent.Payload["amount"]) + assert.Contains(t, sent.Payload, "voidAuthorizerSignature") + + records, err := scheme.Storage().List(context.Background()) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, "0", records[0].CapturableAmount) + assert.Equal(t, "400000", records[0].RefundableAmount) +} + +func TestCoreServer_AuthorizationRouteChargesAfterTheHandler(t *testing.T) { + server, scheme, facilitator, requirements := coreServer(t, AuthCaptureRouteExtra{PaymentFlow: "authorization"}.Map()) + payload := paymentFor(requirements) + + result := settleAt(t, server, payload, requirements, x402.SettlePhaseAfterHandler, &x402.SettlementOverrides{Amount: "250000"}) + assert.Equal(t, "0xcharge", result.Transaction) + + sent := lastSent(t, facilitator) + assert.True(t, authcapture.IsEip3009Payload(sent.Payload)) + assert.Equal(t, "250000", sent.Payload["amount"]) + assert.Contains(t, sent.Payload, "authorizerSignature") + assert.NotContains(t, sent.Payload, "type") + + records, err := scheme.Storage().List(context.Background()) + require.NoError(t, err) + require.Len(t, records, 1) + assert.Equal(t, "250000", records[0].RefundableAmount) +} + +func TestCoreServer_PaymentStillMatchesAfterTheDeadlineBucketRolls(t *testing.T) { + server, _, _, requirements := coreServer(t, AuthCaptureRouteExtra{CaptureMode: "deferred"}.Map()) + payload := paymentFor(requirements) + payload.Accepted.Extra = overlay(payload.Accepted.Extra, map[string]interface{}{ + "captureDeadline": requirements.Extra["captureDeadline"].(uint64) - 60, + "refundDeadline": requirements.Extra["refundDeadline"].(uint64) - 60, + }) + + assert.NotNil(t, server.FindMatchingRequirements([]types.PaymentRequirements{requirements}, payload)) +} diff --git a/go/mechanisms/evm/auth-capture/server/errors.go b/go/mechanisms/evm/auth-capture/server/errors.go new file mode 100644 index 0000000000..b5eb153619 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/server/errors.go @@ -0,0 +1,29 @@ +package server + +// Server error constants for the auth-capture EVM scheme. +const ( + ErrAmountMustBeString = "invalid_auth_capture_evm_server_amount_must_be_string" + ErrNoAssetSpecified = "invalid_auth_capture_evm_server_no_asset_specified" + ErrFailedToParseAmount = "invalid_auth_capture_evm_server_failed_to_parse_amount" + ErrMissingCaptureAuthorizer = "invalid_auth_capture_evm_server_missing_capture_authorizer" + ErrMissingFeeRecipient = "invalid_auth_capture_evm_server_missing_fee_recipient" + ErrInvalidFeeTerms = "invalid_auth_capture_evm_server_invalid_fee_terms" + ErrTimeoutExceedsCaptureDeadline = "invalid_auth_capture_evm_server_timeout_exceeds_capture_deadline" + ErrRefundBeforeCaptureDeadline = "invalid_auth_capture_evm_server_refund_before_capture_deadline" + ErrMissingReceiverAuthorizer = "invalid_auth_capture_evm_server_missing_receiver_authorizer" + ErrUnsignableReceiverAuthorizer = "invalid_auth_capture_evm_server_unsignable_receiver_authorizer" + ErrInvalidCollectPayload = "invalid_auth_capture_evm_server_invalid_collect_payload" + ErrInvalidCaptureAmount = "invalid_auth_capture_evm_server_invalid_capture_amount" + ErrFailedToSignCapture = "invalid_auth_capture_evm_server_failed_to_sign_capture" + ErrFailedToSignVoid = "invalid_auth_capture_evm_server_failed_to_sign_void" + ErrFailedToSignCharge = "invalid_auth_capture_evm_server_failed_to_sign_charge" + ErrFailedToSignRefund = "invalid_auth_capture_evm_server_failed_to_sign_refund" + ErrInvalidRouteExtra = "invalid_auth_capture_evm_server_invalid_route_extra" + ErrInvalidDeadline = "invalid_auth_capture_evm_server_invalid_deadline" + ErrOperatorNotAdmitted = "invalid_auth_capture_evm_server_operator_not_admitted" + ErrAutoCaptureRemoved = "invalid_auth_capture_evm_server_auto_capture_removed" + ErrReceiverAuthorizerMismatch = "invalid_auth_capture_evm_server_receiver_authorizer_mismatch" + ErrPaymentNotFound = "invalid_auth_capture_evm_server_payment_not_found" + ErrLifecycleUnavailable = "invalid_auth_capture_evm_server_lifecycle_unavailable" + ErrInvalidLifecycleAmount = "invalid_auth_capture_evm_server_invalid_lifecycle_amount" +) diff --git a/go/mechanisms/evm/auth-capture/server/lifecycle_test.go b/go/mechanisms/evm/auth-capture/server/lifecycle_test.go new file mode 100644 index 0000000000..01dc51d377 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/server/lifecycle_test.go @@ -0,0 +1,722 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +var ( + deferredExtra = map[string]interface{}{"paymentFlow": "escrow", "captureMode": "deferred"} + authorizationExtra = map[string]interface{}{"paymentFlow": "authorization"} + syncExtra = map[string]interface{}{"paymentFlow": "escrow", "captureMode": "sync"} + collectOnlyExtra = map[string]interface{}{"paymentFlow": "escrow", "captureMode": "deferred", "receiverAuthorizer": authcapture.ZeroAddress} +) + +func settleContext(phase x402.SettlePhase, extra map[string]interface{}) x402.SettleContext { + return settleContextWithAmount(phase, extra, "") +} + +// settleContextWithAmount sets the effective requirements amount, as a settlement override does. +func settleContextWithAmount(phase x402.SettlePhase, extra map[string]interface{}, amount string) x402.SettleContext { + requirements := mockRequirements(extra) + if amount != "" { + requirements.Amount = amount + } + return x402.SettleContext{ + Ctx: context.Background(), + Payload: eip3009CollectPayload(extra), + Requirements: requirements, + Phase: phase, + } +} + +func settled(ctx x402.SettleContext, transaction string) x402.SettleResultContext { + return x402.SettleResultContext{ + SettleContext: ctx, + Result: &x402.SettleResponse{Success: true, Transaction: transaction, Network: testNetwork, Payer: testPayer}, + } +} + +func paymentHash(t *testing.T, scheme *AuthCaptureEvmScheme, ctx x402.SettleContext) string { + t.Helper() + request, err := scheme.newSettleRequest(ctx) + require.NoError(t, err) + return request.paymentInfoHash +} + +func storedPayment(t *testing.T, scheme *AuthCaptureEvmScheme, hash string) *AuthorizedPayment { + t.Helper() + record, err := scheme.Storage().Get(context.Background(), hash) + require.NoError(t, err) + require.NotNil(t, record) + return record +} + +// authorize records the collect of the given route and returns its paymentInfoHash. +func authorize(t *testing.T, scheme *AuthCaptureEvmScheme, extra map[string]interface{}) string { + t.Helper() + ctx := settleContext(x402.SettlePhaseBeforeHandler, extra) + require.NoError(t, scheme.AfterSettleHook()(settled(ctx, "0xcollect"))) + return paymentHash(t, scheme, ctx) +} + +func TestStorage(t *testing.T) { + storage := NewInMemoryAuthorizedPaymentStorage() + ctx := context.Background() + + missing, err := storage.Get(ctx, "0xAB") + require.NoError(t, err) + assert.Nil(t, missing) + + require.NoError(t, storage.Update(ctx, "0xAB", func(current *AuthorizedPayment) *AuthorizedPayment { + assert.Nil(t, current) + return &AuthorizedPayment{PaymentInfoHash: "0xAB", CapturableAmount: "5"} + })) + stored, err := storage.Get(ctx, "0xab") + require.NoError(t, err) + assert.Equal(t, "5", stored.CapturableAmount, "keys are case-insensitive") + + stored.CapturableAmount = "99" + again, _ := storage.Get(ctx, "0xAB") + assert.Equal(t, "5", again.CapturableAmount, "a read is a copy") + + require.NoError(t, storage.Update(ctx, "0xAB", func(current *AuthorizedPayment) *AuthorizedPayment { + current.CapturableAmount = "7" + return current + })) + updated, _ := storage.Get(ctx, "0xAB") + assert.Equal(t, "7", updated.CapturableAmount) + + all, err := storage.List(ctx) + require.NoError(t, err) + assert.Len(t, all, 1) + + require.NoError(t, storage.Update(ctx, "0xAB", func(*AuthorizedPayment) *AuthorizedPayment { return nil })) + gone, _ := storage.Get(ctx, "0xAB") + assert.Nil(t, gone) +} + +func TestScheme_UsesConfiguredStorage(t *testing.T) { + storage := NewInMemoryAuthorizedPaymentStorage() + assert.Same(t, storage, NewAuthCaptureEvmScheme(&Config{Storage: storage}).Storage()) + assert.NotNil(t, NewAuthCaptureEvmScheme(nil).Storage()) +} + +func TestEnrichSettlementPayload_ChargeCompletesAuthorizationFlow(t *testing.T) { + signer := &mockSigner{address: testSignerAddress} + scheme := newTestScheme(signer) + ctx := settleContext(x402.SettlePhaseAfterHandler, authorizationExtra) + + fields, err := scheme.EnrichSettlementPayload(ctx) + require.NoError(t, err) + + merged := mergeEnrichment(t, eip3009CollectPayload(authorizationExtra), fields) + assert.True(t, authcapture.IsEip3009Payload(merged)) + assert.NotContains(t, fields, "type") + assert.Equal(t, "1000000", fields["amount"]) + assert.Equal(t, evm.NormalizeAddress(testFeeRecipient), fields["feeReceiver"]) + assert.Equal(t, "0xdeadbeef", fields["authorizerSignature"]) + assert.Contains(t, fields, "feeAmount") + assert.NotContains(t, fields, "feeBps") + + deployment := authcapture.ResolveAuthCaptureDeployment("") + assert.Equal(t, "Charge", signer.lastPrimaryType) + assert.Equal(t, evm.NormalizeAddress(deployment.EIP3009Collector), signer.lastMessage["tokenCollector"]) + assert.Equal(t, crypto.Keccak256Hash([]byte{0xde, 0xad, 0xbe, 0xef}).Hex(), signer.lastMessage["collectorDataHash"]) + assert.Equal(t, paymentHash(t, scheme, ctx), signer.lastMessage["paymentInfoHash"]) +} + +func TestEnrichSettlementPayload_ChargeVariants(t *testing.T) { + t.Run("v1.0 charges feeBps", func(t *testing.T) { + extra := map[string]interface{}{"paymentFlow": "authorization", "authCaptureEscrow": authcapture.AuthCaptureEscrowV1_0Address, "minFeeBps": float64(25)} + fields, err := newTestScheme(&mockSigner{address: testSignerAddress}).EnrichSettlementPayload(settleContext(x402.SettlePhaseAfterHandler, extra)) + require.NoError(t, err) + assert.Equal(t, uint16(25), fields["feeBps"]) + assert.NotContains(t, fields, "feeAmount") + }) + + t.Run("a settlement override charges less than the signed amount", func(t *testing.T) { + signer := &mockSigner{address: testSignerAddress} + extra := map[string]interface{}{"paymentFlow": "authorization", "minFeeBps": float64(100)} + fields, err := newTestScheme(signer).EnrichSettlementPayload(settleContextWithAmount(x402.SettlePhaseAfterHandler, extra, "400000")) + require.NoError(t, err) + assert.Equal(t, "400000", fields["amount"]) + assert.Equal(t, "4000", fields["feeAmount"]) + assert.Equal(t, "400000", signer.lastMessage["amount"].(*big.Int).String()) + }) + + t.Run("an override above the signed amount fails", func(t *testing.T) { + _, err := newTestScheme(&mockSigner{address: testSignerAddress}).EnrichSettlementPayload( + settleContextWithAmount(x402.SettlePhaseAfterHandler, authorizationExtra, "1000001")) + require.ErrorContains(t, err, ErrInvalidCaptureAmount) + }) + + t.Run("permit2 binds the permit2 collector", func(t *testing.T) { + signer := &mockSigner{address: testSignerAddress} + extra := map[string]interface{}{"paymentFlow": "authorization", "assetTransferMethod": "permit2"} + ctx := settleContext(x402.SettlePhaseAfterHandler, extra) + ctx.Payload = types.PaymentPayload{X402Version: 2, Accepted: mockRequirements(extra), Payload: map[string]interface{}{ + "permit2Authorization": map[string]interface{}{ + "from": testPayer, "spender": authcapture.Permit2TokenCollectorAddress, "nonce": "1", "deadline": "1700003600", + "permitted": map[string]interface{}{"token": testAsset, "amount": "1000000"}, + }, + "signature": "0xdeadbeef", "salt": "0x22", "saltNonce": "0x01", + }} + _, err := newTestScheme(signer).EnrichSettlementPayload(ctx) + require.NoError(t, err) + deployment := authcapture.ResolveAuthCaptureDeployment("") + assert.Equal(t, evm.NormalizeAddress(deployment.Permit2Collector), signer.lastMessage["tokenCollector"]) + }) + + t.Run("a signer error surfaces", func(t *testing.T) { + _, err := newTestScheme(&mockSigner{address: testSignerAddress, err: errors.New("boom")}).EnrichSettlementPayload( + settleContext(x402.SettlePhaseAfterHandler, authorizationExtra)) + require.ErrorContains(t, err, ErrFailedToSignCharge) + }) + +} + +func TestEnrichSettlementPayload_DeferredAddsNothingAfterTheHandler(t *testing.T) { + fields, err := newTestScheme(&mockSigner{address: testSignerAddress}).EnrichSettlementPayload(settleContext(x402.SettlePhaseAfterHandler, deferredExtra)) + require.NoError(t, err) + assert.Nil(t, fields) +} + +func TestEnrichSettlementPayload_CaptureNamesTheStoredBalances(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + hash := authorize(t, scheme, syncExtra) + require.NoError(t, applyCapture(context.Background(), scheme.Storage(), hash, big.NewInt(300000), false)) + + fields, err := scheme.EnrichSettlementPayload(settleContextWithAmount(x402.SettlePhaseAfterHandler, syncExtra, "200000")) + require.NoError(t, err) + assert.Equal(t, "700000", fields["expectedCapturableAmount"]) + assert.Equal(t, "300000", fields["expectedRefundableAmount"]) + assert.Contains(t, fields, "voidAuthorizerSignature") + + _, err = scheme.EnrichSettlementPayload(settleContextWithAmount(x402.SettlePhaseAfterHandler, syncExtra, "700001")) + require.ErrorContains(t, err, ErrInvalidCaptureAmount, "the amount is bounded by what is still capturable") +} + +func TestSettleOnCancel_SkipsHoldsItCannotVoid(t *testing.T) { + cancel := func(scheme *AuthCaptureEvmScheme, extra map[string]interface{}) *types.PaymentRequirements { + requirements, err := scheme.SettleOnCancel(x402.VerifiedPaymentCanceledContext{ + SettleContext: settleContext(x402.SettlePhaseCancel, extra), + Reason: x402.CancellationReasonHandlerFailed, + }) + require.NoError(t, err) + return requirements + } + signed := newTestScheme(&mockSigner{address: testSignerAddress}) + + assert.NotNil(t, cancel(signed, syncExtra)) + assert.NotNil(t, cancel(signed, deferredExtra), "a deferred hold is voided when the handler fails") + assert.Nil(t, cancel(signed, authorizationExtra), "the authorization flow holds nothing") + assert.Nil(t, cancel(signed, map[string]interface{}{"operatorType": "custom", "captureMode": "deferred"})) + assert.NotNil(t, cancel(NewAuthCaptureEvmScheme(&Config{}), deferredExtra), "without a local signer the facilitator signs the void") + assert.Nil(t, cancel(signed, collectOnlyExtra), "a collect-only hold has no authorizer to sign a void") +} + +func TestBeforeSettleHook(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + hook := scheme.BeforeSettleHook() + + t.Run("a deferred route skips the capture and echoes the collect receipt", func(t *testing.T) { + authorize(t, scheme, deferredExtra) + result, err := hook(settleContext(x402.SettlePhaseAfterHandler, deferredExtra)) + require.NoError(t, err) + require.True(t, result.Skip) + assert.Equal(t, "0xcollect", result.SkipResult.Transaction) + assert.True(t, result.SkipResult.Success) + assert.Equal(t, testPayer, result.SkipResult.Payer) + }) + + t.Run("a deferred route with no record aborts", func(t *testing.T) { + empty := newTestScheme(&mockSigner{address: testSignerAddress}) + result, err := empty.BeforeSettleHook()(settleContext(x402.SettlePhaseAfterHandler, deferredExtra)) + require.NoError(t, err) + require.True(t, result.Abort) + assert.Equal(t, ErrPaymentNotFound, result.Reason) + }) + + t.Run("other routes and phases settle normally", func(t *testing.T) { + for _, ctx := range []x402.SettleContext{ + settleContext(x402.SettlePhaseAfterHandler, syncExtra), + settleContext(x402.SettlePhaseAfterHandler, authorizationExtra), + settleContext(x402.SettlePhaseBeforeHandler, deferredExtra), + settleContext(x402.SettlePhaseCancel, deferredExtra), + } { + result, err := hook(ctx) + require.NoError(t, err) + assert.Nil(t, result) + } + }) +} + +func TestAfterSettleHook_RecordsTheCollect(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + hash := authorize(t, scheme, deferredExtra) + + record := storedPayment(t, scheme, hash) + assert.Equal(t, "1000000", record.CapturableAmount) + assert.Equal(t, "0", record.RefundableAmount) + assert.Equal(t, "0xcollect", record.CollectTransaction) + assert.Equal(t, "0x01", record.SaltNonce) + assert.Equal(t, "escrow", record.PaymentFlow) + assert.Equal(t, "delegated", record.OperatorType) + assert.Equal(t, testPayer, record.PaymentInfo.Payer) + assert.Equal(t, "1000000", record.PaymentInfo.MaxAmount) + assert.Equal(t, hash, record.PaymentInfoHash) + + require.NoError(t, applyVoid(context.Background(), scheme.Storage(), hash)) + require.NoError(t, scheme.AfterSettleHook()(settled(settleContext(x402.SettlePhaseBeforeHandler, deferredExtra), "0xretry"))) + retried := storedPayment(t, scheme, hash) + assert.Equal(t, "0", retried.CapturableAmount, "the first write wins") + assert.Equal(t, "0xcollect", retried.CollectTransaction) +} + +func TestAfterSettleHook_KeepsBalancesInStep(t *testing.T) { + hook := func(scheme *AuthCaptureEvmScheme, ctx x402.SettleContext) { + require.NoError(t, scheme.AfterSettleHook()(settled(ctx, "0xsettle"))) + } + + t.Run("a full sync capture", func(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + hash := authorize(t, scheme, syncExtra) + hook(scheme, settleContext(x402.SettlePhaseAfterHandler, syncExtra)) + record := storedPayment(t, scheme, hash) + assert.Equal(t, "0", record.CapturableAmount) + assert.Equal(t, "1000000", record.RefundableAmount) + }) + + t.Run("a partial sync capture voids the remainder", func(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + hash := authorize(t, scheme, syncExtra) + hook(scheme, settleContextWithAmount(x402.SettlePhaseAfterHandler, syncExtra, "400000")) + record := storedPayment(t, scheme, hash) + assert.Equal(t, "0", record.CapturableAmount) + assert.Equal(t, "400000", record.RefundableAmount) + }) + + t.Run("a deferred route keeps its hold", func(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + hash := authorize(t, scheme, deferredExtra) + hook(scheme, settleContext(x402.SettlePhaseAfterHandler, deferredExtra)) + assert.Equal(t, "1000000", storedPayment(t, scheme, hash).CapturableAmount) + }) + + t.Run("a cancel void releases the hold", func(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + hash := authorize(t, scheme, syncExtra) + hook(scheme, settleContext(x402.SettlePhaseCancel, syncExtra)) + assert.Equal(t, "0", storedPayment(t, scheme, hash).CapturableAmount) + }) + + t.Run("a charge is recorded as already captured", func(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + ctx := settleContextWithAmount(x402.SettlePhaseAfterHandler, authorizationExtra, "400000") + hook(scheme, ctx) + record := storedPayment(t, scheme, paymentHash(t, scheme, ctx)) + assert.Equal(t, "0", record.CapturableAmount) + assert.Equal(t, "400000", record.RefundableAmount) + assert.Equal(t, "authorization", record.PaymentFlow) + assert.Equal(t, "1000000", record.PaymentInfo.MaxAmount) + }) + + t.Run("a failed settle changes nothing", func(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + ctx := settleContext(x402.SettlePhaseBeforeHandler, syncExtra) + failed := x402.SettleResultContext{SettleContext: ctx, Result: &x402.SettleResponse{Success: false}} + require.NoError(t, scheme.AfterSettleHook()(failed)) + record, err := scheme.Storage().Get(context.Background(), paymentHash(t, scheme, ctx)) + require.NoError(t, err) + assert.Nil(t, record) + }) +} + +type fakeFacilitator struct { + payloads [][]byte + requirements [][]byte + response *x402.SettleResponse + err error +} + +func (f *fakeFacilitator) Verify(context.Context, []byte, []byte) (*x402.VerifyResponse, error) { + return nil, errors.New("unused") +} + +func (f *fakeFacilitator) Settle(_ context.Context, payload, requirements []byte) (*x402.SettleResponse, error) { + f.payloads = append(f.payloads, payload) + f.requirements = append(f.requirements, requirements) + if f.response == nil && f.err == nil { + return &x402.SettleResponse{Success: true, Transaction: "0xlifecycle", Network: testNetwork}, nil + } + return f.response, f.err +} + +func (f *fakeFacilitator) GetSupported(context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{}, nil +} + +// sent decodes the last lifecycle payload the facilitator received. +func (f *fakeFacilitator) sent(t *testing.T) types.PaymentPayload { + t.Helper() + require.NotEmpty(t, f.payloads) + var payload types.PaymentPayload + require.NoError(t, json.Unmarshal(f.payloads[len(f.payloads)-1], &payload)) + return payload +} + +func newManager(t *testing.T, extra map[string]interface{}) (*LifecycleManager, *fakeFacilitator, *mockSigner, string) { + t.Helper() + signer := &mockSigner{address: testSignerAddress} + scheme := newTestScheme(signer) + facilitator := &fakeFacilitator{} + return scheme.NewLifecycleManager(facilitator), facilitator, signer, authorize(t, scheme, extra) +} + +func TestLifecycleManager_Capture(t *testing.T) { + ctx := context.Background() + + t.Run("captures the full hold by default", func(t *testing.T) { + manager, facilitator, signer, hash := newManager(t, deferredExtra) + response, err := manager.Capture(ctx, hash, nil) + require.NoError(t, err) + assert.True(t, response.Success) + + payload := facilitator.sent(t) + assert.True(t, authcapture.IsCapturePayload(payload.Payload)) + assert.Equal(t, "1000000", payload.Payload["amount"]) + assert.Equal(t, "1000000", payload.Payload["expectedCapturableAmount"]) + assert.Equal(t, "0", payload.Payload["expectedRefundableAmount"]) + assert.Equal(t, "0x01", payload.Payload["saltNonce"]) + assert.NotContains(t, payload.Payload, "voidAuthorizerSignature") + assert.Equal(t, "1000000", payload.Accepted.Amount, "accepted carries the authorized amount") + assert.Equal(t, "Capture", signer.lastPrimaryType) + + record, err := manager.Get(ctx, hash) + require.NoError(t, err) + assert.Equal(t, "0", record.CapturableAmount) + assert.Equal(t, "1000000", record.RefundableAmount) + }) + + t.Run("a partial capture can void the remainder", func(t *testing.T) { + manager, facilitator, _, hash := newManager(t, deferredExtra) + _, err := manager.Capture(ctx, hash, &CaptureOptions{Amount: big.NewInt(250000), VoidRemainder: true}) + require.NoError(t, err) + + payload := facilitator.sent(t) + assert.Equal(t, "250000", payload.Payload["amount"]) + assert.Contains(t, payload.Payload, "voidAuthorizerSignature") + + record, _ := manager.Get(ctx, hash) + assert.Equal(t, "0", record.CapturableAmount) + assert.Equal(t, "250000", record.RefundableAmount) + }) + + t.Run("a partial capture can leave the rest capturable", func(t *testing.T) { + manager, facilitator, _, hash := newManager(t, deferredExtra) + _, err := manager.Capture(ctx, hash, &CaptureOptions{Amount: big.NewInt(250000)}) + require.NoError(t, err) + assert.NotContains(t, facilitator.sent(t).Payload, "voidAuthorizerSignature") + + _, err = manager.Capture(ctx, hash, &CaptureOptions{Amount: big.NewInt(750000)}) + require.NoError(t, err) + payload := facilitator.sent(t) + assert.Equal(t, "750000", payload.Payload["expectedCapturableAmount"]) + assert.Equal(t, "250000", payload.Payload["expectedRefundableAmount"]) + + record, _ := manager.Get(ctx, hash) + assert.Equal(t, "0", record.CapturableAmount) + assert.Equal(t, "1000000", record.RefundableAmount) + }) + + t.Run("a fee override is signed", func(t *testing.T) { + manager, facilitator, signer, hash := newManager(t, deferredExtra) + fee := authcapture.CaptureFee{Amount: big.NewInt(1234)} + _, err := manager.Capture(ctx, hash, &CaptureOptions{Fee: &fee, FeeReceiver: testFeeRecipient}) + require.NoError(t, err) + assert.Equal(t, "1234", facilitator.sent(t).Payload["feeAmount"]) + assert.Equal(t, big.NewInt(1234), signer.lastMessage["feeAmount"]) + }) + + t.Run("invalid amounts fail before anything is sent", func(t *testing.T) { + manager, facilitator, _, hash := newManager(t, deferredExtra) + for _, opts := range []*CaptureOptions{ + {Amount: big.NewInt(0)}, + {Amount: big.NewInt(1000001)}, + {VoidRemainder: true}, + } { + _, err := manager.Capture(ctx, hash, opts) + require.ErrorContains(t, err, ErrInvalidLifecycleAmount) + } + assert.Empty(t, facilitator.payloads) + }) + + t.Run("a facilitator rejection leaves the balances alone", func(t *testing.T) { + manager, facilitator, _, hash := newManager(t, deferredExtra) + facilitator.response = &x402.SettleResponse{Success: false, ErrorReason: "unexpected_payment_state"} + response, err := manager.Capture(ctx, hash, nil) + require.NoError(t, err) + assert.False(t, response.Success) + record, _ := manager.Get(ctx, hash) + assert.Equal(t, "1000000", record.CapturableAmount) + + facilitator.response, facilitator.err = nil, errors.New("down") + _, err = manager.Capture(ctx, hash, nil) + require.ErrorContains(t, err, "down") + record, _ = manager.Get(ctx, hash) + assert.Equal(t, "1000000", record.CapturableAmount) + }) +} + +func TestLifecycleManager_Void(t *testing.T) { + manager, facilitator, signer, hash := newManager(t, deferredExtra) + _, err := manager.Void(context.Background(), hash) + require.NoError(t, err) + + payload := facilitator.sent(t) + assert.True(t, authcapture.IsVoidPayload(payload.Payload)) + assert.Equal(t, "0x01", payload.Payload["saltNonce"]) + assert.Equal(t, "Void", signer.lastPrimaryType) + + record, _ := manager.Get(context.Background(), hash) + assert.Equal(t, "0", record.CapturableAmount) +} + +func TestLifecycleManager_Refund(t *testing.T) { + ctx := context.Background() + manager, facilitator, signer, hash := newManager(t, deferredExtra) + + _, err := manager.Refund(ctx, hash, big.NewInt(1)) + require.ErrorContains(t, err, ErrInvalidLifecycleAmount, "nothing is refundable before a capture") + + _, err = manager.Capture(ctx, hash, nil) + require.NoError(t, err) + _, err = manager.Refund(ctx, hash, big.NewInt(400000)) + require.NoError(t, err) + + payload := facilitator.sent(t) + assert.True(t, authcapture.IsRefundPayload(payload.Payload)) + assert.Equal(t, "400000", payload.Payload["amount"]) + assert.Equal(t, "0", payload.Payload["expectedCapturableAmount"]) + assert.Equal(t, "1000000", payload.Payload["expectedRefundableAmount"]) + assert.Equal(t, "Refund", signer.lastPrimaryType) + deployment := authcapture.ResolveAuthCaptureDeployment("") + assert.Equal(t, evm.NormalizeAddress(deployment.OperatorRefundCollector), signer.lastMessage["tokenCollector"]) + + record, _ := manager.Get(ctx, hash) + assert.Equal(t, "600000", record.RefundableAmount) + + _, err = manager.Refund(ctx, hash, big.NewInt(600001)) + require.ErrorContains(t, err, ErrInvalidLifecycleAmount) +} + +func TestLifecycleManager_RefundsAChargedPayment(t *testing.T) { + ctx := context.Background() + signer := &mockSigner{address: testSignerAddress} + scheme := newTestScheme(signer) + facilitator := &fakeFacilitator{} + manager := scheme.NewLifecycleManager(facilitator) + + charge := settleContext(x402.SettlePhaseAfterHandler, authorizationExtra) + require.NoError(t, scheme.AfterSettleHook()(settled(charge, "0xcharge"))) + hash := paymentHash(t, scheme, charge) + + _, err := manager.Capture(ctx, hash, nil) + require.ErrorContains(t, err, ErrLifecycleUnavailable) + _, err = manager.Void(ctx, hash) + require.ErrorContains(t, err, ErrLifecycleUnavailable) + + _, err = manager.Refund(ctx, hash, big.NewInt(100)) + require.NoError(t, err) + assert.Equal(t, "authorization", facilitator.sent(t).Accepted.Extra["paymentFlow"]) +} + +func TestLifecycleManager_Refusals(t *testing.T) { + ctx := context.Background() + + t.Run("an unknown payment", func(t *testing.T) { + manager, _, _, _ := newManager(t, deferredExtra) + _, err := manager.Capture(ctx, "0xmissing", nil) + require.ErrorContains(t, err, ErrPaymentNotFound) + }) + + t.Run("a custom operator's payment", func(t *testing.T) { + manager, facilitator, _, hash := newManager(t, map[string]interface{}{"paymentFlow": "escrow", "captureMode": "deferred", "operatorType": "custom"}) + for _, call := range []func() error{ + func() error { _, err := manager.Capture(ctx, hash, nil); return err }, + func() error { _, err := manager.Void(ctx, hash); return err }, + func() error { _, err := manager.Refund(ctx, hash, big.NewInt(1)); return err }, + } { + require.ErrorContains(t, call(), ErrLifecycleUnavailable) + } + assert.Empty(t, facilitator.payloads) + }) + + t.Run("a record without a saltNonce", func(t *testing.T) { + manager, _, _, hash := newManager(t, deferredExtra) + require.NoError(t, manager.scheme.Storage().Update(ctx, hash, func(current *AuthorizedPayment) *AuthorizedPayment { + current.SaltNonce = "" + return current + })) + _, err := manager.Void(ctx, hash) + require.ErrorContains(t, err, ErrLifecycleUnavailable) + }) + + t.Run("a collect-only payment", func(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&Config{CaptureAuthorizer: testCaptureAuthorizer}) + hash := authorize(t, scheme, collectOnlyExtra) + manager := scheme.NewLifecycleManager(&fakeFacilitator{}) + _, err := manager.Capture(ctx, hash, nil) + require.ErrorContains(t, err, "lifecycle is out of band") + _, err = manager.Void(ctx, hash) + require.ErrorContains(t, err, "lifecycle is out of band") + _, err = manager.Refund(ctx, hash, big.NewInt(1)) + require.ErrorContains(t, err, "lifecycle is out of band") + require.ErrorContains(t, err, ErrLifecycleUnavailable) + }) + + t.Run("listing", func(t *testing.T) { + manager, _, _, hash := newManager(t, deferredExtra) + all, err := manager.List(ctx) + require.NoError(t, err) + require.Len(t, all, 1) + assert.Equal(t, hash, all[0].PaymentInfoHash) + }) +} + +func TestAuthorizedPayment_RequirementsRoundTrip(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: testSignerAddress}) + hash := authorize(t, scheme, deferredExtra) + record := storedPayment(t, scheme, hash) + + requirements := record.requirements() + extra, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) + require.NoError(t, err) + assert.Equal(t, record.PaymentInfo.Operator, extra.CaptureAuthorizer) + assert.Equal(t, record.PaymentInfo.AuthorizationExpiry, extra.CaptureDeadline) + assert.Equal(t, record.PaymentInfo.RefundExpiry, extra.RefundDeadline) + assert.Equal(t, record.PaymentInfo.MaxAmount, requirements.Amount) + + rebuilt := authcapture.ReconstructPaymentInfo(record.PaymentInfo.Payer, record.PaymentInfo.PreApprovalExpiry, record.PaymentInfo.Salt, requirements, extra) + rebuiltHash, err := authcapture.ComputePaymentInfoHash(mustChainID(t, record.Network), rebuilt, rebuilt.Payer, deployment.Escrow) + require.NoError(t, err) + assert.Equal(t, record.PaymentInfoHash, rebuiltHash, "the facilitator derives the same hash from the rebuilt requirements") +} + +func mustChainID(t *testing.T, network string) *big.Int { + t.Helper() + chainID, err := evm.GetEvmChainId(network) + require.NoError(t, err) + return chainID +} + +var ( + delegatedSyncExtra = map[string]interface{}{"paymentFlow": "escrow", "captureMode": "sync", "receiverAuthorizer": testFacilitatorAddr} + delegatedDeferredExtra = map[string]interface{}{"paymentFlow": "escrow", "captureMode": "deferred", "receiverAuthorizer": testFacilitatorAddr} + delegatedAuthorization = map[string]interface{}{"paymentFlow": "authorization", "receiverAuthorizer": testFacilitatorAddr} +) + +// delegatedScheme has no receiver-authorizer signer: the facilitator signs. +func delegatedScheme() *AuthCaptureEvmScheme { + return NewAuthCaptureEvmScheme(&Config{CaptureAuthorizer: testCaptureAuthorizer}) +} + +func TestEnrichSettlementPayload_CollectOnlyRouteIsNotEnriched(t *testing.T) { + scheme := delegatedScheme() + for _, phase := range []x402.SettlePhase{x402.SettlePhaseAfterHandler, x402.SettlePhaseCancel} { + fields, err := scheme.EnrichSettlementPayload(settleContext(phase, collectOnlyExtra)) + require.NoError(t, err) + assert.Nil(t, fields) + } +} + +func TestEnrichSettlementPayload_UnsignedCaptureWhenTheAuthorizerIsDelegated(t *testing.T) { + t.Run("a partial capture asks the facilitator to void the remainder", func(t *testing.T) { + fields, err := delegatedScheme().EnrichSettlementPayload(settleContextWithAmount(x402.SettlePhaseAfterHandler, delegatedSyncExtra, "400000")) + require.NoError(t, err) + assert.Equal(t, "capture", fields["type"]) + assert.Equal(t, "400000", fields["amount"]) + assert.Equal(t, "1000000", fields["expectedCapturableAmount"]) + assert.Equal(t, "0", fields["expectedRefundableAmount"]) + assert.Equal(t, true, fields["voidRemainder"]) + assert.NotContains(t, fields, "authorizerSignature") + assert.NotContains(t, fields, "voidAuthorizerSignature") + }) + + t.Run("a full capture omits voidRemainder", func(t *testing.T) { + fields, err := delegatedScheme().EnrichSettlementPayload(settleContext(x402.SettlePhaseAfterHandler, delegatedSyncExtra)) + require.NoError(t, err) + assert.Equal(t, "capture", fields["type"]) + assert.Equal(t, "1000000", fields["amount"]) + assert.NotContains(t, fields, "voidRemainder") + }) +} + +func TestEnrichSettlementPayload_UnsignedVoidOnCancelWhenDelegated(t *testing.T) { + fields, err := delegatedScheme().EnrichSettlementPayload(settleContext(x402.SettlePhaseCancel, delegatedSyncExtra)) + require.NoError(t, err) + assert.Equal(t, "void", fields["type"]) + assert.NotContains(t, fields, "authorizerSignature") +} + +func TestEnrichSettlementPayload_UnsignedChargeCompletionWhenDelegated(t *testing.T) { + fields, err := delegatedScheme().EnrichSettlementPayload(settleContext(x402.SettlePhaseAfterHandler, delegatedAuthorization)) + require.NoError(t, err) + assert.Equal(t, "1000000", fields["amount"]) + assert.Contains(t, fields, "feeReceiver") + assert.NotContains(t, fields, "authorizerSignature") +} + +func TestSettleOnCancel_ReturnsRequirementsWithoutASignerWhenDelegated(t *testing.T) { + requirements, err := delegatedScheme().SettleOnCancel(x402.VerifiedPaymentCanceledContext{ + SettleContext: settleContext(x402.SettlePhaseCancel, delegatedDeferredExtra), + Reason: x402.CancellationReasonHandlerFailed, + }) + require.NoError(t, err) + require.NotNil(t, requirements) + assert.Equal(t, testFacilitatorAddr, requirements.Extra["receiverAuthorizer"]) +} + +func TestLifecycleManager_SendsUnsignedPayloadsWhenTheAuthorizerIsDelegated(t *testing.T) { + ctx := context.Background() + scheme := delegatedScheme() + facilitator := &fakeFacilitator{} + manager := scheme.NewLifecycleManager(facilitator) + hash := authorize(t, scheme, delegatedDeferredExtra) + + _, err := manager.Capture(ctx, hash, &CaptureOptions{Amount: big.NewInt(500000), VoidRemainder: true}) + require.NoError(t, err) + _, err = manager.Void(ctx, hash) + require.NoError(t, err) + _, err = manager.Refund(ctx, hash, big.NewInt(1)) + require.NoError(t, err) + + require.Len(t, facilitator.payloads, 3) + var capture, voided, refund types.PaymentPayload + require.NoError(t, json.Unmarshal(facilitator.payloads[0], &capture)) + require.NoError(t, json.Unmarshal(facilitator.payloads[1], &voided)) + require.NoError(t, json.Unmarshal(facilitator.payloads[2], &refund)) + + assert.Equal(t, "capture", capture.Payload["type"]) + assert.Equal(t, "500000", capture.Payload["amount"]) + assert.Equal(t, true, capture.Payload["voidRemainder"]) + assert.NotContains(t, capture.Payload, "authorizerSignature") + assert.NotContains(t, capture.Payload, "voidAuthorizerSignature") + assert.Equal(t, "void", voided.Payload["type"]) + assert.NotContains(t, voided.Payload, "authorizerSignature") + assert.Equal(t, "refund", refund.Payload["type"]) + assert.NotContains(t, refund.Payload, "authorizerSignature") +} diff --git a/go/mechanisms/evm/auth-capture/server/manager.go b/go/mechanisms/evm/auth-capture/server/manager.go new file mode 100644 index 0000000000..6d17d89092 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/server/manager.go @@ -0,0 +1,288 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +// lifecycleRequirementsTimeout is the minimum, since a lifecycle settle has no payer-signed window. +const lifecycleRequirementsTimeout = 1 + +// CaptureOptions tunes a manager capture. A nil Amount captures everything capturable. A nil Fee +// charges the route's minimum fee, and a Fee must use the deployment's encoding (feeBps on v1.0, +// feeAmount on v1.1). An empty FeeReceiver pays the route's feeRecipient. +type CaptureOptions struct { + Amount *big.Int + Fee *authcapture.CaptureFee + FeeReceiver string + VoidRemainder bool +} + +// LifecycleManager captures, voids and refunds stored authorized payments through a facilitator, +// for deferred routes and for any payment the server holds a record of. It refuses payments of a +// custom operator, whose contract performs those calls itself, and collect-only payments. Payments +// whose authorizer is delegated to the facilitator are sent unsigned. +type LifecycleManager struct { + scheme *AuthCaptureEvmScheme + facilitator x402.FacilitatorClient +} + +// NewLifecycleManager binds the scheme's storage and signer to a facilitator client. +func (s *AuthCaptureEvmScheme) NewLifecycleManager(facilitator x402.FacilitatorClient) *LifecycleManager { + return &LifecycleManager{scheme: s, facilitator: facilitator} +} + +// Get returns the stored payment, or nil when none is recorded. +func (m *LifecycleManager) Get(ctx context.Context, paymentInfoHash string) (*AuthorizedPayment, error) { + return m.scheme.storage.Get(ctx, paymentInfoHash) +} + +// List returns every stored payment. +func (m *LifecycleManager) List(ctx context.Context) ([]*AuthorizedPayment, error) { + return m.scheme.storage.List(ctx) +} + +// lifecycleCall is a stored payment resolved for one capture, void or refund. +type lifecycleCall struct { + record *AuthorizedPayment + requirements types.PaymentRequirements + extra authcapture.AuthCaptureExtra + deployment authcapture.AuthCaptureDeployment + chainID *big.Int + capturable *big.Int + refundable *big.Int +} + +// requirements rebuilds the requirements the payment was collected under from its record. +func (p *AuthorizedPayment) requirements() types.PaymentRequirements { + info := p.PaymentInfo + extra := map[string]interface{}{ + "captureAuthorizer": info.Operator, + "captureDeadline": info.AuthorizationExpiry, + "refundDeadline": info.RefundExpiry, + "feeRecipient": info.FeeReceiver, + "minFeeBps": info.MinFeeBps, + "maxFeeBps": info.MaxFeeBps, + "paymentFlow": p.PaymentFlow, + "operatorType": p.OperatorType, + "authCaptureEscrow": p.AuthCaptureEscrow, + } + for key, value := range map[string]string{ + "receiverAuthorizer": p.ReceiverAuthorizer, + "policy": p.Policy, + "name": p.Name, + "version": p.Version, + "assetTransferMethod": p.AssetTransferMethod, + } { + if value != "" { + extra[key] = value + } + } + if p.PaymentFlow == authcapture.PaymentFlowEscrow { + extra["captureMode"] = authcapture.CaptureModeDeferred + } + return types.PaymentRequirements{ + Scheme: authcapture.SchemeAuthCapture, + Network: p.Network, + Asset: info.Token, + Amount: info.MaxAmount, + PayTo: info.Receiver, + MaxTimeoutSeconds: lifecycleRequirementsTimeout, + Extra: extra, + } +} + +func (m *LifecycleManager) resolve(ctx context.Context, paymentInfoHash string, refund bool) (*lifecycleCall, error) { + record, err := m.scheme.storage.Get(ctx, paymentInfoHash) + if err != nil { + return nil, err + } + if record == nil { + return nil, fmt.Errorf("%s: %s", ErrPaymentNotFound, paymentInfoHash) + } + switch { + case record.OperatorType == authcapture.OperatorTypeCustom: + return nil, fmt.Errorf("%s: the custom operator performs capture, void and refund itself", ErrLifecycleUnavailable) + case record.PaymentFlow == authcapture.PaymentFlowAuthorization && !refund: + return nil, fmt.Errorf("%s: an authorization-flow payment is already charged, only a refund applies", ErrLifecycleUnavailable) + case record.SaltNonce == "": + return nil, fmt.Errorf("%s: the record has no saltNonce, which lifecycle payloads need", ErrLifecycleUnavailable) + } + + requirements := record.requirements() + extra, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) + if err != nil { + return nil, fmt.Errorf("%s: %w", ErrLifecycleUnavailable, err) + } + chainID, err := evm.GetEvmChainId(record.Network) + if err != nil { + return nil, err + } + capturable, capturableOK := parseBalance(record.CapturableAmount) + refundable, refundableOK := parseBalance(record.RefundableAmount) + if !capturableOK || !refundableOK { + return nil, fmt.Errorf("%s: stored balances of %s are unreadable", ErrInvalidLifecycleAmount, paymentInfoHash) + } + return &lifecycleCall{ + record: record, requirements: requirements, extra: extra, deployment: deployment, + chainID: chainID, capturable: capturable, refundable: refundable, + }, nil +} + +// settle sends a signed lifecycle payload to the facilitator, reporting whether it succeeded. +func (m *LifecycleManager) settle(ctx context.Context, call *lifecycleCall, fields map[string]interface{}) (*x402.SettleResponse, error) { + paymentInfo, err := call.record.PaymentInfo.ToWireMap() + if err != nil { + return nil, err + } + fields["paymentInfo"] = paymentInfo + fields["saltNonce"] = call.record.SaltNonce + + payloadBytes, err := json.Marshal(types.PaymentPayload{X402Version: 2, Accepted: call.requirements, Payload: fields}) + if err != nil { + return nil, err + } + requirementsBytes, err := json.Marshal(call.requirements) + if err != nil { + return nil, err + } + return m.facilitator.Settle(ctx, payloadBytes, requirementsBytes) +} + +// signerFor is the signer for a stored payment's lifecycle payloads: the scheme signer when it is +// the payment's authorizer, none when the authorizer is delegated to the facilitator (which signs +// the unsigned payload), and an error when the payment is collect-only. +func (m *LifecycleManager) signerFor(record *AuthorizedPayment) (evm.ClientEvmSigner, error) { + signer := m.scheme.config.ReceiverAuthorizerSigner + mode := authcapture.AuthorizerModeFor(record.ReceiverAuthorizer, signer) + switch mode { + case authcapture.AuthorizerModeSelf: + return signer, nil + case authcapture.AuthorizerModeDelegated: + return nil, nil + case authcapture.AuthorizerModeCollectOnly: + return nil, fmt.Errorf("%s: payment %s is collect-only (zero receiverAuthorizer); its lifecycle is out of band", + ErrLifecycleUnavailable, record.PaymentInfoHash) + default: + return nil, fmt.Errorf("unexpected authorizer mode %s", mode) + } +} + +// Capture captures a stored payment, optionally releasing the rest of the hold. +func (m *LifecycleManager) Capture(ctx context.Context, paymentInfoHash string, opts *CaptureOptions) (*x402.SettleResponse, error) { + if opts == nil { + opts = &CaptureOptions{} + } + call, err := m.resolve(ctx, paymentInfoHash, false) + if err != nil { + return nil, err + } + signer, err := m.signerFor(call.record) + if err != nil { + return nil, err + } + amount := call.capturable + if opts.Amount != nil { + amount = opts.Amount + } + if amount.Sign() <= 0 || amount.Cmp(call.capturable) > 0 { + return nil, fmt.Errorf("%s: capture amount %s must be > 0 and <= capturable %s", ErrInvalidLifecycleAmount, amount, call.capturable) + } + if opts.VoidRemainder && amount.Cmp(call.capturable) == 0 { + return nil, fmt.Errorf("%s: nothing remains to void after capturing all %s", ErrInvalidLifecycleAmount, call.capturable) + } + + fee := authcapture.DefaultCaptureFee(&call.deployment, amount, call.extra.MinFeeBps) + if opts.Fee != nil { + fee = *opts.Fee + } + fields, err := buildCaptureFields(ctx, signer, call.deployment, call.extra, call.chainID, captureTerms{ + paymentInfoHash: call.record.PaymentInfoHash, + amount: amount, + fee: fee, + feeReceiver: firstNonEmpty(opts.FeeReceiver, call.extra.FeeRecipient), + capturable: call.capturable, + refundable: call.refundable, + voidRemainder: opts.VoidRemainder, + }) + if err != nil { + return nil, err + } + fields["type"] = "capture" + response, err := m.settle(ctx, call, fields) + if err != nil || !response.Success { + return response, err + } + return response, applyCapture(ctx, m.scheme.storage, call.record.PaymentInfoHash, amount, opts.VoidRemainder) +} + +// Void releases the remaining hold of a stored payment. +func (m *LifecycleManager) Void(ctx context.Context, paymentInfoHash string) (*x402.SettleResponse, error) { + call, err := m.resolve(ctx, paymentInfoHash, false) + if err != nil { + return nil, err + } + signer, err := m.signerFor(call.record) + if err != nil { + return nil, err + } + fields := map[string]interface{}{"type": "void"} + if signer != nil { + signature, err := authcapture.SignVoid(ctx, signer, call.extra.CaptureAuthorizer, call.chainID, call.record.PaymentInfoHash) + if err != nil { + return nil, fmt.Errorf(ErrFailedToSignVoid+": %w", err) + } + fields["authorizerSignature"] = evm.BytesToHex(signature) + } + response, err := m.settle(ctx, call, fields) + if err != nil || !response.Success { + return response, err + } + return response, applyVoid(ctx, m.scheme.storage, call.record.PaymentInfoHash) +} + +// Refund returns captured funds of a stored payment to the payer. +func (m *LifecycleManager) Refund(ctx context.Context, paymentInfoHash string, amount *big.Int) (*x402.SettleResponse, error) { + call, err := m.resolve(ctx, paymentInfoHash, true) + if err != nil { + return nil, err + } + signer, err := m.signerFor(call.record) + if err != nil { + return nil, err + } + if amount == nil || amount.Sign() <= 0 || amount.Cmp(call.refundable) > 0 { + return nil, fmt.Errorf("%s: refund amount %v must be > 0 and <= refundable %s", ErrInvalidLifecycleAmount, amount, call.refundable) + } + fields := map[string]interface{}{ + "type": "refund", + "amount": amount.String(), + "expectedCapturableAmount": call.capturable.String(), + "expectedRefundableAmount": call.refundable.String(), + } + if signer != nil { + signature, err := authcapture.SignRefund(ctx, signer, call.extra.CaptureAuthorizer, call.chainID, authcapture.RefundParams{ + PaymentInfoHash: call.record.PaymentInfoHash, + Amount: amount, + TokenCollector: call.deployment.OperatorRefundCollector, + ExpectedCapturable: call.capturable, + ExpectedRefundable: call.refundable, + }) + if err != nil { + return nil, fmt.Errorf(ErrFailedToSignRefund+": %w", err) + } + fields["authorizerSignature"] = evm.BytesToHex(signature) + } + response, err := m.settle(ctx, call, fields) + if err != nil || !response.Success { + return response, err + } + return response, applyRefund(ctx, m.scheme.storage, call.record.PaymentInfoHash, amount) +} diff --git a/go/mechanisms/evm/auth-capture/server/route.go b/go/mechanisms/evm/auth-capture/server/route.go new file mode 100644 index 0000000000..0013195144 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/server/route.go @@ -0,0 +1,389 @@ +package server + +import ( + "encoding/json" + "fmt" + "slices" + "strings" + "time" + + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" +) + +// deadlineBucketSeconds aligns relative deadlines so 402s issued in the same minute share them. +const deadlineBucketSeconds = 60 + +// maxExpiry is the largest value a uint48 escrow expiry holds. +const maxExpiry = uint64(1)<<48 - 1 + +// AuthCaptureRouteExtra is the typed form of a route's auth-capture extra. Pass Map() as the +// route's extra. A field left unset falls back to Config, then to the facilitator's advertised terms. +type AuthCaptureRouteExtra struct { + // PaymentFlow is "escrow" (default) or "authorization". + PaymentFlow string `json:"paymentFlow,omitempty"` + // CaptureMode is "sync" (default) or "deferred" and only applies to the escrow flow. + CaptureMode string `json:"captureMode,omitempty"` + // OperatorType is "delegated" (default) or "custom". + OperatorType string `json:"operatorType,omitempty"` + + CaptureAuthorizer string `json:"captureAuthorizer,omitempty"` + ReceiverAuthorizer string `json:"receiverAuthorizer,omitempty"` + FeeRecipient string `json:"feeRecipient,omitempty"` + MinFeeBps *uint16 `json:"minFeeBps,omitempty"` + MaxFeeBps *uint16 `json:"maxFeeBps,omitempty"` + Policy string `json:"policy,omitempty"` + + // CaptureDeadline and RefundDeadline are absolute Unix seconds, the Seconds variants offsets + // from issue time. Set both of one form, never a mix. + CaptureDeadline *uint64 `json:"captureDeadline,omitempty"` + RefundDeadline *uint64 `json:"refundDeadline,omitempty"` + CaptureDeadlineSeconds *uint64 `json:"captureDeadlineSeconds,omitempty"` + RefundDeadlineSeconds *uint64 `json:"refundDeadlineSeconds,omitempty"` + + AssetTransferMethod string `json:"assetTransferMethod,omitempty"` + AuthCaptureEscrow string `json:"authCaptureEscrow,omitempty"` +} + +// Map returns the extra as the untyped map route configuration takes. +func (e AuthCaptureRouteExtra) Map() map[string]interface{} { + encoded, err := json.Marshal(e) + if err != nil { + return nil + } + var out map[string]interface{} + if err := json.Unmarshal(encoded, &out); err != nil { + return nil + } + return out +} + +func routeError(format string, args ...interface{}) error { + return fmt.Errorf("%s: %s", ErrInvalidRouteExtra, fmt.Sprintf(format, args...)) +} + +func overlay(base, over map[string]interface{}) map[string]interface{} { + merged := make(map[string]interface{}, len(base)+len(over)) + for key, value := range base { + merged[key] = value + } + for key, value := range over { + merged[key] = value + } + return merged +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +// choice reads a string-enum key, treating an absent or empty value as fallback. +func choice(extra map[string]interface{}, key, fallback string, allowed ...string) (string, error) { + raw, set := extra[key] + if !set || raw == nil || raw == "" { + return fallback, nil + } + if value, ok := raw.(string); ok && slices.Contains(allowed, value) { + return value, nil + } + return "", routeError("extra.%s must be one of %s, got %v", key, strings.Join(allowed, ", "), raw) +} + +// routeAddress reads an optional address key, "" when absent. +func routeAddress(route map[string]interface{}, key string) (string, error) { + raw, set := route[key] + if !set || raw == nil || raw == "" { + return "", nil + } + if value, ok := raw.(string); ok && evm.IsValidAddress(value) { + return value, nil + } + return "", routeError("extra.%s must be an address, got %v", key, raw) +} + +// routeBps reads an optional fee bound, reporting whether the route set it. +func routeBps(route map[string]interface{}, key string) (uint16, bool, error) { + raw, set := route[key] + if !set || raw == nil { + return 0, false, nil + } + bound, ok := authcapture.JSONNumberToUint16(raw) + if !ok { + return 0, false, fmt.Errorf("%s: extra.%s must be an integer of basis points, got %v", ErrInvalidFeeTerms, key, raw) + } + return bound, true, nil +} + +// resolvedTerms are a route's validated flow decisions. +type resolvedTerms struct { + paymentFlow string + captureMode string + operatorType string +} + +// resolveTerms validates the route's payment flow, capture mode and operator type. +func resolveTerms(merged map[string]interface{}) (resolvedTerms, error) { + var terms resolvedTerms + var err error + if terms.paymentFlow, err = choice(merged, "paymentFlow", authcapture.PaymentFlowEscrow, + authcapture.PaymentFlowEscrow, authcapture.PaymentFlowAuthorization); err != nil { + return terms, err + } + if terms.operatorType, err = choice(merged, "operatorType", authcapture.OperatorTypeDelegated, + authcapture.OperatorTypeDelegated, authcapture.OperatorTypeCustom); err != nil { + return terms, err + } + + if terms.paymentFlow == authcapture.PaymentFlowAuthorization { + if _, set := merged["captureMode"]; set { + return terms, routeError("extra.captureMode only applies to paymentFlow escrow") + } + return terms, nil + } + if terms.captureMode, err = choice(merged, "captureMode", authcapture.CaptureModeSync, + authcapture.CaptureModeSync, authcapture.CaptureModeDeferred); err != nil { + return terms, err + } + if terms.captureMode == authcapture.CaptureModeSync && terms.operatorType == authcapture.OperatorTypeCustom { + return terms, routeError("operatorType custom is collect-only, so it needs captureMode deferred") + } + return terms, nil +} + +// resolveCaptureAuthorizer picks the operator: the route's, then Config's, then the facilitator's +// advertised one. A custom operator is the merchant's choice, never the facilitator's. +func (s *AuthCaptureEvmScheme) resolveCaptureAuthorizer(route, advertised map[string]interface{}, operatorType string) (string, error) { + fromRoute, err := routeAddress(route, "captureAuthorizer") + if err != nil { + return "", err + } + captureAuthorizer := firstNonEmpty(fromRoute, s.config.CaptureAuthorizer) + if captureAuthorizer == "" && operatorType == authcapture.OperatorTypeDelegated { + captureAuthorizer, _ = advertised["captureAuthorizer"].(string) + } + if !evm.IsValidAddress(captureAuthorizer) { + return "", fmt.Errorf("%s: set extra.captureAuthorizer or Config.CaptureAuthorizer (operatorType %s)", ErrMissingCaptureAuthorizer, operatorType) + } + return evm.NormalizeAddress(captureAuthorizer), nil +} + +type advertisedOperator struct { + Address string `json:"address"` + OperatorType string `json:"operatorType"` +} + +// checkOperatorAdmitted fails when the facilitator's advertised allowlist does not admit address +// as a custom operator, since every payment on the route would then be rejected. +func checkOperatorAdmitted(advertised map[string]interface{}, address string) error { + var entries []advertisedOperator + if encoded, err := json.Marshal(advertised["operators"]); err == nil { + _ = json.Unmarshal(encoded, &entries) + } + for _, entry := range entries { + if entry.OperatorType == authcapture.OperatorTypeCustom && (entry.Address == "*" || strings.EqualFold(entry.Address, address)) { + return nil + } + } + return fmt.Errorf("%s: the facilitator does not advertise %s as an admitted custom operator", ErrOperatorNotAdmitted, address) +} + +// resolveFeeTerms picks the fee recipient and bounds: the route's, then Config's, then the +// facilitator's advertised terms. Absent terms mean no fee: the zero address with 0/0 bounds. +func (s *AuthCaptureEvmScheme) resolveFeeTerms(route, advertised map[string]interface{}) (string, uint16, uint16, error) { + fromRoute, err := routeAddress(route, "feeRecipient") + if err != nil { + return "", 0, 0, err + } + advertisedRecipient, _ := advertised["feeRecipient"].(string) + feeRecipient := firstNonEmpty(fromRoute, s.config.FeeRecipient, advertisedRecipient, authcapture.ZeroAddress) + if !evm.IsValidAddress(feeRecipient) { + return "", 0, 0, fmt.Errorf("%s: invalid feeRecipient %q", ErrInvalidFeeTerms, feeRecipient) + } + + bounds := [2]uint16{} + for i, term := range []struct { + key string + configured *uint16 + }{{"minFeeBps", s.config.MinFeeBps}, {"maxFeeBps", s.config.MaxFeeBps}} { + fromRoute, set, err := routeBps(route, term.key) + if err != nil { + return "", 0, 0, err + } + switch { + case set: + bounds[i] = fromRoute + case term.configured != nil: + bounds[i] = *term.configured + default: + bounds[i], _ = authcapture.JSONNumberToUint16(advertised[term.key]) + } + } + + minFeeBps, maxFeeBps := bounds[0], bounds[1] + if minFeeBps > maxFeeBps || maxFeeBps > authcapture.BpsDenominator { + return "", 0, 0, fmt.Errorf("%s: minFeeBps %d and maxFeeBps %d must satisfy min <= max <= %d", + ErrInvalidFeeTerms, minFeeBps, maxFeeBps, authcapture.BpsDenominator) + } + if !authcapture.IsNonZeroAddress(feeRecipient) && maxFeeBps != 0 { + return "", 0, 0, fmt.Errorf("%s: a non-zero maxFeeBps needs a feeRecipient", ErrMissingFeeRecipient) + } + return evm.NormalizeAddress(feeRecipient), minFeeBps, maxFeeBps, nil +} + +// advertisedAddress reads an optional address the facilitator advertised, "" when absent. +func advertisedAddress(advertised map[string]interface{}, key string) (string, error) { + raw, set := advertised[key] + if !set || raw == nil || raw == "" { + return "", nil + } + if value, ok := raw.(string); ok && evm.IsValidAddress(value) { + return value, nil + } + return "", fmt.Errorf("%s: the facilitator advertised an invalid %s %v", ErrInvalidRouteExtra, key, raw) +} + +// resolveReceiverAuthorizer resolves who authorizes facilitator-relayed charge and lifecycle, +// strictly and in order: the scheme signer (self-managed); else the route's own +// receiverAuthorizer (the zero address is explicit collect-only, the facilitator-advertised +// address is delegated, anything else has no way to be signed); else the facilitator-advertised +// address. It fails instead of silently falling back to collect-only, and rejects a collect-only +// result on a route that needs a signed charge or capture. +func (s *AuthCaptureEvmScheme) resolveReceiverAuthorizer(route, advertised map[string]interface{}, terms resolvedTerms) (string, error) { + fromRoute, err := routeAddress(route, "receiverAuthorizer") + if err != nil { + return "", err + } + advertisedAddr, err := advertisedAddress(advertised, "receiverAuthorizer") + if err != nil { + return "", err + } + advertisedNonZero := "" + if authcapture.IsNonZeroAddress(advertisedAddr) { + advertisedNonZero = advertisedAddr + } + + receiverAuthorizer, err := s.pickReceiverAuthorizer(fromRoute, advertisedNonZero) + if err != nil { + return "", err + } + if authcapture.IsNonZeroAddress(receiverAuthorizer) { + return receiverAuthorizer, nil + } + switch { + case terms.paymentFlow == authcapture.PaymentFlowAuthorization: + return "", fmt.Errorf("%s: paymentFlow authorization requires a non-zero receiverAuthorizer "+ + "(extra.receiverAuthorizer of the zero address is collect-only, valid only for escrow with captureMode deferred)", + ErrMissingReceiverAuthorizer) + case terms.captureMode == authcapture.CaptureModeSync: + return "", fmt.Errorf("%s: escrow sync routes require a non-zero receiverAuthorizer "+ + "(extra.receiverAuthorizer of the zero address is collect-only, valid only with captureMode deferred)", + ErrMissingReceiverAuthorizer) + } + return receiverAuthorizer, nil +} + +// pickReceiverAuthorizer applies the resolution order of resolveReceiverAuthorizer. +func (s *AuthCaptureEvmScheme) pickReceiverAuthorizer(fromRoute, advertisedNonZero string) (string, error) { + if signer := s.config.ReceiverAuthorizerSigner; signer != nil { + address := evm.NormalizeAddress(signer.Address()) + if authcapture.IsNonZeroAddress(fromRoute) && !strings.EqualFold(fromRoute, address) { + return "", fmt.Errorf("%s: extra.receiverAuthorizer %s is not the configured signer %s", ErrReceiverAuthorizerMismatch, fromRoute, address) + } + return address, nil + } + + if fromRoute != "" { + switch { + case !authcapture.IsNonZeroAddress(fromRoute): + return authcapture.ZeroAddress, nil + case advertisedNonZero != "" && strings.EqualFold(fromRoute, advertisedNonZero): + return evm.NormalizeAddress(fromRoute), nil + } + return "", fmt.Errorf("%s: extra.receiverAuthorizer %s can not be signed: the scheme has no "+ + "ReceiverAuthorizerSigner and the facilitator does not advertise that address. "+ + "Configure a ReceiverAuthorizerSigner, omit the field to use the facilitator's authorizer, "+ + "or set it to the zero address for collect-only with captureMode deferred", + ErrUnsignableReceiverAuthorizer, fromRoute) + } + + if advertisedNonZero != "" { + return evm.NormalizeAddress(advertisedNonZero), nil + } + return "", fmt.Errorf("%s: AuthCapture has no receiverAuthorizer: the route sets none, the scheme has no "+ + "ReceiverAuthorizerSigner, and the facilitator advertises none. Fix one of: "+ + "(1) configure a ReceiverAuthorizerSigner on the scheme, "+ + "(2) use a facilitator that delegates the authorizer (advertises extra.receiverAuthorizer), "+ + "or (3) set extra.receiverAuthorizer to the zero address with captureMode deferred (collect-only)", + ErrMissingReceiverAuthorizer) +} + +// deadlineKey reads an optional positive integer, reporting whether the route set it. +func deadlineKey(route map[string]interface{}, key string) (uint64, bool, error) { + raw, set := route[key] + if !set || raw == nil { + return 0, false, nil + } + value, ok := authcapture.JSONNumberToUint64(raw) + if !ok || value == 0 || value > maxExpiry { + return 0, false, fmt.Errorf("%s: extra.%s must be a positive whole number of seconds, got %v", ErrInvalidDeadline, key, raw) + } + return value, true, nil +} + +// resolveDeadlines turns the route's absolute pair, relative pair or Config durations into +// absolute deadlines. Relative offsets count from the start of the current minute so every 402 +// issued in that minute carries the same deadlines. +func (s *AuthCaptureEvmScheme) resolveDeadlines(route map[string]interface{}, maxTimeoutSeconds int, now time.Time) (uint64, uint64, error) { + var set [4]bool + var values [4]uint64 + for i, key := range []string{"captureDeadline", "refundDeadline", "captureDeadlineSeconds", "refundDeadlineSeconds"} { + var err error + if values[i], set[i], err = deadlineKey(route, key); err != nil { + return 0, 0, err + } + } + absCapture, absRefund, relCapture, relRefund := set[0], set[1], set[2], set[3] + if (absCapture || absRefund) && (relCapture || relRefund) || absCapture != absRefund || relCapture != relRefund { + return 0, 0, fmt.Errorf("%s: set both absolute deadlines or both relative offsets, not a mix or a single one", ErrInvalidDeadline) + } + + bucketStart := uint64(now.Unix()) - uint64(now.Unix())%deadlineBucketSeconds + var capture, refund, window uint64 + switch { + case absCapture: + capture, refund = values[0], values[1] + if capture > uint64(now.Unix()) { + window = capture - uint64(now.Unix()) + } + case relCapture: + capture, refund, window = bucketStart+values[2], bucketStart+values[3], values[2] + default: + window = uint64(durationOrDefault(s.config.CaptureDeadline, DefaultCaptureDeadline) / time.Second) + capture = bucketStart + window + refund = bucketStart + uint64(durationOrDefault(s.config.RefundDeadline, DefaultRefundDeadline)/time.Second) + } + + if uint64(maxTimeoutSeconds) > window { + return 0, 0, fmt.Errorf("%s: maxTimeoutSeconds %d exceeds the capture window of %ds", + ErrTimeoutExceedsCaptureDeadline, maxTimeoutSeconds, window) + } + if refund < capture { + return 0, 0, fmt.Errorf("%s: refund deadline %d is before the capture deadline %d", ErrRefundBeforeCaptureDeadline, refund, capture) + } + if refund > maxExpiry { + return 0, 0, fmt.Errorf("%s: refund deadline %d exceeds the uint48 expiry range", ErrInvalidDeadline, refund) + } + return capture, refund, nil +} + +func durationOrDefault(configured, fallback time.Duration) time.Duration { + if configured > 0 { + return configured + } + return fallback +} diff --git a/go/mechanisms/evm/auth-capture/server/route_test.go b/go/mechanisms/evm/auth-capture/server/route_test.go new file mode 100644 index 0000000000..24800f7975 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/server/route_test.go @@ -0,0 +1,419 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +const ( + testSignerAddress = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + testCustomOperator = "0xdddddddddddddddddddddddddddddddddddddddd" + testOtherOperator = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + testFacilitatorAddr = "0xffffffffffffffffffffffffffffffffffffffff" +) + +func signedScheme() *AuthCaptureEvmScheme { + return NewAuthCaptureEvmScheme(&Config{ReceiverAuthorizerSigner: &mockSigner{address: testSignerAddress}}) +} + +// routeRequirements are requirements whose route sets only the given extra. +func routeRequirements(extra map[string]interface{}) types.PaymentRequirements { + requirements := mockRequirements(nil) + requirements.Extra = extra + return requirements +} + +func facilitatorKind(extra map[string]interface{}) types.SupportedKind { + base := map[string]interface{}{"captureAuthorizer": testFacilitatorAddr} + for key, value := range extra { + base[key] = value + } + return types.SupportedKind{Extra: base} +} + +func enhance(scheme *AuthCaptureEvmScheme, requirements types.PaymentRequirements, kind types.SupportedKind) (types.PaymentRequirements, error) { + return scheme.EnhancePaymentRequirements(context.Background(), requirements, kind, nil) +} + +func TestSchemeDeclaresDynamicDeadlinesAndBothFlows(t *testing.T) { + scheme := signedScheme() + assert.Equal(t, []string{"captureDeadline", "refundDeadline"}, scheme.DynamicExtraFields()) + + flows := scheme.PaymentFlows() + for _, method := range []evm.AssetTransferMethod{evm.AssetTransferMethodEIP3009, evm.AssetTransferMethodPermit2} { + assert.Equal(t, x402.PaymentFlowEscrow, flows[string(method)].Default) + assert.Equal(t, []x402.PaymentFlowName{x402.PaymentFlowEscrow, x402.PaymentFlowAuthorization}, flows[string(method)].Supported) + } +} + +func TestEnhance_RelativeDeadlinesAreBucketed(t *testing.T) { + scheme := signedScheme() + route := map[string]interface{}{"captureDeadlineSeconds": float64(600), "refundDeadlineSeconds": float64(1200)} + + first, err := enhance(scheme, routeRequirements(route), facilitatorKind(nil)) + require.NoError(t, err) + assertBucketed(t, first.Extra["captureDeadline"], 600) + assertBucketed(t, first.Extra["refundDeadline"], 1200) + assert.NotContains(t, first.Extra, "captureDeadlineSeconds") + assert.NotContains(t, first.Extra, "refundDeadlineSeconds") + + second, err := enhance(scheme, routeRequirements(route), facilitatorKind(nil)) + require.NoError(t, err) + if first.Extra["captureDeadline"] != second.Extra["captureDeadline"] { + t.Skip("minute rolled over between the two calls") + } + assert.Equal(t, first.Extra["refundDeadline"], second.Extra["refundDeadline"]) +} + +func TestEnhance_AbsoluteDeadlinesAreKept(t *testing.T) { + future := uint64(time.Now().Unix()) + 7200 + enhanced, err := enhance(signedScheme(), routeRequirements(map[string]interface{}{ + "captureDeadline": float64(future), "refundDeadline": float64(future + 3600), + }), facilitatorKind(nil)) + require.NoError(t, err) + assert.Equal(t, future, enhanced.Extra["captureDeadline"]) + assert.Equal(t, future+3600, enhanced.Extra["refundDeadline"]) +} + +func TestEnhance_RouteDeadlinesBeatConfigDurations(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&Config{ + ReceiverAuthorizerSigner: &mockSigner{address: testSignerAddress}, + CaptureDeadline: time.Hour, + RefundDeadline: 48 * time.Hour, + }) + enhanced, err := enhance(scheme, routeRequirements(map[string]interface{}{ + "captureDeadlineSeconds": float64(900), "refundDeadlineSeconds": float64(1800), + }), facilitatorKind(nil)) + require.NoError(t, err) + assertBucketed(t, enhanced.Extra["captureDeadline"], 900) +} + +func TestEnhance_InvalidDeadlines(t *testing.T) { + future := float64(time.Now().Unix() + 7200) + tests := map[string]map[string]interface{}{ + "absolute and relative mixed": {"captureDeadline": future, "refundDeadline": future + 1, "captureDeadlineSeconds": float64(60), "refundDeadlineSeconds": float64(120)}, + "absolute with relative": {"captureDeadline": future, "refundDeadlineSeconds": float64(120)}, + "only a capture deadline": {"captureDeadline": future}, + "only a refund offset": {"refundDeadlineSeconds": float64(120)}, + "zero offset": {"captureDeadlineSeconds": float64(0), "refundDeadlineSeconds": float64(120)}, + "fractional offset": {"captureDeadlineSeconds": 1.5, "refundDeadlineSeconds": float64(120)}, + "negative offset": {"captureDeadlineSeconds": float64(-60), "refundDeadlineSeconds": float64(120)}, + "string offset": {"captureDeadlineSeconds": "600", "refundDeadlineSeconds": float64(1200)}, + } + for name, route := range tests { + t.Run(name, func(t *testing.T) { + _, err := enhance(signedScheme(), routeRequirements(route), facilitatorKind(nil)) + require.ErrorContains(t, err, ErrInvalidDeadline) + }) + } + + t.Run("refund before capture", func(t *testing.T) { + _, err := enhance(signedScheme(), routeRequirements(map[string]interface{}{ + "captureDeadlineSeconds": float64(1200), "refundDeadlineSeconds": float64(600), + }), facilitatorKind(nil)) + require.ErrorContains(t, err, ErrRefundBeforeCaptureDeadline) + }) + + t.Run("timeout beyond the relative capture window", func(t *testing.T) { + requirements := routeRequirements(map[string]interface{}{"captureDeadlineSeconds": float64(120), "refundDeadlineSeconds": float64(600)}) + requirements.MaxTimeoutSeconds = 121 + _, err := enhance(signedScheme(), requirements, facilitatorKind(nil)) + require.ErrorContains(t, err, ErrTimeoutExceedsCaptureDeadline) + }) +} + +func TestEnhance_PrecedenceIsRouteThenConfigThenFacilitator(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&Config{ + ReceiverAuthorizerSigner: &mockSigner{address: testSignerAddress}, + CaptureAuthorizer: testCustomOperator, + FeeRecipient: testFeeRecipient, + }) + + t.Run("route wins", func(t *testing.T) { + enhanced, err := enhance(scheme, routeRequirements(AuthCaptureRouteExtra{ + CaptureAuthorizer: testOtherOperator, + FeeRecipient: testCaptureAuthorizer, + MinFeeBps: ptr(uint16(5)), + MaxFeeBps: ptr(uint16(50)), + }.Map()), facilitatorKind(map[string]interface{}{"feeRecipient": testFacilitatorAddr, "minFeeBps": float64(1), "maxFeeBps": float64(2)})) + require.NoError(t, err) + assert.Equal(t, evm.NormalizeAddress(testOtherOperator), enhanced.Extra["captureAuthorizer"]) + assert.Equal(t, evm.NormalizeAddress(testCaptureAuthorizer), enhanced.Extra["feeRecipient"]) + assert.Equal(t, uint16(5), enhanced.Extra["minFeeBps"]) + assert.Equal(t, uint16(50), enhanced.Extra["maxFeeBps"]) + }) + + t.Run("config beats the facilitator", func(t *testing.T) { + enhanced, err := enhance(scheme, routeRequirements(map[string]interface{}{}), + facilitatorKind(map[string]interface{}{"feeRecipient": testFacilitatorAddr, "maxFeeBps": float64(9)})) + require.NoError(t, err) + assert.Equal(t, evm.NormalizeAddress(testCustomOperator), enhanced.Extra["captureAuthorizer"]) + assert.Equal(t, evm.NormalizeAddress(testFeeRecipient), enhanced.Extra["feeRecipient"]) + assert.Equal(t, uint16(9), enhanced.Extra["maxFeeBps"]) + }) +} + +func TestEnhance_InvalidRouteTerms(t *testing.T) { + tests := map[string]struct { + route map[string]interface{} + wantErr string + }{ + "unknown payment flow": {map[string]interface{}{"paymentFlow": "upfront"}, ErrInvalidRouteExtra}, + "non-string payment flow": {map[string]interface{}{"paymentFlow": 1}, ErrInvalidRouteExtra}, + "unknown capture mode": {map[string]interface{}{"captureMode": "later"}, ErrInvalidRouteExtra}, + "unknown operator type": {map[string]interface{}{"operatorType": "policy"}, ErrInvalidRouteExtra}, + "capture mode on charge": {map[string]interface{}{"paymentFlow": "authorization", "captureMode": "sync"}, ErrInvalidRouteExtra}, + "custom operator syncs": {map[string]interface{}{"operatorType": "custom", "captureMode": "sync"}, ErrInvalidRouteExtra}, + "custom operator by default": {map[string]interface{}{"operatorType": "custom"}, ErrInvalidRouteExtra}, + "autoCapture": {map[string]interface{}{"autoCapture": true}, ErrAutoCaptureRemoved}, + "malformed captureAuthorizer": {map[string]interface{}{"captureAuthorizer": "nope"}, ErrInvalidRouteExtra}, + "malformed feeRecipient": {map[string]interface{}{"feeRecipient": "nope"}, ErrInvalidRouteExtra}, + "malformed policy": {map[string]interface{}{"policy": "nope"}, ErrInvalidRouteExtra}, + "fractional fee bound": {map[string]interface{}{"maxFeeBps": 1.5}, ErrInvalidFeeTerms}, + "foreign receiverAuthorizer": {map[string]interface{}{"receiverAuthorizer": testOtherOperator}, ErrReceiverAuthorizerMismatch}, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + _, err := enhance(signedScheme(), routeRequirements(test.route), facilitatorKind(nil)) + require.ErrorContains(t, err, test.wantErr) + }) + } +} + +func TestEnhance_FlowsAndModes(t *testing.T) { + t.Run("escrow defaults to a sync delegated capture", func(t *testing.T) { + enhanced, err := enhance(signedScheme(), routeRequirements(map[string]interface{}{}), facilitatorKind(nil)) + require.NoError(t, err) + assert.Equal(t, "escrow", enhanced.Extra["paymentFlow"]) + assert.Equal(t, "sync", enhanced.Extra["captureMode"]) + assert.Equal(t, "delegated", enhanced.Extra["operatorType"]) + }) + + t.Run("deferred escrow", func(t *testing.T) { + enhanced, err := enhance(signedScheme(), routeRequirements(AuthCaptureRouteExtra{CaptureMode: "deferred"}.Map()), facilitatorKind(nil)) + require.NoError(t, err) + assert.Equal(t, "deferred", enhanced.Extra["captureMode"]) + }) + + t.Run("authorization publishes its flow and no capture mode", func(t *testing.T) { + enhanced, err := enhance(signedScheme(), routeRequirements(AuthCaptureRouteExtra{PaymentFlow: "authorization"}.Map()), facilitatorKind(nil)) + require.NoError(t, err) + assert.Equal(t, "authorization", enhanced.Extra["paymentFlow"]) + assert.NotContains(t, enhanced.Extra, "captureMode") + assert.Equal(t, evm.NormalizeAddress(testSignerAddress), enhanced.Extra["receiverAuthorizer"]) + }) + + t.Run("the facilitator's operator allowlist stays off the wire", func(t *testing.T) { + enhanced, err := enhance(signedScheme(), routeRequirements(map[string]interface{}{}), + facilitatorKind(map[string]interface{}{"operators": []interface{}{map[string]interface{}{"address": "*", "operatorType": "custom"}}})) + require.NoError(t, err) + assert.NotContains(t, enhanced.Extra, "operators") + }) + + t.Run("the route may restate the signer as receiverAuthorizer", func(t *testing.T) { + enhanced, err := enhance(signedScheme(), routeRequirements(map[string]interface{}{"receiverAuthorizer": testSignerAddress}), facilitatorKind(nil)) + require.NoError(t, err) + assert.Equal(t, evm.NormalizeAddress(testSignerAddress), enhanced.Extra["receiverAuthorizer"]) + }) +} + +func TestEnhance_CustomOperators(t *testing.T) { + custom := AuthCaptureRouteExtra{OperatorType: "custom", CaptureMode: "deferred", CaptureAuthorizer: testCustomOperator}.Map() + admitted := func(address string) map[string]interface{} { + return map[string]interface{}{"operators": []interface{}{map[string]interface{}{"address": address, "operatorType": "custom"}}} + } + + t.Run("an allowlisted operator is published", func(t *testing.T) { + enhanced, err := enhance(signedScheme(), routeRequirements(custom), facilitatorKind(admitted(testCustomOperator))) + require.NoError(t, err) + assert.Equal(t, "custom", enhanced.Extra["operatorType"]) + assert.Equal(t, "deferred", enhanced.Extra["captureMode"]) + assert.Equal(t, evm.NormalizeAddress(testCustomOperator), enhanced.Extra["captureAuthorizer"]) + }) + + t.Run("a wildcard entry admits any operator", func(t *testing.T) { + _, err := enhance(signedScheme(), routeRequirements(custom), facilitatorKind(admitted("*"))) + require.NoError(t, err) + }) + + t.Run("an operator off the allowlist fails fast", func(t *testing.T) { + _, err := enhance(signedScheme(), routeRequirements(custom), facilitatorKind(admitted(testOtherOperator))) + require.ErrorContains(t, err, ErrOperatorNotAdmitted) + }) + + t.Run("a facilitator without an allowlist fails fast", func(t *testing.T) { + _, err := enhance(signedScheme(), routeRequirements(custom), facilitatorKind(nil)) + require.ErrorContains(t, err, ErrOperatorNotAdmitted) + }) + + t.Run("an allowlist entry of another type does not admit", func(t *testing.T) { + kind := facilitatorKind(map[string]interface{}{"operators": []interface{}{map[string]interface{}{"address": "*", "operatorType": "delegated"}}}) + _, err := enhance(signedScheme(), routeRequirements(custom), kind) + require.ErrorContains(t, err, ErrOperatorNotAdmitted) + }) + + t.Run("a custom operator never defaults to the facilitator's", func(t *testing.T) { + route := AuthCaptureRouteExtra{OperatorType: "custom", CaptureMode: "deferred"}.Map() + _, err := enhance(signedScheme(), routeRequirements(route), facilitatorKind(admitted("*"))) + require.ErrorContains(t, err, ErrMissingCaptureAuthorizer) + }) + + t.Run("a collect-only route needs no signer", func(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&Config{}) + collectOnly := AuthCaptureRouteExtra{ + OperatorType: "custom", CaptureMode: "deferred", CaptureAuthorizer: testCustomOperator, ReceiverAuthorizer: authcapture.ZeroAddress, + }.Map() + enhanced, err := enhance(scheme, routeRequirements(collectOnly), facilitatorKind(admitted("*"))) + require.NoError(t, err) + assert.Equal(t, authcapture.ZeroAddress, enhanced.Extra["receiverAuthorizer"]) + }) +} + +func TestEnhance_RoutesThatSignNeedAnAuthorizer(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&Config{CaptureAuthorizer: testCaptureAuthorizer}) + for name, route := range map[string]map[string]interface{}{ + "sync escrow": {}, + "authorization": {"paymentFlow": "authorization"}, + } { + t.Run(name, func(t *testing.T) { + _, err := enhance(scheme, routeRequirements(route), facilitatorKind(nil)) + require.ErrorContains(t, err, ErrMissingReceiverAuthorizer) + }) + } + + t.Run("deferred escrow must name its authorizer or be explicitly collect-only", func(t *testing.T) { + deferred := map[string]interface{}{"captureMode": "deferred"} + _, err := enhance(scheme, routeRequirements(deferred), facilitatorKind(nil)) + require.ErrorContains(t, err, ErrMissingReceiverAuthorizer) + + deferred["receiverAuthorizer"] = authcapture.ZeroAddress + _, err = enhance(scheme, routeRequirements(deferred), facilitatorKind(nil)) + require.NoError(t, err) + }) +} + +func TestValidateFacilitatorSupport_CustomOperatorsNeedNoCaptureAuthorizer(t *testing.T) { + kind := types.SupportedKind{Extra: map[string]interface{}{"operators": []interface{}{}}} + require.ErrorContains(t, NewAuthCaptureEvmScheme(&Config{}).ValidateFacilitatorSupport(testNetwork, kind, nil), "receiverAuthorizer") + require.NoError(t, NewAuthCaptureEvmScheme(&Config{CollectOnlyRoutes: true}).ValidateFacilitatorSupport(testNetwork, kind, nil)) + require.NoError(t, newTestScheme(&mockSigner{address: testSignerAddress}).ValidateFacilitatorSupport(testNetwork, kind, nil)) +} + +func TestAuthCaptureRouteExtra_Map(t *testing.T) { + assert.Empty(t, AuthCaptureRouteExtra{}.Map()) + + capture, refund := uint64(600), uint64(1200) + route := AuthCaptureRouteExtra{ + PaymentFlow: "escrow", + CaptureMode: "deferred", + OperatorType: "delegated", + MinFeeBps: ptr(uint16(0)), + CaptureDeadlineSeconds: &capture, + RefundDeadlineSeconds: &refund, + }.Map() + assert.Equal(t, "deferred", route["captureMode"]) + assert.Equal(t, float64(0), route["minFeeBps"], "an explicit zero bound is kept") + assert.Equal(t, float64(600), route["captureDeadlineSeconds"]) + assert.NotContains(t, route, "maxFeeBps") + + _, err := enhance(signedScheme(), routeRequirements(route), facilitatorKind(nil)) + require.NoError(t, err) +} + +func ptr[T any](value T) *T { return &value } + +func TestEnhance_ReceiverAuthorizerResolution(t *testing.T) { + const ( + facilitatorAuthorizer = "0x2222222222222222222222222222222222222222" + other = "0x9999999999999999999999999999999999999999" + ) + route := func(overrides map[string]interface{}) types.PaymentRequirements { + extra := map[string]interface{}{"captureAuthorizer": testCaptureAuthorizer} + for key, value := range overrides { + extra[key] = value + } + return routeRequirements(extra) + } + kind := func(extra map[string]interface{}) types.SupportedKind { return facilitatorKind(extra) } + unsignedScheme := func() *AuthCaptureEvmScheme { return NewAuthCaptureEvmScheme(&Config{}) } + + t.Run("defaults an omitted route authorizer to the facilitator-advertised one", func(t *testing.T) { + result, err := enhance(unsignedScheme(), route(nil), kind(map[string]interface{}{"receiverAuthorizer": facilitatorAuthorizer})) + require.NoError(t, err) + assert.Equal(t, facilitatorAuthorizer, result.Extra["receiverAuthorizer"]) + assert.Equal(t, "sync", result.Extra["captureMode"]) + }) + + t.Run("accepts a route authorizer equal to the facilitator-advertised one", func(t *testing.T) { + result, err := enhance(unsignedScheme(), + route(map[string]interface{}{"receiverAuthorizer": "0x2222222222222222222222222222222222222222"}), + kind(map[string]interface{}{"receiverAuthorizer": facilitatorAuthorizer})) + require.NoError(t, err) + assert.Equal(t, facilitatorAuthorizer, result.Extra["receiverAuthorizer"]) + }) + + t.Run("lets the scheme signer win over a facilitator-advertised authorizer", func(t *testing.T) { + result, err := enhance(signedScheme(), route(nil), kind(map[string]interface{}{"receiverAuthorizer": facilitatorAuthorizer})) + require.NoError(t, err) + assert.Equal(t, evm.NormalizeAddress(testSignerAddress), result.Extra["receiverAuthorizer"]) + }) + + t.Run("throws naming all three fixes when nothing can authorize", func(t *testing.T) { + _, err := enhance(unsignedScheme(), route(map[string]interface{}{"captureMode": "deferred"}), kind(nil)) + require.ErrorContains(t, err, "ReceiverAuthorizerSigner") + require.ErrorContains(t, err, "delegates the authorizer") + require.ErrorContains(t, err, "zero address") + }) + + t.Run("throws when the route names a non-zero authorizer nobody can sign for", func(t *testing.T) { + _, err := enhance(unsignedScheme(), + route(map[string]interface{}{"receiverAuthorizer": other}), + kind(map[string]interface{}{"receiverAuthorizer": facilitatorAuthorizer})) + require.ErrorContains(t, err, "can not be signed") + require.ErrorContains(t, err, ErrUnsignableReceiverAuthorizer) + }) + + t.Run("allows an explicit zero authorizer for escrow deferred, ignoring the advertisement", func(t *testing.T) { + result, err := enhance(unsignedScheme(), + route(map[string]interface{}{"receiverAuthorizer": authcapture.ZeroAddress, "captureMode": "deferred"}), + kind(map[string]interface{}{"receiverAuthorizer": facilitatorAuthorizer})) + require.NoError(t, err) + assert.Equal(t, authcapture.ZeroAddress, result.Extra["receiverAuthorizer"]) + }) + + t.Run("rejects an explicit zero authorizer on escrow sync and on authorization", func(t *testing.T) { + _, err := enhance(unsignedScheme(), route(map[string]interface{}{"receiverAuthorizer": authcapture.ZeroAddress}), kind(nil)) + require.ErrorContains(t, err, "non-zero receiverAuthorizer") + _, err = enhance(unsignedScheme(), + route(map[string]interface{}{"receiverAuthorizer": authcapture.ZeroAddress, "paymentFlow": "authorization"}), kind(nil)) + require.ErrorContains(t, err, "non-zero receiverAuthorizer") + }) + + t.Run("keeps the signer's conflict check against a different route authorizer", func(t *testing.T) { + _, err := enhance(signedScheme(), route(map[string]interface{}{"receiverAuthorizer": other}), kind(nil)) + require.ErrorContains(t, err, ErrReceiverAuthorizerMismatch) + }) + + t.Run("delegates an authorization route to the facilitator authorizer", func(t *testing.T) { + result, err := enhance(unsignedScheme(), + route(map[string]interface{}{"paymentFlow": "authorization"}), + kind(map[string]interface{}{"receiverAuthorizer": facilitatorAuthorizer})) + require.NoError(t, err) + assert.Equal(t, facilitatorAuthorizer, result.Extra["receiverAuthorizer"]) + assert.Equal(t, "authorization", result.Extra["paymentFlow"]) + }) + + t.Run("ignores a zero facilitator advertisement", func(t *testing.T) { + _, err := enhance(unsignedScheme(), route(nil), kind(map[string]interface{}{"receiverAuthorizer": authcapture.ZeroAddress})) + require.ErrorContains(t, err, "has no receiverAuthorizer") + }) +} diff --git a/go/mechanisms/evm/auth-capture/server/scheme.go b/go/mechanisms/evm/auth-capture/server/scheme.go new file mode 100644 index 0000000000..2063faa587 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/server/scheme.go @@ -0,0 +1,355 @@ +// Package server implements the resource-server role of the EVM auth-capture scheme: it +// advertises the escrow and authorization payment flows, signs the receiver-authorizer +// Capture/Void/Charge/Refund EIP-712 messages the facilitator relays onchain, and records +// authorized payments so a deferred capture, void or refund can happen after the request. +package server + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +// DefaultCaptureDeadline is how far in the future authorizationExpiry is set +// when Config.CaptureDeadline is zero. +const DefaultCaptureDeadline = 10 * time.Minute + +// DefaultRefundDeadline is how far in the future refundExpiry is set when +// Config.RefundDeadline is zero. +const DefaultRefundDeadline = 24 * time.Hour + +// Config configures the server-side EVM auth-capture scheme. +type Config struct { + // ReceiverAuthorizerSigner signs the Capture, Void, Charge and Refund messages that let the + // facilitator release funds. Without it a route's receiverAuthorizer is delegated to the + // facilitator, which signs the unsigned payloads after authenticating the caller, or is the + // zero address for a collect-only route. + ReceiverAuthorizerSigner evm.ClientEvmSigner + + // CollectOnlyRoutes skips the startup check that the facilitator advertises + // extra.receiverAuthorizer when no ReceiverAuthorizerSigner is configured. Use it only when + // every route is collect-only (escrow + deferred + explicit receiverAuthorizer of the zero address). + CollectOnlyRoutes bool + + // CaptureAuthorizer is the default escrow operator for every route, which a route's own + // extra.captureAuthorizer overrides. A delegated route falls back to the facilitator's + // advertised operator, a custom route has no fallback. + CaptureAuthorizer string + + // FeeRecipient receives the fee; empty uses the facilitator's advertised one. + FeeRecipient string + + // MinFeeBps and MaxFeeBps bound the fee; nil uses the facilitator's advertised value, + // else 0. With no fee terms the recipient is the zero address and both bounds are 0. + MinFeeBps *uint16 + MaxFeeBps *uint16 + + // CaptureDeadline and RefundDeadline are how long after issuing requirements capture and + // refund stay possible onchain, used when a route sets no deadlines; zero selects the defaults. + CaptureDeadline time.Duration + RefundDeadline time.Duration + + // Policy is an optional policy contract bound into the payment's salt. + Policy string + + // AuthCaptureEscrow optionally pins a commerce-payments deployment (v1.0 or v1.1 escrow address). + AuthCaptureEscrow string + + // Storage records authorized payments for later capture, void and refund; nil keeps them in memory. + Storage AuthorizedPaymentStorage +} + +// AuthCaptureEvmScheme implements SchemeNetworkServer for EVM auth-capture payments. +type AuthCaptureEvmScheme struct { + moneyParsers []x402.MoneyParser + config *Config + storage AuthorizedPaymentStorage +} + +// NewAuthCaptureEvmScheme creates a new AuthCaptureEvmScheme. +func NewAuthCaptureEvmScheme(config *Config) *AuthCaptureEvmScheme { + if config == nil { + config = &Config{} + } + storage := config.Storage + if storage == nil { + storage = NewInMemoryAuthorizedPaymentStorage() + } + return &AuthCaptureEvmScheme{ + moneyParsers: []x402.MoneyParser{}, + config: config, + storage: storage, + } +} + +// Scheme returns the scheme identifier. +func (s *AuthCaptureEvmScheme) Scheme() string { + return authcapture.SchemeAuthCapture +} + +// DefaultAssetTransferMethod returns the ATM used when extra.assetTransferMethod is absent. +func (s *AuthCaptureEvmScheme) DefaultAssetTransferMethod() string { + return string(evm.AssetTransferMethodEIP3009) +} + +// DynamicExtraFields lists the extra keys regenerated on every 402, so a payment signed against an +// earlier 402 still matches the route. +func (s *AuthCaptureEvmScheme) DynamicExtraFields() []string { + return []string{"captureDeadline", "refundDeadline"} +} + +// PaymentFlows declares the escrow and authorization flows for both asset transfer methods. +func (s *AuthCaptureEvmScheme) PaymentFlows() map[string]x402.PaymentFlowConfig { + both := x402.PaymentFlowConfig{ + Supported: []x402.PaymentFlowName{x402.PaymentFlowEscrow, x402.PaymentFlowAuthorization}, + Default: x402.PaymentFlowEscrow, + } + return map[string]x402.PaymentFlowConfig{ + string(evm.AssetTransferMethodEIP3009): both, + string(evm.AssetTransferMethodPermit2): both, + } +} + +// Storage returns the authorized-payment storage backend. +func (s *AuthCaptureEvmScheme) Storage() AuthorizedPaymentStorage { + return s.storage +} + +// ValidateFacilitatorSupport fails startup when the facilitator advertises no usable +// captureAuthorizer, or when this server delegates receiver signing but the facilitator does not +// advertise a non-zero receiverAuthorizer. A facilitator that admits custom operators lets a +// server run without a captureAuthorizer, because its routes then name their own operator. +func (s *AuthCaptureEvmScheme) ValidateFacilitatorSupport( + network x402.Network, + supportedKind types.SupportedKind, + _ []string, +) error { + customOperators := supportedKind.Extra["operators"] != nil + if s.config.CaptureAuthorizer == "" && !customOperators { + advertised, _ := supportedKind.Extra["captureAuthorizer"].(string) + if !authcapture.IsNonZeroAddress(advertised) { + return fmt.Errorf( + "no captureAuthorizer is configured and the facilitator does not advertise one for auth-capture on %s", + network, + ) + } + } + + if s.config.ReceiverAuthorizerSigner != nil || s.config.CollectOnlyRoutes { + return nil + } + advertised, _ := supportedKind.Extra["receiverAuthorizer"].(string) + if !authcapture.IsNonZeroAddress(advertised) { + return fmt.Errorf( + "no ReceiverAuthorizerSigner is configured and the facilitator does not advertise a "+ + "receiverAuthorizer on %s. Configure a ReceiverAuthorizerSigner, use a facilitator that "+ + "advertises one, or set CollectOnlyRoutes when every route is collect-only "+ + "(escrow + deferred + extra.receiverAuthorizer of the zero address)", + network, + ) + } + return nil +} + +// RegisterMoneyParser adds a custom money parser, tried in registration order before the default. +func (s *AuthCaptureEvmScheme) RegisterMoneyParser(parser x402.MoneyParser) *AuthCaptureEvmScheme { + s.moneyParsers = append(s.moneyParsers, parser) + return s +} + +// ParsePrice converts a price to an asset amount, returning an AssetAmount as-is. +func (s *AuthCaptureEvmScheme) ParsePrice(price x402.Price, network x402.Network) (x402.AssetAmount, error) { + if priceMap, ok := price.(map[string]interface{}); ok { + if amountVal, hasAmount := priceMap["amount"]; hasAmount { + amountStr, ok := amountVal.(string) + if !ok { + return x402.AssetAmount{}, errors.New(ErrAmountMustBeString) + } + + asset := "" + if assetVal, ok := priceMap["asset"].(string); ok { + asset = assetVal + } + if asset == "" { + return x402.AssetAmount{}, errors.New(ErrNoAssetSpecified) + } + + extra := make(map[string]interface{}) + if extraMap, ok := priceMap["extra"].(map[string]interface{}); ok { + extra = extraMap + } + + return x402.AssetAmount{Amount: amountStr, Asset: asset, Extra: extra}, nil + } + } + + decimalAmount, symbol, err := x402.ParseMoney(price) + if err != nil { + return x402.AssetAmount{}, err + } + + for _, parser := range s.moneyParsers { + result, err := parser(decimalAmount, network) + if err != nil { + continue + } + if result != nil { + return *result, nil + } + } + + return s.defaultMoneyConversion(decimalAmount, network, symbol) +} + +func (s *AuthCaptureEvmScheme) defaultMoneyConversion(amount string, network x402.Network, symbol string) (x402.AssetAmount, error) { + assetInfo, tokenAmount, err := evm.ConvertDefaultMoney(amount, string(network), symbol) + if err != nil { + return x402.AssetAmount{}, err + } + return x402.AssetAmount{ + Asset: assetInfo.Asset, + Amount: tokenAmount, + Extra: assetExtra(assetInfo.Name, assetInfo.Version, assetInfo.AssetTransferMethod, assetInfo.SupportsEip2612), + }, nil +} + +// assetExtra returns the asset-derived extra fields. Permit2-only tokens omit the EIP-712 +// domain because they never sign an EIP-3009 authorization. +func assetExtra(name, version string, method evm.AssetTransferMethod, supportsEip2612 bool) map[string]interface{} { + extra := map[string]interface{}{} + if includesEip712Domain(method, supportsEip2612) { + extra["name"] = name + extra["version"] = version + } + if method != "" { + extra["assetTransferMethod"] = string(method) + } + return extra +} + +func includesEip712Domain(method evm.AssetTransferMethod, supportsEip2612 bool) bool { + return method == "" || supportsEip2612 +} + +// EnhancePaymentRequirements resolves the asset and amount and builds the auth-capture extra. A term +// the route sets wins over Config, which wins over the facilitator's advertised kind, and a route +// that cannot work fails here rather than at the first payment. +func (s *AuthCaptureEvmScheme) EnhancePaymentRequirements( + _ context.Context, + requirements types.PaymentRequirements, + supportedKind types.SupportedKind, + extensionKeys []string, +) (types.PaymentRequirements, error) { + networkStr := string(requirements.Network) + var assetInfo *evm.AssetInfo + var err error + if requirements.Asset != "" { + assetInfo, err = evm.GetAssetInfo(networkStr, requirements.Asset) + } else { + assetInfo, err = evm.GetAssetInfo(networkStr, "") + if err == nil { + requirements.Asset = assetInfo.Address + } + } + if err != nil { + return requirements, fmt.Errorf(ErrNoAssetSpecified+": %w", err) + } + + if requirements.Amount != "" && strings.Contains(requirements.Amount, ".") { + amount, err := evm.ParseAmount(requirements.Amount, assetInfo.Decimals) + if err != nil { + return requirements, fmt.Errorf(ErrFailedToParseAmount+": %w", err) + } + requirements.Amount = amount.String() + } + + route, advertised := requirements.Extra, supportedKind.Extra + extra := overlay(advertised, route) + delete(extra, "operators") + delete(extra, "captureDeadlineSeconds") + delete(extra, "refundDeadlineSeconds") + if _, removed := extra["autoCapture"]; removed { + return requirements, fmt.Errorf("%s: use extra.paymentFlow instead", ErrAutoCaptureRemoved) + } + + terms, err := resolveTerms(extra) + if err != nil { + return requirements, err + } + captureAuthorizer, err := s.resolveCaptureAuthorizer(route, advertised, terms.operatorType) + if err != nil { + return requirements, err + } + if terms.operatorType == authcapture.OperatorTypeCustom { + if err := checkOperatorAdmitted(advertised, captureAuthorizer); err != nil { + return requirements, err + } + } + feeRecipient, minFeeBps, maxFeeBps, err := s.resolveFeeTerms(route, advertised) + if err != nil { + return requirements, err + } + receiverAuthorizer, err := s.resolveReceiverAuthorizer(route, advertised, terms) + if err != nil { + return requirements, err + } + policy, err := routeAddress(route, "policy") + if err != nil { + return requirements, err + } + captureDeadline, refundDeadline, err := s.resolveDeadlines(route, requirements.MaxTimeoutSeconds, time.Now()) + if err != nil { + return requirements, err + } + + extra["captureAuthorizer"] = captureAuthorizer + extra["feeRecipient"] = feeRecipient + extra["minFeeBps"] = minFeeBps + extra["maxFeeBps"] = maxFeeBps + extra["receiverAuthorizer"] = receiverAuthorizer + if policy = firstNonEmpty(policy, s.config.Policy); policy != "" { + extra["policy"] = evm.NormalizeAddress(policy) + } + extra["captureDeadline"] = captureDeadline + extra["refundDeadline"] = refundDeadline + extra["paymentFlow"] = terms.paymentFlow + extra["operatorType"] = terms.operatorType + if terms.captureMode != "" { + extra["captureMode"] = terms.captureMode + } + + if includesEip712Domain(assetInfo.AssetTransferMethod, assetInfo.SupportsEip2612) { + if _, ok := extra["name"]; !ok { + extra["name"] = assetInfo.Name + } + if _, ok := extra["version"]; !ok { + extra["version"] = assetInfo.Version + } + } + + escrow, _ := extra["authCaptureEscrow"].(string) + if escrow == "" { + escrow = s.config.AuthCaptureEscrow + } + deployment := authcapture.ResolveAuthCaptureDeployment(escrow) + if deployment == nil { + return requirements, fmt.Errorf("invalid configured AuthCaptureEscrow: %s", escrow) + } + extra["authCaptureEscrow"] = deployment.Escrow + + for _, key := range extensionKeys { + if value, ok := advertised[key]; ok { + extra[key] = value + } + } + + requirements.Extra = extra + return requirements, nil +} diff --git a/go/mechanisms/evm/auth-capture/server/scheme_test.go b/go/mechanisms/evm/auth-capture/server/scheme_test.go new file mode 100644 index 0000000000..ecc8f6562a --- /dev/null +++ b/go/mechanisms/evm/auth-capture/server/scheme_test.go @@ -0,0 +1,788 @@ +package server + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +type mockSigner struct { + address string + sig []byte + err error + + lastDomain evm.TypedDataDomain + lastTypes map[string][]evm.TypedDataField + lastPrimaryType string + lastMessage map[string]interface{} +} + +func (m *mockSigner) Address() string { return m.address } +func (m *mockSigner) SignTypedData(_ context.Context, domain evm.TypedDataDomain, types map[string][]evm.TypedDataField, primaryType string, message map[string]interface{}) ([]byte, error) { + m.lastDomain = domain + m.lastTypes = types + m.lastPrimaryType = primaryType + m.lastMessage = message + if m.err != nil { + return nil, m.err + } + if m.sig != nil { + return m.sig, nil + } + return []byte{0xde, 0xad, 0xbe, 0xef}, nil +} + +const ( + testNetwork = "eip155:84532" + testCaptureAuthorizer = "0xcccccccccccccccccccccccccccccccccccccccc" + testFeeRecipient = "0x4444444444444444444444444444444444444444" + testPayTo = "0x1234567890123456789012345678901234567890" + testAsset = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" + testPayer = "0x9999999999999999999999999999999999999999" +) + +func mockRequirements(extra map[string]interface{}) types.PaymentRequirements { + future := time.Now().Unix() + 86400 + baseExtra := map[string]interface{}{ + "captureAuthorizer": testCaptureAuthorizer, + "receiverAuthorizer": testSignerAddress, + "captureDeadline": float64(future), + "refundDeadline": float64(future + 86400), + "feeRecipient": testFeeRecipient, + "minFeeBps": float64(0), + "maxFeeBps": float64(100), + "name": "USDC", + "version": "2", + } + for k, v := range extra { + baseExtra[k] = v + } + return types.PaymentRequirements{ + Scheme: authcapture.SchemeAuthCapture, + Network: testNetwork, + Amount: "1000000", + Asset: testAsset, + PayTo: testPayTo, + MaxTimeoutSeconds: 300, + Extra: baseExtra, + } +} + +func eip3009CollectPayload(extra map[string]interface{}) types.PaymentPayload { + requirements := mockRequirements(extra) + return types.PaymentPayload{ + X402Version: 2, + Accepted: requirements, + Payload: map[string]interface{}{ + "authorization": map[string]interface{}{ + "from": testPayer, + "to": authcapture.EIP3009TokenCollectorAddress, + "value": requirements.Amount, + "validAfter": "0", + "validBefore": "1700003600", + "nonce": "0x1111111111111111111111111111111111111111111111111111111111111111", + }, + "signature": "0xdeadbeef", + "salt": "0x2222222222222222222222222222222222222222222222222222222222222222", + "saltNonce": "0x01", + }, + } +} + +func TestValidateFacilitatorSupport(t *testing.T) { + tests := []struct { + name string + config *Config + supportedKind types.SupportedKind + wantErr string + }{ + { + name: "no signer and the facilitator advertises no receiverAuthorizer", + config: &Config{}, + supportedKind: types.SupportedKind{Extra: map[string]interface{}{"captureAuthorizer": testCaptureAuthorizer}}, + wantErr: "receiverAuthorizer", + }, + { + name: "facilitator advertises capture and receiver authorizers", + config: &Config{}, + supportedKind: types.SupportedKind{Extra: map[string]interface{}{"captureAuthorizer": testCaptureAuthorizer, "receiverAuthorizer": testFacilitatorAddr}}, + }, + { + name: "collect-only routes need no receiverAuthorizer", + config: &Config{CollectOnlyRoutes: true}, + supportedKind: types.SupportedKind{Extra: map[string]interface{}{"captureAuthorizer": testCaptureAuthorizer}}, + }, + { + name: "a zero advertised receiverAuthorizer does not count", + config: &Config{}, + supportedKind: types.SupportedKind{Extra: map[string]interface{}{"captureAuthorizer": testCaptureAuthorizer, "receiverAuthorizer": authcapture.ZeroAddress}}, + wantErr: "receiverAuthorizer", + }, + { + name: "facilitator omits captureAuthorizer", + config: &Config{}, + supportedKind: types.SupportedKind{Extra: map[string]interface{}{"receiverAuthorizer": testFacilitatorAddr}}, + wantErr: "captureAuthorizer", + }, + { + name: "local captureAuthorizer configured", + config: &Config{ReceiverAuthorizerSigner: &mockSigner{}, CaptureAuthorizer: testCaptureAuthorizer}, + }, + { + name: "facilitator-advertised captureAuthorizer", + config: &Config{ReceiverAuthorizerSigner: &mockSigner{}}, + supportedKind: types.SupportedKind{Extra: map[string]interface{}{"captureAuthorizer": testCaptureAuthorizer}}, + }, + { + name: "neither side supplies a captureAuthorizer", + config: &Config{ReceiverAuthorizerSigner: &mockSigner{}}, + wantErr: "no captureAuthorizer is configured", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(test.config) + err := scheme.ValidateFacilitatorSupport(testNetwork, test.supportedKind, nil) + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + return + } + require.NoError(t, err) + }) + } +} + +func newTestScheme(signer *mockSigner) *AuthCaptureEvmScheme { + return NewAuthCaptureEvmScheme(&Config{ReceiverAuthorizerSigner: signer}) +} + +func TestEnhancePaymentRequirements_NoAuthorizer(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&Config{CaptureAuthorizer: testCaptureAuthorizer}) + _, err := scheme.EnhancePaymentRequirements(context.Background(), routeRequirements(nil), types.SupportedKind{}, nil) + require.ErrorContains(t, err, ErrMissingReceiverAuthorizer) +} + +func TestEnhancePaymentRequirements_ResolvesLocalConfigFirst(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + minFee := uint16(5) + maxFee := uint16(50) + scheme := NewAuthCaptureEvmScheme(&Config{ + ReceiverAuthorizerSigner: signer, + CaptureAuthorizer: testCaptureAuthorizer, + FeeRecipient: testFeeRecipient, + MinFeeBps: &minFee, + MaxFeeBps: &maxFee, + }) + + requirements := mockRequirements(nil) + requirements.Extra = map[string]interface{}{} + supportedKind := types.SupportedKind{Extra: map[string]interface{}{ + "captureAuthorizer": "0xffffffffffffffffffffffffffffffffffffffff", + "feeRecipient": "0xffffffffffffffffffffffffffffffffffffffff", + "minFeeBps": float64(1), + "maxFeeBps": float64(1), + }} + + enhanced, err := scheme.EnhancePaymentRequirements(context.Background(), requirements, supportedKind, nil) + require.NoError(t, err) + + assert.Equal(t, evm.NormalizeAddress(testCaptureAuthorizer), enhanced.Extra["captureAuthorizer"]) + assert.Equal(t, evm.NormalizeAddress(testFeeRecipient), enhanced.Extra["feeRecipient"]) + assert.Equal(t, uint16(5), enhanced.Extra["minFeeBps"]) + assert.Equal(t, uint16(50), enhanced.Extra["maxFeeBps"]) + assert.Equal(t, evm.NormalizeAddress(signer.address), enhanced.Extra["receiverAuthorizer"]) + assert.Equal(t, "escrow", enhanced.Extra["paymentFlow"]) + assert.Equal(t, "sync", enhanced.Extra["captureMode"]) + assert.Equal(t, "delegated", enhanced.Extra["operatorType"]) + assert.Equal(t, "USDC", enhanced.Extra["name"]) + assert.Equal(t, "2", enhanced.Extra["version"]) + assert.NotEmpty(t, enhanced.Extra["authCaptureEscrow"]) + assert.NotZero(t, enhanced.Extra["captureDeadline"]) + assert.NotZero(t, enhanced.Extra["refundDeadline"]) +} + +func TestEnhancePaymentRequirements_FallsBackToFacilitatorAdvertisement(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := NewAuthCaptureEvmScheme(&Config{ReceiverAuthorizerSigner: signer}) + + requirements := mockRequirements(nil) + requirements.Extra = map[string]interface{}{} + supportedKind := types.SupportedKind{Extra: map[string]interface{}{ + "captureAuthorizer": testCaptureAuthorizer, + "feeRecipient": testFeeRecipient, + "minFeeBps": float64(2), + "maxFeeBps": float64(20), + }} + + enhanced, err := scheme.EnhancePaymentRequirements(context.Background(), requirements, supportedKind, nil) + require.NoError(t, err) + + assert.Equal(t, evm.NormalizeAddress(testCaptureAuthorizer), enhanced.Extra["captureAuthorizer"]) + assert.Equal(t, evm.NormalizeAddress(testFeeRecipient), enhanced.Extra["feeRecipient"]) + assert.Equal(t, uint16(2), enhanced.Extra["minFeeBps"]) + assert.Equal(t, uint16(20), enhanced.Extra["maxFeeBps"]) +} + +func TestEnhancePaymentRequirements_MissingCaptureAuthorizer(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := NewAuthCaptureEvmScheme(&Config{ReceiverAuthorizerSigner: signer}) + + requirements := mockRequirements(nil) + requirements.Extra = map[string]interface{}{} + + _, err := scheme.EnhancePaymentRequirements(context.Background(), requirements, types.SupportedKind{}, nil) + require.ErrorContains(t, err, ErrMissingCaptureAuthorizer) +} + +func TestEnhancePaymentRequirements_NoFeeTermsPublishesZeroRecipient(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&Config{ + ReceiverAuthorizerSigner: &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + CaptureAuthorizer: testCaptureAuthorizer, + }) + requirements := mockRequirements(nil) + requirements.Extra = map[string]interface{}{} + + enhanced, err := scheme.EnhancePaymentRequirements(context.Background(), requirements, types.SupportedKind{}, nil) + require.NoError(t, err) + + assert.Equal(t, authcapture.ZeroAddress, enhanced.Extra["feeRecipient"]) + assert.Equal(t, uint16(0), enhanced.Extra["minFeeBps"]) + assert.Equal(t, uint16(0), enhanced.Extra["maxFeeBps"]) +} + +func TestEnhancePaymentRequirements_ConfiguredRecipientDefaultsToNoFee(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&Config{ + ReceiverAuthorizerSigner: &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + CaptureAuthorizer: testCaptureAuthorizer, + FeeRecipient: testFeeRecipient, + }) + requirements := mockRequirements(nil) + requirements.Extra = map[string]interface{}{} + + enhanced, err := scheme.EnhancePaymentRequirements(context.Background(), requirements, types.SupportedKind{}, nil) + require.NoError(t, err) + + assert.Equal(t, uint16(0), enhanced.Extra["minFeeBps"]) + assert.Equal(t, uint16(0), enhanced.Extra["maxFeeBps"]) +} + +func TestEnhancePaymentRequirements_InvalidFeeTerms(t *testing.T) { + maxFee := uint16(100) + overMax := uint16(10001) + minFee := uint16(200) + tests := []struct { + name string + config Config + wantErr string + }{ + {name: "non-zero bounds without a recipient", config: Config{MaxFeeBps: &maxFee}, wantErr: ErrMissingFeeRecipient}, + {name: "min above max", config: Config{FeeRecipient: testFeeRecipient, MinFeeBps: &minFee, MaxFeeBps: &maxFee}, wantErr: ErrInvalidFeeTerms}, + {name: "max above 10000", config: Config{FeeRecipient: testFeeRecipient, MaxFeeBps: &overMax}, wantErr: ErrInvalidFeeTerms}, + {name: "malformed recipient", config: Config{FeeRecipient: "not-an-address"}, wantErr: ErrInvalidFeeTerms}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + config := test.config + config.ReceiverAuthorizerSigner = &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + config.CaptureAuthorizer = testCaptureAuthorizer + requirements := mockRequirements(nil) + requirements.Extra = map[string]interface{}{} + + _, err := NewAuthCaptureEvmScheme(&config).EnhancePaymentRequirements(context.Background(), requirements, types.SupportedKind{}, nil) + require.ErrorContains(t, err, test.wantErr) + }) + } +} + +func TestEnhancePaymentRequirements_TimeoutMustFitCaptureDeadline(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&Config{ + ReceiverAuthorizerSigner: &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + CaptureAuthorizer: testCaptureAuthorizer, + CaptureDeadline: time.Minute, + }) + requirements := mockRequirements(nil) + requirements.Extra = map[string]interface{}{} + requirements.MaxTimeoutSeconds = 61 + + _, err := scheme.EnhancePaymentRequirements(context.Background(), requirements, types.SupportedKind{}, nil) + require.ErrorContains(t, err, ErrTimeoutExceedsCaptureDeadline) + + requirements.MaxTimeoutSeconds = 60 + _, err = scheme.EnhancePaymentRequirements(context.Background(), requirements, types.SupportedKind{}, nil) + require.NoError(t, err) +} + +func TestEnhancePaymentRequirements_CopiesThroughExtensionKeys(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := NewAuthCaptureEvmScheme(&Config{ + ReceiverAuthorizerSigner: signer, + CaptureAuthorizer: testCaptureAuthorizer, + FeeRecipient: testFeeRecipient, + }) + + requirements := mockRequirements(nil) + requirements.Extra = map[string]interface{}{} + supportedKind := types.SupportedKind{Extra: map[string]interface{}{"someExtensionField": "value"}} + + enhanced, err := scheme.EnhancePaymentRequirements(context.Background(), requirements, supportedKind, []string{"someExtensionField"}) + require.NoError(t, err) + + assert.Equal(t, "value", enhanced.Extra["someExtensionField"]) +} + +func TestEnhancePaymentRequirements_ParsesDollarAmount(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := NewAuthCaptureEvmScheme(&Config{ + ReceiverAuthorizerSigner: signer, + CaptureAuthorizer: testCaptureAuthorizer, + FeeRecipient: testFeeRecipient, + }) + + requirements := mockRequirements(nil) + requirements.Extra = map[string]interface{}{} + requirements.Amount = "1.5" + + enhanced, err := scheme.EnhancePaymentRequirements(context.Background(), requirements, types.SupportedKind{}, nil) + require.NoError(t, err) + assert.Equal(t, "1500000", enhanced.Amount) +} + +func TestEnrichSettlementPayload_BeforeHandlerNoOp(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := newTestScheme(signer) + + fields, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: eip3009CollectPayload(nil), + Requirements: mockRequirements(nil), + Phase: x402.SettlePhaseBeforeHandler, + }) + require.NoError(t, err) + assert.Nil(t, fields) +} + +// mergeEnrichment applies the core's additive settlement-payload policy and merge. +func mergeEnrichment(t *testing.T, payload types.PaymentPayload, enrichment map[string]interface{}) map[string]interface{} { + t.Helper() + require.NoError(t, x402.AssertAdditivePayloadEnrichment(payload.Payload, enrichment, "auth-capture")) + merged := map[string]interface{}{} + for k, v := range payload.Payload { + merged[k] = v + } + for k, v := range enrichment { + merged[k] = v + } + return merged +} + +func TestEnrichSettlementPayload_AfterHandlerSignsCapture(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := newTestScheme(signer) + requirements := mockRequirements(nil) + + fields, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: eip3009CollectPayload(nil), + Requirements: requirements, + Phase: x402.SettlePhaseAfterHandler, + }) + require.NoError(t, err) + + assert.True(t, authcapture.IsCapturePayload(mergeEnrichment(t, eip3009CollectPayload(nil), fields))) + assert.Equal(t, "capture", fields["type"]) + assert.Equal(t, requirements.Amount, fields["amount"]) + assert.Equal(t, requirements.Amount, fields["expectedCapturableAmount"]) + assert.Equal(t, "0", fields["expectedRefundableAmount"]) + assert.Equal(t, "0xdeadbeef", fields["authorizerSignature"]) + assert.NotContains(t, fields, "voidAuthorizerSignature") + + // v1.1 is the default deployment: feeAmount (absolute), not feeBps. + assert.Contains(t, fields, "feeAmount") + assert.NotContains(t, fields, "feeBps") + + assert.Equal(t, "Capture", signer.lastPrimaryType) + assert.Equal(t, authcapture.OperatorEIP712Domain.Name, signer.lastDomain.Name) + assert.Equal(t, authcapture.OperatorEIP712Domain.Version, signer.lastDomain.Version) + assert.Equal(t, evm.NormalizeAddress(testCaptureAuthorizer), signer.lastDomain.VerifyingContract) + assert.Equal(t, requirements.Amount, signer.lastMessage["amount"].(*big.Int).String()) + assert.Equal(t, evm.NormalizeAddress(testFeeRecipient), signer.lastMessage["feeReceiver"]) +} + +func TestEnrichSettlementPayload_AfterHandlerV1_0UsesFeeBps(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := newTestScheme(signer) + requirements := mockRequirements(map[string]interface{}{ + "authCaptureEscrow": authcapture.AuthCaptureEscrowV1_0Address, + "minFeeBps": float64(25), + }) + + fields, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: eip3009CollectPayload(map[string]interface{}{"authCaptureEscrow": authcapture.AuthCaptureEscrowV1_0Address}), + Requirements: requirements, + Phase: x402.SettlePhaseAfterHandler, + }) + require.NoError(t, err) + + assert.Contains(t, fields, "feeBps") + assert.NotContains(t, fields, "feeAmount") + assert.Equal(t, uint16(25), fields["feeBps"]) + assert.Equal(t, "Capture", signer.lastPrimaryType) + assert.Contains(t, signer.lastMessage, "feeBps") +} + +func TestEnrichSettlementPayload_CancelSignsVoid(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := newTestScheme(signer) + requirements := mockRequirements(nil) + + fields, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: eip3009CollectPayload(nil), + Requirements: requirements, + Phase: x402.SettlePhaseCancel, + }) + require.NoError(t, err) + + assert.True(t, authcapture.IsVoidPayload(mergeEnrichment(t, eip3009CollectPayload(nil), fields))) + assert.Equal(t, "void", fields["type"]) + assert.NotEmpty(t, fields["authorizerSignature"]) + assert.Equal(t, "Void", signer.lastPrimaryType) + assert.Equal(t, evm.NormalizeAddress(testCaptureAuthorizer), signer.lastDomain.VerifyingContract) + assert.Len(t, signer.lastMessage, 1) + assert.Contains(t, signer.lastMessage, "paymentInfoHash") +} + +func TestEnrichSettlementPayload_InvalidCollectPayload(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := newTestScheme(signer) + + badPayload := types.PaymentPayload{ + X402Version: 2, + Accepted: mockRequirements(nil), + Payload: map[string]interface{}{"foo": "bar"}, + } + + _, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: badPayload, + Requirements: mockRequirements(nil), + Phase: x402.SettlePhaseAfterHandler, + }) + require.ErrorContains(t, err, ErrInvalidCollectPayload) +} + +func TestEnrichSettlementPayload_SignerError(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", err: assert.AnError} + scheme := newTestScheme(signer) + + _, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: eip3009CollectPayload(nil), + Requirements: mockRequirements(nil), + Phase: x402.SettlePhaseAfterHandler, + }) + require.ErrorContains(t, err, ErrFailedToSignCapture) +} + +func TestSettleOnCancel(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := newTestScheme(signer) + requirements := mockRequirements(nil) + + tests := []struct { + name string + reason x402.VerifiedPaymentCancellationReason + wantResult bool + }{ + {name: "handler failed", reason: x402.CancellationReasonHandlerFailed, wantResult: true}, + {name: "handler threw", reason: x402.CancellationReasonHandlerThrew, wantResult: true}, + {name: "after verify aborted", reason: x402.CancellationReasonAfterVerifyAborted, wantResult: true}, + {name: "unknown reason", reason: "something_else", wantResult: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := scheme.SettleOnCancel(x402.VerifiedPaymentCanceledContext{ + SettleContext: x402.SettleContext{Requirements: requirements}, + Reason: test.reason, + }) + require.NoError(t, err) + if !test.wantResult { + assert.Nil(t, result) + return + } + require.NotNil(t, result) + assert.Equal(t, requirements.Amount, result.Amount) + assert.Equal(t, requirements.PayTo, result.PayTo) + }) + } +} + +func TestParsePrice(t *testing.T) { + scheme := NewAuthCaptureEvmScheme(&Config{}) + network := x402.Network("eip155:84532") + + t.Run("asset amount passes through", func(t *testing.T) { + got, err := scheme.ParsePrice(map[string]interface{}{"amount": "42", "asset": "0xasset"}, network) + require.NoError(t, err) + assert.Equal(t, "42", got.Amount) + assert.Equal(t, "0xasset", got.Asset) + }) + + t.Run("asset amount needs a string amount and an asset", func(t *testing.T) { + _, err := scheme.ParsePrice(map[string]interface{}{"amount": 42, "asset": "0xasset"}, network) + assert.EqualError(t, err, ErrAmountMustBeString) + _, err = scheme.ParsePrice(map[string]interface{}{"amount": "42"}, network) + assert.EqualError(t, err, ErrNoAssetSpecified) + }) + + t.Run("dollar amount uses the network default asset", func(t *testing.T) { + got, err := scheme.ParsePrice("$0.50", network) + require.NoError(t, err) + assert.Equal(t, "500000", got.Amount) + assert.NotEmpty(t, got.Asset) + }) + + t.Run("a registered parser wins over the default", func(t *testing.T) { + custom := &x402.AssetAmount{Amount: "7", Asset: "0xcustom"} + scheme.RegisterMoneyParser(func(string, x402.Network) (*x402.AssetAmount, error) { return custom, nil }) + got, err := scheme.ParsePrice("$1", network) + require.NoError(t, err) + assert.Equal(t, *custom, got) + }) +} + +func TestEnhancePaymentRequirements_DeadlinesAreRelativeToIssueTime(t *testing.T) { + newScheme := func(capture, refund time.Duration) *AuthCaptureEvmScheme { + return NewAuthCaptureEvmScheme(&Config{ + ReceiverAuthorizerSigner: &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + CaptureAuthorizer: testCaptureAuthorizer, + CaptureDeadline: capture, + RefundDeadline: refund, + }) + } + requirements := mockRequirements(nil) + requirements.Extra = map[string]interface{}{} + + enhanced, err := newScheme(0, 0).EnhancePaymentRequirements(context.Background(), requirements, types.SupportedKind{}, nil) + require.NoError(t, err) + assertBucketed(t, enhanced.Extra["captureDeadline"], uint64(DefaultCaptureDeadline/time.Second)) + assertBucketed(t, enhanced.Extra["refundDeadline"], uint64(DefaultRefundDeadline/time.Second)) + + _, err = newScheme(time.Hour, time.Minute).EnhancePaymentRequirements(context.Background(), requirements, types.SupportedKind{}, nil) + require.ErrorContains(t, err, ErrRefundBeforeCaptureDeadline) +} + +// assertBucketed checks a deadline is the start of the current (or just-ended) minute plus offset. +func assertBucketed(t *testing.T, deadline interface{}, offset uint64) { + t.Helper() + got, ok := deadline.(uint64) + require.True(t, ok, "deadline is %T", deadline) + start := got - offset + assert.Zero(t, start%deadlineBucketSeconds) + now := uint64(time.Now().Unix()) + assert.True(t, start <= now && now-start < 2*deadlineBucketSeconds, "bucket start %d is not within a minute of %d", start, now) +} + +func TestEnrichSettlementPayload_PartialCaptureVoidsRemainder(t *testing.T) { + signer := &mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + scheme := newTestScheme(signer) + payload := eip3009CollectPayload(nil) + override := mockRequirements(nil) + override.Amount = "400000" + + fields, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: payload, + Requirements: override, + Phase: x402.SettlePhaseAfterHandler, + }) + require.NoError(t, err) + + assert.True(t, authcapture.IsCapturePayload(mergeEnrichment(t, payload, fields))) + assert.Equal(t, "400000", fields["amount"]) + assert.Equal(t, "1000000", fields["expectedCapturableAmount"]) + assert.Equal(t, "0", fields["expectedRefundableAmount"]) + assert.Equal(t, "0xdeadbeef", fields["voidAuthorizerSignature"]) + assert.Equal(t, "1000000", fields["paymentInfo"].(map[string]interface{})["maxAmount"]) + assert.Equal(t, "Void", signer.lastPrimaryType) +} + +func TestEnrichSettlementPayload_PartialCaptureFeeFollowsCaptureAmount(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}) + override := mockRequirements(map[string]interface{}{"minFeeBps": float64(100)}) + override.Amount = "400000" + + fields, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: eip3009CollectPayload(nil), + Requirements: override, + Phase: x402.SettlePhaseAfterHandler, + }) + require.NoError(t, err) + + assert.Equal(t, "4000", fields["feeAmount"]) +} + +func TestEnrichSettlementPayload_CaptureAmountMustFitAuthorizedHold(t *testing.T) { + for _, amount := range []string{"1000001", "0", "abc"} { + t.Run(amount, func(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}) + override := mockRequirements(nil) + override.Amount = amount + + _, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: eip3009CollectPayload(nil), + Requirements: override, + Phase: x402.SettlePhaseAfterHandler, + }) + require.ErrorContains(t, err, ErrInvalidCaptureAmount) + }) + } +} + +func TestEnrichSettlementPayload_CancelAfterAmountOverrideUsesAuthorizedHold(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}) + override := mockRequirements(nil) + override.Amount = "400000" + + fields, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: eip3009CollectPayload(nil), + Requirements: override, + Phase: x402.SettlePhaseCancel, + }) + require.NoError(t, err) + assert.Equal(t, "1000000", fields["paymentInfo"].(map[string]interface{})["maxAmount"]) +} + +func TestCollectPayloadFields_Permit2UsesPermittedAmount(t *testing.T) { + fields, err := collectPayloadFields(map[string]interface{}{ + "permit2Authorization": map[string]interface{}{ + "from": testPayer, + "spender": authcapture.Permit2TokenCollectorAddress, + "nonce": "1", + "deadline": "1700003600", + "permitted": map[string]interface{}{"token": testAsset, "amount": "750000"}, + }, + "signature": "0xdeadbeef", + "salt": "0x22", + "saltNonce": "0x01", + }) + require.NoError(t, err) + assert.Equal(t, "750000", fields.authorizedAmount) + assert.Equal(t, uint64(1700003600), fields.preApprovalExpiry) + assert.Equal(t, testPayer, fields.payer) +} + +func TestEnhancePaymentRequirements_MerchantExtraWinsOverFacilitatorKind(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}) + requirements := mockRequirements(map[string]interface{}{"minFeeBps": float64(3), "maxFeeBps": float64(30)}) + supportedKind := types.SupportedKind{Extra: map[string]interface{}{ + "captureAuthorizer": "0xffffffffffffffffffffffffffffffffffffffff", + "feeRecipient": "0xffffffffffffffffffffffffffffffffffffffff", + "minFeeBps": float64(1), + "maxFeeBps": float64(1), + "name": "Facilitator Name", + }} + + enhanced, err := scheme.EnhancePaymentRequirements(context.Background(), requirements, supportedKind, nil) + require.NoError(t, err) + + assert.Equal(t, evm.NormalizeAddress(testCaptureAuthorizer), enhanced.Extra["captureAuthorizer"]) + assert.Equal(t, evm.NormalizeAddress(testFeeRecipient), enhanced.Extra["feeRecipient"]) + assert.Equal(t, uint16(3), enhanced.Extra["minFeeBps"]) + assert.Equal(t, uint16(30), enhanced.Extra["maxFeeBps"]) + assert.Equal(t, "USDC", enhanced.Extra["name"]) +} + +func TestAssetDerivedExtra(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}) + const ( + permit2Only = x402.Network("eip155:38833") + permit2With = x402.Network("eip155:4326") + eip3009Asset = x402.Network("eip155:84532") + ) + + t.Run("eip3009 default asset carries the domain only", func(t *testing.T) { + got, err := scheme.ParsePrice("$1", eip3009Asset) + require.NoError(t, err) + assert.Contains(t, got.Extra, "name") + assert.Contains(t, got.Extra, "version") + assert.NotContains(t, got.Extra, "assetTransferMethod") + }) + + t.Run("permit2 asset without eip2612 publishes the method and no domain", func(t *testing.T) { + got, err := scheme.ParsePrice("$1", permit2Only) + require.NoError(t, err) + assert.Equal(t, "permit2", got.Extra["assetTransferMethod"]) + assert.NotContains(t, got.Extra, "name") + assert.NotContains(t, got.Extra, "version") + }) + + t.Run("permit2 asset with eip2612 publishes the method and the domain", func(t *testing.T) { + got, err := scheme.ParsePrice("$1", permit2With) + require.NoError(t, err) + assert.Equal(t, "permit2", got.Extra["assetTransferMethod"]) + assert.Contains(t, got.Extra, "name") + assert.Contains(t, got.Extra, "version") + }) + + t.Run("enhance leaves the domain off a permit2-only asset", func(t *testing.T) { + requirements := mockRequirements(nil) + requirements.Network = string(permit2Only) + requirements.Asset = "" + requirements.Extra = map[string]interface{}{"assetTransferMethod": "permit2"} + supportedKind := types.SupportedKind{Extra: map[string]interface{}{ + "captureAuthorizer": testCaptureAuthorizer, + "feeRecipient": testFeeRecipient, + "minFeeBps": float64(0), + "maxFeeBps": float64(100), + }} + + enhanced, err := scheme.EnhancePaymentRequirements(context.Background(), requirements, supportedKind, nil) + require.NoError(t, err) + assert.Equal(t, "permit2", enhanced.Extra["assetTransferMethod"]) + assert.NotContains(t, enhanced.Extra, "name") + assert.NotContains(t, enhanced.Extra, "version") + }) +} + +func TestPermit2OnlyAsset_WithoutTokenDomain(t *testing.T) { + scheme := newTestScheme(&mockSigner{address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}) + requirements := mockRequirements(map[string]interface{}{"assetTransferMethod": "permit2"}) + delete(requirements.Extra, "name") + delete(requirements.Extra, "version") + + fields, err := scheme.EnrichSettlementPayload(x402.SettleContext{ + Ctx: context.Background(), + Payload: types.PaymentPayload{X402Version: 2, Accepted: requirements, Payload: map[string]interface{}{ + "permit2Authorization": map[string]interface{}{ + "from": testPayer, + "spender": authcapture.Permit2TokenCollectorAddress, + "nonce": "1", + "deadline": "1700003600", + "permitted": map[string]interface{}{"token": testAsset, "amount": requirements.Amount}, + }, + "signature": "0xdeadbeef", + "salt": "0x2222222222222222222222222222222222222222222222222222222222222222", + "saltNonce": "0x01", + }}, + Requirements: requirements, + Phase: x402.SettlePhaseAfterHandler, + }) + require.NoError(t, err) + assert.Equal(t, "capture", fields["type"]) +} diff --git a/go/mechanisms/evm/auth-capture/server/settlement_hooks.go b/go/mechanisms/evm/auth-capture/server/settlement_hooks.go new file mode 100644 index 0000000000..a856936b98 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/server/settlement_hooks.go @@ -0,0 +1,492 @@ +package server + +import ( + "context" + "fmt" + "math/big" + "strconv" + "time" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + "github.com/x402-foundation/x402/go/v2/types" +) + +// settleRequest is what every settle hook derives from the client's collect payload and the +// effective requirements. Its paymentInfo is bound to the amount the payer signed, which a +// settlement amount override may undercut. +type settleRequest struct { + requirements types.PaymentRequirements + extra authcapture.AuthCaptureExtra + deployment authcapture.AuthCaptureDeployment + chainID *big.Int + collect collectFields + tokenCollector string + paymentInfo authcapture.PaymentInfoStruct + paymentInfoHash string + signedAmount *big.Int +} + +func (s *AuthCaptureEvmScheme) newSettleRequest(ctx x402.SettleContext) (*settleRequest, error) { + requirements := requirementsFromView(ctx.Requirements) + extra, deployment, err := authcapture.ParseAuthCaptureExtra(requirements) + if err != nil { + return nil, fmt.Errorf(ErrInvalidCollectPayload+": %w", err) + } + chainID, err := evm.GetEvmChainId(requirements.Network) + if err != nil { + return nil, err + } + collect, err := collectPayloadFields(ctx.Payload.GetPayload()) + if err != nil { + return nil, fmt.Errorf(ErrInvalidCollectPayload+": %w", err) + } + signedAmount, ok := new(big.Int).SetString(collect.authorizedAmount, 10) + if !ok || signedAmount.Sign() <= 0 { + return nil, fmt.Errorf(ErrInvalidCollectPayload+": invalid authorized amount %s", collect.authorizedAmount) + } + + signed := requirements + signed.Amount = collect.authorizedAmount + paymentInfo := authcapture.ReconstructPaymentInfo(collect.payer, collect.preApprovalExpiry, collect.salt, signed, extra) + hash, err := authcapture.ComputePaymentInfoHash(chainID, paymentInfo, collect.payer, deployment.Escrow) + if err != nil { + return nil, err + } + tokenCollector := deployment.EIP3009Collector + if collect.permit2 { + tokenCollector = deployment.Permit2Collector + } + return &settleRequest{ + requirements: requirements, + extra: extra, + deployment: deployment, + chainID: chainID, + collect: collect, + tokenCollector: tokenCollector, + paymentInfo: paymentInfo, + paymentInfoHash: hash, + signedAmount: signedAmount, + }, nil +} + +// settlementAmount is the amount this settle moves: the requirements' amount, which a settlement +// override sets, and which must fit within what the payer signed. +func (r *settleRequest) settlementAmount(limit *big.Int) (*big.Int, error) { + amount, ok := new(big.Int).SetString(r.requirements.Amount, 10) + if !ok || amount.Sign() <= 0 || amount.Cmp(limit) > 0 { + return nil, fmt.Errorf(ErrInvalidCaptureAmount+": amount %s must be > 0 and <= %s", r.requirements.Amount, limit) + } + return amount, nil +} + +// SettleOnCancel voids the escrow hold when a verified payment is canceled before the handler +// completes, releasing funds without waiting for onchain expiry. A custom operator's hold is its +// own contract's to release, and the authorization flow holds nothing. +func (s *AuthCaptureEvmScheme) SettleOnCancel(ctx x402.VerifiedPaymentCanceledContext) (*types.PaymentRequirements, error) { + switch ctx.Reason { + case x402.CancellationReasonHandlerFailed, + x402.CancellationReasonHandlerThrew, + x402.CancellationReasonAfterVerifyAborted: + default: + return nil, nil + } + requirements := requirementsFromView(ctx.Requirements) + extra, _, err := authcapture.ParseAuthCaptureExtra(requirements) + if err != nil { + return nil, fmt.Errorf(ErrInvalidCollectPayload+": %w", err) + } + if extra.PaymentFlow == authcapture.PaymentFlowAuthorization || + extra.OperatorType == authcapture.OperatorTypeCustom || + authcapture.AuthorizerModeFor(extra.ReceiverAuthorizer, s.config.ReceiverAuthorizerSigner) == authcapture.AuthorizerModeCollectOnly { + return nil, nil + } + return &requirements, nil +} + +// EnrichSettlementPayload adds the receiver-authorizer fields the facilitator needs: none for +// authorize, a Charge completing the authorization flow, a Capture after the handler (plus a +// Void of the remainder on a partial capture), and a Void on cancel. A deferred route adds +// nothing after the handler because its capture happens later through the lifecycle manager. +// Without a local signer for the route's receiverAuthorizer (delegated to the facilitator) the +// enrichments carry no signatures, and a collect-only route gets no enrichment. +func (s *AuthCaptureEvmScheme) EnrichSettlementPayload(ctx x402.SettleContext) (map[string]interface{}, error) { + if ctx.Phase == x402.SettlePhaseBeforeHandler { + return nil, nil + } + request, err := s.newSettleRequest(ctx) + if err != nil { + return nil, err + } + + // Delegated mode builds the same payloads without signatures; the facilitator signs them. + mode := authcapture.AuthorizerModeFor(request.extra.ReceiverAuthorizer, s.config.ReceiverAuthorizerSigner) + var signer evm.ClientEvmSigner + switch mode { + case authcapture.AuthorizerModeCollectOnly: + return nil, nil + case authcapture.AuthorizerModeSelf: + signer = s.config.ReceiverAuthorizerSigner + case authcapture.AuthorizerModeDelegated: + default: + return nil, fmt.Errorf("unexpected authorizer mode %s", mode) + } + + switch { + case ctx.Phase == x402.SettlePhaseCancel: + return s.voidEnrichment(ctx.Ctx, signer, request) + case request.extra.PaymentFlow == authcapture.PaymentFlowAuthorization: + return s.chargeEnrichment(ctx.Ctx, signer, request) + case request.extra.CaptureMode == authcapture.CaptureModeDeferred: + return nil, nil + default: + return s.captureEnrichment(ctx.Ctx, signer, request) + } +} + +// voidEnrichment builds the cancel Void, signed when a local signer is given. +func (s *AuthCaptureEvmScheme) voidEnrichment(ctx context.Context, signer evm.ClientEvmSigner, request *settleRequest) (map[string]interface{}, error) { + paymentInfo, err := request.paymentInfo.ToWireMap() + if err != nil { + return nil, err + } + result := map[string]interface{}{ + "type": "void", + "paymentInfo": paymentInfo, + } + if signer == nil { + return result, nil + } + signature, err := authcapture.SignVoid(ctx, signer, request.extra.CaptureAuthorizer, request.chainID, request.paymentInfoHash) + if err != nil { + return nil, fmt.Errorf(ErrFailedToSignVoid+": %w", err) + } + result["authorizerSignature"] = evm.BytesToHex(signature) + return result, nil +} + +// chargeEnrichment completes an authorization-flow charge with the settlement amount, the fee at +// the route's minimum, and, when a local signer is given, a Charge signature bound to the +// client's collector data. +func (s *AuthCaptureEvmScheme) chargeEnrichment(ctx context.Context, signer evm.ClientEvmSigner, request *settleRequest) (map[string]interface{}, error) { + amount, err := request.settlementAmount(request.signedAmount) + if err != nil { + return nil, err + } + fee := authcapture.DefaultCaptureFee(&request.deployment, amount, request.extra.MinFeeBps) + result := map[string]interface{}{ + "amount": amount.String(), + "feeReceiver": request.extra.FeeRecipient, + } + fee.AddToWire(result) + if signer == nil { + return result, nil + } + collectorData, err := evm.HexToBytes(request.collect.signature) + if err != nil { + return nil, fmt.Errorf(ErrInvalidCollectPayload+": signature: %w", err) + } + signature, err := authcapture.SignCharge(ctx, signer, &request.deployment, request.extra.CaptureAuthorizer, request.chainID, + authcapture.ChargeParams{ + PaymentInfoHash: request.paymentInfoHash, + Amount: amount, + TokenCollector: request.tokenCollector, + CollectorData: collectorData, + Fee: fee, + FeeReceiver: request.extra.FeeRecipient, + }) + if err != nil { + return nil, fmt.Errorf(ErrFailedToSignCharge+": %w", err) + } + result["authorizerSignature"] = evm.BytesToHex(signature) + return result, nil +} + +// storedBalances returns the capturable and refundable amounts a lifecycle signature must name: +// the stored ones, else the full hold of a payment just collected. +func (s *AuthCaptureEvmScheme) storedBalances(ctx context.Context, request *settleRequest) (*big.Int, *big.Int, error) { + record, err := s.storage.Get(ctx, request.paymentInfoHash) + if err != nil { + return nil, nil, err + } + if record == nil { + return request.signedAmount, new(big.Int), nil + } + capturable, capturableOK := parseBalance(record.CapturableAmount) + refundable, refundableOK := parseBalance(record.RefundableAmount) + if !capturableOK || !refundableOK { + return nil, nil, fmt.Errorf(ErrInvalidLifecycleAmount+": stored balances of %s are unreadable", request.paymentInfoHash) + } + return capturable, refundable, nil +} + +func (s *AuthCaptureEvmScheme) captureEnrichment(ctx context.Context, signer evm.ClientEvmSigner, request *settleRequest) (map[string]interface{}, error) { + capturable, refundable, err := s.storedBalances(ctx, request) + if err != nil { + return nil, err + } + amount, err := request.settlementAmount(capturable) + if err != nil { + return nil, err + } + signed, err := buildCaptureFields(ctx, signer, request.deployment, request.extra, request.chainID, captureTerms{ + paymentInfoHash: request.paymentInfoHash, + amount: amount, + fee: authcapture.DefaultCaptureFee(&request.deployment, amount, request.extra.MinFeeBps), + feeReceiver: request.extra.FeeRecipient, + capturable: capturable, + refundable: refundable, + voidRemainder: amount.Cmp(capturable) < 0, + }) + if err != nil { + return nil, err + } + paymentInfo, err := request.paymentInfo.ToWireMap() + if err != nil { + return nil, err + } + signed["type"] = "capture" + signed["paymentInfo"] = paymentInfo + return signed, nil +} + +// captureTerms are the values a Capture signature binds, and whether to also sign a Void of +// whatever the capture leaves. +type captureTerms struct { + paymentInfoHash string + amount *big.Int + fee authcapture.CaptureFee + feeReceiver string + capturable *big.Int + refundable *big.Int + voidRemainder bool +} + +// buildCaptureFields builds the capture wire fields shared by in-request and manager captures, +// signing the Capture (and a Void when the remainder is released) when a local signer is given. +// Without one (authorizer delegated to the facilitator) the signatures are omitted and +// voidRemainder asks the facilitator to sign the Void leg. +func buildCaptureFields( + ctx context.Context, + signer evm.ClientEvmSigner, + deployment authcapture.AuthCaptureDeployment, + extra authcapture.AuthCaptureExtra, + chainID *big.Int, + terms captureTerms, +) (map[string]interface{}, error) { + result := map[string]interface{}{ + "amount": terms.amount.String(), + "feeReceiver": terms.feeReceiver, + "expectedCapturableAmount": terms.capturable.String(), + "expectedRefundableAmount": terms.refundable.String(), + } + terms.fee.AddToWire(result) + if signer == nil { + if terms.voidRemainder { + result["voidRemainder"] = true + } + return result, nil + } + + signature, err := authcapture.SignCapture(ctx, signer, &deployment, extra.CaptureAuthorizer, chainID, + authcapture.CaptureParams{ + PaymentInfoHash: terms.paymentInfoHash, + Amount: terms.amount, + Fee: terms.fee, + FeeReceiver: terms.feeReceiver, + ExpectedCapturable: terms.capturable, + ExpectedRefundable: terms.refundable, + }) + if err != nil { + return nil, fmt.Errorf(ErrFailedToSignCapture+": %w", err) + } + result["authorizerSignature"] = evm.BytesToHex(signature) + if terms.voidRemainder { + voidSignature, err := authcapture.SignVoid(ctx, signer, extra.CaptureAuthorizer, chainID, terms.paymentInfoHash) + if err != nil { + return nil, fmt.Errorf(ErrFailedToSignVoid+": %w", err) + } + result["voidAuthorizerSignature"] = evm.BytesToHex(voidSignature) + } + return result, nil +} + +// BeforeSettleHook skips the after-handler settle of a deferred escrow route, echoing the stored +// collect receipt, because that route captures later through the lifecycle manager. +func (s *AuthCaptureEvmScheme) BeforeSettleHook() x402.BeforeSettleHook { + return func(ctx x402.SettleContext) (*x402.BeforeHookResult, error) { + if ctx.Phase != x402.SettlePhaseAfterHandler { + return nil, nil + } + request, err := s.newSettleRequest(ctx) + if err != nil || !request.deferred() { + return nil, nil //nolint:nilerr // a malformed payload fails in the settle itself + } + record, err := s.storage.Get(ctx.Ctx, request.paymentInfoHash) + if err != nil { + return nil, err + } + if record == nil { + return &x402.BeforeHookResult{ + Abort: true, + Reason: ErrPaymentNotFound, + Message: "the authorized payment was not recorded, so it cannot be captured later", + }, nil + } + return &x402.BeforeHookResult{ + Skip: true, + SkipResult: &x402.SettleResponse{ + Success: true, + Payer: record.PaymentInfo.Payer, + Transaction: record.CollectTransaction, + Network: x402.Network(record.Network), + Amount: record.PaymentInfo.MaxAmount, + }, + }, nil + } +} + +func (r *settleRequest) deferred() bool { + return r.extra.PaymentFlow != authcapture.PaymentFlowAuthorization && r.extra.CaptureMode == authcapture.CaptureModeDeferred +} + +// AfterSettleHook records an authorized payment after its collect settles, and keeps the stored +// balances in step with an in-request capture, charge or cancel void. +func (s *AuthCaptureEvmScheme) AfterSettleHook() x402.AfterSettleHook { + return func(ctx x402.SettleResultContext) error { + if ctx.Result == nil || !ctx.Result.Success { + return nil + } + request, err := s.newSettleRequest(ctx.SettleContext) + if err != nil { + return err + } + switch { + case ctx.Phase == x402.SettlePhaseCancel: + return applyVoid(ctx.Ctx, s.storage, request.paymentInfoHash) + case ctx.Phase == x402.SettlePhaseBeforeHandler: + return s.persist(ctx.Ctx, request, ctx.Result, request.signedAmount, new(big.Int)) + case request.extra.PaymentFlow == authcapture.PaymentFlowAuthorization: + charged := request.requirements.Amount + if ctx.Result.Amount != "" { + charged = ctx.Result.Amount + } + amount, ok := new(big.Int).SetString(charged, 10) + if !ok { + return fmt.Errorf(ErrInvalidCaptureAmount+": settled amount %s", charged) + } + return s.persist(ctx.Ctx, request, ctx.Result, new(big.Int), amount) + case request.deferred(): + return nil + default: + return s.recordCapture(ctx.Ctx, request) + } + } +} + +func (s *AuthCaptureEvmScheme) recordCapture(ctx context.Context, request *settleRequest) error { + capturable, _, err := s.storedBalances(ctx, request) + if err != nil { + return err + } + amount, err := request.settlementAmount(capturable) + if err != nil { + return err + } + return applyCapture(ctx, s.storage, request.paymentInfoHash, amount, amount.Cmp(capturable) < 0) +} + +// persist stores the collected payment. The first write wins: a second collect of the same +// paymentInfoHash cannot succeed onchain, so reaching here twice is a retry whose stored balances +// are authoritative. +func (s *AuthCaptureEvmScheme) persist( + ctx context.Context, + request *settleRequest, + result *x402.SettleResponse, + capturable, refundable *big.Int, +) error { + record := &AuthorizedPayment{ + PaymentInfoHash: request.paymentInfoHash, + PaymentInfo: request.paymentInfo, + SaltNonce: request.collect.saltNonce, + Network: request.requirements.Network, + ReceiverAuthorizer: request.extra.ReceiverAuthorizer, + Policy: request.extra.Policy, + Name: request.extra.Name, + Version: request.extra.Version, + PaymentFlow: firstNonEmpty(request.extra.PaymentFlow, authcapture.PaymentFlowEscrow), + OperatorType: firstNonEmpty(request.extra.OperatorType, authcapture.OperatorTypeDelegated), + AssetTransferMethod: request.extra.AssetTransferMethod, + AuthCaptureEscrow: request.extra.AuthCaptureEscrow, + CapturableAmount: capturable.String(), + RefundableAmount: refundable.String(), + CollectTransaction: result.Transaction, + CreatedAt: time.Now(), + } + return s.storage.Update(ctx, request.paymentInfoHash, func(current *AuthorizedPayment) *AuthorizedPayment { + if current != nil { + return current + } + return record + }) +} + +type collectFields struct { + payer string + preApprovalExpiry uint64 + salt string + saltNonce string + signature string + authorizedAmount string + permit2 bool +} + +// collectPayloadFields reads the payer, expiry, salt and authorized hold from the client's +// EIP-3009 or Permit2 collect payload, the only shape the server ever receives. The signed +// amount is the escrow's maxAmount, which the settlement amount override may undercut. +func collectPayloadFields(payload map[string]interface{}) (collectFields, error) { + switch { + case authcapture.IsEip3009Payload(payload): + p, err := authcapture.Eip3009CollectPayloadFromMap(payload) + if err != nil { + return collectFields{}, err + } + expiry, err := strconv.ParseUint(p.Authorization.ValidBefore, 10, 64) + if err != nil { + return collectFields{}, fmt.Errorf("invalid authorization.validBefore: %s", p.Authorization.ValidBefore) + } + return collectFields{ + payer: p.Authorization.From, preApprovalExpiry: expiry, salt: p.Salt, saltNonce: p.SaltNonce, + signature: p.Signature, authorizedAmount: p.Authorization.Value, + }, nil + case authcapture.IsPermit2Payload(payload): + p, err := authcapture.Permit2CollectPayloadFromMap(payload) + if err != nil { + return collectFields{}, err + } + expiry, err := strconv.ParseUint(p.Permit2Authorization.Deadline, 10, 64) + if err != nil { + return collectFields{}, fmt.Errorf("invalid permit2Authorization.deadline: %s", p.Permit2Authorization.Deadline) + } + return collectFields{ + payer: p.Permit2Authorization.From, preApprovalExpiry: expiry, salt: p.Salt, saltNonce: p.SaltNonce, + signature: p.Signature, authorizedAmount: p.Permit2Authorization.Permitted.Amount, permit2: true, + }, nil + default: + return collectFields{}, fmt.Errorf("payload is neither an EIP-3009 nor a Permit2 auth-capture collect payload") + } +} + +// requirementsFromView rebuilds concrete requirements from the version-agnostic hook view. +func requirementsFromView(view x402.PaymentRequirementsView) types.PaymentRequirements { + return types.PaymentRequirements{ + Scheme: view.GetScheme(), + Network: view.GetNetwork(), + Amount: view.GetAmount(), + Asset: view.GetAsset(), + PayTo: view.GetPayTo(), + MaxTimeoutSeconds: view.GetMaxTimeoutSeconds(), + Extra: view.GetExtra(), + } +} diff --git a/go/mechanisms/evm/auth-capture/server/storage.go b/go/mechanisms/evm/auth-capture/server/storage.go new file mode 100644 index 0000000000..5f2a0f4851 --- /dev/null +++ b/go/mechanisms/evm/auth-capture/server/storage.go @@ -0,0 +1,150 @@ +package server + +import ( + "context" + "math/big" + "sort" + "strings" + "sync" + "time" + + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" +) + +// AuthorizedPayment is a collected payment the server can still capture, void or refund. +// Amounts are decimal strings in token base units so records serialize to any backend. +type AuthorizedPayment struct { + PaymentInfoHash string + PaymentInfo authcapture.PaymentInfoStruct + SaltNonce string + Network string + + ReceiverAuthorizer string + Policy string + Name string + Version string + PaymentFlow string + OperatorType string + AssetTransferMethod string + AuthCaptureEscrow string + + CapturableAmount string + RefundableAmount string + CollectTransaction string + CreatedAt time.Time +} + +// AuthorizedPaymentStorage persists AuthorizedPayment records keyed by paymentInfoHash. +type AuthorizedPaymentStorage interface { + // Get returns a copy of the record, or nil when absent. + Get(ctx context.Context, paymentInfoHash string) (*AuthorizedPayment, error) + List(ctx context.Context) ([]*AuthorizedPayment, error) + // Update runs update on a copy of the current record, nil when absent, and stores what it + // returns. Returning nil deletes the record and returning the argument keeps it. An + // implementation shared by several instances must make the read-modify-write atomic, for + // example with a SQL transaction or a Lua script, and update must not call back into storage. + Update(ctx context.Context, paymentInfoHash string, update func(current *AuthorizedPayment) *AuthorizedPayment) error +} + +// InMemoryAuthorizedPaymentStorage keeps records in process memory. Records are lost on restart, +// so a deferred capture then falls back on the payer's reclaim after the capture deadline. +type InMemoryAuthorizedPaymentStorage struct { + mu sync.Mutex + payments map[string]*AuthorizedPayment +} + +// NewInMemoryAuthorizedPaymentStorage returns an empty in-memory store. +func NewInMemoryAuthorizedPaymentStorage() *InMemoryAuthorizedPaymentStorage { + return &InMemoryAuthorizedPaymentStorage{payments: map[string]*AuthorizedPayment{}} +} + +func (p *AuthorizedPayment) clone() *AuthorizedPayment { + if p == nil { + return nil + } + copied := *p + return &copied +} + +func storageKey(paymentInfoHash string) string { return strings.ToLower(paymentInfoHash) } + +// Get implements AuthorizedPaymentStorage. +func (s *InMemoryAuthorizedPaymentStorage) Get(_ context.Context, paymentInfoHash string) (*AuthorizedPayment, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.payments[storageKey(paymentInfoHash)].clone(), nil +} + +// List implements AuthorizedPaymentStorage, oldest record first. +func (s *InMemoryAuthorizedPaymentStorage) List(_ context.Context) ([]*AuthorizedPayment, error) { + s.mu.Lock() + defer s.mu.Unlock() + records := make([]*AuthorizedPayment, 0, len(s.payments)) + for _, record := range s.payments { + records = append(records, record.clone()) + } + sort.Slice(records, func(i, j int) bool { return records[i].CreatedAt.Before(records[j].CreatedAt) }) + return records, nil +} + +// Update implements AuthorizedPaymentStorage. +func (s *InMemoryAuthorizedPaymentStorage) Update( + _ context.Context, + paymentInfoHash string, + update func(current *AuthorizedPayment) *AuthorizedPayment, +) error { + s.mu.Lock() + defer s.mu.Unlock() + key := storageKey(paymentInfoHash) + next := update(s.payments[key].clone()) + if next == nil { + delete(s.payments, key) + return nil + } + s.payments[key] = next.clone() + return nil +} + +func parseBalance(value string) (*big.Int, bool) { + n, ok := new(big.Int).SetString(value, 10) + return n, ok && n.Sign() >= 0 +} + +// adjustBalances applies mutate to the stored capturable and refundable amounts. A missing record +// or one with unreadable balances is left alone. +func adjustBalances(ctx context.Context, storage AuthorizedPaymentStorage, paymentInfoHash string, mutate func(capturable, refundable *big.Int)) error { + return storage.Update(ctx, paymentInfoHash, func(current *AuthorizedPayment) *AuthorizedPayment { + if current == nil { + return nil + } + capturable, capturableOK := parseBalance(current.CapturableAmount) + refundable, refundableOK := parseBalance(current.RefundableAmount) + if !capturableOK || !refundableOK { + return current + } + mutate(capturable, refundable) + current.CapturableAmount, current.RefundableAmount = capturable.String(), refundable.String() + return current + }) +} + +// applyCapture moves a captured amount from capturable to refundable, zeroing the hold when the +// remainder was voided. +func applyCapture(ctx context.Context, storage AuthorizedPaymentStorage, paymentInfoHash string, amount *big.Int, voidRemainder bool) error { + return adjustBalances(ctx, storage, paymentInfoHash, func(capturable, refundable *big.Int) { + if voidRemainder { + capturable.SetInt64(0) + } else { + capturable.Sub(capturable, amount) + } + refundable.Add(refundable, amount) + }) +} + +func applyVoid(ctx context.Context, storage AuthorizedPaymentStorage, paymentInfoHash string) error { + return adjustBalances(ctx, storage, paymentInfoHash, func(capturable, _ *big.Int) { capturable.SetInt64(0) }) +} + +func applyRefund(ctx context.Context, storage AuthorizedPaymentStorage, paymentInfoHash string, amount *big.Int) error { + return adjustBalances(ctx, storage, paymentInfoHash, func(_, refundable *big.Int) { refundable.Sub(refundable, amount) }) +} diff --git a/go/mechanisms/evm/auth-capture/types.go b/go/mechanisms/evm/auth-capture/types.go index 5effae2d06..607a2c77a6 100644 --- a/go/mechanisms/evm/auth-capture/types.go +++ b/go/mechanisms/evm/auth-capture/types.go @@ -20,7 +20,20 @@ type PaymentInfoStruct struct { Salt string `json:"salt"` } -// AuthCaptureExtra is the wire extra after facilitator enhancement (absolute deadlines). +// AuthorizerMode says who produces authorizerSignature for a published receiverAuthorizer: +// - AuthorizerModeSelf: the server's receiver-authorizer signer, which signs locally. +// - AuthorizerModeDelegated: a non-zero authorizer the server holds no key for; payloads go +// out unsigned and the facilitator signs after authenticating the request out of band. +// - AuthorizerModeCollectOnly: the zero address; the facilitator relays no lifecycle. +type AuthorizerMode string + +const ( + AuthorizerModeSelf AuthorizerMode = "self" + AuthorizerModeDelegated AuthorizerMode = "delegated" + AuthorizerModeCollectOnly AuthorizerMode = "collect-only" +) + +// AuthCaptureExtra is the parsed requirements.Extra (absolute deadlines). type AuthCaptureExtra struct { CaptureAuthorizer string CaptureDeadline uint64 @@ -32,6 +45,7 @@ type AuthCaptureExtra struct { Version string PaymentFlow string CaptureMode string + AutoCapture bool ReceiverAuthorizer string Policy string OperatorType string @@ -64,35 +78,8 @@ type Permit2Authorization struct { Deadline string `json:"deadline"` } -// IsAuthCaptureExtra reports whether value has every spec-mandated AuthCaptureExtra field. -func IsAuthCaptureExtra(value interface{}) bool { - m, ok := value.(map[string]interface{}) - if !ok { - return false - } - if !isNonEmptyString(m["captureAuthorizer"]) { - return false - } - if !isJSONNumber(m["captureDeadline"]) { - return false - } - if !isJSONNumber(m["refundDeadline"]) { - return false - } - if !isNonEmptyString(m["feeRecipient"]) { - return false - } - if !isJSONNumber(m["minFeeBps"]) { - return false - } - if !isJSONNumber(m["maxFeeBps"]) { - return false - } - if !isNonEmptyString(m["name"]) { - return false - } - return isNonEmptyString(m["version"]) -} +// chargeFields are the completion fields a server adds to a collect payload for a charge. +var chargeFields = []string{"amount", "feeBps", "feeAmount", "feeReceiver", "authorizerSignature"} func isNonEmptyString(value interface{}) bool { s, ok := value.(string) @@ -100,12 +87,8 @@ func isNonEmptyString(value interface{}) bool { } func isJSONNumber(value interface{}) bool { - switch value.(type) { - case float64, int, int64, uint64, uint16, int32, uint32: - return true - default: - return false - } + _, ok := JSONNumberToUint64(value) + return ok } func isHexString(value interface{}) bool { @@ -128,139 +111,194 @@ func isHexString(value interface{}) bool { return true } -func collectSaltFields(v map[string]interface{}) (saltNonce string, ok bool) { - if !isHexString(v["salt"]) { - return "", false - } - if v["saltNonce"] == nil { - return "", true - } - if !isHexString(v["saltNonce"]) { - return "", false - } - return v["saltNonce"].(string), true -} - -func readChargeCompletion(v map[string]interface{}) bool { - hasAny := false - for _, key := range []string{"amount", "feeBps", "feeAmount", "feeReceiver", "authorizerSignature"} { +func hasChargeFields(v map[string]interface{}) bool { + for _, key := range chargeFields { if _, ok := v[key]; ok { - hasAny = true - break + return true } } - if !hasAny { + return false +} + +// hasFeeField reports which fee field a payload carries: feeBps (v1.0) or feeAmount (v1.1). +func hasFeeField(v map[string]interface{}) (hasFeeBps, hasFeeAmount bool) { + _, hasFeeBps = JSONNumberToUint16(v["feeBps"]) + _, hasFeeAmount = v["feeAmount"].(string) + return hasFeeBps, hasFeeAmount +} + +// isOptionalString reports whether key is absent or a string. +func isOptionalString(v map[string]interface{}, key string) bool { + value, present := v[key] + if !present { return true } + _, ok := value.(string) + return ok +} + +// isCompleteCharge requires the amount, feeReceiver and exactly one fee field. The authorizer +// signature is optional because a facilitator-delegated authorizer produces it at settle time. +func isCompleteCharge(v map[string]interface{}) bool { _, hasAmount := v["amount"].(string) - _, hasFeeBpsFloat := v["feeBps"].(float64) - _, hasFeeBpsInt := v["feeBps"].(int) - hasFeeBps := hasFeeBpsFloat || hasFeeBpsInt - _, hasFeeAmount := v["feeAmount"].(string) _, hasFeeReceiver := v["feeReceiver"].(string) - _, hasAuthorizerSig := v["authorizerSignature"].(string) - if !hasAmount || !hasFeeReceiver || !hasAuthorizerSig { + hasFeeBps, hasFeeAmount := hasFeeField(v) + return hasAmount && hasFeeReceiver && isOptionalString(v, "authorizerSignature") && hasFeeBps != hasFeeAmount +} + +// isCollectEnvelope checks the fields shared by both collect payload shapes. +func isCollectEnvelope(v map[string]interface{}) bool { + if _, ok := v["signature"].(string); !ok { return false } - if hasFeeBps && !hasFeeAmount { - return true + if !isHexString(v["salt"]) { + return false } - if hasFeeAmount && !hasFeeBps { + saltNonce := v["saltNonce"] + if saltNonce != nil && !isHexString(saltNonce) { + return false + } + if !hasChargeFields(v) { return true } - return false + return saltNonce != nil && isCompleteCharge(v) } // IsEip3009Payload reports whether value is an EIP-3009-shaped auth-capture collect payload. func IsEip3009Payload(value interface{}) bool { v, ok := value.(map[string]interface{}) - if !ok { - return false - } - if v["type"] != nil { + if !ok || v["type"] != nil { return false } auth, ok := v["authorization"].(map[string]interface{}) if !ok || auth == nil { return false } - if _, ok := v["signature"].(string); !ok { + return isCollectEnvelope(v) +} + +// IsPermit2Payload reports whether value is a Permit2-shaped auth-capture collect payload. +func IsPermit2Payload(value interface{}) bool { + v, ok := value.(map[string]interface{}) + if !ok || v["type"] != nil { return false } - saltNonce, ok := collectSaltFields(v) - if !ok { + auth, ok := v["permit2Authorization"].(map[string]interface{}) + if !ok || auth == nil { return false } - hasAnyCharge := false - for _, key := range []string{"amount", "feeBps", "feeAmount", "feeReceiver", "authorizerSignature"} { - if _, ok := v[key]; ok { - hasAnyCharge = true - break + for _, key := range []string{"from", "spender", "nonce", "deadline"} { + if _, ok := auth[key].(string); !ok { + return false } } - if hasAnyCharge { - if saltNonce == "" { + permitted, ok := auth["permitted"].(map[string]interface{}) + if !ok || permitted == nil { + return false + } + for _, key := range []string{"token", "amount"} { + if _, ok := permitted[key].(string); !ok { return false } - return readChargeCompletion(v) } - return true + return isCollectEnvelope(v) } -// IsPermit2Payload reports whether value is a Permit2-shaped auth-capture collect payload. -func IsPermit2Payload(value interface{}) bool { +func isPaymentInfoStructMap(value interface{}) bool { v, ok := value.(map[string]interface{}) if !ok { return false } - if v["type"] != nil { - return false + for _, key := range []string{"operator", "payer", "receiver", "token", "feeReceiver", "maxAmount"} { + if !isNonEmptyString(v[key]) { + return false + } } - if _, ok := v["signature"].(string); !ok { + if !isHexString(v["salt"]) { return false } - auth, ok := v["permit2Authorization"].(map[string]interface{}) - if !ok || auth == nil { - return false + for _, key := range []string{"preApprovalExpiry", "authorizationExpiry", "refundExpiry", "minFeeBps", "maxFeeBps"} { + if !isJSONNumber(v[key]) { + return false + } } - if _, ok := auth["from"].(string); !ok { + return true +} + +// IsLifecyclePayload reports whether value names a supported lifecycle operation +// ("capture", "void" or "refund"). Field-level validation is in the per-type guards. +func IsLifecyclePayload(value interface{}) bool { + v, ok := value.(map[string]interface{}) + if !ok { return false } - if _, ok := auth["spender"].(string); !ok { + t, ok := v["type"].(string) + return ok && (t == "capture" || t == "void" || t == "refund") +} + +// IsCapturePayload reports whether value is a capture lifecycle payload. The signatures are +// optional so a facilitator-delegated authorizer can sign them at settle time. +func IsCapturePayload(value interface{}) bool { + v, ok := value.(map[string]interface{}) + if !ok || !IsLifecyclePayload(value) || v["type"] != "capture" { return false } - if _, ok := auth["nonce"].(string); !ok { + hasFeeBps, hasFeeAmount := hasFeeField(v) + if hasFeeBps == hasFeeAmount { return false } - if _, ok := auth["deadline"].(string); !ok { + if !isPaymentInfoStructMap(v["paymentInfo"]) || !isHexString(v["saltNonce"]) { return false } - permitted, ok := auth["permitted"].(map[string]interface{}) - if !ok || permitted == nil { - return false + for _, key := range []string{"amount", "feeReceiver", "expectedCapturableAmount", "expectedRefundableAmount"} { + if !isNonEmptyString(v[key]) { + return false + } } - if _, ok := permitted["token"].(string); !ok { + return isOptionalString(v, "authorizerSignature") && + isOptionalString(v, "voidAuthorizerSignature") && + isDelegatedCaptureShape(v) +} + +// isDelegatedCaptureShape applies the cross-field rules for a capture whose signatures may be +// delegated: a void signature needs the capture signature, and voidRemainder is the unsigned +// stand-in for the void signature. +func isDelegatedCaptureShape(v map[string]interface{}) bool { + _, hasAuthorizerSig := v["authorizerSignature"] + _, hasVoidSig := v["voidAuthorizerSignature"] + if voidRemainder, present := v["voidRemainder"]; present { + return voidRemainder == true && !hasAuthorizerSig && !hasVoidSig + } + return hasAuthorizerSig || !hasVoidSig +} + +// IsVoidPayload reports whether value is a void lifecycle payload. +func IsVoidPayload(value interface{}) bool { + v, ok := value.(map[string]interface{}) + if !ok || !IsLifecyclePayload(value) || v["type"] != "void" { return false } - if _, ok := permitted["amount"].(string); !ok { + _, hasVoidRemainder := v["voidRemainder"] + return isPaymentInfoStructMap(v["paymentInfo"]) && + isHexString(v["saltNonce"]) && + isOptionalString(v, "authorizerSignature") && + !hasVoidRemainder +} + +// IsRefundPayload reports whether value is a refund lifecycle payload. +func IsRefundPayload(value interface{}) bool { + v, ok := value.(map[string]interface{}) + if !ok || !IsLifecyclePayload(value) || v["type"] != "refund" { return false } - saltNonce, ok := collectSaltFields(v) - if !ok { + if !isPaymentInfoStructMap(v["paymentInfo"]) || !isHexString(v["saltNonce"]) { return false } - hasAnyCharge := false - for _, key := range []string{"amount", "feeBps", "feeAmount", "feeReceiver", "authorizerSignature"} { - if _, ok := v[key]; ok { - hasAnyCharge = true - break - } - } - if hasAnyCharge { - if saltNonce == "" { + for _, key := range []string{"amount", "expectedCapturableAmount", "expectedRefundableAmount"} { + if !isNonEmptyString(v[key]) { return false } - return readChargeCompletion(v) } - return true + _, hasVoidRemainder := v["voidRemainder"] + return isOptionalString(v, "authorizerSignature") && !hasVoidRemainder } diff --git a/go/mechanisms/evm/auth-capture/types_test.go b/go/mechanisms/evm/auth-capture/types_test.go index 87033a81cb..a4860e32d8 100644 --- a/go/mechanisms/evm/auth-capture/types_test.go +++ b/go/mechanisms/evm/auth-capture/types_test.go @@ -2,74 +2,6 @@ package authcapture import "testing" -func TestIsAuthCaptureExtra(t *testing.T) { - future := float64(4102444800) - valid := map[string]interface{}{ - "captureAuthorizer": "0xcccccccccccccccccccccccccccccccccccccccc", - "captureDeadline": future, - "refundDeadline": future + 86400, - "feeRecipient": "0x4444444444444444444444444444444444444444", - "minFeeBps": float64(0), - "maxFeeBps": float64(100), - "name": "USDC", - "version": "2", - } - - if !IsAuthCaptureExtra(valid) { - t.Fatal("expected valid extra") - } - if IsAuthCaptureExtra(nil) || IsAuthCaptureExtra("string") || IsAuthCaptureExtra(42) { - t.Fatal("expected non-object to be rejected") - } - - noAuthorizer := copyMap(valid) - delete(noAuthorizer, "captureAuthorizer") - if IsAuthCaptureExtra(noAuthorizer) { - t.Fatal("expected missing captureAuthorizer to be rejected") - } - - badDeadline := copyMap(valid) - badDeadline["captureDeadline"] = "soon" - if IsAuthCaptureExtra(badDeadline) { - t.Fatal("expected non-number captureDeadline to be rejected") - } - - badFeeRecipient := copyMap(valid) - badFeeRecipient["feeRecipient"] = 42 - if IsAuthCaptureExtra(badFeeRecipient) { - t.Fatal("expected non-string feeRecipient to be rejected") - } - - noName := copyMap(valid) - delete(noName, "name") - if IsAuthCaptureExtra(noName) { - t.Fatal("expected missing name to be rejected") - } - - noMinFee := copyMap(valid) - delete(noMinFee, "minFeeBps") - if IsAuthCaptureExtra(noMinFee) { - t.Fatal("expected missing minFeeBps to be rejected") - } - - noMaxFee := copyMap(valid) - delete(noMaxFee, "maxFeeBps") - if IsAuthCaptureExtra(noMaxFee) { - t.Fatal("expected missing maxFeeBps to be rejected") - } - - oldShape := map[string]interface{}{ - "escrowAddress": "0xeee", - "operatorAddress": "0xccc", - "tokenCollector": "0xbbb", - "name": "USDC", - "version": "2", - } - if IsAuthCaptureExtra(oldShape) { - t.Fatal("expected old commerce-era extra to be rejected") - } -} - func TestIsEip3009Payload(t *testing.T) { future := "4102444800" valid := map[string]interface{}{ @@ -198,3 +130,202 @@ func copyMap(in map[string]interface{}) map[string]interface{} { } return out } + +func validCapture() map[string]interface{} { + return map[string]interface{}{ + "type": "capture", + "paymentInfo": validPaymentInfoWire(), + "saltNonce": "0x01", + "amount": "600000", + "feeAmount": "0", + "feeReceiver": "0x4444444444444444444444444444444444444444", + "expectedCapturableAmount": "400000", + "expectedRefundableAmount": "0", + "authorizerSignature": "0xabcd", + } +} + +func validPaymentInfoWire() map[string]interface{} { + wire, err := mockPaymentInfo().ToWireMap() + if err != nil { + panic(err) + } + return wire +} + +func TestIsCapturePayload(t *testing.T) { + valid := validCapture() + if !IsCapturePayload(valid) { + t.Fatal("expected a v1.1 capture payload") + } + + v10 := copyMap(valid) + delete(v10, "feeAmount") + v10["feeBps"] = float64(50) + if !IsCapturePayload(v10) { + t.Fatal("expected a v1.0 capture payload") + } + + both := copyMap(valid) + both["feeBps"] = float64(50) + neither := copyMap(valid) + delete(neither, "feeAmount") + noSaltNonce := copyMap(valid) + delete(noSaltNonce, "saltNonce") + badInfo := copyMap(valid) + badInfo["paymentInfo"] = map[string]interface{}{} + void := copyMap(valid) + void["type"] = "void" + + for name, payload := range map[string]interface{}{ + "both fee fields": both, "no fee field": neither, "no saltNonce": noSaltNonce, + "bad paymentInfo": badInfo, "wrong type": void, "nil": nil, + } { + if IsCapturePayload(payload) { + t.Fatalf("expected %s to be rejected", name) + } + } +} + +func TestIsVoidPayload(t *testing.T) { + valid := map[string]interface{}{ + "type": "void", + "paymentInfo": validPaymentInfoWire(), + "saltNonce": "0x01", + "authorizerSignature": "0xabcd", + } + if !IsVoidPayload(valid) { + t.Fatal("expected a void payload") + } + + capture := copyMap(valid) + capture["type"] = "capture" + if IsVoidPayload(capture) || IsVoidPayload("void") { + t.Fatal("expected malformed void payloads to be rejected") + } +} + +func TestIsLifecyclePayload_AcceptsUnsignedPayloadsForADelegatedAuthorizer(t *testing.T) { + capture := validCapture() + delete(capture, "authorizerSignature") + if !IsCapturePayload(capture) { + t.Fatal("expected an unsigned capture payload") + } + withVoidRemainder := copyMap(capture) + withVoidRemainder["voidRemainder"] = true + if !IsCapturePayload(withVoidRemainder) { + t.Fatal("expected an unsigned capture payload with voidRemainder") + } + + unsignedVoid := map[string]interface{}{"type": "void", "paymentInfo": validPaymentInfoWire(), "saltNonce": "0x01"} + if !IsVoidPayload(unsignedVoid) { + t.Fatal("expected an unsigned void payload") + } + unsignedRefund := validRefund() + delete(unsignedRefund, "authorizerSignature") + if !IsRefundPayload(unsignedRefund) { + t.Fatal("expected an unsigned refund payload") + } + + // voidRemainder only stands in for a void signature the facilitator will produce. + voidRemainderFalse := copyMap(capture) + voidRemainderFalse["voidRemainder"] = false + signedWithVoidRemainder := copyMap(validCapture()) + signedWithVoidRemainder["voidRemainder"] = true + voidSignatureOnly := copyMap(capture) + voidSignatureOnly["voidAuthorizerSignature"] = "0xab" + voidWithVoidRemainder := copyMap(unsignedVoid) + voidWithVoidRemainder["voidRemainder"] = true + for name, accepted := range map[string]bool{ + "voidRemainder false": IsCapturePayload(voidRemainderFalse), + "voidRemainder with a signature": IsCapturePayload(signedWithVoidRemainder), + "a void signature without a capture": IsCapturePayload(voidSignatureOnly), + "voidRemainder on a void payload": IsVoidPayload(voidWithVoidRemainder), + } { + if accepted { + t.Fatalf("expected %s to be rejected", name) + } + } +} + +func TestLifecyclePayloadParsing(t *testing.T) { + capture, err := CapturePayloadFromMap(validCapture()) + if err != nil { + t.Fatal(err) + } + if capture.Amount != "600000" || capture.FeeAmount != "0" || capture.FeeBps != nil || capture.PaymentInfo.MaxFeeBps != 100 { + t.Fatalf("unexpected capture payload: %+v", capture) + } + + v10 := validCapture() + delete(v10, "feeAmount") + v10["feeBps"] = float64(50) + v10["voidAuthorizerSignature"] = "0xbeef" + parsed, err := CapturePayloadFromMap(v10) + if err != nil || parsed.FeeBps == nil || *parsed.FeeBps != 50 || parsed.VoidAuthorizerSignature != "0xbeef" { + t.Fatalf("unexpected v1.0 capture payload: %+v, %v", parsed, err) + } + + void, err := VoidPayloadFromMap(map[string]interface{}{ + "paymentInfo": validPaymentInfoWire(), "saltNonce": "0x01", "authorizerSignature": "0xabcd", + }) + if err != nil || void.SaltNonce != "0x01" || void.AuthorizerSignature != "0xabcd" { + t.Fatalf("unexpected void payload: %+v, %v", void, err) + } + + if _, err := CapturePayloadFromMap(map[string]interface{}{}); err == nil { + t.Fatal("expected a missing paymentInfo to fail") + } + if _, err := VoidPayloadFromMap(map[string]interface{}{"paymentInfo": map[string]interface{}{}}); err == nil { + t.Fatal("expected an incomplete paymentInfo to fail") + } +} + +func validRefund() map[string]interface{} { + return map[string]interface{}{ + "type": "refund", + "paymentInfo": validPaymentInfoWire(), + "saltNonce": "0x01", + "amount": "250000", + "expectedCapturableAmount": "0", + "expectedRefundableAmount": "750000", + "authorizerSignature": "0xabcd", + } +} + +func TestIsRefundPayload(t *testing.T) { + valid := validRefund() + if !IsRefundPayload(valid) || !IsLifecyclePayload(valid) { + t.Fatal("expected a refund payload") + } + + for _, missing := range []string{"saltNonce", "amount", "expectedCapturableAmount", "expectedRefundableAmount", "paymentInfo"} { + payload := copyMap(valid) + delete(payload, missing) + if IsRefundPayload(payload) { + t.Fatalf("expected a refund without %s to be rejected", missing) + } + } + capture := copyMap(valid) + capture["type"] = "capture" + if IsRefundPayload(capture) || IsRefundPayload("refund") { + t.Fatal("expected a non-refund payload to be rejected") + } +} + +func TestRefundPayloadFromMap(t *testing.T) { + refund, err := RefundPayloadFromMap(validRefund()) + if err != nil { + t.Fatal(err) + } + if refund.Amount != "250000" || refund.ExpectedRefundableAmount != "750000" || refund.SaltNonce != "0x01" || refund.AuthorizerSignature != "0xabcd" { + t.Fatalf("unexpected refund payload: %+v", refund) + } + + if _, err := RefundPayloadFromMap(map[string]interface{}{}); err == nil { + t.Fatal("expected a missing paymentInfo to fail") + } + if _, err := RefundPayloadFromMap(map[string]interface{}{"paymentInfo": map[string]interface{}{}}); err == nil { + t.Fatal("expected an incomplete paymentInfo to fail") + } +} diff --git a/go/mechanisms/evm/auth-capture/wire.go b/go/mechanisms/evm/auth-capture/wire.go new file mode 100644 index 0000000000..1ff938671c --- /dev/null +++ b/go/mechanisms/evm/auth-capture/wire.go @@ -0,0 +1,268 @@ +package authcapture + +import ( + "encoding/json" + "fmt" +) + +// ChargeCompletion carries the fields a server adds to a collect payload to complete +// a charge. FeeBps is nil when the payload uses feeAmount (v1.1). AuthorizerSignature is +// empty when the authorizer is delegated to the facilitator. +type ChargeCompletion struct { + Amount string + FeeBps *uint16 + FeeAmount string + FeeReceiver string + AuthorizerSignature string +} + +// Eip3009CollectPayload is the parsed EIP-3009-shaped auth-capture collect payload. +type Eip3009CollectPayload struct { + Authorization Eip3009Authorization + Signature string + Salt string + SaltNonce string + Charge *ChargeCompletion +} + +// Permit2CollectPayload is the parsed Permit2-shaped auth-capture collect payload. +type Permit2CollectPayload struct { + Permit2Authorization Permit2Authorization + Signature string + Salt string + SaltNonce string + Charge *ChargeCompletion +} + +// CapturePayload is the parsed capture lifecycle payload. Without a local signer (authorizer +// delegated to the facilitator) both signatures are empty and VoidRemainder asks the facilitator +// to sign the Void leg. +type CapturePayload struct { + PaymentInfo PaymentInfoStruct + SaltNonce string + Amount string + FeeBps *uint16 + FeeAmount string + FeeReceiver string + ExpectedCapturableAmount string + ExpectedRefundableAmount string + AuthorizerSignature string + VoidAuthorizerSignature string + VoidRemainder bool +} + +// VoidPayload is the parsed void lifecycle payload. VoidAuthorizerSignature is only +// parsed so the facilitator can reject it. +type VoidPayload struct { + PaymentInfo PaymentInfoStruct + SaltNonce string + AuthorizerSignature string + VoidAuthorizerSignature string +} + +// RefundPayload is the parsed refund lifecycle payload. +type RefundPayload struct { + PaymentInfo PaymentInfoStruct + SaltNonce string + Amount string + ExpectedCapturableAmount string + ExpectedRefundableAmount string + AuthorizerSignature string +} + +// ToWireMap returns the JSON wire form of the PaymentInfo carried by lifecycle payloads. +func (p PaymentInfoStruct) ToWireMap() (map[string]interface{}, error) { + encoded, err := json.Marshal(p) + if err != nil { + return nil, err + } + var out map[string]interface{} + if err := json.Unmarshal(encoded, &out); err != nil { + return nil, err + } + return out, nil +} + +// Eip3009CollectPayloadFromMap parses a wire payload already confirmed by IsEip3009Payload. +func Eip3009CollectPayloadFromMap(data map[string]interface{}) (*Eip3009CollectPayload, error) { + auth, ok := data["authorization"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("missing or invalid authorization field") + } + payload := &Eip3009CollectPayload{} + payload.Authorization.From, _ = auth["from"].(string) + payload.Authorization.To, _ = auth["to"].(string) + payload.Authorization.Value, _ = auth["value"].(string) + payload.Authorization.ValidAfter, _ = auth["validAfter"].(string) + payload.Authorization.ValidBefore, _ = auth["validBefore"].(string) + payload.Authorization.Nonce, _ = auth["nonce"].(string) + + payload.Signature, _ = data["signature"].(string) + payload.Salt, _ = data["salt"].(string) + payload.SaltNonce, _ = data["saltNonce"].(string) + charge, err := chargeCompletionFromMap(data) + if err != nil { + return nil, err + } + payload.Charge = charge + return payload, nil +} + +// Permit2CollectPayloadFromMap parses a wire payload already confirmed by IsPermit2Payload. +func Permit2CollectPayloadFromMap(data map[string]interface{}) (*Permit2CollectPayload, error) { + auth, ok := data["permit2Authorization"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("missing or invalid permit2Authorization field") + } + permitted, ok := auth["permitted"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("missing or invalid permit2Authorization.permitted field") + } + + payload := &Permit2CollectPayload{} + payload.Permit2Authorization.From, _ = auth["from"].(string) + payload.Permit2Authorization.Spender, _ = auth["spender"].(string) + payload.Permit2Authorization.Nonce, _ = auth["nonce"].(string) + payload.Permit2Authorization.Deadline, _ = auth["deadline"].(string) + payload.Permit2Authorization.Permitted.Token, _ = permitted["token"].(string) + payload.Permit2Authorization.Permitted.Amount, _ = permitted["amount"].(string) + + payload.Signature, _ = data["signature"].(string) + payload.Salt, _ = data["salt"].(string) + payload.SaltNonce, _ = data["saltNonce"].(string) + charge, err := chargeCompletionFromMap(data) + if err != nil { + return nil, err + } + payload.Charge = charge + return payload, nil +} + +// chargeCompletionFromMap reads the server-added charge fields. The amount, a fee field and +// feeReceiver must be present together or not at all; the authorizer signature is optional +// because a facilitator-delegated authorizer produces it at settle time. +func chargeCompletionFromMap(data map[string]interface{}) (*ChargeCompletion, error) { + feeBps, hasFeeBps := JSONNumberToUint16(data["feeBps"]) + feeAmount, hasFeeAmount := data["feeAmount"].(string) + amount, hasAmount := data["amount"].(string) + feeReceiver, hasFeeReceiver := data["feeReceiver"].(string) + authorizerSignature, hasAuthorizerSig := data["authorizerSignature"].(string) + + hasFee := hasFeeBps || hasFeeAmount + if !hasAmount && !hasFee && !hasFeeReceiver && !hasAuthorizerSig { + return nil, nil + } + if !hasAmount || !hasFee || !hasFeeReceiver { + return nil, fmt.Errorf("charge completion needs amount, a fee field and feeReceiver together") + } + + charge := &ChargeCompletion{ + Amount: amount, + FeeAmount: feeAmount, + FeeReceiver: feeReceiver, + AuthorizerSignature: authorizerSignature, + } + if hasFeeBps { + charge.FeeBps = &feeBps + } + return charge, nil +} + +func paymentInfoStructFromMap(v map[string]interface{}) (PaymentInfoStruct, error) { + info := PaymentInfoStruct{} + info.Operator, _ = v["operator"].(string) + info.Payer, _ = v["payer"].(string) + info.Receiver, _ = v["receiver"].(string) + info.Token, _ = v["token"].(string) + info.MaxAmount, _ = v["maxAmount"].(string) + info.FeeReceiver, _ = v["feeReceiver"].(string) + info.Salt, _ = v["salt"].(string) + + for key, dst := range map[string]*uint64{ + "preApprovalExpiry": &info.PreApprovalExpiry, + "authorizationExpiry": &info.AuthorizationExpiry, + "refundExpiry": &info.RefundExpiry, + } { + n, ok := JSONNumberToUint64(v[key]) + if !ok { + return PaymentInfoStruct{}, fmt.Errorf("invalid %s", key) + } + *dst = n + } + for key, dst := range map[string]*uint16{ + "minFeeBps": &info.MinFeeBps, + "maxFeeBps": &info.MaxFeeBps, + } { + n, ok := JSONNumberToUint16(v[key]) + if !ok { + return PaymentInfoStruct{}, fmt.Errorf("invalid %s", key) + } + *dst = n + } + return info, nil +} + +// CapturePayloadFromMap parses a wire payload already confirmed by IsCapturePayload. +func CapturePayloadFromMap(data map[string]interface{}) (*CapturePayload, error) { + infoMap, ok := data["paymentInfo"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("missing or invalid paymentInfo field") + } + info, err := paymentInfoStructFromMap(infoMap) + if err != nil { + return nil, err + } + + payload := &CapturePayload{PaymentInfo: info} + payload.SaltNonce, _ = data["saltNonce"].(string) + payload.Amount, _ = data["amount"].(string) + payload.FeeReceiver, _ = data["feeReceiver"].(string) + payload.FeeAmount, _ = data["feeAmount"].(string) + payload.ExpectedCapturableAmount, _ = data["expectedCapturableAmount"].(string) + payload.ExpectedRefundableAmount, _ = data["expectedRefundableAmount"].(string) + payload.AuthorizerSignature, _ = data["authorizerSignature"].(string) + payload.VoidAuthorizerSignature, _ = data["voidAuthorizerSignature"].(string) + payload.VoidRemainder = data["voidRemainder"] == true + if feeBps, ok := JSONNumberToUint16(data["feeBps"]); ok { + payload.FeeBps = &feeBps + } + return payload, nil +} + +// VoidPayloadFromMap parses a wire payload already confirmed by IsVoidPayload. +func VoidPayloadFromMap(data map[string]interface{}) (*VoidPayload, error) { + infoMap, ok := data["paymentInfo"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("missing or invalid paymentInfo field") + } + info, err := paymentInfoStructFromMap(infoMap) + if err != nil { + return nil, err + } + + payload := &VoidPayload{PaymentInfo: info} + payload.SaltNonce, _ = data["saltNonce"].(string) + payload.AuthorizerSignature, _ = data["authorizerSignature"].(string) + payload.VoidAuthorizerSignature, _ = data["voidAuthorizerSignature"].(string) + return payload, nil +} + +// RefundPayloadFromMap parses a wire payload already confirmed by IsRefundPayload. +func RefundPayloadFromMap(data map[string]interface{}) (*RefundPayload, error) { + infoMap, ok := data["paymentInfo"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("missing or invalid paymentInfo field") + } + info, err := paymentInfoStructFromMap(infoMap) + if err != nil { + return nil, err + } + + payload := &RefundPayload{PaymentInfo: info} + payload.SaltNonce, _ = data["saltNonce"].(string) + payload.Amount, _ = data["amount"].(string) + payload.ExpectedCapturableAmount, _ = data["expectedCapturableAmount"].(string) + payload.ExpectedRefundableAmount, _ = data["expectedRefundableAmount"].(string) + payload.AuthorizerSignature, _ = data["authorizerSignature"].(string) + return payload, nil +} diff --git a/go/mechanisms/evm/datasuffix.go b/go/mechanisms/evm/datasuffix.go index b189243168..20f08adbef 100644 --- a/go/mechanisms/evm/datasuffix.go +++ b/go/mechanisms/evm/datasuffix.go @@ -17,6 +17,7 @@ const BuilderCodeKey = "builder-code" type DataSuffixContext struct { Payload types.PaymentPayload Requirements types.PaymentRequirements + Metadata map[string]any } // BuilderCodeFacilitatorExtension is implemented by the builder-code facilitator diff --git a/go/mechanisms/evm/multicall.go b/go/mechanisms/evm/multicall.go index c22141204b..1b0e2c36f9 100644 --- a/go/mechanisms/evm/multicall.go +++ b/go/mechanisms/evm/multicall.go @@ -25,6 +25,8 @@ type MulticallResult struct { Status string Result interface{} Error error + // ReturnData is the raw revert data of a failed call. + ReturnData []byte } type multicallTryAggregateCall struct { @@ -89,8 +91,9 @@ func Multicall( for i, raw := range decodedResults { if !raw.Success { results = append(results, MulticallResult{ - Status: "failure", - Error: fmt.Errorf("multicall: call reverted"), + Status: "failure", + Error: fmt.Errorf("multicall: call reverted"), + ReturnData: raw.ReturnData, }) continue } diff --git a/go/test/integration/README.md b/go/test/integration/README.md index 436d81ee7f..7f10946eff 100644 --- a/go/test/integration/README.md +++ b/go/test/integration/README.md @@ -30,6 +30,15 @@ issue **real Base Sepolia transactions** and skip when env vars are missing. - 🔐 **TestBatchSettlementIntegration_AutoClaimAndSettleTick** — both OnClaim and OnSettle fire - 🔐 **TestBatchSettlementIntegration_WithdrawalPendingRefund** — pending-withdraw detection + manager.Refund +### Auth-Capture Integration Tests (Require Configuration) +EVM auth-capture escrow tests in `evm_auth_capture_test.go`, run through the HTTP +middleware with **real Base Sepolia transactions** for both `eip3009` and `permit2`. +`EVM_RESOURCE_SERVER_ADDRESS` must differ from the payer's address. + +- 🔐 **TestAuthCaptureIntegration_AuthorizeThenCapture** — authorize before the handler, capture after; payer and receiver balances move by the price +- 🔐 **TestAuthCaptureIntegration_VoidOnHandlerFailure** — handler fails after authorize; the hold is voided back to the payer +- 🔐 **TestAuthCaptureIntegration_CustomOperators** — `evm_auth_capture_custom_operator_test.go`: verify and settle through the deployed forwarding operator, and reject the noop and gas-wasting operators without a broadcast + Tests marked with 🔐 require environment variables and will **skip** if not configured. ## Running Tests diff --git a/go/test/integration/evm_auth_capture_custom_operator_test.go b/go/test/integration/evm_auth_capture_custom_operator_test.go new file mode 100644 index 0000000000..a7a68134f4 --- /dev/null +++ b/go/test/integration/evm_auth_capture_custom_operator_test.go @@ -0,0 +1,451 @@ +// Custom-operator auth-capture against the operators deployed for the TypeScript +// integration tests (typescript/packages/mechanisms/evm/test/contracts/auth-capture). +// Addresses are fixed; this file does not deploy. +// +// Required env vars match the other EVM integration tests: +// - EVM_CLIENT_PRIVATE_KEY, EVM_FACILITATOR_PRIVATE_KEY, EVM_RESOURCE_SERVER_ADDRESS +// +// Optional: EVM_RPC_URL (defaults to https://sepolia.base.org). +package integration_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + gethtypes "github.com/ethereum/go-ethereum/core/types" + + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + authcaptureclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/client" + authcapturefacilitator "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/facilitator" + authcaptureserver "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/server" + evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm" + "github.com/x402-foundation/x402/go/v2/types" +) + +const ( + authCaptureForwardingOperator = "0x8FE415CdB559fBF5B235B81CC4F7a69684A274bb" + authCaptureNoopOperator = "0x00Cc67f415Fec4ddAFB4F4a36F324AbBfdddFf27" + authCaptureGasWastingOperator = "0x637233E61c1cCC12f2Aa5e83524d976be5E2dE04" +) + +type writeCountingSigner struct { + *realFacilitatorEvmSigner + writes atomic.Int32 +} + +func (s *writeCountingSigner) WriteContractWithGas( + ctx context.Context, + address string, + abiJSON []byte, + functionName string, + dataSuffix []byte, + gas uint64, + args ...interface{}, +) (string, error) { + s.writes.Add(1) + return s.realFacilitatorEvmSigner.WriteContractWithGas(ctx, address, abiJSON, functionName, dataSuffix, gas, args...) +} + +func (s *realFacilitatorEvmSigner) WriteContractWithGas( + ctx context.Context, + contractAddress string, + abiBytes []byte, + functionName string, + dataSuffix []byte, + gas uint64, + args ...interface{}, +) (string, error) { + contractABI, err := abi.JSON(strings.NewReader(string(abiBytes))) + if err != nil { + return "", fmt.Errorf("failed to parse ABI: %w", err) + } + data, err := contractABI.Pack(functionName, args...) + if err != nil { + return "", fmt.Errorf("failed to pack method call: %w", err) + } + data = evm.AppendDataSuffix(data, dataSuffix) + if gas == 0 { + gas = 300000 + } + return s.sendTxWithRetry(ctx, common.HexToAddress(contractAddress), data, gas) +} + +type rpcSimulateCall struct { + From string `json:"from"` + To string `json:"to"` + Data string `json:"data"` + Gas string `json:"gas,omitempty"` +} + +type rpcSimulateLog struct { + Address string `json:"address"` + Topics []string `json:"topics"` + Data string `json:"data"` +} + +type rpcSimulateCallOutcome struct { + Status string `json:"status"` + ReturnData string `json:"returnData"` + GasUsed string `json:"gasUsed"` + Logs []rpcSimulateLog `json:"logs"` + Error *struct { + Data string `json:"data"` + } `json:"error,omitempty"` +} + +type rpcSimulatedBlock struct { + Calls []rpcSimulateCallOutcome `json:"calls"` +} + +func (s *realFacilitatorEvmSigner) SimulateCalls( + ctx context.Context, + from string, + calls []authcapturefacilitator.SimulatedCall, +) ([]authcapturefacilitator.SimulatedCallResult, error) { + rpcCalls := make([]rpcSimulateCall, len(calls)) + for i, call := range calls { + data := call.Data + if len(data) == 0 { + data = []byte{} + } + entry := rpcSimulateCall{ + From: common.HexToAddress(from).Hex(), + To: common.HexToAddress(call.To).Hex(), + Data: hexutil.Encode(data), + } + if call.Gas > 0 { + entry.Gas = hexutil.EncodeUint64(call.Gas) + } + rpcCalls[i] = entry + } + + var blocks []rpcSimulatedBlock + err := s.ethClient.Client().CallContext(ctx, &blocks, "eth_simulateV1", []interface{}{ + map[string]interface{}{ + "blockStateCalls": []map[string]interface{}{ + {"calls": rpcCalls}, + }, + }, + "latest", + }...) + if err != nil { + return nil, fmt.Errorf("eth_simulateV1 failed: %w", err) + } + if len(blocks) == 0 || len(blocks[0].Calls) != len(calls) { + return nil, fmt.Errorf("eth_simulateV1 returned unexpected call count") + } + + results := make([]authcapturefacilitator.SimulatedCallResult, len(calls)) + for i, outcome := range blocks[0].Calls { + returnData := outcome.ReturnData + if outcome.Error != nil && outcome.Error.Data != "" { + returnData = outcome.Error.Data + } + if returnData == "" { + returnData = "0x" + } + data, err := hexutil.Decode(returnData) + if err != nil { + return nil, fmt.Errorf("invalid simulate return data: %w", err) + } + var gasUsed uint64 + if outcome.GasUsed != "" { + gasUsed, err = hexutil.DecodeUint64(outcome.GasUsed) + if err != nil { + return nil, fmt.Errorf("invalid simulate gasUsed: %w", err) + } + } + results[i] = authcapturefacilitator.SimulatedCallResult{ + Success: outcome.Status == "0x1", + ReturnData: data, + GasUsed: gasUsed, + Logs: parseSimulateLogs(outcome.Logs), + } + } + return results, nil +} + +func parseSimulateLogs(logs []rpcSimulateLog) []*gethtypes.Log { + parsed := make([]*gethtypes.Log, 0, len(logs)) + for _, log := range logs { + topics := make([]common.Hash, len(log.Topics)) + for i, topic := range log.Topics { + topics[i] = common.HexToHash(topic) + } + data, err := hexutil.Decode(log.Data) + if err != nil { + data = []byte{} + } + parsed = append(parsed, &gethtypes.Log{ + Address: common.HexToAddress(log.Address), + Topics: topics, + Data: data, + }) + } + return parsed +} + +type customOperatorPipeline struct { + keys *batchedTestKeys + signer *writeCountingSigner + payer string + client *x402.X402Client + server *x402.X402ResourceServer + facilitator *x402.X402Facilitator +} + +func buildCustomOperatorPipeline(t *testing.T, keys *batchedTestKeys) *customOperatorPipeline { + t.Helper() + + clientSigner, err := evmsigners.NewClientSignerFromPrivateKey(keys.clientPK) + if err != nil { + t.Fatalf("client signer: %v", err) + } + facilitatorSigner, err := newRealFacilitatorEvmSigner(keys.facilitatorPK, keys.rpcURL) + if err != nil { + t.Fatalf("facilitator signer: %v", err) + } + signer := &writeCountingSigner{realFacilitatorEvmSigner: facilitatorSigner} + + operators := []authcapturefacilitator.OperatorAllowlistEntry{ + {Address: authCaptureForwardingOperator, OperatorType: authcapture.OperatorTypeCustom}, + {Address: authCaptureNoopOperator, OperatorType: authcapture.OperatorTypeCustom}, + {Address: authCaptureGasWastingOperator, OperatorType: authcapture.OperatorTypeCustom}, + } + + x402Client := x402.Newx402Client() + x402Client.Register(batchedTestNetwork, authcaptureclient.NewAuthCaptureEvmScheme(clientSigner)) + + x402Facilitator := x402.Newx402Facilitator() + x402Facilitator.Register([]x402.Network{batchedTestNetwork}, authcapturefacilitator.NewAuthCaptureEvmScheme( + signer, + authcapturefacilitator.AuthCaptureEvmSchemeConfig{ + Operators: operators, + CustomOperatorGasLimit: authcapture.DefaultCustomOperatorGasLimit, + }, + )) + + x402Server := x402.Newx402ResourceServer(x402.WithFacilitatorClient(&localEvmFacilitatorClient{facilitator: x402Facilitator})) + x402Server.Register(batchedTestNetwork, authcaptureserver.NewAuthCaptureEvmScheme(&authcaptureserver.Config{ + CollectOnlyRoutes: true, + })) + if err := x402Server.Initialize(context.Background()); err != nil { + t.Fatalf("server initialize: %v", err) + } + + return &customOperatorPipeline{ + keys: keys, + signer: signer, + payer: clientSigner.Address(), + client: x402Client, + server: x402Server, + facilitator: x402Facilitator, + } +} + +func (p *customOperatorPipeline) collect(t *testing.T, operator string) (types.PaymentPayload, types.PaymentRequirements) { + t.Helper() + ctx := context.Background() + zero := uint16(0) + captureSeconds := uint64(7200) + refundSeconds := uint64(14400) + config := x402.ResourceConfig{ + Scheme: authcapture.SchemeAuthCapture, + Network: batchedTestNetwork, + PayTo: p.keys.receiver, + Price: "$0.001", + MaxTimeoutSeconds: 300, + Extra: authcaptureserver.AuthCaptureRouteExtra{ + PaymentFlow: authcapture.PaymentFlowEscrow, + CaptureMode: authcapture.CaptureModeDeferred, + OperatorType: authcapture.OperatorTypeCustom, + CaptureAuthorizer: operator, + ReceiverAuthorizer: authcapture.ZeroAddress, + FeeRecipient: authcapture.ZeroAddress, + MinFeeBps: &zero, + MaxFeeBps: &zero, + AssetTransferMethod: string(evm.AssetTransferMethodEIP3009), + CaptureDeadlineSeconds: &captureSeconds, + RefundDeadlineSeconds: &refundSeconds, + }.Map(), + } + + var supported types.SupportedKind + for _, kind := range p.facilitator.GetSupported().Kinds { + if kind.X402Version == 2 && kind.Scheme == authcapture.SchemeAuthCapture && kind.Network == string(batchedTestNetwork) { + supported = kind + break + } + } + requirements, err := p.server.BuildPaymentRequirements(ctx, config, supported, nil) + if err != nil { + t.Fatalf("build requirements: %v", err) + } + accepts := []types.PaymentRequirements{requirements} + + selected, err := p.client.SelectPaymentRequirements(accepts) + if err != nil { + t.Fatalf("select requirements: %v", err) + } + resource := &types.ResourceInfo{ + URL: "https://example.com/api", + Description: "auth-capture custom-operator test", + MimeType: "application/json", + } + payload, err := p.client.CreatePaymentPayload(ctx, selected, resource, nil) + if err != nil { + t.Fatalf("create payload: %v", err) + } + accepted := p.server.FindMatchingRequirements(accepts, payload) + if accepted == nil { + t.Fatal("no matching payment requirements") + } + return payload, *accepted +} + +func (p *customOperatorPipeline) verify(t *testing.T, payload types.PaymentPayload, requirements types.PaymentRequirements) (*x402.VerifyResponse, error) { + t.Helper() + payloadBytes, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + requirementsBytes, err := json.Marshal(requirements) + if err != nil { + t.Fatalf("marshal requirements: %v", err) + } + return p.facilitator.Verify(context.Background(), payloadBytes, requirementsBytes) +} + +func TestAuthCaptureIntegration_CustomOperators(t *testing.T) { + keys := loadBatchedTestKeys(t) + pipe := buildCustomOperatorPipeline(t, keys) + + t.Run("forwarding operator authorizes through escrow", func(t *testing.T) { + pipe.signer.writes.Store(0) + payload, requirements := pipe.collect(t, authCaptureForwardingOperator) + + verified, err := pipe.verify(t, payload, requirements) + if err != nil { + t.Fatalf("verify: %v", err) + } + if !verified.IsValid { + t.Fatalf("verify invalid: %s", verified.InvalidReason) + } + if !strings.EqualFold(verified.Payer, pipe.payer) { + t.Fatalf("verify payer %s, want %s", verified.Payer, pipe.payer) + } + + settle, err := pipe.server.SettlePaymentWithExtensions( + context.Background(), + payload, + requirements, + nil, + nil, + x402.SettlePhaseBeforeHandler, + ) + if err != nil { + t.Fatalf("settle: %v", err) + } + if !settle.Success { + t.Fatalf("settle failed: %s %s", settle.ErrorReason, settle.ErrorMessage) + } + if settle.Transaction == "" { + t.Fatal("expected a transaction hash") + } + if settle.Network != batchedTestNetwork { + t.Fatalf("network %s", settle.Network) + } + if pipe.signer.writes.Load() == 0 { + t.Fatal("expected a broadcast") + } + + receipt, err := pipe.signer.WaitForTransactionReceipt(context.Background(), settle.Transaction) + if err != nil { + t.Fatalf("receipt: %v", err) + } + if !receiptAuthorizesOperator(t, receipt.Logs, authCaptureForwardingOperator) { + t.Fatalf("escrow PaymentAuthorized for %s not found", authCaptureForwardingOperator) + } + }) + + t.Run("noop operator rejects with no broadcast", func(t *testing.T) { + expectSimulationRejection(t, pipe, authCaptureNoopOperator) + }) + + t.Run("gas-wasting operator rejects with no broadcast", func(t *testing.T) { + expectSimulationRejection(t, pipe, authCaptureGasWastingOperator) + }) +} + +func expectSimulationRejection(t *testing.T, pipe *customOperatorPipeline, operator string) { + t.Helper() + pipe.signer.writes.Store(0) + payload, requirements := pipe.collect(t, operator) + _, err := pipe.verify(t, payload, requirements) + var verifyErr *x402.VerifyError + if err == nil || !errors.As(err, &verifyErr) || verifyErr.InvalidReason != authcapturefacilitator.ErrSimulationFailed { + t.Fatalf("verify error = %v, want %s", err, authcapturefacilitator.ErrSimulationFailed) + } + if pipe.signer.writes.Load() != 0 { + t.Fatalf("broadcasts = %d, want 0", pipe.signer.writes.Load()) + } +} + +func receiptAuthorizesOperator(t *testing.T, logs []*gethtypes.Log, operator string) bool { + t.Helper() + parsed, err := abi.JSON(bytes.NewReader(authcapture.AuthCaptureEscrowABIV1_1)) + if err != nil { + t.Fatalf("escrow ABI: %v", err) + } + event, ok := parsed.Events["PaymentAuthorized"] + if !ok { + t.Fatal("PaymentAuthorized missing from escrow ABI") + } + want := common.HexToAddress(operator) + escrow := common.HexToAddress(authcapture.AuthCaptureEscrowAddress) + for _, log := range logs { + if log.Address != escrow || len(log.Topics) == 0 || log.Topics[0] != event.ID { + continue + } + fields, err := event.Inputs.NonIndexed().Unpack(log.Data) + if err != nil || len(fields) == 0 { + continue + } + operator, ok := structAddressField(fields[0], "Operator") + if ok && operator == want { + return true + } + } + return false +} + +func structAddressField(value interface{}, name string) (common.Address, bool) { + field := reflect.ValueOf(value) + if field.Kind() == reflect.Pointer { + if field.IsNil() { + return common.Address{}, false + } + field = field.Elem() + } + if field.Kind() != reflect.Struct { + return common.Address{}, false + } + operator := field.FieldByName(name) + if !operator.IsValid() || !operator.CanInterface() { + return common.Address{}, false + } + address, ok := operator.Interface().(common.Address) + return address, ok +} diff --git a/go/test/integration/evm_auth_capture_test.go b/go/test/integration/evm_auth_capture_test.go new file mode 100644 index 0000000000..0d0f978b85 --- /dev/null +++ b/go/test/integration/evm_auth_capture_test.go @@ -0,0 +1,318 @@ +// This file tests the EVM auth-capture mechanism end to end with REAL Base Sepolia +// transactions: the client signs a collect payload, the facilitator (escrow operator) +// authorizes it before the handler runs, and the resource server's signed Capture or +// Void releases the escrowed funds. Tests skip when the env vars below are missing. +// +// Required env vars (shared with the other EVM integration tests): +// - EVM_CLIENT_PRIVATE_KEY, EVM_FACILITATOR_PRIVATE_KEY, EVM_RESOURCE_SERVER_ADDRESS +// +// Optional: EVM_RPC_URL (defaults to https://sepolia.base.org). +package integration_test + +import ( + "context" + "encoding/base64" + "encoding/hex" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + + x402 "github.com/x402-foundation/x402/go/v2" + x402http "github.com/x402-foundation/x402/go/v2/http" + nethttpmw "github.com/x402-foundation/x402/go/v2/http/nethttp" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" + authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" + authcaptureclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/client" + authcapturefacilitator "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/facilitator" + authcaptureserver "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture/server" + evmsigners "github.com/x402-foundation/x402/go/v2/signers/evm" +) + +const authCaptureAmount = 1000 + +// permit2AllowanceFloor is the allowance we top up to; Permit2 transfers draw it down. +var permit2AllowanceFloor = new(big.Int).Lsh(big.NewInt(1), 128) + +type authCapturePipeline struct { + keys *batchedTestKeys + clientSigner evm.ClientEvmSigner + facilitator *realFacilitatorEvmSigner + x402Client *x402.X402Client + x402Server *x402.X402ResourceServer +} + +func buildAuthCapturePipeline(t *testing.T, keys *batchedTestKeys) *authCapturePipeline { + t.Helper() + + clientSigner, err := evmsigners.NewClientSignerFromPrivateKey(keys.clientPK) + if err != nil { + t.Fatalf("client signer: %v", err) + } + facilitatorSigner, err := newRealFacilitatorEvmSigner(keys.facilitatorPK, keys.rpcURL) + if err != nil { + t.Fatalf("facilitator signer: %v", err) + } + receiverAuthorizerKey := newEphemeralPrivateKeyHex(t) + receiverAuthorizer, err := evmsigners.NewClientSignerFromPrivateKey(receiverAuthorizerKey) + if err != nil { + t.Fatalf("receiver authorizer signer: %v", err) + } + + x402Client := x402.Newx402Client() + x402Client.Register(batchedTestNetwork, authcaptureclient.NewAuthCaptureEvmScheme(clientSigner)) + + x402Facilitator := x402.Newx402Facilitator() + x402Facilitator.Register([]x402.Network{batchedTestNetwork}, authcapturefacilitator.NewAuthCaptureEvmScheme( + facilitatorSigner, + authcapturefacilitator.AuthCaptureEvmSchemeConfig{CaptureAuthorizer: facilitatorSigner.GetAddresses()[0]}, + )) + + x402Server := x402.Newx402ResourceServer(x402.WithFacilitatorClient(&localEvmFacilitatorClient{facilitator: x402Facilitator})) + x402Server.Register(batchedTestNetwork, authcaptureserver.NewAuthCaptureEvmScheme(&authcaptureserver.Config{ + ReceiverAuthorizerSigner: receiverAuthorizer, + })) + if err := x402Server.Initialize(context.Background()); err != nil { + t.Fatalf("server initialize: %v", err) + } + + return &authCapturePipeline{ + keys: keys, + clientSigner: clientSigner, + facilitator: facilitatorSigner, + x402Client: x402Client, + x402Server: x402Server, + } +} + +func newEphemeralPrivateKeyHex(t *testing.T) string { + t.Helper() + key, err := crypto.GenerateKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + return hex.EncodeToString(crypto.FromECDSA(key)) +} + +// serve starts the paid endpoint. handlerStatus is what the handler returns once the payment is +// authorized; the counter records how many times the handler ran. +func (p *authCapturePipeline) serve(transferMethod evm.AssetTransferMethod, handlerStatus int) (*httptest.Server, *atomic.Int32) { + handlerCalls := &atomic.Int32{} + var price interface{} = "$0.001" + if transferMethod == evm.AssetTransferMethodPermit2 { + price = map[string]interface{}{ + "amount": "1000", + "asset": batchedTestUSDC, + "extra": map[string]interface{}{ + "assetTransferMethod": string(evm.AssetTransferMethodPermit2), + "name": "USDC", + "version": "2", + }, + } + } + + routes := x402http.RoutesConfig{ + "GET /paid": { + Accepts: x402http.PaymentOptions{{ + Scheme: authcapture.SchemeAuthCapture, + Price: price, + Network: batchedTestNetwork, + PayTo: p.keys.receiver, + MaxTimeoutSeconds: 300, + }}, + Description: "auth-capture integration test", + MimeType: "application/json", + }, + } + mux := http.NewServeMux() + mux.HandleFunc("GET /paid", func(w http.ResponseWriter, _ *http.Request) { + handlerCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(handlerStatus) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "done"}) + }) + + handler := nethttpmw.PaymentMiddlewareFromHTTPServer( + x402http.Wrappedx402HTTPResourceServer(routes, p.x402Server), + nethttpmw.WithTimeout(90*time.Second), + nethttpmw.WithSyncFacilitatorOnStart(false), + )(mux) + return httptest.NewServer(handler), handlerCalls +} + +// get performs the paid request and returns the status, headers and body with the body already read. +func (p *authCapturePipeline) get(t *testing.T, url string) (int, http.Header, []byte) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) + defer cancel() + + httpClient := x402http.WrapHTTPClientWithPayment(&http.Client{}, x402http.Newx402HTTPClient(p.x402Client)) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + resp, err := httpClient.Do(req) + if err != nil { + t.Fatalf("HTTP request: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp.StatusCode, resp.Header, body +} + +func (p *authCapturePipeline) balance(t *testing.T, holder string) *big.Int { + t.Helper() + out, err := p.facilitator.ReadContract(context.Background(), batchedTestUSDC, evm.ERC20BalanceOfABI, "balanceOf", common.HexToAddress(holder)) + if err != nil { + t.Fatalf("balanceOf(%s): %v", holder, err) + } + balance, ok := out.(*big.Int) + if !ok { + t.Fatalf("unexpected balanceOf result type %T", out) + } + return balance +} + +// waitForBalance polls until holder's balance equals want, since the RPC can trail the receipt. +func (p *authCapturePipeline) waitForBalance(t *testing.T, holder string, want *big.Int) { + t.Helper() + deadline := time.Now().Add(45 * time.Second) + for { + if got := p.balance(t, holder); got.Cmp(want) == 0 { + return + } else if time.Now().After(deadline) { + t.Fatalf("balance of %s = %s, want %s", holder, got, want) + } + time.Sleep(2 * time.Second) + } +} + +func decodeSettleHeader(t *testing.T, headers http.Header) x402.SettleResponse { + t.Helper() + header := headers.Get("PAYMENT-RESPONSE") + if header == "" { + t.Fatal("expected PAYMENT-RESPONSE header") + } + raw, err := base64.StdEncoding.DecodeString(header) + if err != nil { + t.Fatalf("decode PAYMENT-RESPONSE: %v", err) + } + var settle x402.SettleResponse + if err := json.Unmarshal(raw, &settle); err != nil { + t.Fatalf("unmarshal PAYMENT-RESPONSE: %v", err) + } + return settle +} + +func (p *authCapturePipeline) prepare(t *testing.T, transferMethod evm.AssetTransferMethod) { + t.Helper() + ctx := context.Background() + waitForPendingTransactions(t, ctx, p.keys.facilitatorPK, p.keys.rpcURL) + if transferMethod == evm.AssetTransferMethodPermit2 { + setPermit2Allowance(t, ctx, p.keys.clientPK, batchedTestUSDC, p.keys.rpcURL, permit2AllowanceFloor) + } +} + +// revokePermit2AfterTest leaves the payer without a Permit2 allowance, the state the other EVM +// integration tests assume when they exercise the EIP-2612 gas-sponsoring path. +func revokePermit2AfterTest(t *testing.T, keys *batchedTestKeys) { + t.Helper() + t.Cleanup(func() { + revokePermit2Approval(t, context.Background(), keys.clientPK, batchedTestUSDC, keys.rpcURL) + }) +} + +func authCaptureTransferMethods() []evm.AssetTransferMethod { + return []evm.AssetTransferMethod{evm.AssetTransferMethodEIP3009, evm.AssetTransferMethodPermit2} +} + +func TestAuthCaptureIntegration_AuthorizeThenCapture(t *testing.T) { + keys := loadBatchedTestKeys(t) + revokePermit2AfterTest(t, keys) + if strings.EqualFold(keys.receiver, mustAddressOf(t, keys.clientPK)) { + t.Skip("EVM_RESOURCE_SERVER_ADDRESS must differ from the payer to observe the capture") + } + + for _, method := range authCaptureTransferMethods() { + t.Run(string(method), func(t *testing.T) { + pipe := buildAuthCapturePipeline(t, keys) + pipe.prepare(t, method) + srv, handlerCalls := pipe.serve(method, http.StatusOK) + defer srv.Close() + + payer := pipe.clientSigner.Address() + payerBefore := pipe.balance(t, payer) + receiverBefore := pipe.balance(t, keys.receiver) + + status, header, body := pipe.get(t, srv.URL+"/paid") + if status != http.StatusOK { + t.Fatalf("expected 200 after payment, got %d: %s", status, body) + } + if got := handlerCalls.Load(); got != 1 { + t.Fatalf("handler ran %d times, want 1", got) + } + settle := decodeSettleHeader(t, header) + if !settle.Success || settle.Transaction == "" { + t.Fatalf("expected successful capture settlement, got %+v", settle) + } + if !strings.EqualFold(settle.Payer, payer) { + t.Fatalf("settlement payer = %s, want %s", settle.Payer, payer) + } + t.Logf("captured, tx=%s", settle.Transaction) + + amount := big.NewInt(authCaptureAmount) + pipe.waitForBalance(t, payer, new(big.Int).Sub(payerBefore, amount)) + pipe.waitForBalance(t, keys.receiver, new(big.Int).Add(receiverBefore, amount)) + }) + } +} + +func TestAuthCaptureIntegration_VoidOnHandlerFailure(t *testing.T) { + keys := loadBatchedTestKeys(t) + revokePermit2AfterTest(t, keys) + if strings.EqualFold(keys.receiver, mustAddressOf(t, keys.clientPK)) { + t.Skip("EVM_RESOURCE_SERVER_ADDRESS must differ from the payer to observe the void") + } + + for _, method := range authCaptureTransferMethods() { + t.Run(string(method), func(t *testing.T) { + pipe := buildAuthCapturePipeline(t, keys) + pipe.prepare(t, method) + srv, handlerCalls := pipe.serve(method, http.StatusInternalServerError) + defer srv.Close() + + payer := pipe.clientSigner.Address() + payerBefore := pipe.balance(t, payer) + receiverBefore := pipe.balance(t, keys.receiver) + + status, _, _ := pipe.get(t, srv.URL+"/paid") + if status == http.StatusOK { + t.Fatalf("expected a failure status when the handler fails, got %d", status) + } + + if got := handlerCalls.Load(); got != 1 { + t.Fatalf("handler ran %d times, want 1 (payment must be authorized before the handler)", got) + } + + // The escrow hold is voided back to the payer; the receiver is never paid. + pipe.waitForBalance(t, payer, payerBefore) + if got := pipe.balance(t, keys.receiver); got.Cmp(receiverBefore) != 0 { + t.Fatalf("receiver balance changed on void: %s -> %s", receiverBefore, got) + } + }) + } +} + +func mustAddressOf(t *testing.T, privateKeyHex string) string { + t.Helper() + signer, err := evmsigners.NewClientSignerFromPrivateKey(privateKeyHex) + if err != nil { + t.Fatalf("client signer: %v", err) + } + return signer.Address() +} diff --git a/go/test/integration/evm_test.go b/go/test/integration/evm_test.go index 67c17417cb..dc3edd9d18 100644 --- a/go/test/integration/evm_test.go +++ b/go/test/integration/evm_test.go @@ -1133,6 +1133,13 @@ func waitForPendingTransactions(t *testing.T, ctx context.Context, privateKeyHex // revokePermit2Approval sets the Permit2 allowance to 0 so the test exercises the settleWithPermit path. func revokePermit2Approval(t *testing.T, ctx context.Context, clientPrivateKey string, tokenAddress string, rpcURL string) { t.Helper() + setPermit2Allowance(t, ctx, clientPrivateKey, tokenAddress, rpcURL, big.NewInt(0)) +} + +// setPermit2Allowance approves Permit2 for target when the current allowance is below it (or, for a +// zero target, revokes any existing allowance). +func setPermit2Allowance(t *testing.T, ctx context.Context, clientPrivateKey string, tokenAddress string, rpcURL string, target *big.Int) { + t.Helper() privateKeyHex := strings.TrimPrefix(clientPrivateKey, "0x") privateKey, err := crypto.HexToECDSA(privateKeyHex) @@ -1170,20 +1177,24 @@ func revokePermit2Approval(t *testing.T, ctx context.Context, clientPrivateKey s } allowance := new(big.Int).SetBytes(result) - if allowance.Sign() == 0 { + if target.Sign() == 0 && allowance.Sign() == 0 { t.Logf("✅ Permit2 allowance already revoked") return } + if target.Sign() > 0 && allowance.Cmp(target) >= 0 { + t.Logf("✅ Permit2 allowance already sufficient") + return + } - t.Logf("🔓 Revoking Permit2 approval (current allowance: %s)...", allowance.String()) + t.Logf("🔓 Setting Permit2 allowance to %s (current allowance: %s)...", target.String(), allowance.String()) - // Build approve(PERMIT2, 0) transaction + // Build approve(PERMIT2, target) transaction approveABI, err := abi.JSON(strings.NewReader(string(evm.ERC20ApproveABI))) if err != nil { t.Fatalf("Failed to parse ERC20 approve ABI: %v", err) } - approveData, err := approveABI.Pack("approve", permit2Addr, big.NewInt(0)) + approveData, err := approveABI.Pack("approve", permit2Addr, target) if err != nil { t.Fatalf("Failed to pack approve call: %v", err) } @@ -1206,15 +1217,15 @@ func revokePermit2Approval(t *testing.T, ctx context.Context, clientPrivateKey s tx := ethtypes.NewTransaction(nonce, tokenAddr, big.NewInt(0), 100000, gasPrice, approveData) signedTx, err := ethtypes.SignTx(tx, ethtypes.LatestSignerForChainID(chainID), privateKey) if err != nil { - t.Fatalf("Failed to sign revoke transaction: %v", err) + t.Fatalf("Failed to sign approve transaction: %v", err) } err = ethClient.SendTransaction(ctx, signedTx) if err != nil { - t.Fatalf("Failed to send revoke transaction: %v", err) + t.Fatalf("Failed to send approve transaction: %v", err) } - t.Logf("📤 Revoke tx sent: %s", signedTx.Hash().Hex()) + t.Logf("📤 Approve tx sent: %s", signedTx.Hash().Hex()) deadline := time.Now().Add(2 * time.Minute) ticker := time.NewTicker(2 * time.Second) @@ -1224,19 +1235,19 @@ func revokePermit2Approval(t *testing.T, ctx context.Context, clientPrivateKey s receipt, err := ethClient.TransactionReceipt(ctx, signedTx.Hash()) if err == nil && receipt != nil { if receipt.Status == 1 { - t.Logf("✅ Permit2 approval revoked in block %d", receipt.BlockNumber.Uint64()) + t.Logf("✅ Permit2 allowance set in block %d", receipt.BlockNumber.Uint64()) return } - t.Fatalf("Permit2 revoke transaction reverted (status=0)") + t.Fatalf("Permit2 approve transaction reverted (status=0)") } if time.Now().After(deadline) { - t.Fatalf("Permit2 revoke transaction not mined after 2 minutes") + t.Fatalf("Permit2 approve transaction not mined after 2 minutes") } select { case <-ctx.Done(): - t.Fatalf("Context cancelled waiting for revoke receipt") + t.Fatalf("Context cancelled waiting for approve receipt") case <-ticker.C: } } @@ -2367,3 +2378,13 @@ func TestEVMIntegrationV2UptoPermit2_SettlementPendingReconciliation(t *testing. firstTxHash, settleResponse.Transaction) } } + +// ReadContractFrom ignores from: this signer holds a single address. +func (r *realFacilitatorEvmSigner) ReadContractFrom(ctx context.Context, _, address string, abiJSON []byte, functionName string, args ...interface{}) (interface{}, error) { + return r.ReadContract(ctx, address, abiJSON, functionName, args...) +} + +// WriteContractFrom ignores from: this signer holds a single address. +func (r *realFacilitatorEvmSigner) WriteContractFrom(ctx context.Context, _, address string, abiJSON []byte, functionName string, dataSuffix []byte, args ...interface{}) (string, error) { + return r.WriteContract(ctx, address, abiJSON, functionName, dataSuffix, args...) +} diff --git a/specs/extensions/builder_code.md b/specs/extensions/builder_code.md index 15a083662a..91068c1d80 100644 --- a/specs/extensions/builder_code.md +++ b/specs/extensions/builder_code.md @@ -4,7 +4,7 @@ The `builder-code` extension enables **on-chain attribution tracking** for x402 payments by appending [ERC-8021](https://eip.tools/eip/8021) Schema 2 builder codes to settlement transaction calldata. It attributes which application exposed the paid endpoint and which facilitator settled the payment. -This extension implements **Schema 2** (CBOR-encoded) of ERC-8021. The `m` (custom metadata) and `r` (custom registries) fields are not supported. +This extension implements **Schema 2** (CBOR-encoded) of ERC-8021. The `r` (custom registries) field is not supported. The `m` (custom metadata) field is written by the facilitator only, see [Settlement Metadata](#settlement-metadata-m). --- @@ -32,6 +32,7 @@ Wire order: `[cborData][cborLength (2B)][schemaId (1B)][ercMarker (16B)]` | `a` | string | App code — the application that exposed the paid endpoint | | `w` | string | Wallet code — the facilitator that settled the payment on-chain | | `s` | string or array of strings | Service code(s) — client-provided attribution | +| `m` | map | Settlement metadata — facilitator-authored, see [Settlement Metadata](#settlement-metadata-m) | All fields are optional. @@ -170,8 +171,9 @@ When a facilitator settles a payment containing the `builder-code` extension, it 1. Reads `a` (app code) and `s` (service codes) from the payment payload extensions 2. Adds its own builder code as the `w` (wallet) field 3. Optionally appends its own service code to `s` (deduped against the echoed entries), up to its `MAX_FACILITATOR_SERVICE_CODES` reservation -4. Encodes the combined data as an ERC-8021 Schema 2 CBOR suffix -5. Appends the suffix to the settlement transaction calldata +4. Adds `m` when the settling mechanism supplies [settlement metadata](#settlement-metadata-m) +5. Encodes the combined data as an ERC-8021 Schema 2 CBOR suffix +6. Appends the suffix to the settlement transaction calldata The facilitator's builder code and service code are configured at initialization and validated against the same `^[a-z0-9_]{1,32}$` pattern. @@ -179,11 +181,22 @@ The facilitator's builder code and service code are configured at initialization The facilitator builds the suffix as follows: -1. CBOR-encode a map containing all present fields (`a`, `s`, `w`) +1. CBOR-encode a map containing all present fields (`a`, `s`, `w`, `m`) 2. Compute `cborLength` as the byte length of the CBOR data (2 bytes, big-endian) 3. Append: `[cborData][cborLength][0x02][80218021802180218021802180218021]` 4. Return the hex-encoded result for the settlement mechanism to append to calldata +A suffix is emitted when any of `a`, `w`, `s`, or `m` is present, so metadata alone is enough. + +### Settlement Metadata (`m`) + +- Only the facilitator writes `m`, at settlement time, from metadata supplied by the settling mechanism. `m` never appears in `PaymentRequired` or `PaymentPayload`, and the facilitator MUST ignore any `m` found in a payload. +- Keys inside `m` are defined by the scheme spec that emits them and MUST start with `x402`. +- Values MUST be unsigned integers, text strings, arrays, or maps with text keys. Negative integers, floats, tags, byte strings, and indefinite-length items are not allowed. +- Encoding MUST be deterministic (RFC 8949 section 4.2.1): shortest-form arguments, and map keys sorted bytewise by their encoded form. Top-level order is `a`, `w`, `s`, `m`. +- The CBOR data MUST fit in 65,535 bytes (the 2-byte `cborLength`). Encoders MUST fail rather than truncate. +- `m` is public, facilitator-reported data that no contract validates. + --- ## Protocol Flow @@ -270,6 +283,21 @@ Decoded: - schemaId: `0x02` - marker: `80218021802180218021802180218021` +### App + Facilitator + Settlement Metadata + +After the settling mechanism supplies `{"x402Example": 7}` as metadata: + +``` +{original_calldata} a361616862635f6d7961707061777062635f6d79666163696c697461746f72616da16b783430324578616d706c6507 002f 02 80218021802180218021802180218021 +``` + +Decoded: + +- CBOR: `{"a": "bc_myapp", "w": "bc_myfacilitator", "m": {"x402Example": 7}}` +- cborLength: `0x002f` (47 bytes) +- schemaId: `0x02` +- marker: `80218021802180218021802180218021` + --- ## Validation @@ -306,7 +334,7 @@ Off-chain parsers can extract builder code attribution from settlement calldata 3. Extract the preceding 2 bytes as `cborLength` (big-endian) 4. Extract the preceding `cborLength` bytes as `cborData` 5. Decode `cborData` as a CBOR map -6. Read `a` (app code), `w` (wallet code), and `s` (service codes array) from the map +6. Read `a` (app code), `w` (wallet code), `s` (service codes array), and `m` (settlement metadata) from the map --- @@ -316,4 +344,4 @@ Off-chain parsers can extract builder code attribution from settlement calldata | --------------- | ----------------------------------------------------------------------------------------------------------- | | **Application** | Declares `a` (app code) per-route in the payment middleware configuration, and optionally up to `MAX_SERVER_SERVICE_CODES` of its own service code(s) as `s` (e.g. attribution for a server-side SDK) | | **Client** | Attaches up to `MAX_CLIENT_SERVICE_CODES` service code(s) as `s` when `BuilderCodeClientExtension` is registered; echoes `a` only when the server declared `builder-code` | -| **Facilitator** | Adds `w` (wallet code) at settlement, optionally appends up to `MAX_FACILITATOR_SERVICE_CODES` of its own service code(s) to `s`, encodes the full CBOR suffix (`a`, `s`, `w`), appends to calldata | +| **Facilitator** | Adds `w` (wallet code) at settlement, optionally appends up to `MAX_FACILITATOR_SERVICE_CODES` of its own service code(s) to `s`, adds `m` when the settling mechanism supplies settlement metadata, encodes the full CBOR suffix (`a`, `s`, `w`, `m`), appends to calldata | diff --git a/specs/schemes/auth-capture/scheme_auth_capture_evm.md b/specs/schemes/auth-capture/scheme_auth_capture_evm.md index 2739f01b80..7d9f3aabdd 100644 --- a/specs/schemes/auth-capture/scheme_auth_capture_evm.md +++ b/specs/schemes/auth-capture/scheme_auth_capture_evm.md @@ -425,6 +425,8 @@ Lifecycle payloads are bind-on: they carry `saltNonce` next to `paymentInfo.salt `voidAuthorizerSignature` is OPTIONAL and present only for a sync partial close-out: when set, this single `/settle` performs `capture` and then `void` on the remaining hold. It is the `Void` digest for the same `paymentInfoHash`, verified like a standalone `void`. The capture leg uses the same single-use balance check as a capture-only settle; the following `void` needs no replay key of its own. Omit it for a capture-only settle (full capture, or a partial that leaves the hold for later). +`voidRemainder: true` is the delegated alternative to `voidAuthorizerSignature`: the server omits both signatures and the facilitator, after authenticating the request, produces them under the delegation rule in [Authorizer signatures](#authorizer-signatures). The two fields are mutually exclusive, and `voidRemainder` is valid only when the authorizer is delegated: alongside any signature the payload is malformed (`invalid_auth_capture_evm_payload_format`), and at a facilitator that does not hold the `receiverAuthorizer` key it fails with `invalid_auth_capture_evm_authorizer_signature`. The rules for `voidAuthorizerSignature` otherwise apply to it. + ### `void` ```json @@ -496,7 +498,7 @@ For an admitted lifecycle payload, the facilitator repeats the scheme, network, 2. **Salt binding**: `payload.paymentInfo.salt === uint256(keccak256(abi.encode(SALT_BINDING_TYPEHASH, extra.receiverAuthorizer, extra.policy, payload.saltNonce)))`. There is no client signature here to enforce this transitively, so it is an explicit check. 3. **Requirements match**: every remaining `paymentInfo` field equals what `extra` and the top-level requirements dictate. 4. **Authorizer signature**: the operation's signature recovers to `extra.receiverAuthorizer`, or the facilitator produces it under the delegation rule in [Authorizer signatures](#authorizer-signatures). -5. `voidAuthorizerSignature` (capture only): if present, it MUST recover to `extra.receiverAuthorizer` over the `Void` digest, and it falls under the same delegation rule as step 4. It MUST NOT appear on `void` or `refund` payloads. +5. `voidAuthorizerSignature` (capture only): if present, it MUST recover to `extra.receiverAuthorizer` over the `Void` digest, and it falls under the same delegation rule as step 4. It MUST NOT appear on `void` or `refund` payloads. `voidRemainder: true` stands in for it when the authorizer is delegated, is mutually exclusive with it, and is otherwise rejected (see [`capture`](#capture)). 6. **Operation preconditions**, read from `AuthCaptureEscrow.paymentState(paymentInfoHash)` and `paymentInfo`: | Operation | Preconditions | @@ -505,7 +507,7 @@ For an admitted lifecycle payload, the facilitator repeats the scheme, network, | `void` | `capturableAmount > 0` | | `refund` | `now < refundExpiry`; `0 < amount <= refundableAmount`; the single-use balance check; refund liquidity available per [Refund funding](#refund-funding) | - When `voidAuthorizerSignature` is present, apply the `capture` row to `payload.amount`, and require `amount < capturableAmount` so a hold remains for `void` (a full capture omits the field). Simulation covers both legs. + When `voidAuthorizerSignature` is present, or `voidRemainder` is set, apply the `capture` row to `payload.amount`, and require `amount < capturableAmount` so a hold remains for `void` (a full capture omits the field). Simulation covers both legs. 7. **Simulate** the settlement call and require success. @@ -554,7 +556,7 @@ Facilitator-relayed refunds use `OperatorRefundCollector`, which pulls the refun ``` - `signers` is the authoritative set of addresses the facilitator submits transactions from. A smart-contract account the facilitator submits from MAY appear here. It is not where a server reads `extra.captureAuthorizer`: `signers` is response-level and family-keyed, while submitter sets are not in general uniform across the chains a facilitator serves. -- `extra.captureAuthorizer` is the address the facilitator commits to submitting from on this network. It is REQUIRED unless `extra.operators` is non-empty, which is the relay-only case where the facilitator never acts as operator itself: a kind without it admits nothing but `operatorType: "delegated"`, and a server has no other source for the submitter address, so a kind carrying neither is malformed. A server using that kind for `operatorType: "delegated"` copies it verbatim into its payment requirements. A facilitator that advertises a `captureAuthorizer` MUST submit `"delegated"` calls from it. A facilitator MAY vary which submitter it advertises between `/supported` responses to spread load across its keys. Because the address is committed onchain as `PaymentInfo.operator` and is the only address the escrow accepts for that payment's `capture`, `void`, and `refund`, a facilitator MUST remain able to submit from an address it has advertised until every payment that used it has passed its `refundDeadline`. Retiring a submitter earlier strands outstanding holds: nothing can capture or void them, and the payer recovers the funds only by calling `reclaim` after the capture deadline. A facilitator that rotates submitters and also relays `type: "refund"` for `"delegated"` MUST fund and approve **every** address in the rotation, since `OperatorRefundCollector` pulls with `safeTransferFrom(token, PaymentInfo.operator, tokenStore, amount)`. +- `extra.captureAuthorizer` is the address the facilitator commits to submitting from on this network. It is REQUIRED unless `extra.operators` is non-empty, which is the relay-only case where the facilitator never acts as operator itself: a kind without it admits nothing but `operatorType: "delegated"`, and a server has no other source for the submitter address, so a kind carrying neither is malformed. A server using that kind for `operatorType: "delegated"` copies it verbatim into its payment requirements. A facilitator that advertises a `captureAuthorizer` MUST submit `"delegated"` calls from it. A facilitator MAY vary which submitter it advertises between `/supported` responses to spread load across its keys, for example by picking one at random from a pool on every response. A server MUST copy the address from the response it read and MUST NOT assume it is stable across responses. A facilitator that does so MUST send every later `capture`, `void` and `refund` of a payment from that payment's `PaymentInfo.operator`, not from another address of the pool, and MUST keep every pool address funded for gas. Because the address is committed onchain as `PaymentInfo.operator` and is the only address the escrow accepts for that payment's `capture`, `void`, and `refund`, a facilitator MUST remain able to submit from an address it has advertised until every payment that used it has passed its `refundDeadline`. Retiring a submitter earlier strands outstanding holds: nothing can capture or void them, and the payer recovers the funds only by calling `reclaim` after the capture deadline. A facilitator that rotates submitters and also relays `type: "refund"` for `"delegated"` MUST fund and approve **every** address in the rotation, since `OperatorRefundCollector` pulls with `safeTransferFrom(token, PaymentInfo.operator, tokenStore, amount)`. - `extra.receiverAuthorizer` is OPTIONAL: an address the facilitator will sign with on a server's behalf, which a server MAY adopt in place of an authorizer it owns under [Authorizer signatures](#authorizer-signatures). A facilitator MUST NOT advertise one unless it can authenticate those requests out of band, for example with SIWX, a JWT, or an API credential bound at payment creation. A payment commits to both addresses — `receiverAuthorizer` through the salt, `captureAuthorizer` as `PaymentInfo.operator` — so one that delegates both is captive to this facilitator until its `refundDeadline`, and a server changing facilitators SHOULD let outstanding payments finalize first. - `extra.feeRecipient`, `extra.minFeeBps`, and `extra.maxFeeBps` are OPTIONAL facilitator fee terms. A server using that supported kind MUST copy all three verbatim into its payment requirements. Equal bounds fix the fee and are what a facilitator SHOULD advertise; unequal bounds ask the server to grant free choice up to `maxFeeBps`, since nothing onchain holds the facilitator to a narrower value. Omission means the facilitator claims no fee, and a `"delegated"` server publishes zero bounds per [Fee system](#fee-system). - `extra.operators` is an OPTIONAL allowlist of the contract operators the facilitator will relay for, each entry pairing an address with the type it is admitted as. Omitted or `[]` admits no contract operator at all, leaving only `operatorType: "delegated"` with the facilitator's own submitter. `"address": "*"` admits every contract of that type; the wildcard MUST be written out, and an empty list MUST NOT be read as one. A facilitator MAY advertise the wildcard, but it is not recommended: admission then extends to contract code the facilitator has never reviewed, bounded only by the requirements below. @@ -577,9 +579,9 @@ The scheme uses the standard x402 error codes plus the following. Every reason t | Error Code | Description | | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `invalid_auth_capture_evm_payload_format` | Payload matches neither the EIP-3009 nor the Permit2 shape, omits `salt`, omits `saltNonce` when bound (unless probing), carries it unbound, or carries the wrong fee field for the resolved deployment (`feeBps` on v1.1 extra, or `feeAmount` on a v1.0 extra pin). | +| `invalid_auth_capture_evm_payload_format` | Payload matches neither the EIP-3009 nor the Permit2 shape, omits `salt`, omits `saltNonce` when bound (unless probing), carries it unbound, sets `voidRemainder` with a signature or to a value other than `true`, or carries the wrong fee field for the resolved deployment (`feeBps` on v1.1 extra, or `feeAmount` on a v1.0 extra pin). | | `invalid_auth_capture_evm_payload_type` | A lifecycle payload's `payload.type` is missing or is not `"capture"`, `"void"`, or `"refund"`. | -| `invalid_auth_capture_evm_void_authorizer_signature` | `voidAuthorizerSignature` is present on a non-capture payload, or does not recover to `extra.receiverAuthorizer` over the `Void` digest. | +| `invalid_auth_capture_evm_void_authorizer_signature` | `voidAuthorizerSignature` is present on a non-capture payload, or does not recover to `extra.receiverAuthorizer` over the `Void` digest. | | `invalid_auth_capture_evm_void_remainder_full_capture` | `voidAuthorizerSignature` is present but `amount` equals the full `capturableAmount`, leaving nothing to void. | | `invalid_auth_capture_evm_unsupported_payment_flow` | Resolved `paymentFlow` is neither `"escrow"` nor `"authorization"`, or `extra` carries the removed `autoCapture: true`. | | `invalid_auth_capture_evm_scheme` | Scheme is not `auth-capture`. | @@ -596,6 +598,9 @@ The scheme uses the standard x402 error codes plus the following. Every reason t | `invalid_auth_capture_evm_salt_binding_mismatch` | Bound `paymentInfo.salt` is not the salt derived from `receiverAuthorizer`, `policy`, and `saltNonce`. | | `invalid_auth_capture_evm_authorizer_signature` | The authorizer signature does not recover to `extra.receiverAuthorizer`. | | `invalid_auth_capture_evm_unauthenticated_authorizer_request` | The authorizer is delegated to the facilitator and the `charge` or lifecycle request carries no out-of-band authentication. | +| `invalid_auth_capture_evm_delegated_auth_unavailable` | The authorizer is delegated to the facilitator and its caller-binding store could not be read or written, so the request cannot be authenticated. Retryable. | +| `invalid_auth_capture_evm_payment_info_mismatch` | A lifecycle payload's `paymentInfo` differs from what `extra` and the top-level requirements dictate (receiver, token, maximum amount, fee terms or deadlines). | +| `invalid_auth_capture_evm_payload_undeployed_smart_wallet` | The payer is a counterfactual smart wallet whose signature cannot be verified, or the operator type does not support counterfactual wallets. | | `invalid_auth_capture_evm_unexpected_payment_state` | Observed `capturableAmount` or `refundableAmount` differs from the signed expectation. | | `invalid_auth_capture_evm_refund_funding_unavailable` | No refund liquidity path exists for the declared operator type. | | `invalid_auth_capture_evm_unsupported_asset_transfer_method` | `assetTransferMethod` is neither `"eip3009"` nor `"permit2"`. | @@ -652,6 +657,7 @@ When simulation reverts with a custom error declared in the call's ABI, the faci | ----------------------------------------------- | ------------------------------------------------ | | `invalid_auth_capture_evm_verification_failed` | Re-verification before settlement failed. | | `invalid_auth_capture_evm_transaction_reverted` | Onchain transaction reverted after confirmation. | +| `smart_wallet_deployment_failed` | The factory call that deploys a counterfactual payer failed. A shared x402 code, so it is not namespaced `invalid_auth_capture_evm_*`. | ## Appendix diff --git a/typescript/.changeset/auth-capture-v1.1.md b/typescript/.changeset/auth-capture-v1.1.md new file mode 100644 index 0000000000..26f4261fc0 --- /dev/null +++ b/typescript/.changeset/auth-capture-v1.1.md @@ -0,0 +1,7 @@ +--- +"@x402/evm": minor +--- + +Bring the EVM auth-capture SDK to the v1.1 proposed spec: `paymentFlow` / `captureMode` replace `autoCapture`, facilitator-relayed capture/void/refund, salt binding, and namespaced `invalid_auth_capture_evm_*` error codes. + +**Breaking:** `extra.autoCapture` is rejected. Error reasons move from `invalid_*` to `invalid_auth_capture_evm_*`. Settle target is selected by `operatorType`, not `getCode`. Deadlines must be both-absolute or both-relative (mixing throws). Escrow routes with `operatorType: "custom"` must set `captureMode: "deferred"`; the sync default now throws at enhance time rather than escrowing funds the facilitator will not finalize. diff --git a/typescript/.changeset/builder-code-metadata.md b/typescript/.changeset/builder-code-metadata.md new file mode 100644 index 0000000000..9c3fd2794c --- /dev/null +++ b/typescript/.changeset/builder-code-metadata.md @@ -0,0 +1,6 @@ +--- +"@x402/extensions": minor +"@x402/evm": minor +--- + +Added facilitator-authored settlement metadata (`m`) to the builder-code ERC-8021 Schema 2 suffix. `DataSuffixContext` accepts `metadata`, `encodeBuilderCodeSuffix` and `parseBuilderCodeSuffixFromCalldata` handle `m`, and encoding now throws instead of truncating when the CBOR exceeds 65,535 bytes. diff --git a/typescript/packages/extensions/src/builder-code/README.md b/typescript/packages/extensions/src/builder-code/README.md index 1ec9f6b8ec..c2e7540d89 100644 --- a/typescript/packages/extensions/src/builder-code/README.md +++ b/typescript/packages/extensions/src/builder-code/README.md @@ -86,7 +86,8 @@ import { parseBuilderCodeSuffixFromCalldata } from "@x402/extensions/builder-cod const data = parseBuilderCodeSuffixFromCalldata(calldata); if (data) { - // { a?: "bc_my_service", w?: "bc_my_facilitator", s?: ["bc_my_client"] } + // { a?: "bc_my_service", w?: "bc_my_facilitator", s?: ["bc_my_client"], m?: { ... } } + // unsigned integers inside `m` are returned as bigint } ``` @@ -106,7 +107,7 @@ Creates the `{ info: { a, s? }, schema }` declaration for `PaymentRequired.exten ### `encodeBuilderCodeSuffix(data)` / `parseBuilderCodeSuffixFromCalldata(calldata)` -Low-level CBOR helpers to encode a `BuilderCodeExtensionData` object into an ERC-8021 suffix and to parse the suffix back out of settlement calldata. +Low-level CBOR helpers to encode a `BuilderCodeSuffixData` object into an ERC-8021 suffix and to parse the suffix back out of settlement calldata. Encoding throws when the CBOR exceeds 65,535 bytes. ### Constants and types @@ -117,7 +118,7 @@ Low-level CBOR helpers to encode a `BuilderCodeExtensionData` object into an ERC - `MAX_FACILITATOR_SERVICE_CODES` — `1` (facilitator's dedicated `s` reservation) - `MAX_SERVICE_CODES` — `11` (on-chain cap for `s`; the sum of each side's reservation) - `ERC_8021_MARKER`, `SCHEMA_2_ID`, `BUILDER_CODE_SCHEMA` -- Types: `BuilderCodeExtensionData`, `BuilderCodeFacilitatorConfig`, `BuilderCodeRequiredExtension`, `DataSuffixContext` +- Types: `BuilderCodeExtensionData`, `BuilderCodeSuffixData`, `SettlementMetadata`, `BuilderCodeFacilitatorConfig`, `BuilderCodeRequiredExtension`, `DataSuffixContext` See [`index.ts`](./index.ts) for the full list of exports. diff --git a/typescript/packages/extensions/src/builder-code/cbor.ts b/typescript/packages/extensions/src/builder-code/cbor.ts index 99c4f58b52..08a42f1a4e 100644 --- a/typescript/packages/extensions/src/builder-code/cbor.ts +++ b/typescript/packages/extensions/src/builder-code/cbor.ts @@ -8,10 +8,29 @@ * "a" — app builder code (string) * "w" — wallet/facilitator builder code (string) * "s" — service codes (string array) + * "m" — facilitator-authored settlement metadata (map of unsigned integers, strings, arrays, and maps) */ import { type Hex } from "viem"; -import { ERC_8021_MARKER, SCHEMA_2_ID, type BuilderCodeExtensionData } from "./types"; +import { + ERC_8021_MARKER, + SCHEMA_2_ID, + type BuilderCodeExtensionData, + type SettlementMetadata, + type SettlementMetadataValue, + type BuilderCodeSuffixData, +} from "./types"; + +const MAX_CBOR_LENGTH = 0xffff; +const MAX_UINT64 = (1n << 64n) - 1n; + +/** CBOR additional-information values 24-27 and the byte width of the argument each introduces. */ +const CBOR_ARGUMENT_WIDTHS = [ + { info: 24, bytes: 1 }, + { info: 25, bytes: 2 }, + { info: 26, bytes: 4 }, + { info: 27, bytes: 8 }, +]; /** * Normalizes the `s` field (string or array of strings) into an array of strings. @@ -32,13 +51,14 @@ function normalizeServiceCodes(s: BuilderCodeExtensionData["s"]): string[] { * - "a" key (major type 3, length 1) → string value (app/service code) * - "w" key (major type 3, length 1) → string value (facilitator code) * - "s" key (major type 3, length 1) → array of strings (related services) + * - "m" key (major type 3, length 1) → map of settlement metadata * * Uses hand-rolled CBOR to avoid adding a dependency. * - * @param data - Builder code extension fields to encode + * @param data - Builder code suffix fields to encode * @returns CBOR-encoded map bytes */ -function encodeCborMap(data: BuilderCodeExtensionData): Uint8Array { +function encodeCborMap(data: BuilderCodeSuffixData): Uint8Array { const entries: Uint8Array[] = []; let mapSize = 0; @@ -61,6 +81,12 @@ function encodeCborMap(data: BuilderCodeExtensionData): Uint8Array { entries.push(encodeCborArray(serviceCodes)); } + if (data.m) { + mapSize++; + entries.push(encodeCborString("m")); + entries.push(encodeCborValue(data.m)); + } + // CBOR map header const header = encodeCborMajorType(5, mapSize); // major type 5 = map @@ -126,25 +152,138 @@ function encodeCborArray(values: string[]): Uint8Array { * - 0-23: single byte (major type << 5 | value) * - 24-255: two bytes (major type << 5 | 24, value) * - 256-65535: three bytes (major type << 5 | 25, value high, value low) + * - 65536-2^32-1: five bytes (major type << 5 | 26, 4-byte big-endian value) + * - 2^32-2^64-1: nine bytes (major type << 5 | 27, 8-byte big-endian value) * * @param majorType - CBOR major type (0–7) * @param value - Argument length or inline value for the header * @returns CBOR header bytes */ -function encodeCborMajorType(majorType: number, value: number): Uint8Array { +function encodeCborMajorType(majorType: number, value: number | bigint): Uint8Array { const mt = majorType << 5; + const argument = BigInt(value); - if (value <= 23) { - return new Uint8Array([mt | value]); + if (argument < 0n || argument > MAX_UINT64) { + throw new Error(`CBOR value out of range: ${value}`); } - if (value <= 0xff) { - return new Uint8Array([mt | 24, value]); + if (argument <= 23n) { + return new Uint8Array([mt | Number(argument)]); } - if (value <= 0xffff) { - return new Uint8Array([mt | 25, (value >> 8) & 0xff, value & 0xff]); + + const width = CBOR_ARGUMENT_WIDTHS.find(({ bytes }) => argument < 1n << BigInt(bytes * 8)); + if (!width) { + throw new Error(`CBOR value out of range: ${value}`); } - throw new Error(`CBOR value too large: ${value}`); + const header = new Uint8Array(1 + width.bytes); + header[0] = mt | width.info; + for (let i = 0; i < width.bytes; i++) { + header[width.bytes - i] = Number((argument >> BigInt(i * 8)) & 0xffn); + } + return header; +} + +/** + * Concatenates byte arrays in order. + * + * @param parts - Byte arrays to join + * @returns Single byte array holding all parts + */ +function concatBytes(parts: Uint8Array[]): Uint8Array { + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let offset = 0; + for (const part of parts) { + result.set(part, offset); + offset += part.length; + } + return result; +} + +/** + * Compares byte arrays lexicographically, as required for deterministic CBOR map key order. + * + * @param a - First byte array + * @param b - Second byte array + * @returns Negative when a sorts first, positive when b sorts first, zero when equal + */ +function compareBytes(a: Uint8Array, b: Uint8Array): number { + const common = Math.min(a.length, b.length); + for (let i = 0; i < common; i++) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return a.length - b.length; +} + +/** + * Encodes a settlement metadata value: unsigned integer, text string, array, or map. + * + * @param value - Metadata value to encode + * @returns CBOR-encoded value bytes + */ +function encodeCborValue(value: SettlementMetadataValue): Uint8Array { + if (typeof value === "string") { + return encodeCborString(value); + } + if (typeof value === "number" || typeof value === "bigint") { + if (typeof value === "number" && !Number.isSafeInteger(value)) { + throw new Error(`CBOR metadata number must be a safe integer, use bigint instead: ${value}`); + } + return encodeCborMajorType(0, value); // major type 0 = unsigned integer + } + if (isMetadataArray(value)) { + return concatBytes([encodeCborMajorType(4, value.length), ...value.map(encodeCborValue)]); + } + + // Runtime guard: the type system is not enforced for JS callers, and Object.entries would + // silently turn booleans, dates, functions and typed arrays into empty or index-keyed maps. + if (!isPlainObject(value)) { + throw new Error(`Unsupported CBOR metadata value: ${describeValue(value)}`); + } + + // Deterministic encoding: keys sorted bytewise by their encoded form (RFC 8949 section 4.2.1) + const entries = Object.entries(value) + .map(([key, entry]) => ({ key: encodeCborString(key), value: encodeCborValue(entry) })) + .sort((a, b) => compareBytes(a.key, b.key)); + return concatBytes([ + encodeCborMajorType(5, entries.length), + ...entries.flatMap(entry => [entry.key, entry.value]), + ]); +} + +/** + * Checks that a value is a plain object (prototype is `Object.prototype` or `null`). + * + * @param value - Value to check + * @returns True when the value is a plain object + */ +function isPlainObject(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +/** + * Describes an unsupported value for error messages without serializing its contents. + * + * @param value - Unsupported value + * @returns Short type description + */ +function describeValue(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "object") return value.constructor?.name ?? "object"; + return typeof value; +} + +/** + * Narrows a metadata value to an array. `Array.isArray` does not narrow readonly arrays. + * + * @param value - Non-scalar metadata value + * @returns True when the value is an array + */ +function isMetadataArray( + value: Exclude, +): value is readonly SettlementMetadataValue[] { + return Array.isArray(value); } /** @@ -154,12 +293,16 @@ function encodeCborMajorType(majorType: number, value: number): Uint8Array { * * The suffix_data_length covers the cbor_data only (not itself, schema_id, or marker). * - * @param data - Builder code extension data with "a" and/or "s" fields + * @param data - Builder code suffix data with "a", "w", "s", and/or "m" fields * @returns Hex-encoded suffix bytes (without 0x prefix) ready to append to calldata + * @throws When the CBOR data exceeds the 2-byte suffix_data_length */ -export function encodeBuilderCodeSuffix(data: BuilderCodeExtensionData): Hex { +export function encodeBuilderCodeSuffix(data: BuilderCodeSuffixData): Hex { const cborBytes = encodeCborMap(data); const cborLength = cborBytes.length; + if (cborLength > MAX_CBOR_LENGTH) { + throw new Error(`Builder code CBOR data is ${cborLength} bytes, maximum is ${MAX_CBOR_LENGTH}`); + } // suffix_data_length is 2 bytes, big-endian const lengthHigh = (cborLength >> 8) & 0xff; @@ -221,7 +364,7 @@ function bytesToHex(bytes: Uint8Array): string { */ export function parseBuilderCodeSuffixFromCalldata( calldata: Hex, -): BuilderCodeExtensionData | undefined { +): BuilderCodeSuffixData | undefined { const hex = calldata.startsWith("0x") ? calldata.slice(2) : calldata; const markerPos = hex.lastIndexOf(ERC_8021_MARKER.toLowerCase()); if (markerPos < 6) { @@ -251,7 +394,7 @@ export function parseBuilderCodeSuffixFromCalldata( return undefined; } - const result: BuilderCodeExtensionData = {}; + const result: BuilderCodeSuffixData = {}; for (let entry = 0; entry < mapSize; entry++) { if (bytes[o] >> 5 !== 3) { return undefined; @@ -315,8 +458,138 @@ export function parseBuilderCodeSuffixFromCalldata( continue; } + if (key === "m") { + const cursor = { bytes, offset: o }; + try { + result.m = readCborMap(cursor); + } catch { + return undefined; + } + o = cursor.offset; + continue; + } + return undefined; } return result; } + +interface CborCursor { + bytes: Uint8Array; + offset: number; +} + +/** + * Reads the major type of the next CBOR item without consuming it. + * + * @param cursor - Read position + * @returns CBOR major type (0–7) + */ +function peekMajorType(cursor: CborCursor): number { + if (cursor.offset >= cursor.bytes.length) { + throw new Error("Unexpected end of CBOR data"); + } + return cursor.bytes[cursor.offset] >> 5; +} + +/** + * Reads a CBOR item header argument (inline value or 1, 2, 4, or 8 extra bytes). + * + * @param cursor - Read position, advanced past the argument + * @returns Argument value + */ +function readCborArgument(cursor: CborCursor): bigint { + peekMajorType(cursor); + const info = cursor.bytes[cursor.offset++] & 0x1f; + if (info <= 23) { + return BigInt(info); + } + + const width = CBOR_ARGUMENT_WIDTHS.find(w => w.info === info); + if (!width || cursor.offset + width.bytes > cursor.bytes.length) { + throw new Error("Unsupported CBOR argument"); + } + + let argument = 0n; + for (let i = 0; i < width.bytes; i++) { + argument = (argument << 8n) | BigInt(cursor.bytes[cursor.offset++]); + } + return argument; +} + +/** + * Reads a CBOR length (string byte length, array size, or map size). + * + * @param cursor - Read position, advanced past the length argument + * @returns Length, bounded by the remaining bytes since every element takes at least one byte + */ +function readCborLength(cursor: CborCursor): number { + const length = readCborArgument(cursor); + if (length > BigInt(cursor.bytes.length - cursor.offset)) { + throw new Error("CBOR length exceeds available data"); + } + return Number(length); +} + +/** + * Reads a CBOR text string (major type 3). + * + * @param cursor - Read position, advanced past the string + * @returns Decoded text + */ +function readCborText(cursor: CborCursor): string { + if (peekMajorType(cursor) !== 3) { + throw new Error("Expected CBOR text string"); + } + const length = readCborLength(cursor); + const text = new TextDecoder().decode( + cursor.bytes.subarray(cursor.offset, cursor.offset + length), + ); + cursor.offset += length; + return text; +} + +/** + * Reads a settlement metadata value. Unsigned integers are returned as bigint. + * + * @param cursor - Read position, advanced past the value + * @returns Decoded metadata value + */ +function readCborValue(cursor: CborCursor): SettlementMetadataValue { + switch (peekMajorType(cursor)) { + case 0: + return readCborArgument(cursor); + case 3: + return readCborText(cursor); + case 4: { + const items: SettlementMetadataValue[] = []; + for (let remaining = readCborLength(cursor); remaining > 0; remaining--) { + items.push(readCborValue(cursor)); + } + return items; + } + case 5: + return readCborMap(cursor); + default: + throw new Error("Unsupported CBOR type in metadata"); + } +} + +/** + * Reads a CBOR map with text keys (major type 5). + * + * @param cursor - Read position, advanced past the map + * @returns Decoded metadata map + */ +function readCborMap(cursor: CborCursor): SettlementMetadata { + if (peekMajorType(cursor) !== 5) { + throw new Error("Expected CBOR map"); + } + + const entries: [string, SettlementMetadataValue][] = []; + for (let remaining = readCborLength(cursor); remaining > 0; remaining--) { + entries.push([readCborText(cursor), readCborValue(cursor)]); + } + return Object.fromEntries(entries); +} diff --git a/typescript/packages/extensions/src/builder-code/facilitator.ts b/typescript/packages/extensions/src/builder-code/facilitator.ts index a32dcb53c8..687d914ebb 100644 --- a/typescript/packages/extensions/src/builder-code/facilitator.ts +++ b/typescript/packages/extensions/src/builder-code/facilitator.ts @@ -14,8 +14,8 @@ import { BUILDER_CODE_PATTERN, MAX_CLIENT_SERVICE_CODES, MAX_SERVER_SERVICE_CODES, - type BuilderCodeExtensionData, type BuilderCodeFacilitatorConfig, + type BuilderCodeSuffixData, type DataSuffixContext, } from "./types"; @@ -105,8 +105,11 @@ export class BuilderCodeFacilitatorExtension implements FacilitatorExtension { * echoed client/server codes, within its own {@link MAX_FACILITATOR_SERVICE_CODES} * reservation. * - * @param ctx - Settlement context with payment-payload extensions - * @returns Hex-encoded ERC-8021 builder-code calldata suffix, or undefined when no attribution is present + * - `m` is the settlement metadata supplied by the settling mechanism in `ctx.metadata`. + * It is never read from the client payload. + * + * @param ctx - Settlement context with payment-payload extensions and optional metadata + * @returns Hex-encoded ERC-8021 builder-code calldata suffix, or undefined when no attribution or metadata is present */ buildDataSuffix(ctx: DataSuffixContext): Hex | undefined { const clientExt = extractClientExtension(ctx.paymentPayload.extensions); @@ -123,13 +126,20 @@ export class BuilderCodeFacilitatorExtension implements FacilitatorExtension { ? [...echoedServiceCodes, this.config.serviceCode] : echoedServiceCodes; - const data: BuilderCodeExtensionData = { + const hasMetadata = ctx.metadata !== undefined && Object.keys(ctx.metadata).length > 0; + const data: BuilderCodeSuffixData = { ...(this.config.builderCode && { w: this.config.builderCode }), ...(a && { a }), ...(s.length > 0 && { s }), + ...(hasMetadata && { m: ctx.metadata }), }; - if (!data.a && !data.w && (!data.s || (Array.isArray(data.s) && data.s.length === 0))) { + if ( + !data.a && + !data.w && + !data.m && + (!data.s || (Array.isArray(data.s) && data.s.length === 0)) + ) { return undefined; } diff --git a/typescript/packages/extensions/src/builder-code/index.ts b/typescript/packages/extensions/src/builder-code/index.ts index 35c186d43c..1dc2c6d6c9 100644 --- a/typescript/packages/extensions/src/builder-code/index.ts +++ b/typescript/packages/extensions/src/builder-code/index.ts @@ -45,6 +45,9 @@ export type { BuilderCodeExtensionData, BuilderCodeFacilitatorConfig, + SettlementMetadata, + SettlementMetadataValue, + BuilderCodeSuffixData, DataSuffixContext, } from "./types"; diff --git a/typescript/packages/extensions/src/builder-code/types.ts b/typescript/packages/extensions/src/builder-code/types.ts index 3ef0f5810e..76cab44964 100644 --- a/typescript/packages/extensions/src/builder-code/types.ts +++ b/typescript/packages/extensions/src/builder-code/types.ts @@ -104,7 +104,33 @@ export interface BuilderCodeFacilitatorConfig { serviceCode?: string; } +/** + * Value allowed inside the ERC-8021 Schema 2 `m` field: unsigned integers, text + * strings, arrays, and maps with text keys. + */ +export type SettlementMetadataValue = + | number + | bigint + | string + | readonly SettlementMetadataValue[] + | { readonly [key: string]: SettlementMetadataValue }; + +/** + * Facilitator-authored settlement metadata, encoded as the "m" field in ERC-8021 Schema 2. + */ +export interface SettlementMetadata { + readonly [key: string]: SettlementMetadataValue; +} + +/** + * Fields present in a settlement calldata suffix: the extension fields plus `m`. + */ +export interface BuilderCodeSuffixData extends BuilderCodeExtensionData { + m?: SettlementMetadata; +} + export interface DataSuffixContext { paymentPayload: PaymentPayload; paymentRequirements: PaymentRequirements; + metadata?: SettlementMetadata; } diff --git a/typescript/packages/extensions/test/builder-code.test.ts b/typescript/packages/extensions/test/builder-code.test.ts index 64c0b1c75d..117e756fe2 100644 --- a/typescript/packages/extensions/test/builder-code.test.ts +++ b/typescript/packages/extensions/test/builder-code.test.ts @@ -14,6 +14,8 @@ import { MAX_CLIENT_SERVICE_CODES, MAX_SERVER_SERVICE_CODES, type DataSuffixContext, + type SettlementMetadata, + type SettlementMetadataValue, } from "../src/builder-code"; const APP = "bc_my_app"; @@ -467,5 +469,183 @@ describe("Builder Code Extension", () => { it("returns undefined when calldata has no ERC-8021 suffix", () => { expect(parseBuilderCodeSuffixFromCalldata("0xdeadbeef")).toBeUndefined(); }); + + it("keeps the spec vectors unchanged", () => { + expect(encodeBuilderCodeSuffix({ a: "bc_myapp" })).toBe( + "0xa161616862635f6d79617070000c0280218021802180218021802180218021", + ); + expect(encodeBuilderCodeSuffix({ a: "bc_myapp", w: "bc_myfacilitator" })).toBe( + "0xa261616862635f6d7961707061777062635f6d79666163696c697461746f72001f0280218021802180218021802180218021", + ); + }); + }); + + describe("settlement metadata (m)", () => { + const MARKER = "80218021802180218021802180218021"; + + /** + * Wraps raw CBOR bytes into a Schema 2 suffix and appends it to dummy calldata. + * + * @param cborHex - Hex-encoded CBOR data without 0x prefix + * @returns Calldata ending with the ERC-8021 suffix + */ + function calldataWithCbor(cborHex: string): `0x${string}` { + const length = (cborHex.length / 2).toString(16).padStart(4, "0"); + return `0xdeadbeef${cborHex}${length}02${MARKER}`; + } + + /** + * Encodes and re-parses a suffix. + * + * @param data - Suffix data to encode + * @returns Parsed suffix data + */ + function roundTrip( + data: Parameters[0], + ): ReturnType { + const suffix = encodeBuilderCodeSuffix(data); + return parseBuilderCodeSuffixFromCalldata(`0xdeadbeef${suffix.slice(2)}` as `0x${string}`); + } + + it("matches the spec vector", () => { + const suffix = encodeBuilderCodeSuffix({ + a: "bc_myapp", + w: "bc_myfacilitator", + m: { x402Example: 7 }, + }); + + expect(suffix).toBe( + "0xa361616862635f6d7961707061777062635f6d79666163696c697461746f72616da16b783430324578616d706c6507002f0280218021802180218021802180218021", + ); + }); + + it("round-trips nested maps, arrays, text, and uints at argument boundaries", () => { + const m = { + zero: 0, + inline: 23, + oneByte: 24, + twoBytes: 2 ** 16, + fourBytes: 2 ** 32, + eightBytes: 2n ** 63n + 5n, + maxUint64: 2n ** 64n - 1n, + text: "hello", + list: [1, "two", [3], { four: 4 }], + nested: { inner: { deep: 1 } }, + }; + + expect(roundTrip({ a: APP, w: WALLET, s: SERVICE, m })?.m).toEqual({ + zero: 0n, + inline: 23n, + oneByte: 24n, + twoBytes: 65536n, + fourBytes: 4294967296n, + eightBytes: 2n ** 63n + 5n, + maxUint64: 2n ** 64n - 1n, + text: "hello", + list: [1n, "two", [3n], { four: 4n }], + nested: { inner: { deep: 1n } }, + }); + }); + + it("sorts map keys bytewise by encoded form regardless of insertion order", () => { + const forward = encodeBuilderCodeSuffix({ m: { aa: 1, b: 2, c: 3 } }); + const reversed = encodeBuilderCodeSuffix({ m: { c: 3, b: 2, aa: 1 } }); + + expect(forward).toBe(reversed); + // Shorter encoded keys sort first: b, c, then aa. + expect(forward).toContain("a3616202616303626161" + "01"); + }); + + it("builds a suffix from metadata alone", () => { + const ext = new BuilderCodeFacilitatorExtension(); + const suffix = ext.buildDataSuffix({ + ...suffixContext({}), + metadata: { x402Example: 7 }, + }); + + expect(suffix).toBeDefined(); + expect(parseBuilderCodeSuffixFromCalldata(`0xdeadbeef${suffix!.slice(2)}`)).toEqual({ + m: { x402Example: 7n }, + }); + }); + + it("emits no suffix for empty metadata and no attribution", () => { + const ext = new BuilderCodeFacilitatorExtension(); + + expect(ext.buildDataSuffix({ ...suffixContext({}), metadata: {} })).toBeUndefined(); + }); + + it("ignores m supplied in the client payload", () => { + const ext = new BuilderCodeFacilitatorExtension({ builderCode: WALLET }); + const suffix = ext.buildDataSuffix( + suffixContext({ + paymentPayloadExtensions: { + [BUILDER_CODE]: { info: { a: APP, m: { x402Example: 1 } }, schema: {} }, + }, + }), + ); + + expect(parseBuilderCodeSuffixFromCalldata(`0xdeadbeef${suffix!.slice(2)}`)).toEqual({ + a: APP, + w: WALLET, + }); + }); + + it("rejects metadata that does not fit in 65,535 bytes", () => { + expect(() => encodeBuilderCodeSuffix({ m: { big: "x".repeat(0x10000) } })).toThrow( + /maximum is 65535/, + ); + }); + + it("rejects unsupported metadata values when encoding", () => { + expect(() => encodeBuilderCodeSuffix({ m: { negative: -1 } })).toThrow(); + expect(() => encodeBuilderCodeSuffix({ m: { float: 1.5 } })).toThrow(); + expect(() => encodeBuilderCodeSuffix({ m: { tooLarge: 2n ** 64n } })).toThrow(); + }); + + it.each([ + ["true", true], + ["false", false], + ["null", null], + ["undefined", undefined], + ["Uint8Array", new Uint8Array([1, 2])], + ["Date", new Date(0)], + ["function", () => 1], + ["symbol", Symbol("x")], + ["Map", new Map([["k", 1]])], + ["class instance", new (class Foo {})()], + ])("rejects %s as a metadata value instead of encoding it as a map", (_name, bad) => { + const invalid = bad as unknown as SettlementMetadataValue; + expect(() => encodeBuilderCodeSuffix({ m: { bad: invalid } })).toThrow(/Unsupported CBOR/); + expect(() => encodeBuilderCodeSuffix({ m: { nested: { list: [invalid] } } })).toThrow( + /Unsupported CBOR/, + ); + }); + + it("rejects unsupported metadata at the top level of m", () => { + const invalid = true as unknown as SettlementMetadata; + expect(() => encodeBuilderCodeSuffix({ m: invalid })).toThrow(/Unsupported CBOR/); + }); + + it("still encodes null-prototype objects as maps", () => { + const m = Object.assign(Object.create(null), { k: 1 }) as SettlementMetadata; + expect(parseBuilderCodeSuffixFromCalldata(encodeBuilderCodeSuffix({ m }))?.m).toEqual({ + k: 1n, + }); + }); + + it.each([ + ["negative integer", "a1616d" + "a1616b20"], + ["byte string", "a1616d" + "a1616b4100"], + ["float", "a1616d" + "a1616bf90000"], + ["tag", "a1616d" + "a1616bc101"], + ["boolean", "a1616d" + "a1616bf5"], + ["indefinite-length array", "a1616d" + "a1616b9fff"], + ["non-text map key", "a1616d" + "a10100"], + ["truncated value", "a1616d" + "a1616b1b00"], + ["m that is not a map", "a1616d" + "01"], + ])("parser rejects %s inside m", (_name, cborHex) => { + expect(parseBuilderCodeSuffixFromCalldata(calldataWithCbor(cborHex))).toBeUndefined(); + }); }); }); diff --git a/typescript/packages/mechanisms/evm/NOTICE b/typescript/packages/mechanisms/evm/NOTICE index e58b325b0e..fd00f0172e 100644 --- a/typescript/packages/mechanisms/evm/NOTICE +++ b/typescript/packages/mechanisms/evm/NOTICE @@ -1,6 +1,6 @@ auth-capture scheme: -The auth-capture payment scheme and the client portion of its TypeScript +The auth-capture payment scheme v1.0 and the client portion of its TypeScript implementation in @x402/evm were contributed by the x402r team. See proposal issue #1011 and specification PR #1425. The implementation was developed in https://github.com/BackTrackCo/x402r-scheme prior to diff --git a/typescript/packages/mechanisms/evm/TODO.md b/typescript/packages/mechanisms/evm/TODO.md deleted file mode 100644 index 3f7b086772..0000000000 --- a/typescript/packages/mechanisms/evm/TODO.md +++ /dev/null @@ -1,74 +0,0 @@ -# @x402/evm - -## Overview - -The `@x402/evm` package provides EVM blockchain implementations for x402 payment schemes. It serves as the **reference implementation** for how mechanism packages should be structured, providing scheme clients, facilitators, and resource servers for EVM-compatible chains. - -## Current Implementation Status - -### Completed ✅ -- **EIP-3009 Support**: Full implementation for gasless token transfers - - Supports both v1 and v2 protocol versions -- **Exact Scheme**: Complete implementation for exact-value payments -- **Multi-chain Support**: Works with any EVM-compatible chain via network identifiers (e.g., `eip155:*`) - -### Architecture -The package exports three core classes following the x402 pattern: -- `ExactEvmClient`: Client-side payment creation and signing -- `ExactEvmFacilitator`: Facilitator for payment settlement -- `ExactEvmResourceServer`: Server-side payment verification - -## Planned Expansions - -### 1. Permit2 Integration -**Goal**: Implement Uniswap's Permit2 for more efficient token approvals and transfers - -**Benefits**: -- Single approval for multiple transfers -- Improved gas efficiency -- Better security through expiring approvals -- Reduced transaction count for users - -**Implementation Notes**: -- Maintain backward compatibility with EIP-3009 -- Allow configuration to choose between EIP-3009 and Permit2 -- Consider auto-detection based on token contract capabilities - -### 2. Up-To Scheme Implementation -**Goal**: Support payments "up to" a maximum value, enabling flexible pricing models - -**Use Cases**: -- Metered API usage -- Variable-cost operations -- Pay-per-use services with caps - -**Requirements**: -- Define schema for up-to payment requirements -- Implement partial payment settlement -- Support refunds for unused amounts -- Handle edge cases around maximum values - -## Contributing Guidelines - -Before implementing these expansions: - -1. **Open a GitHub Issue** to discuss the approach - - Outline the implementation plan - - Consider backward compatibility - - Discuss any protocol changes needed - -2. **Follow the Reference Pattern** - - Review how the exact scheme is implemented - - Maintain consistency with the three-class export pattern - - Ensure compatibility with `@x402/core` abstractions - -3. **Testing Requirements** - - Unit tests for all new functionality - - Integration tests with `@x402/core` - - E2E test implementation in `/e2e` - -## Technical Notes - -- This package serves as the reference for other mechanism implementations (Solana, etc.) -- All exports should extend the base classes from `@x402/core/types` -- Maintain support for both x402 v1 and v2 protocols where applicable \ No newline at end of file diff --git a/typescript/packages/mechanisms/evm/eslint.config.js b/typescript/packages/mechanisms/evm/eslint.config.js index ccdacfa659..f90f75badd 100644 --- a/typescript/packages/mechanisms/evm/eslint.config.js +++ b/typescript/packages/mechanisms/evm/eslint.config.js @@ -39,9 +39,9 @@ export default [ }, rules: { ...ts.configs.recommended.rules, - complexity: ["error", 50], + complexity: ["error", 60], "max-lines": ["error", { max: 2000 }], - "sonarjs/cognitive-complexity": ["error", 50], + "sonarjs/cognitive-complexity": ["error", 70], "import/first": "error", "prettier/prettier": "error", "@typescript-eslint/member-ordering": "error", diff --git a/typescript/packages/mechanisms/evm/package.json b/typescript/packages/mechanisms/evm/package.json index e24f95bab3..a8c82d056a 100644 --- a/typescript/packages/mechanisms/evm/package.json +++ b/typescript/packages/mechanisms/evm/package.json @@ -222,6 +222,26 @@ "types": "./dist/cjs/auth-capture/client/index.d.ts", "default": "./dist/cjs/auth-capture/client/index.js" } + }, + "./auth-capture/server": { + "import": { + "types": "./dist/esm/auth-capture/server/index.d.mts", + "default": "./dist/esm/auth-capture/server/index.mjs" + }, + "require": { + "types": "./dist/cjs/auth-capture/server/index.d.ts", + "default": "./dist/cjs/auth-capture/server/index.js" + } + }, + "./auth-capture/facilitator": { + "import": { + "types": "./dist/esm/auth-capture/facilitator/index.d.mts", + "default": "./dist/esm/auth-capture/facilitator/index.mjs" + }, + "require": { + "types": "./dist/cjs/auth-capture/facilitator/index.d.ts", + "default": "./dist/cjs/auth-capture/facilitator/index.js" + } } }, "files": [ diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/README.md b/typescript/packages/mechanisms/evm/src/auth-capture/README.md index dff9603fde..7c46d421ef 100644 --- a/typescript/packages/mechanisms/evm/src/auth-capture/README.md +++ b/typescript/packages/mechanisms/evm/src/auth-capture/README.md @@ -1,24 +1,27 @@ -# auth-capture EVM Scheme (client) +# Auth-Capture EVM Scheme (`@x402/evm/auth-capture`) -The **auth-capture** scheme adds refundable payments to x402, built on Base's audited [Commerce Payments Protocol](https://github.com/base/commerce-payments). The client signs a single payload (ERC-3009 or Permit2) over a payer-agnostic PaymentInfo hash. A facilitator later submits that payload to `AuthCaptureEscrow`, where funds are held under a `captureAuthorizer` role rather than transferred straight to the merchant, enabling capture, void, and refund flows before settlement is final. +The **auth-capture** scheme adds refundable payments to x402, built on Base's audited [Commerce Payments Protocol](https://github.com/base/commerce-payments). The client signs a single payload (ERC-3009 or Permit2). The facilitator submits to `AuthCaptureEscrow`, where funds are escrowed under a `captureAuthorizer` role rather than transferred straight to the merchant, enabling capture, void, and refund before settlement is final. -This package currently ships the **client only**: detecting auth-capture payment requirements and signing the payment payload. Server and facilitator support follow in a later change. +Two payment flows, selected per route by `extra.paymentFlow` (default `"escrow"`): -See the [scheme specification](https://github.com/x402-foundation/x402/blob/main/specs/schemes/auth-capture/scheme_auth-capture_evm.md) for full protocol details. +- **Escrow**: first settle is `authorize` (hold). After the resource runs, finalize with `capture` / `void` (`captureMode: "sync"`, default) or skip that settle and capture later (`captureMode: "deferred"`). +- **Authorization**: verify, run the resource, then settle as a terminal `charge`. No hold, therefore no `capture` / `void`. -## Import +See the [auth-capture EVM specification](../../../../../specs/schemes/auth-capture/scheme_auth_capture_evm.md) for protocol details. -| Role | Import | -| ------ | ------------------------------- | -| Client | `@x402/evm/auth-capture/client` | +## Import Paths -## Usage +| Role | Import | +| ----------- | ------------------------------------ | +| Client | `@x402/evm/auth-capture/client` | +| Server | `@x402/evm/auth-capture/server` | +| Facilitator | `@x402/evm/auth-capture/facilitator` | -Register `AuthCaptureEvmScheme` with an `x402Client`. The client validates the requirement's `extra` fields, reconstructs the PaymentInfo struct, computes the payer-agnostic hash, and emits an ERC-3009 (default) or Permit2 payload. +## Client Usage -When `extra.receiverAuthorizer` or `extra.policy` is non-zero, salt binding is on: the client emits a random `saltNonce` and a keccak `salt` committing to those addresses. Otherwise the wire is the unbound shape (`salt` is random 32 bytes, no `saltNonce`). +Register `AuthCaptureEvmScheme` with an `x402Client`. The client signs the payer-agnostic PaymentInfo hash and emits an ERC-3009 (default) or Permit2 payload. -The client resolves the commerce-payments deployment from optional `extra.authCaptureEscrow` (v1.1 default when omitted). That selects the escrow bound into the signature nonce and the collector used for `authorization.to` / `permit2Authorization.spender`. +When `extra.receiverAuthorizer` or `extra.policy` is non-zero, salt binding is on: the client emits a random `saltNonce` and a keccak `salt` committing to those addresses. Otherwise the wire is the v1.0 unbound shape (`salt` is random 32 bytes, no `saltNonce`). ```typescript import { x402Client } from "@x402/core/client"; @@ -31,61 +34,303 @@ const client = new x402Client(); client.register("eip155:*", new AuthCaptureEvmScheme(account)); ``` -`ClientEvmSigner` only needs `address` + `signTypedData`; a bare viem `LocalAccount` satisfies the shape, with no `PublicClient` required. +`ClientEvmSigner` only needs `address` + `signTypedData`; a bare viem `LocalAccount` satisfies the shape, no `PublicClient` required. -## Payment requirements the client reads +## Server Usage -The client reads these fields from `requirements.extra` and throws if any required field is missing or wrongly typed: +Register `AuthCaptureEvmScheme` with an `x402ResourceServer` and publish payment requirements with the spec-mandated `extra` fields. `paymentFlows` is static: both collectors support `"escrow"` (default) and `"authorization"`. Choose the flow and capture mode on the route's `extra`. + +The example below is collect-only escrow (`captureMode: "deferred"`): the facilitator relays `authorize`, and later lifecycle runs out of band (or through `createLifecycleManager` once the route has a [receiver authorizer](#receiver-authorizer-modes)). + +```typescript +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { + AuthCaptureEvmScheme, + type AuthCaptureRouteExtra, +} from "@x402/evm/auth-capture/server"; +import { paymentMiddleware, x402ResourceServer } from "@x402/express"; +import { zeroAddress } from "viem"; + +const facilitator = new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL! }); +const resourceServer = new x402ResourceServer(facilitator).register( + "eip155:84532", + new AuthCaptureEvmScheme(), +); + +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { + scheme: "auth-capture", + price: "$0.01", + network: "eip155:84532", + payTo: receiverAddress, + extra: { + captureDeadlineSeconds: 3600, // seconds-from-now; scheme converts to absolute per request + refundDeadlineSeconds: 7200, + feeRecipient: zeroAddress, // address(0) with zero bounds: no facilitator fee + minFeeBps: 0, + maxFeeBps: 0, + captureMode: "deferred", + } satisfies AuthCaptureRouteExtra, + }, + description: "Weather data", + mimeType: "application/json", + }, + }, + resourceServer, + ), +); +``` + +Import `AuthCaptureRouteExtra` from `@x402/evm/auth-capture/server` if you want the compiler to reject forbidden combinations (`captureMode` on an authorization route, mixed absolute/relative deadlines). `PaymentOption.extra` is `Record` on the wire, so the `satisfies` check only applies to the literal you write. Escrow sync and authorization routes resolve `receiverAuthorizer` as described in [Receiver authorizer modes](#receiver-authorizer-modes). + +### Required `extra` fields + +| Field | Type | Notes | +| --- | --- | --- | +| `captureAuthorizer` | `address` | Committed onchain as `PaymentInfo.operator`. For `operatorType: "delegated"`, omit this — the scheme copies `extra.captureAuthorizer` from the facilitator's `/supported` kind (like SVM `feePayer`). Required for `"custom"`. See [Operator types](#operator-types) below. | +| `feeRecipient` | `address` | `address(0)` lets the captureAuthorizer pick a non-zero recipient at capture/charge time. | +| `minFeeBps` | `uint16` | Floor on the captureAuthorizer's fee. `0` = no minimum. | +| `maxFeeBps` | `uint16` | Cap on the captureAuthorizer's fee. | + +Either set **both** deadline windows as relative offsets (recommended) or **both** as absolute Unix seconds. Mixing one absolute with one relative throws at enhance time. | Field | Type | Notes | | --- | --- | --- | -| `captureAuthorizer` | `address` | Committed on-chain as `PaymentInfo.operator`. | -| `feeRecipient` | `address` | Address that receives the fee portion. | -| `captureDeadline` | `uint48` | Absolute Unix seconds; capture must occur before this. | -| `refundDeadline` | `uint48` | Absolute Unix seconds; refunds allowed until this. | -| `minFeeBps` | `uint16` | Floor on the `captureAuthorizer`'s fee. `0` = no minimum. | -| `maxFeeBps` | `uint16` | Cap on the `captureAuthorizer`'s fee. | -| `name` | `string` | EIP-712 token-domain name (e.g. `"USDC"`). | -| `version` | `string` | EIP-712 token-domain version (e.g. `"2"`). | +| `captureDeadlineSeconds` | `number` | Seconds-from-now. The scheme converts to `captureDeadline` (absolute) inside `enhancePaymentRequirements` per request, then strips this key from the published `extra`. | +| `refundDeadlineSeconds` | `number` | Seconds-from-now. Converted to `refundDeadline` the same way. | +| `captureDeadline` | `uint48` | Absolute Unix seconds. Use this when the deadline is tied to an external commitment (e.g., a delivery date). | +| `refundDeadline` | `uint48` | Absolute Unix seconds. | + +If neither pair is set, `enhancePaymentRequirements` throws server-side with a message naming the missing field, so misconfiguration surfaces in the merchant's logs immediately rather than as an `invalid_auth_capture_evm_extra` 402 to the payer. + +The server-side fail-fast also covers the other directly-merchant-set fields (`captureAuthorizer`, `feeRecipient`, `minFeeBps`, `maxFeeBps`); a missing or wrongly-typed value throws at enhance time with a message naming the offending key. `autoCapture` is rejected outright — v1.1 replaced it with `paymentFlow`. + +### Auto-populated by the scheme -`maxTimeoutSeconds` on the requirements (not `extra`) is used to derive the authorization's `preApprovalExpiry`. +`AuthCaptureEvmScheme.parsePrice` resolves decimal prices like `"$0.01"` (or ticker-suffixed ones like `"0.01 USDC"`, which select among the network's default assets) against `@x402/evm`'s default-asset table and writes `name` / `version` (EIP-712 token domain) and, where the chain's default uses Permit2, `assetTransferMethod: "permit2"` into the resulting `AssetAmount.extra` for you. The middleware merges these into the published `requirements.extra`, so merchants using decimal pricing do not need to set them by hand. Merchants supplying their own `AssetAmount` (custom token) must set `name` / `version` themselves on the `AssetAmount.extra`; the facilitator's `isAuthCaptureExtra` guard catches the case where they're missing on the wire side (no server-side fail-fast, matching how `batch-settlement` handles its scheme-auto-populated EIP-712 domain fields). -Optional: +### Optional `extra` fields | Field | Default | Notes | | --- | --- | --- | -| `assetTransferMethod` | `"eip3009"` | `"eip3009"` (ERC-3009) or `"permit2"` (Uniswap Permit2). See below. | -| `authCaptureEscrow` | v1.1 escrow | Escrow address selecting the commerce-payments deployment (v1.0 or v1.1). | -| `receiverAuthorizer` | `address(0)` | When non-zero, enables salt binding. | -| `policy` | `address(0)` | When non-zero, enables salt binding. | +| `paymentFlow` | `"escrow"` | `"escrow"` → `authorize` then capture/void. `"authorization"` → terminal `charge`. Written back onto extra so core cannot drop it. | +| `captureMode` | `"sync"` | Escrow only. `"sync"` authors a signed capture (or void) on the after-handler settle. `"deferred"` skips that settle; capture later via helpers. Forbidden on `"authorization"`. Must be `"deferred"` for `operatorType: "custom"`, which is collect-only. | +| `receiverAuthorizer` | resolved per [Receiver authorizer modes](#receiver-authorizer-modes) | Signer of facilitator-relayed `charge` / `capture` / `void` / `refund`. Zero means collect-only, which is valid only for escrow + deferred. Must be non-zero for every authorization route and for escrow + sync. Non-zero turns salt binding on. | +| `policy` | zero address | Reserved. Non-zero is rejected for `"delegated"` and `"custom"` (`operatorType: "policy"` is not implemented). Still bound into the salt derivation. | +| `operatorType` | `"delegated"` | `"delegated"` (facilitator is the operator) or `"custom"` (allowlisted contract operator). `"policy"` is rejected. | +| `assetTransferMethod` | `"eip3009"` | `"eip3009"` (ERC-3009) or `"permit2"` (Uniswap Permit2). See [Asset Transfer Methods](#asset-transfer-methods). | +| `authCaptureEscrow` | v1.1 escrow | Selects the commerce-payments deployment (v1.1 default, or v1.0 when pinned). The server writes the resolved address out on publish. Collectors follow from the deployment — they are not on `extra`. | -## Asset transfer methods +Fail-fast in `enhancePaymentRequirements`: -The `assetTransferMethod` field selects how the signed authorization is shaped: +- escrow + sync on an `operatorType: "custom"` route (collect-only: the facilitator refuses relayed capture/void, so the hold could never be finalized) +- an authorization route, or escrow + sync, with no non-zero `receiverAuthorizer` (none from the scheme signer, the route, or the facilitator's `/supported`; a route that sets the zero address explicitly is also rejected) +- a route `receiverAuthorizer` that conflicts with `receiverAuthorizerSigner.address` +- a non-zero route `receiverAuthorizer` that is neither the scheme signer's address nor the facilitator-advertised address (the server holds no key for it) +- `captureMode` set on an authorization route +- `autoCapture` present at all -| Method | Description | Wire shape | -| --- | --- | --- | -| `"eip3009"` (default) | ERC-3009 `ReceiveWithAuthorization` to the canonical EIP-3009 token collector. EIP-712 domain is bound to the **token contract**. | `Eip3009Payload` | -| `"permit2"` | Uniswap Permit2 `PermitTransferFrom` to the canonical Permit2 token collector. Useful for tokens without `receiveWithAuthorization` (e.g. BSC USDC). | `Permit2Payload` | +Escrow + deferred with an explicit `receiverAuthorizer: zeroAddress` is collect-only: its lifecycle runs out of band. Omitting the field no longer implies collect-only; it resolves to the scheme signer or the facilitator-advertised authorizer, and throws when neither exists. + +## Receiver authorizer modes + +`receiverAuthorizer` is the address whose EIP-712 signature the escrow operator needs for `charge`, `capture`, `void`, and `refund`. Three modes exist, and the server picks one per route. + +| Mode | `receiverAuthorizer` | Who signs | Wire payloads | +| --- | --- | --- | --- | +| Self | The scheme's `receiverAuthorizerSigner.address` | The server | Signed (`authorizerSignature`, and `voidAuthorizerSignature` when a capture also voids) | +| Delegated | A non-zero address the server holds no key for, normally the one the facilitator advertises in `/supported` | The facilitator, after authenticating the caller | Unsigned: the server omits the signatures | +| Collect-only | `zeroAddress` | Nobody onchain (escrow + deferred only) | None: lifecycle runs out of band | + +The server resolves the mode in this order: + +1. A configured `receiverAuthorizerSigner` wins. A route value that differs from its address throws. +2. Otherwise a route value of `zeroAddress` is collect-only, and a route value equal to the facilitator-advertised address is delegated. Any other non-zero route value throws, because nobody could sign for it. +3. Otherwise the facilitator-advertised non-zero address is used (delegated). +4. Otherwise `enhancePaymentRequirements` throws. Set a `receiverAuthorizerSigner`, set `receiverAuthorizer: zeroAddress` on an escrow + deferred route, or point the server at a facilitator that advertises one. + +In delegated mode the facilitator's signature no longer proves the server's intent, so the facilitator authenticates each settle out of band. Opt in by configuring all four fields together (construction throws if any piece is missing): + +```typescript +new AuthCaptureEvmScheme(evmSigner, { + authorizerSigner: { address: account.address, signTypedData: params => account.signTypedData(params) }, + // Return a stable identity for the caller (for example from an API key or mTLS client + // certificate), or undefined to reject. A throw also rejects. + resolveCallerIdentity: async ({ step, paymentInfoHash, payload }) => lookupMerchant(payload), + delegatedAuthStorage: new MyDelegatedAuthStorage(), + onStorageError: (error, network, paymentInfoHash) => reportBindingFailure(error, network, paymentInfoHash), + // refundFunding: true, // needed to relay delegated refunds +}); +``` + +How the facilitator uses the identity: + +- `/verify` never resolves identity and never writes a binding. +- On `/settle` of an `authorize`, or of a `charge` with `refundFunding`, the facilitator re-verifies, resolves the identity, binds it to the `paymentInfoHash` (with `expiresAt` set to `refundDeadline`), then broadcasts. If the bind fails (another identity already holds it, or the storage is down) nothing is broadcast and the settle fails with `invalid_auth_capture_evm_unauthenticated_authorizer_request` or `invalid_auth_capture_evm_delegated_auth_unavailable`. +- The binding is reverted only when this call created it and the send failed or the transaction reverted onchain. It is kept when the outcome is unknown, such as a receipt timeout. +- Each later `capture`, `void`, or `refund` must resolve to the bound identity before the facilitator signs. A missing, expired, or different binding is rejected. +- A confirmed settle that leaves nothing capturable or refundable deletes the binding early. This is best effort and failures are reported through `onStorageError`. +- A capture that also voids the remainder carries `voidRemainder: true` instead of `voidAuthorizerSignature`; the facilitator signs both legs. + +Operational notes: + +- Without `delegatedAuthStorage` and `onStorageError`, facilitator-delegated signing is off (`getDelegatedAuthorizer` returns undefined). Use a durable store with an atomic insert-if-absent `bind` when more than one process serves the same payments. +- Identity is first-writer-wins, so anyone who can present the same credential can drive that payment's lifecycle. Give each merchant its own credential. +- `validateFacilitatorSupport` runs during `x402ResourceServer.initialize()` and fails fast when the facilitator omits `captureAuthorizer` or, for delegated receiver signing, `receiverAuthorizer`. Collect-only merchants whose routes always set `receiverAuthorizer: zeroAddress` may pass `collectOnlyRoutes: true` on the scheme constructor to skip the receiver check. Route-specific mistakes (for example escrow + sync without any authorizer) still surface in `enhancePaymentRequirements`. + +## Deferred lifecycle: storage and helpers + +Pass `receiverAuthorizerSigner` (self mode) or rely on a delegated authorizer, plus an optional `storage`, to the scheme constructor. `createLifecycleManager` supports self and delegated routes; delegated payloads are sent unsigned. It refuses, before calling the facilitator, a collect-only payment (its lifecycle is out of band), a `"custom"` operator's payment (the operator performs capture, void and refund itself), `capture` / `void` on an `"authorization"`-flow payment (already a terminal charge; only `refund` applies), and a record without a `saltNonce`. Each throws an error prefixed `invalid_auth_capture_evm_server_lifecycle_unavailable`. Successful collect settles persist an `AuthorizedPayment` record via `onAfterSettle` (the before-handler `authorize` under escrow, or the after-handler `charge` under authorization). Sync routes persist too, because a later refund still needs durable state. + +Out-of-band `capture` / `void` / `refund` go through `scheme.createLifecycleManager(facilitator)`. In-request hooks stay on the scheme. + +The default `InMemoryAuthorizedPaymentStorage` is atomic only inside one JS runtime and loses deferred captures on restart; the payer's protection in that case is `reclaim` after the capture deadline. Production multi-instance deployments need a backend with atomic conditional mutation. + +```typescript +const scheme = new AuthCaptureEvmScheme({ + receiverAuthorizerSigner, + // storage: new MyAuthorizedPaymentStorage(), // optional; in-memory default +}); +const lifecycle = scheme.createLifecycleManager(facilitator); + +const response = await lifecycle.capture(paymentInfoHash, { + amount: "500000", + voidRemainder: true, // requires an explicit partial amount +}); +await lifecycle.voidPayment(paymentInfoHash); +await lifecycle.refund(paymentInfoHash, { amount: "100000" }); +await lifecycle.getAuthorizedPayment(paymentInfoHash); +await lifecycle.listAuthorizedPayments(); +``` -A server may advertise multiple `accepts[]` entries with different `assetTransferMethod` values so the client picks whichever matches its token approvals. +Each helper builds the lifecycle payload, signs it with the receiver authorizer (self mode; delegated payloads go out unsigned for the facilitator to sign), POSTs it to the facilitator's `/settle`, and writes the new balances back. `voidPayment` rather than `void` because `void` is a reserved word. -## Supported networks +Sync escrow routes author the signed capture in `enrichSettlementPayload` (additive: `type`, `paymentInfo`, amounts, fees, signatures — never re-emitting `saltNonce`). Deferred routes implement `onBeforeSettle` and return `{ skip: true, result }` so core never calls the facilitator for that after-handler settle. Handler failures go through `settleOnCancel`, which returns requirements for a void. + +Partial capture amount uses the same settlement-overrides response header as `upto`: core resolves it into `ctx.requirements.amount` before the scheme sees it. + +## Facilitator Usage + +Register the scheme with an `x402Facilitator` instance. Optional config groups fee terms and the custom-operator allowlist advertised on `/supported`: + +```typescript +import { x402Facilitator } from "@x402/core/facilitator"; +import { toFacilitatorEvmSigner } from "@x402/evm"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/facilitator"; +import { createWalletClient, http, publicActions, nonceManager } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; + +const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`, { nonceManager }); +const viemClient = createWalletClient({ + account, + chain: baseSepolia, + transport: http(), +}).extend(publicActions); + +const evmSigner = toFacilitatorEvmSigner({ + address: account.address, + getCode: args => viemClient.getCode(args), + readContract: args => viemClient.readContract(args), + simulateCalls: args => viemClient.simulateCalls(args), + verifyTypedData: args => viemClient.verifyTypedData(args), + writeContract: args => viemClient.writeContract(args), + sendTransaction: args => viemClient.sendTransaction(args), + waitForTransactionReceipt: args => viemClient.waitForTransactionReceipt(args), +}); + +const facilitator = new x402Facilitator(); +facilitator.register( + "eip155:84532", + new AuthCaptureEvmScheme(evmSigner, { + feeTerms: { feeRecipient, minFeeBps: 100, maxFeeBps: 100 }, + operators: [{ address: customOperatorAddress, operatorType: "custom" }], + customOperatorAuthorizeGasLimit: 1_000_000n, + eip6492AllowedFactories: [smartWalletFactoryAddress], // omit to reject undeployed payers + refundFunding: false, // set true only with an out-of-band funding agreement + }), +); +``` + +`operators` admits by address, not by code: an operator behind a proxy can swap its implementation after you allowlist it, and `{ address: "*" }` admits contracts you have never reviewed. Prefer immutable operator contracts, re-review on every upgrade, and treat the per-relay outcome checks below as the thing that actually bounds the exposure. + +`getExtra` advertises a randomly selected `captureAuthorizer` from the signer set so delegated servers copy it into payment requirements (same rotation pattern as SVM `feePayer`). A facilitator that rotates submitters MUST remain able to submit from every address it has advertised until those payments pass `refundDeadline`, and with `refundFunding: true` MUST fund and approve every address in the rotation. Multiple submitters go into one constructor array — do not `facilitator.register` the scheme twice on the same network; the second registration is silently unreachable. + +```typescript +const signerA = toFacilitatorEvmSigner({ address: accountA.address, ...clientA }); +const signerB = toFacilitatorEvmSigner({ address: accountB.address, ...clientB }); +facilitator.register("eip155:84532", new AuthCaptureEvmScheme([signerA, signerB], config)); +``` + +Each submitter gets its own CREATE2 token store on first authorize, so N keys mean N first-authorize deployment costs and escrow-held balances split N ways. + +By default every signer address is in the advertised pool. Set `captureAuthorizers` to advertise only some of them (each must be an address of one of the signers, or the constructor throws), and `selectCaptureAuthorizer(network, candidates)` to choose the member per `/supported` response instead of at random. The pool only controls what `/supported` advertises. Verification and settlement still admit any signer address as `extra.captureAuthorizer`, so dropping an address from the pool stops new payments from pinning it without stranding payments that already did. For a `"delegated"` payment, every `capture`, `void` and `refund` is submitted from the signer holding that payment's pinned `PaymentInfo.operator`, never from another pool member. + +`verify` performs envelope shape checks, scheme/network agreement, `extra` validation, operator-type / allowlist rules, salt binding, deadline-ordering invariants, per-method field checks, client signature verification (ECDSA, ERC-1271, or the inner signature of an EIP-6492 envelope), nonce binding to the payer-agnostic PaymentInfo hash, authorizer EIP-712 signatures on lifecycle / completed charge payloads, single-use `paymentState` checks, and an onchain simulation of the target call so typed escrow reverts surface as stable `invalid_auth_capture_evm_*` reasons. The initial verification of an authorization-flow client payload has no server-authored completion fields, so it provisionally simulates a full-amount charge with default fee terms; settlement re-verification requires `amount`, `feeBps`, `feeReceiver`, and `authorizerSignature`, then simulates that exact signed charge. For `"custom"` operators, collect verification uses `eth_simulateV1` (`simulateCalls`) with a facilitator-chosen gas cap and outcome checks (canonical escrow event, `paymentState`, token deltas, facilitator balance unchanged). That costs materially more RPC than a `"delegated"` verify, which preflights with a single `eth_call`: the custom path reads the operator's token store and then simulates a bundle of 9 calls for `authorize` (11 for `charge`) to snapshot state on both sides of the relay. When extension context adds a calldata suffix, the custom-operator simulation includes the same suffix that settlement broadcasts. The mined transaction is checked against the same payment-state and payment-token balance invariants before settlement is reported as successful. Facilitators that advertise `"custom"` on `/supported` must wire `simulateCalls` on every signer; otherwise `operators` is omitted from `getExtra`. + +`settle` re-verifies then dispatches: + +- no `payload.type` + `paymentFlow: "escrow"` (default) → `authorize` +- no `payload.type` + `paymentFlow: "authorization"` → `charge` +- `payload.type` of `"capture"` / `"void"` / `"refund"` → lifecycle (capture-and-void when `voidAuthorizerSignature` is present) + +The settle target is the canonical escrow for `"delegated"` and `extra.captureAuthorizer` for `"custom"`. Bytecode is not probed. `collectorData` is payer-controlled opaque bytes; custom operators must forward it unchanged. ERC-6492 preparation calldata is executed by the token collector through a neutral Multicall3 sender, not as the facilitator or operator. + +### Undeployed (counterfactual) payer wallets + +`collectorData` is submitted as the client signed it, ERC-6492 wrapper included. The collector's `ERC6492SignatureHandler` strips the wrapper onchain, runs the preparation call through Multicall3, and only then hands the inner signature to the token or Permit2 — so unwrapping before submitting would drop the deployment an undeployed wallet needs. + +A payer whose wallet is not deployed yet has no `isValidSignature` to call, so `verify` cannot check its signature locally. It instead requires the preparation target to appear in `eip6492AllowedFactories` and leaves the signature to the onchain simulation, which deploys the wallet and then validates. A target outside the list is rejected with `invalid_auth_capture_evm_erc6492_factory_not_allowed`, and an omitted list rejects every counterfactual payer. The allowlist is about gas, not authority: Multicall3 makes the facilitator a bystander to the preparation call, but an unknown target can still burn gas inside the facilitator's transaction. A deployed wallet that still sends a wrapped signature takes the normal ERC-1271 path and needs no allowlist entry. + +## Supported Networks | Network | CAIP-2 ID | | ------------ | -------------- | | Base Mainnet | `eip155:8453` | | Base Sepolia | `eip155:84532` | -Canonical `AuthCaptureEscrow` and EIP-3009 / Permit2 token collector addresses live in [`./constants.ts`](./constants.ts). +Requires the canonical `AuthCaptureEscrow` and EIP-3009 / Permit2 / operator-refund token collectors deployed at universal CREATE2 addresses on the target network. The constants live in [`./constants.ts`](./constants.ts). + +## Payment flows and capture modes + +| `paymentFlow` | `captureMode` | First settle | After the resource | +| --- | --- | --- | --- | +| `"escrow"` (default) | `"sync"` (default) | `escrow.authorize(...)` | Second settle relays `capture` / `void`. Requires a non-zero `receiverAuthorizer` (self or delegated). | +| `"escrow"` | `"deferred"` | `escrow.authorize(...)` | After-handler settle is skipped. Capture later via `createLifecycleManager(facilitator).capture` / `voidPayment` / `refund`, or out of band. | +| `"authorization"` | n/a | (verify only) | `escrow.charge(...)` with an authorizer-signed (or, when delegated, facilitator-signed) completion. A non-zero `receiverAuthorizer` is required and `captureMode` must be omitted. | + +## Operator types + +`extra.operatorType` names the kind of `extra.captureAuthorizer`, committed onchain as `PaymentInfo.operator`. + +| Type | Settle target | Facilitator relays | +| --- | --- | --- | +| `"delegated"` (default) | Canonical `AuthCaptureEscrow` | `authorize` always; `charge` and lifecycle only when `receiverAuthorizer` is non-zero. `captureAuthorizer` must be an address the facilitator submits from (EOA or smart-contract account). | +| `"custom"` | `extra.captureAuthorizer` | Collect only, and only if that address is on the facilitator's operator allowlist. `charge` requires a non-zero `receiverAuthorizer`; lifecycle is out of band, so escrow routes must set `captureMode: "deferred"`. | +| `"policy"` | — | Reserved. Rejected with `invalid_auth_capture_evm_unsupported_operator_type`. | + +## Asset Transfer Methods + +The `assetTransferMethod` field on `extra` selects how the payer's funds reach escrow: + +| Method | Description | Wire shape | +| --- | --- | --- | +| `"eip3009"` (default) | ERC-3009 `ReceiveWithAuthorization` to the canonical EIP-3009 token collector. EIP-712 domain is bound to the **token contract**. | `Eip3009Payload` | +| `"permit2"` | Uniswap Permit2 `PermitTransferFrom` to the canonical Permit2 token collector. Useful for tokens without `receiveWithAuthorization` (e.g., BSC USDC, Tempo pathUSD). | `Permit2Payload` | + +A server MAY advertise multiple `accepts[]` entries with different `assetTransferMethod` values so the client picks whichever matches its token approvals. ## Examples -- [`@x402/fetch` client example](../../../../../examples/typescript/clients/fetch/) -- [`@x402/axios` client example](../../../../../examples/typescript/clients/axios/) -- [Go HTTP client example](../../../../../examples/go/clients/http/) +- [Client example](../../../../../examples/typescript/clients/fetch) +- [Server example](../../../../../examples/servers/auth-capture) +- [Facilitator example](../../../../../examples/facilitator/auth-capture) -## See also +## See Also -- [Scheme specification](https://github.com/x402-foundation/x402/blob/main/specs/schemes/auth-capture/scheme_auth-capture_evm.md) +- [Proposed v1.1 scheme specification](../../../../../specs/proposed/scheme_auth_capture_evm.md) +- [Frozen v1.0 scheme specification](https://github.com/x402-foundation/x402/blob/main/specs/schemes/auth-capture/scheme_auth-capture_evm.md) - [`AuthCaptureEscrow` contract](https://github.com/base/commerce-payments) diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/abi.ts b/typescript/packages/mechanisms/evm/src/auth-capture/abi.ts new file mode 100644 index 0000000000..af968699bb --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/abi.ts @@ -0,0 +1,373 @@ +import type { AuthCaptureDeployment, AuthCaptureDeploymentVersion } from "./constants"; + +// PaymentInfo struct for AuthCaptureEscrow (matches base/commerce-payments contract). +// Field names are canonical Solidity; do not rename. Spec-level field renames +// (captureAuthorizer, captureDeadline, refundDeadline, feeRecipient) live at the +// extra/wire layer; this struct preserves the canonical EIP-712 typehash. +export const PAYMENT_INFO_COMPONENTS = [ + { name: "operator", type: "address" }, + { name: "payer", type: "address" }, + { name: "receiver", type: "address" }, + { name: "token", type: "address" }, + { name: "maxAmount", type: "uint120" }, + { name: "preApprovalExpiry", type: "uint48" }, + { name: "authorizationExpiry", type: "uint48" }, + { name: "refundExpiry", type: "uint48" }, + { name: "minFeeBps", type: "uint16" }, + { name: "maxFeeBps", type: "uint16" }, + { name: "feeReceiver", type: "address" }, + { name: "salt", type: "uint256" }, +] as const; + +const ESCROW_SHARED_ABI = [ + { + name: "authorize", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256" }, + { name: "tokenCollector", type: "address" }, + { name: "collectorData", type: "bytes" }, + ], + outputs: [], + }, + { + name: "void", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + ], + outputs: [], + }, + { + name: "refund", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256" }, + { name: "tokenCollector", type: "address" }, + { name: "collectorData", type: "bytes" }, + ], + outputs: [], + }, +] as const; + +const CHARGE_V1_0_ABI = { + name: "charge", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256" }, + { name: "tokenCollector", type: "address" }, + { name: "collectorData", type: "bytes" }, + { name: "feeBps", type: "uint16" }, + { name: "feeReceiver", type: "address" }, + ], + outputs: [], +} as const; + +const CAPTURE_V1_0_ABI = { + name: "capture", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256" }, + { name: "feeBps", type: "uint16" }, + { name: "feeReceiver", type: "address" }, + ], + outputs: [], +} as const; + +const CHARGE_V1_1_ABI = { + name: "charge", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256" }, + { name: "tokenCollector", type: "address" }, + { name: "collectorData", type: "bytes" }, + { name: "feeAmount", type: "uint256" }, + { name: "feeReceiver", type: "address" }, + ], + outputs: [], +} as const; + +const CAPTURE_V1_1_ABI = { + name: "capture", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256" }, + { name: "feeAmount", type: "uint256" }, + { name: "feeReceiver", type: "address" }, + ], + outputs: [], +} as const; + +export const ESCROW_ABI_V1_0 = [ + ...ESCROW_SHARED_ABI.slice(0, 1), + CHARGE_V1_0_ABI, + CAPTURE_V1_0_ABI, + ...ESCROW_SHARED_ABI.slice(1), +] as const; + +export const ESCROW_ABI_V1_1 = [ + ...ESCROW_SHARED_ABI.slice(0, 1), + CHARGE_V1_1_ABI, + CAPTURE_V1_1_ABI, + ...ESCROW_SHARED_ABI.slice(1), +] as const; + +/** Default escrow ABI (v1.1). */ +export const ESCROW_ABI = ESCROW_ABI_V1_1; + +/** + * Escrow function ABI for a commerce-payments protocol version. + * + * @param version - Deployment version (`v1.0` or `v1.1`). + * @returns Charge/capture ABI matching that version's fee encoding. + */ +export function escrowAbiForVersion(version: AuthCaptureDeploymentVersion) { + return version === "v1.0" ? ESCROW_ABI_V1_0 : ESCROW_ABI_V1_1; +} + +/** + * Escrow function ABI for a resolved commerce-payments deployment. + * + * @param deployment - Resolved escrow addresses and version. + * @returns Charge/capture ABI matching the deployment version. + */ +export function escrowAbiForDeployment(deployment: AuthCaptureDeployment) { + return escrowAbiForVersion(deployment.version); +} + +// ERC-20 balanceOf ABI for balance checks +export const ERC20_BALANCE_OF_ABI = [ + { + name: "balanceOf", + type: "function", + stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "balance", type: "uint256" }], + }, +] as const; + +const PAYMENT_AUTHORIZED_EVENT = { + type: "event", + name: "PaymentAuthorized", + inputs: [ + { name: "paymentInfoHash", type: "bytes32", indexed: true }, + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256", indexed: false }, + { name: "tokenCollector", type: "address", indexed: false }, + ], +} as const; + +export const ESCROW_EVENTS_ABI_V1_0 = [ + PAYMENT_AUTHORIZED_EVENT, + { + type: "event", + name: "PaymentCharged", + inputs: [ + { name: "paymentInfoHash", type: "bytes32", indexed: true }, + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256", indexed: false }, + { name: "tokenCollector", type: "address", indexed: false }, + { name: "feeBps", type: "uint16", indexed: false }, + { name: "feeReceiver", type: "address", indexed: false }, + ], + }, +] as const; + +export const ESCROW_EVENTS_ABI_V1_1 = [ + PAYMENT_AUTHORIZED_EVENT, + { + type: "event", + name: "PaymentCharged", + inputs: [ + { name: "paymentInfoHash", type: "bytes32", indexed: true }, + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256", indexed: false }, + { name: "tokenCollector", type: "address", indexed: false }, + { name: "feeAmount", type: "uint256", indexed: false }, + { name: "feeReceiver", type: "address", indexed: false }, + ], + }, +] as const; + +/** Default escrow events ABI (v1.1). */ +export const ESCROW_EVENTS_ABI = ESCROW_EVENTS_ABI_V1_1; + +/** + * Escrow event ABI for a resolved commerce-payments deployment. + * + * @param deployment - Resolved escrow addresses and version. + * @returns PaymentCharged event ABI matching the deployment version. + */ +export function escrowEventsAbiForDeployment(deployment: AuthCaptureDeployment) { + return deployment.version === "v1.0" ? ESCROW_EVENTS_ABI_V1_0 : ESCROW_EVENTS_ABI_V1_1; +} + +// View functions on AuthCaptureEscrow used by verify (paymentState single-use +// checks) and tests / introspection. +export const ESCROW_VIEW_ABI = [ + { + name: "getTokenStore", + type: "function", + stateMutability: "view", + inputs: [{ name: "operator", type: "address" }], + outputs: [{ type: "address" }], + }, + { + name: "getHash", + type: "function", + stateMutability: "view", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + ], + outputs: [{ type: "bytes32" }], + }, + { + name: "paymentState", + type: "function", + stateMutability: "view", + inputs: [{ name: "paymentInfoHash", type: "bytes32" }], + outputs: [ + { + name: "state", + type: "tuple", + components: [ + { name: "hasCollectedPayment", type: "bool" }, + { name: "capturableAmount", type: "uint120" }, + { name: "refundableAmount", type: "uint120" }, + ], + }, + ], + }, +] as const; + +// AuthCaptureEscrow custom errors. Spliced into the ABI passed to simulateContract +// so viem can decode `ContractFunctionRevertedError.data.errorName` instead of +// falling back to an opaque hex selector. Names mirror the Solidity definitions +// at base/commerce-payments/src/AuthCaptureEscrow.sol. +export const ESCROW_ERRORS_ABI = [ + { type: "error", name: "InvalidSender", inputs: [{ type: "address" }, { type: "address" }] }, + { type: "error", name: "ZeroAmount", inputs: [] }, + { type: "error", name: "AmountOverflow", inputs: [{ type: "uint256" }, { type: "uint256" }] }, + { type: "error", name: "ExceedsMaxAmount", inputs: [{ type: "uint256" }, { type: "uint256" }] }, + { + type: "error", + name: "AfterPreApprovalExpiry", + inputs: [{ type: "uint48" }, { type: "uint48" }], + }, + { + type: "error", + name: "InvalidExpiries", + inputs: [{ type: "uint48" }, { type: "uint48" }, { type: "uint48" }], + }, + { type: "error", name: "FeeBpsOverflow", inputs: [{ type: "uint16" }] }, + { type: "error", name: "InvalidFeeBpsRange", inputs: [{ type: "uint16" }, { type: "uint16" }] }, + { + type: "error", + name: "FeeBpsOutOfRange", + inputs: [{ type: "uint16" }, { type: "uint16" }, { type: "uint16" }], + }, + { + type: "error", + name: "FeeAmountOutOfRange", + inputs: [{ type: "uint256" }, { type: "uint256" }, { type: "uint256" }], + }, + { type: "error", name: "ZeroFeeReceiver", inputs: [] }, + { type: "error", name: "InvalidFeeReceiver", inputs: [{ type: "address" }, { type: "address" }] }, + { type: "error", name: "InvalidCollectorForOperation", inputs: [] }, + { type: "error", name: "TokenCollectionFailed", inputs: [] }, + { type: "error", name: "PaymentAlreadyCollected", inputs: [{ type: "bytes32" }] }, + { + type: "error", + name: "AfterAuthorizationExpiry", + inputs: [{ type: "uint48" }, { type: "uint48" }], + }, + { + type: "error", + name: "InsufficientAuthorization", + inputs: [{ type: "bytes32" }, { type: "uint256" }, { type: "uint256" }], + }, + { type: "error", name: "ZeroAuthorization", inputs: [{ type: "bytes32" }] }, + { + type: "error", + name: "AfterRefundExpiry", + inputs: [{ type: "uint48" }, { type: "uint48" }], + }, + { + type: "error", + name: "RefundExceedsCapture", + inputs: [{ type: "uint256" }, { type: "uint256" }], + }, +] as const; + +export const ESCROW_ABI_WITH_ERRORS_V1_0 = [...ESCROW_ABI_V1_0, ...ESCROW_ERRORS_ABI] as const; +export const ESCROW_ABI_WITH_ERRORS_V1_1 = [...ESCROW_ABI_V1_1, ...ESCROW_ERRORS_ABI] as const; + +/** Default escrow ABI with errors (v1.1). */ +export const ESCROW_ABI_WITH_ERRORS = ESCROW_ABI_WITH_ERRORS_V1_1; + +/** + * Escrow ABI including custom errors for a resolved deployment. + * + * @param deployment - Resolved escrow addresses and version. + * @returns Function plus error ABI matching the deployment version. + */ +export function escrowAbiWithErrorsForDeployment(deployment: AuthCaptureDeployment) { + return deployment.version === "v1.0" ? ESCROW_ABI_WITH_ERRORS_V1_0 : ESCROW_ABI_WITH_ERRORS_V1_1; +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/authorizerSigner.ts b/typescript/packages/mechanisms/evm/src/auth-capture/authorizerSigner.ts new file mode 100644 index 0000000000..3d4aaef48c --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/authorizerSigner.ts @@ -0,0 +1,366 @@ +import { getAddress, keccak256 } from "viem"; +import type { FacilitatorEvmSigner } from "../signer"; +import { verifyTypedDataSignature } from "../shared/verifySignature"; +import { + captureTypesForDeployment, + chargeTypesForDeployment, + OPERATOR_EIP712_DOMAIN, + REFUND_TYPES, + VOID_TYPES, + type AuthCaptureDeployment, +} from "./constants"; +import type { AuthorizerSigner } from "./types"; + +/** + * EIP-712 domain for operator Charge/Void/Capture/Refund signatures. + * `verifyingContract` is the capture authorizer so a signature is bound to + * its operator by the domain as well as by `paymentInfoHash`. + * + * @param chainId - EVM chain id. + * @param captureAuthorizer - extra.captureAuthorizer (PaymentInfo.operator). + * @returns Domain fields for `signTypedData` / `verifyTypedData`. + */ +export function getOperatorEip712Domain( + chainId: number, + captureAuthorizer: `0x${string}`, +): { + name: string; + version: string; + chainId: number; + verifyingContract: `0x${string}`; +} { + return { + name: OPERATOR_EIP712_DOMAIN.name, + version: OPERATOR_EIP712_DOMAIN.version, + chainId, + verifyingContract: getAddress(captureAuthorizer), + }; +} + +export type ChargeDigestV1_0 = { + paymentInfoHash: `0x${string}`; + amount: bigint | string; + tokenCollector: `0x${string}`; + collectorData: `0x${string}`; + feeBps: number; + feeReceiver: `0x${string}`; +}; + +export type ChargeDigestV1_1 = { + paymentInfoHash: `0x${string}`; + amount: bigint | string; + tokenCollector: `0x${string}`; + collectorData: `0x${string}`; + feeAmount: bigint | string; + feeReceiver: `0x${string}`; +}; + +export type ChargeDigest = ChargeDigestV1_0 | ChargeDigestV1_1; + +export type CaptureDigestV1_0 = { + paymentInfoHash: `0x${string}`; + amount: bigint | string; + feeBps: number; + feeReceiver: `0x${string}`; + expectedCapturableAmount: bigint | string; + expectedRefundableAmount: bigint | string; +}; + +export type CaptureDigestV1_1 = { + paymentInfoHash: `0x${string}`; + amount: bigint | string; + feeAmount: bigint | string; + feeReceiver: `0x${string}`; + expectedCapturableAmount: bigint | string; + expectedRefundableAmount: bigint | string; +}; + +export type CaptureDigest = CaptureDigestV1_0 | CaptureDigestV1_1; + +export type RefundDigest = { + paymentInfoHash: `0x${string}`; + amount: bigint | string; + tokenCollector: `0x${string}`; + expectedCapturableAmount: bigint | string; + expectedRefundableAmount: bigint | string; +}; + +/** + * Sign a Charge digest. + * + * @param signer - Receiver-authorizer signer. + * @param chainId - EVM chain id. + * @param captureAuthorizer - Domain verifyingContract. + * @param deployment - Resolved commerce-payments deployment. + * @param digest - Charge parameters. + * @returns EIP-712 signature. + */ +export async function signCharge( + signer: AuthorizerSigner, + chainId: number, + captureAuthorizer: `0x${string}`, + deployment: AuthCaptureDeployment, + digest: ChargeDigest, +): Promise<`0x${string}`> { + return signer.signTypedData({ + domain: getOperatorEip712Domain(chainId, captureAuthorizer), + types: chargeTypesForDeployment(deployment), + primaryType: "Charge", + message: chargeMessage(deployment, digest), + }); +} + +/** + * Sign a Void digest. + * + * @param signer - Receiver-authorizer signer. + * @param chainId - EVM chain id. + * @param captureAuthorizer - Domain verifyingContract. + * @param paymentInfoHash - Escrow payment identifier. + * @returns EIP-712 signature. + */ +export async function signVoid( + signer: AuthorizerSigner, + chainId: number, + captureAuthorizer: `0x${string}`, + paymentInfoHash: `0x${string}`, +): Promise<`0x${string}`> { + return signer.signTypedData({ + domain: getOperatorEip712Domain(chainId, captureAuthorizer), + types: VOID_TYPES, + primaryType: "Void", + message: { paymentInfoHash }, + }); +} + +/** + * Sign a Capture digest. + * + * @param signer - Receiver-authorizer signer. + * @param chainId - EVM chain id. + * @param captureAuthorizer - Domain verifyingContract. + * @param deployment - Resolved commerce-payments deployment. + * @param digest - Capture parameters including expected balances. + * @returns EIP-712 signature. + */ +export async function signCapture( + signer: AuthorizerSigner, + chainId: number, + captureAuthorizer: `0x${string}`, + deployment: AuthCaptureDeployment, + digest: CaptureDigest, +): Promise<`0x${string}`> { + return signer.signTypedData({ + domain: getOperatorEip712Domain(chainId, captureAuthorizer), + types: captureTypesForDeployment(deployment), + primaryType: "Capture", + message: captureMessage(deployment, digest), + }); +} + +/** + * Sign a Refund digest. + * + * @param signer - Receiver-authorizer signer. + * @param chainId - EVM chain id. + * @param captureAuthorizer - Domain verifyingContract. + * @param digest - Refund parameters including expected balances. + * @returns EIP-712 signature. + */ +export async function signRefund( + signer: AuthorizerSigner, + chainId: number, + captureAuthorizer: `0x${string}`, + digest: RefundDigest, +): Promise<`0x${string}`> { + return signer.signTypedData({ + domain: getOperatorEip712Domain(chainId, captureAuthorizer), + types: REFUND_TYPES, + primaryType: "Refund", + message: refundMessage(digest), + }); +} + +/** + * Verify a Charge signature (ECDSA or ERC-1271). + * + * @param facilitatorSigner - Used for `eth_getCode` / `isValidSignature`. + * @param authorizer - extra.receiverAuthorizer. + * @param chainId - EVM chain id. + * @param captureAuthorizer - Domain verifyingContract. + * @param deployment - Resolved commerce-payments deployment. + * @param digest - Charge parameters. + * @param signature - Authorizer signature from the payload. + * @returns True if the signature is valid for `authorizer`. + */ +export async function verifyCharge( + facilitatorSigner: FacilitatorEvmSigner, + authorizer: `0x${string}`, + chainId: number, + captureAuthorizer: `0x${string}`, + deployment: AuthCaptureDeployment, + digest: ChargeDigest, + signature: `0x${string}`, +): Promise { + return verifyTypedDataSignature(facilitatorSigner, { + address: getAddress(authorizer), + domain: getOperatorEip712Domain(chainId, captureAuthorizer), + types: chargeTypesForDeployment(deployment), + primaryType: "Charge", + message: chargeMessage(deployment, digest), + signature, + }); +} + +/** + * Verify a Void signature (ECDSA or ERC-1271). + * + * @param facilitatorSigner - Used for `eth_getCode` / `isValidSignature`. + * @param authorizer - extra.receiverAuthorizer. + * @param chainId - EVM chain id. + * @param captureAuthorizer - Domain verifyingContract. + * @param paymentInfoHash - Escrow payment identifier. + * @param signature - Authorizer signature from the payload. + * @returns True if the signature is valid for `authorizer`. + */ +export async function verifyVoid( + facilitatorSigner: FacilitatorEvmSigner, + authorizer: `0x${string}`, + chainId: number, + captureAuthorizer: `0x${string}`, + paymentInfoHash: `0x${string}`, + signature: `0x${string}`, +): Promise { + return verifyTypedDataSignature(facilitatorSigner, { + address: getAddress(authorizer), + domain: getOperatorEip712Domain(chainId, captureAuthorizer), + types: VOID_TYPES, + primaryType: "Void", + message: { paymentInfoHash }, + signature, + }); +} + +/** + * Verify a Capture signature (ECDSA or ERC-1271). + * + * @param facilitatorSigner - Used for `eth_getCode` / `isValidSignature`. + * @param authorizer - extra.receiverAuthorizer. + * @param chainId - EVM chain id. + * @param captureAuthorizer - Domain verifyingContract. + * @param deployment - Resolved commerce-payments deployment. + * @param digest - Capture parameters. + * @param signature - Authorizer signature from the payload. + * @returns True if the signature is valid for `authorizer`. + */ +export async function verifyCapture( + facilitatorSigner: FacilitatorEvmSigner, + authorizer: `0x${string}`, + chainId: number, + captureAuthorizer: `0x${string}`, + deployment: AuthCaptureDeployment, + digest: CaptureDigest, + signature: `0x${string}`, +): Promise { + return verifyTypedDataSignature(facilitatorSigner, { + address: getAddress(authorizer), + domain: getOperatorEip712Domain(chainId, captureAuthorizer), + types: captureTypesForDeployment(deployment), + primaryType: "Capture", + message: captureMessage(deployment, digest), + signature, + }); +} + +/** + * Verify a Refund signature (ECDSA or ERC-1271). + * + * @param facilitatorSigner - Used for `eth_getCode` / `isValidSignature`. + * @param authorizer - extra.receiverAuthorizer. + * @param chainId - EVM chain id. + * @param captureAuthorizer - Domain verifyingContract. + * @param digest - Refund parameters. + * @param signature - Authorizer signature from the payload. + * @returns True if the signature is valid for `authorizer`. + */ +export async function verifyRefund( + facilitatorSigner: FacilitatorEvmSigner, + authorizer: `0x${string}`, + chainId: number, + captureAuthorizer: `0x${string}`, + digest: RefundDigest, + signature: `0x${string}`, +): Promise { + return verifyTypedDataSignature(facilitatorSigner, { + address: getAddress(authorizer), + domain: getOperatorEip712Domain(chainId, captureAuthorizer), + types: REFUND_TYPES, + primaryType: "Refund", + message: refundMessage(digest), + signature, + }); +} + +/** + * EIP-712 Charge message fields (collectorData is hashed). + * + * @param deployment - Resolved commerce-payments deployment. + * @param digest - Charge parameters. + * @returns Typed-data message. + */ +function chargeMessage( + deployment: AuthCaptureDeployment, + digest: ChargeDigest, +): Record { + const base = { + paymentInfoHash: digest.paymentInfoHash, + amount: BigInt(digest.amount), + tokenCollector: getAddress(digest.tokenCollector), + collectorDataHash: keccak256(digest.collectorData), + feeReceiver: getAddress(digest.feeReceiver), + }; + if (deployment.version === "v1.0") { + return { ...base, feeBps: (digest as ChargeDigestV1_0).feeBps }; + } + return { ...base, feeAmount: BigInt((digest as ChargeDigestV1_1).feeAmount) }; +} + +/** + * EIP-712 Capture message fields. + * + * @param deployment - Resolved commerce-payments deployment. + * @param digest - Capture parameters. + * @returns Typed-data message. + */ +function captureMessage( + deployment: AuthCaptureDeployment, + digest: CaptureDigest, +): Record { + const base = { + paymentInfoHash: digest.paymentInfoHash, + amount: BigInt(digest.amount), + feeReceiver: getAddress(digest.feeReceiver), + expectedCapturableAmount: BigInt(digest.expectedCapturableAmount), + expectedRefundableAmount: BigInt(digest.expectedRefundableAmount), + }; + if (deployment.version === "v1.0") { + return { ...base, feeBps: (digest as CaptureDigestV1_0).feeBps }; + } + return { ...base, feeAmount: BigInt((digest as CaptureDigestV1_1).feeAmount) }; +} + +/** + * EIP-712 Refund message fields. + * + * @param digest - Refund parameters. + * @returns Typed-data message. + */ +function refundMessage(digest: RefundDigest): Record { + return { + paymentInfoHash: digest.paymentInfoHash, + amount: BigInt(digest.amount), + tokenCollector: getAddress(digest.tokenCollector), + expectedCapturableAmount: BigInt(digest.expectedCapturableAmount), + expectedRefundableAmount: BigInt(digest.expectedRefundableAmount), + }; +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/errors.ts b/typescript/packages/mechanisms/evm/src/auth-capture/errors.ts new file mode 100644 index 0000000000..9b1066f364 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/errors.ts @@ -0,0 +1,102 @@ +/** + * Named error reason constants for the auth-capture EVM scheme. + * + * Every scheme-defined reason is namespaced `invalid_auth_capture_evm_*`. + * Standard x402 reasons this binding also returns (e.g. `invalid_network`) + * keep their canonical names. + */ + +// Verify errors: pre-simulation +export const ErrInvalidPayloadFormat = "invalid_auth_capture_evm_payload_format"; +export const ErrInvalidPayloadType = "invalid_auth_capture_evm_payload_type"; +export const ErrVoidAuthorizerSignature = "invalid_auth_capture_evm_void_authorizer_signature"; +export const ErrVoidRemainderFullCapture = "invalid_auth_capture_evm_void_remainder_full_capture"; +export const ErrUnsupportedPaymentFlow = "invalid_auth_capture_evm_unsupported_payment_flow"; +export const ErrUnsupportedScheme = "invalid_auth_capture_evm_scheme"; +export const ErrNetworkMismatch = "invalid_auth_capture_evm_network_mismatch"; +export const ErrInvalidNetwork = "invalid_network"; +export const ErrInvalidAuthCaptureExtra = "invalid_auth_capture_evm_extra"; +export const ErrMissingReceiverAuthorizer = "invalid_auth_capture_evm_missing_receiver_authorizer"; +export const ErrUnsupportedOperatorType = "invalid_auth_capture_evm_unsupported_operator_type"; +export const ErrInvalidPolicy = "invalid_auth_capture_evm_policy"; +export const ErrLifecycleNotRelayed = "invalid_auth_capture_evm_lifecycle_not_relayed"; +export const ErrOperatorTypeMismatch = "invalid_auth_capture_evm_operator_type_mismatch"; +export const ErrOperatorNotAdmitted = "invalid_auth_capture_evm_operator_not_admitted"; +export const ErrOperatorMismatch = "invalid_auth_capture_evm_operator_mismatch"; +export const ErrSaltBindingMismatch = "invalid_auth_capture_evm_salt_binding_mismatch"; +export const ErrAuthorizerSignature = "invalid_auth_capture_evm_authorizer_signature"; +export const ErrUnauthenticatedAuthorizerRequest = + "invalid_auth_capture_evm_unauthenticated_authorizer_request"; +export const ErrDelegatedAuthUnavailable = "invalid_auth_capture_evm_delegated_auth_unavailable"; +export const ErrUnexpectedPaymentState = "invalid_auth_capture_evm_unexpected_payment_state"; +export const ErrRefundFundingUnavailable = "invalid_auth_capture_evm_refund_funding_unavailable"; +export const ErrUnsupportedAssetTransferMethod = + "invalid_auth_capture_evm_unsupported_asset_transfer_method"; +export const ErrPayloadMethodMismatch = "invalid_auth_capture_evm_payload_method_mismatch"; +export const ErrCaptureDeadlineExpired = "invalid_auth_capture_evm_capture_deadline_expired"; +export const ErrRefundDeadlineExpired = "invalid_auth_capture_evm_refund_deadline_expired"; +export const ErrInvalidDeadlineOrdering = "invalid_auth_capture_evm_deadline_ordering"; +export const ErrAuthorizationExpired = "invalid_auth_capture_evm_authorization_expired"; +export const ErrAuthorizationNotYetValid = "invalid_auth_capture_evm_authorization_not_yet_valid"; +export const ErrTokenCollectorMismatch = "invalid_auth_capture_evm_token_collector_mismatch"; +export const ErrTokenMismatch = "invalid_auth_capture_evm_token_mismatch"; +export const ErrInvalidAuthCaptureSignature = "invalid_auth_capture_evm_signature"; +export const ErrErc6492FactoryNotAllowed = "invalid_auth_capture_evm_erc6492_factory_not_allowed"; +export const ErrAmountMismatch = "invalid_auth_capture_evm_amount_mismatch"; +export const ErrNonceMismatch = "invalid_auth_capture_evm_nonce_mismatch"; +export const ErrInsufficientBalance = "invalid_auth_capture_evm_insufficient_balance"; +export const ErrSimulationFailed = "invalid_auth_capture_evm_simulation_failed"; +export const ErrPaymentInfoMismatch = "invalid_auth_capture_evm_payment_info_mismatch"; +export const ErrUndeployedSmartWallet = "invalid_auth_capture_evm_payload_undeployed_smart_wallet"; + +// Typed simulation reverts +export const ErrPaymentAlreadyCollected = "invalid_auth_capture_evm_payment_already_collected"; +export const ErrTokenCollectionFailed = "invalid_auth_capture_evm_token_collection_failed"; +export const ErrInvalidCollector = "invalid_auth_capture_evm_collector"; +export const ErrAmountOverflow = "invalid_auth_capture_evm_amount_overflow"; +export const ErrInvalidFeeBps = "invalid_auth_capture_evm_fee_bps"; +export const ErrInvalidFeeBpsRange = "invalid_auth_capture_evm_fee_bps_range"; +export const ErrFeeBpsOutOfRange = "invalid_auth_capture_evm_fee_bps_out_of_range"; +export const ErrZeroFeeReceiver = "invalid_auth_capture_evm_zero_fee_receiver"; +export const ErrInvalidFeeReceiver = "invalid_auth_capture_evm_fee_receiver"; +export const ErrInsufficientAuthorization = "invalid_auth_capture_evm_insufficient_authorization"; +export const ErrZeroAuthorization = "invalid_auth_capture_evm_zero_authorization"; +export const ErrRefundExceedsCapture = "invalid_auth_capture_evm_refund_exceeds_capture"; + +// Settle errors +export const ErrVerificationFailed = "invalid_auth_capture_evm_verification_failed"; +export const ErrTransactionReverted = "invalid_auth_capture_evm_transaction_reverted"; +export const ErrSettlementPending = "settlement_pending"; + +// Resource-server lifecycle manager errors (never sent on the wire) +export const ErrServerLifecycleUnavailable = + "invalid_auth_capture_evm_server_lifecycle_unavailable"; + +/** + * Map an AuthCaptureEscrow custom-error name (decoded by viem from a + * ContractFunctionRevertedError) to a stable invalidReason string. Anything + * unmapped falls through to ErrSimulationFailed so verify() never leaks raw + * selectors to callers. + */ +export const ESCROW_ERROR_TO_INVALID_REASON: Record = { + AfterPreApprovalExpiry: ErrAuthorizationExpired, + InvalidExpiries: ErrInvalidDeadlineOrdering, + ExceedsMaxAmount: ErrAmountMismatch, + PaymentAlreadyCollected: ErrPaymentAlreadyCollected, + TokenCollectionFailed: ErrTokenCollectionFailed, + InvalidCollectorForOperation: ErrInvalidCollector, + InvalidSender: ErrOperatorMismatch, + ZeroAmount: ErrAmountMismatch, + AmountOverflow: ErrAmountOverflow, + FeeBpsOverflow: ErrInvalidFeeBps, + InvalidFeeBpsRange: ErrInvalidFeeBpsRange, + FeeBpsOutOfRange: ErrFeeBpsOutOfRange, + FeeAmountOutOfRange: ErrFeeBpsOutOfRange, + ZeroFeeReceiver: ErrZeroFeeReceiver, + InvalidFeeReceiver: ErrInvalidFeeReceiver, + AfterAuthorizationExpiry: ErrCaptureDeadlineExpired, + InsufficientAuthorization: ErrInsufficientAuthorization, + ZeroAuthorization: ErrZeroAuthorization, + AfterRefundExpiry: ErrRefundDeadlineExpired, + RefundExceedsCapture: ErrRefundExceedsCapture, +}; diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/extra.ts b/typescript/packages/mechanisms/evm/src/auth-capture/extra.ts new file mode 100644 index 0000000000..cb34628372 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/extra.ts @@ -0,0 +1,511 @@ +import { getAddress, isAddress, isAddressEqual, zeroAddress } from "viem"; +import type { PaymentRequirements } from "@x402/core/types"; +import { getEvmChainId } from "../utils"; +import { extraAddress, isNonZeroAddress } from "./nonce"; +import type { + AuthCaptureCaptureMode, + AuthCaptureExtra, + AuthCaptureFacilitatorConfig, + AuthCapturePaymentFlow, + AuthorizerMode, + AuthorizerSigner, + OperatorAllowlistEntry, +} from "./types"; +import { isAuthCaptureExtra } from "./types"; +import * as Errors from "./errors"; +import { + type AuthCaptureDeployment, + feeAmountFromBps, + resolveAuthCaptureDeployment, +} from "./constants"; +import { paymentInfoToContractTuple } from "./utils"; + +const MAX_FEE_BPS = 10_000; + +export type NormalizedAuthCaptureExtra = AuthCaptureExtra & { + paymentFlow: AuthCapturePaymentFlow; + operatorType: "delegated" | "custom"; + assetTransferMethod: "eip3009" | "permit2"; + receiverAuthorizer: `0x${string}`; + policy: `0x${string}`; + captureMode: AuthCaptureCaptureMode; + authCaptureEscrow: `0x${string}`; + deployment: AuthCaptureDeployment; +}; + +export type SubmittedFeeV1_0 = { + version: "v1.0"; + feeBps: number; + feeReceiver: `0x${string}`; +}; + +export type SubmittedFeeV1_1 = { + version: "v1.1"; + feeAmount: string; + feeReceiver: `0x${string}`; +}; + +export type SubmittedFee = SubmittedFeeV1_0 | SubmittedFeeV1_1; + +/** + * Parse and validate `requirements.extra` into a normalized form with defaults + * applied. Returns a stable invalidReason on any spec violation. + * + * @param extra - Untrusted `requirements.extra`. + * @returns Normalized extra, or an error code. + */ +export function parseAuthCaptureExtra( + extra: unknown, +): { extra: NormalizedAuthCaptureExtra } | { error: string } { + if (!isAuthCaptureExtra(extra)) { + return { error: Errors.ErrInvalidAuthCaptureExtra }; + } + const raw = extra as AuthCaptureExtra & { autoCapture?: unknown }; + + if (raw.autoCapture === true) { + return { error: Errors.ErrUnsupportedPaymentFlow }; + } + + const deployment = resolveAuthCaptureDeployment(raw.authCaptureEscrow); + if (!deployment) { + return { error: Errors.ErrInvalidAuthCaptureExtra }; + } + + const paymentFlow = raw.paymentFlow ?? "escrow"; + if (paymentFlow !== "escrow" && paymentFlow !== "authorization") { + return { error: Errors.ErrUnsupportedPaymentFlow }; + } + + const captureMode = raw.captureMode ?? "sync"; + if (captureMode !== "sync" && captureMode !== "deferred") { + return { error: Errors.ErrInvalidAuthCaptureExtra }; + } + + const operatorType = raw.operatorType ?? "delegated"; + if (operatorType === "policy") { + return { error: Errors.ErrUnsupportedOperatorType }; + } + if (operatorType !== "delegated" && operatorType !== "custom") { + return { error: Errors.ErrUnsupportedOperatorType }; + } + + const assetTransferMethod = raw.assetTransferMethod ?? "eip3009"; + if (assetTransferMethod !== "eip3009" && assetTransferMethod !== "permit2") { + return { error: Errors.ErrUnsupportedAssetTransferMethod }; + } + + if ( + !Number.isInteger(raw.minFeeBps) || + !Number.isInteger(raw.maxFeeBps) || + raw.minFeeBps < 0 || + raw.maxFeeBps < 0 || + raw.minFeeBps > MAX_FEE_BPS || + raw.maxFeeBps > MAX_FEE_BPS + ) { + return { error: Errors.ErrInvalidAuthCaptureExtra }; + } + if (raw.minFeeBps > raw.maxFeeBps) { + return { error: Errors.ErrInvalidAuthCaptureExtra }; + } + + if (!isAddress(raw.captureAuthorizer) || !isAddress(raw.feeRecipient)) { + return { error: Errors.ErrInvalidAuthCaptureExtra }; + } + + const feeRecipient = getAddress(raw.feeRecipient); + if (isAddressEqual(feeRecipient, zeroAddress) && (raw.minFeeBps !== 0 || raw.maxFeeBps !== 0)) { + return { error: Errors.ErrInvalidAuthCaptureExtra }; + } + const receiverAuthorizer = extraAddress(raw.receiverAuthorizer); + if (paymentFlow === "authorization" && !isNonZeroAddress(receiverAuthorizer)) { + return { error: Errors.ErrMissingReceiverAuthorizer }; + } + + return { + extra: { + ...raw, + captureAuthorizer: getAddress(raw.captureAuthorizer), + feeRecipient, + paymentFlow, + captureMode, + operatorType, + assetTransferMethod, + receiverAuthorizer, + policy: extraAddress(raw.policy), + authCaptureEscrow: deployment.escrow, + deployment, + }, + }; +} + +/** + * Whether a wire payload uses the fee field expected for the resolved deployment. + * + * @param deployment - Resolved commerce-payments deployment. + * @param fields - Candidate fee fields from the payload. + * @param fields.feeBps - v1.0 basis-point fee, if present. + * @param fields.feeAmount - v1.1 absolute fee, if present. + * @returns True when the fee field matches the deployment ABI. + */ +export function feeFieldMatchesDeployment( + deployment: AuthCaptureDeployment, + fields: { feeBps?: unknown; feeAmount?: unknown }, +): boolean { + if (deployment.version === "v1.0") { + return fields.feeBps !== undefined && fields.feeAmount === undefined; + } + return fields.feeAmount !== undefined && fields.feeBps === undefined; +} + +/** + * Validate submitted fee / feeReceiver against the client-signed extra bounds. + * v1.0 checks `feeBps`; v1.1 checks absolute `feeAmount` against the bps bounds. + * + * @param extra - Normalized extra. + * @param amount - Collect or capture amount in atomic token units. + * @param fee - Submitted fee parameters (`feeBps` or `feeAmount` per deployment). + * @returns An error code, or undefined when valid. + */ +export function validateSubmittedFee( + extra: NormalizedAuthCaptureExtra, + amount: bigint | string, + fee: SubmittedFee, +): string | undefined { + const amountBig = typeof amount === "bigint" ? amount : BigInt(amount); + const feeReceiver = fee.feeReceiver; + + if (!isAddress(feeReceiver)) { + return Errors.ErrInvalidFeeReceiver; + } + const receiver = getAddress(feeReceiver); + if (!isAddressEqual(extra.feeRecipient, zeroAddress)) { + if (!isAddressEqual(receiver, extra.feeRecipient)) { + return Errors.ErrInvalidFeeReceiver; + } + } + + if (fee.version === "v1.0") { + const feeBps = fee.feeBps; + if (!Number.isInteger(feeBps) || feeBps < extra.minFeeBps || feeBps > extra.maxFeeBps) { + return Errors.ErrFeeBpsOutOfRange; + } + if (isAddressEqual(receiver, zeroAddress) && feeBps !== 0) { + return Errors.ErrZeroFeeReceiver; + } + return undefined; + } + + let feeAmount: bigint; + try { + feeAmount = BigInt(fee.feeAmount); + } catch { + return Errors.ErrFeeBpsOutOfRange; + } + const minFee = feeAmountFromBps(amountBig, extra.minFeeBps); + const maxFee = feeAmountFromBps(amountBig, extra.maxFeeBps); + if (feeAmount < minFee || feeAmount > maxFee) { + return Errors.ErrFeeBpsOutOfRange; + } + if (isAddressEqual(receiver, zeroAddress) && feeAmount !== 0n) { + return Errors.ErrZeroFeeReceiver; + } + return undefined; +} + +/** + * Default submitted fee for server-authored signed operations: the minimum fee + * and extra.feeRecipient. v1.0 returns `feeBps`; v1.1 returns absolute `feeAmount`. + * + * @param extra - Normalized extra. + * @param amount - Collect or capture amount in atomic token units. + * @returns Fee parameters to submit with charge/capture. + */ +export function defaultSubmittedFee( + extra: NormalizedAuthCaptureExtra, + amount: bigint | string, +): SubmittedFee { + const amountBig = typeof amount === "bigint" ? amount : BigInt(amount); + if (extra.deployment.version === "v1.0") { + return { version: "v1.0", feeBps: extra.minFeeBps, feeReceiver: extra.feeRecipient }; + } + return { + version: "v1.1", + feeAmount: feeAmountFromBps(amountBig, extra.minFeeBps).toString(), + feeReceiver: extra.feeRecipient, + }; +} + +/** + * Absolute fee amount for balance-delta checks on charge/capture. + * + * @param fee - Submitted fee parameters. + * @param amount - Settled amount. + * @returns Fee in atomic token units. + */ +export function submittedFeeAmount(fee: SubmittedFee, amount: bigint): bigint { + if (fee.version === "v1.0") { + return feeAmountFromBps(amount, fee.feeBps); + } + return BigInt(fee.feeAmount); +} + +/** + * Check operator-type, policy, allowlist, and lifecycle-relay rules. + * + * @param extra - Normalized extra. + * @param submitters - Facilitator signer addresses. + * @param config - Facilitator config (allowlist). + * @param isLifecycle - True when the payload is capture/void/refund. + * @returns An error code, or undefined when admitted. + */ +export function validateOperator( + extra: NormalizedAuthCaptureExtra, + submitters: readonly `0x${string}`[], + config: AuthCaptureFacilitatorConfig | undefined, + isLifecycle: boolean, +): string | undefined { + if (isNonZeroAddress(extra.policy)) { + return Errors.ErrInvalidPolicy; + } + + if (extra.operatorType === "custom") { + if (!isOperatorAdmitted(extra.captureAuthorizer, config?.operators)) { + return Errors.ErrOperatorNotAdmitted; + } + if (isLifecycle) { + return Errors.ErrLifecycleNotRelayed; + } + return undefined; + } + + // delegated + if (!isSubmitter(extra.captureAuthorizer, submitters)) { + return Errors.ErrOperatorNotAdmitted; + } + if (isLifecycle && !isNonZeroAddress(extra.receiverAuthorizer)) { + return Errors.ErrLifecycleNotRelayed; + } + return undefined; +} + +/** + * Whether `address` is one of the facilitator's submitters. + * + * @param address - Candidate operator address. + * @param submitters - Facilitator signer addresses. + * @returns True when the address is a submitter. + */ +function isSubmitter(address: `0x${string}`, submitters: readonly `0x${string}`[]): boolean { + return submitters.some(s => isAddressEqual(s, address)); +} + +/** + * Whether a custom operator is on the facilitator's allowlist. + * + * @param address - extra.captureAuthorizer. + * @param operators - Allowlist from facilitator config. + * @returns True when admitted as `"custom"`. + */ +function isOperatorAdmitted( + address: `0x${string}`, + operators: OperatorAllowlistEntry[] | undefined, +): boolean { + if (!operators || operators.length === 0) return false; + return operators.some(entry => { + if (entry.operatorType !== "custom") return false; + if (entry.address === "*") return true; + return isAddress(entry.address) && isAddressEqual(entry.address, address); + }); +} + +/** + * Resolve the onchain target for a settle call from operatorType, not bytecode. + * `"delegated"` always calls the resolved escrow; `"custom"` calls + * extra.captureAuthorizer. + * + * @param extra - Normalized extra. + * @returns Address to pass to writeContract / simulate. + */ +export function resolveSettleTarget(extra: NormalizedAuthCaptureExtra): `0x${string}` { + return extra.operatorType === "custom" ? extra.captureAuthorizer : extra.deployment.escrow; +} + +/** + * Common scheme / network / extra / operator checks (spec verification + * steps 2–5). Returns the normalized extra on success. + * + * @param payloadScheme - payload.accepted.scheme. + * @param payloadNetwork - payload.accepted.network. + * @param requirements - Published requirements. + * @param scheme - Expected scheme id. + * @param submitters - Facilitator signer addresses. + * @param config - Facilitator config. + * @param isLifecycle - True for capture/void/refund payloads. + * @returns Normalized extra, or an invalidReason. + */ +export function verifyCommon( + payloadScheme: string, + payloadNetwork: string, + requirements: PaymentRequirements, + scheme: string, + submitters: readonly `0x${string}`[], + config: AuthCaptureFacilitatorConfig | undefined, + isLifecycle: boolean, +): { extra: NormalizedAuthCaptureExtra } | { error: string } { + if (payloadScheme !== scheme || requirements.scheme !== scheme) { + return { error: Errors.ErrUnsupportedScheme }; + } + if (payloadNetwork !== requirements.network) { + return { error: Errors.ErrNetworkMismatch }; + } + try { + getEvmChainId(requirements.network); + } catch { + return { error: Errors.ErrInvalidNetwork }; + } + const parsed = parseAuthCaptureExtra(requirements.extra); + if ("error" in parsed) { + return parsed; + } + const operatorError = validateOperator(parsed.extra, submitters, config, isLifecycle); + if (operatorError) { + return { error: operatorError }; + } + return parsed; +} + +/** + * Resolve and canonicalize `authCaptureEscrow` for publication. + * + * @param value - Merchant-provided escrow pin, if any. + * @returns Known escrow address for the wire. + */ +export function canonicalAuthCaptureEscrow(value: string | undefined): `0x${string}` { + const deployment = resolveAuthCaptureDeployment(value); + return (deployment ?? resolveAuthCaptureDeployment(undefined)!).escrow; +} + +/** + * Read the submitted capture fee from a lifecycle payload. + * + * @param extra - Normalized extra. + * @param wirePayload - Capture envelope. + * @param wirePayload.feeBps - v1.0 basis-point fee, if present. + * @param wirePayload.feeAmount - v1.1 absolute fee, if present. + * @param wirePayload.feeReceiver - Fee recipient on the capture call. + * @returns Submitted fee, or undefined when fields mismatch the deployment. + */ +export function captureFeeFromPayload( + extra: NormalizedAuthCaptureExtra, + wirePayload: { feeBps?: number; feeAmount?: string; feeReceiver: `0x${string}` }, +): SubmittedFee | undefined { + if (!feeFieldMatchesDeployment(extra.deployment, wirePayload)) { + return undefined; + } + if (extra.deployment.version === "v1.0") { + return { + version: "v1.0", + feeBps: wirePayload.feeBps as number, + feeReceiver: wirePayload.feeReceiver, + }; + } + return { + version: "v1.1", + feeAmount: wirePayload.feeAmount as string, + feeReceiver: wirePayload.feeReceiver, + }; +} + +type PaymentInfoContractTuple = ReturnType; + +/** + * Encode capture() arguments for the resolved deployment. + * + * @param tuple - PaymentInfo struct tuple for the escrow call. + * @param amount - Capture amount in atomic token units. + * @param fee - Submitted fee matching the deployment version. + * @returns Positional arguments for `capture`. + */ +export function captureEscrowArgs( + tuple: PaymentInfoContractTuple, + amount: bigint, + fee: SubmittedFee, +): readonly unknown[] { + if (fee.version === "v1.0") { + return [tuple, amount, fee.feeBps, fee.feeReceiver] as const; + } + return [tuple, amount, BigInt(fee.feeAmount), fee.feeReceiver] as const; +} + +/** + * Encode charge() arguments for the resolved deployment. + * + * @param tuple - PaymentInfo struct tuple for the escrow call. + * @param amount - Charge amount in atomic token units. + * @param tokenCollector - Collector contract used for this charge. + * @param collectorData - Collector-specific calldata. + * @param fee - Submitted fee matching the deployment version. + * @returns Positional arguments for `charge`. + */ +export function chargeEscrowArgs( + tuple: PaymentInfoContractTuple, + amount: bigint, + tokenCollector: `0x${string}`, + collectorData: `0x${string}`, + fee: SubmittedFee, +): readonly unknown[] { + if (fee.version === "v1.0") { + return [tuple, amount, tokenCollector, collectorData, fee.feeBps, fee.feeReceiver] as const; + } + return [ + tuple, + amount, + tokenCollector, + collectorData, + BigInt(fee.feeAmount), + fee.feeReceiver, + ] as const; +} + +/** + * Read charge-completion fee fields from a collect payload. + * + * @param extra - Normalized extra (deployment and fee bounds). + * @param wirePayload - Collect payload fields including fee and feeReceiver. + * @returns Submitted fee, or undefined when fields mismatch the deployment. + */ +export function chargeFeeFromCollectPayload( + extra: NormalizedAuthCaptureExtra, + wirePayload: Record, +): SubmittedFee | undefined { + if (!feeFieldMatchesDeployment(extra.deployment, wirePayload)) { + return undefined; + } + if (extra.deployment.version === "v1.0") { + return { + version: "v1.0", + feeBps: wirePayload.feeBps as number, + feeReceiver: wirePayload.feeReceiver as `0x${string}`, + }; + } + return { + version: "v1.1", + feeAmount: wirePayload.feeAmount as string, + feeReceiver: wirePayload.feeReceiver as `0x${string}`, + }; +} + +/** + * Select the authorizer mode for a resolved `receiverAuthorizer`. + * + * @param receiverAuthorizer - `extra.receiverAuthorizer` after enhancement (or the stored one). + * @param signer - The scheme's receiver-authorizer signer, if any. + * @returns The mode. + */ +export function authorizerMode( + receiverAuthorizer: `0x${string}`, + signer: AuthorizerSigner | undefined, +): AuthorizerMode { + if (!isNonZeroAddress(receiverAuthorizer)) return "collect-only"; + return signer && isAddressEqual(signer.address, receiverAuthorizer) ? "self" : "delegated"; +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/collect.ts b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/collect.ts new file mode 100644 index 0000000000..3050a42b55 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/collect.ts @@ -0,0 +1,1554 @@ +import type { + FacilitatorContext, + Network, + PaymentPayload, + PaymentRequirements, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import type { PendingSettlementStore } from "@x402/core/facilitator"; +import { + encodeFunctionData, + hexToBigInt, + isAddressEqual, + parseEventLogs, + size, + type Log, +} from "viem"; +import type { FacilitatorEvmSigner } from "../../signer"; +import { + ERC20_BALANCE_OF_ABI, + escrowAbiWithErrorsForDeployment, + escrowEventsAbiForDeployment, + ESCROW_VIEW_ABI, +} from "../abi"; +import { + AUTH_CAPTURE_DEPLOYMENT_V1_1, + AUTH_CAPTURE_SCHEME, + DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT, + type AuthCaptureDeployment, +} from "../constants"; +import { + computePayerAgnosticPaymentInfoHash, + computePaymentInfoHash, + deriveBoundSalt, + isSaltBindingOn, + verifyERC3009Signature, + verifyPermit2Signature, +} from "../nonce"; +import { appendDataSuffix, resolveDataSuffix } from "../../shared/extensions"; +import { + waitAndReturnSettleResponse, + withPendingSettlementStore, +} from "../../shared/settleReceipt"; +import { classifyErc6492Payer } from "../../shared/verifySignature"; +import { getEvmChainId, truncateErrorMessage } from "../../utils"; +import { paymentInfoToContractTuple, reconstructPaymentInfo, unpackForSettle } from "../utils"; +import { signCharge, verifyCharge, type ChargeDigest } from "../authorizerSigner"; +import type { + AuthCaptureCollectPayload, + AuthCaptureFacilitatorConfig, + Eip3009Payload, + PaymentInfoStruct, + Permit2Payload, +} from "../types"; +import { isEip3009Payload, isPermit2Payload } from "../types"; +import * as Errors from "../errors"; +import { + bindThenBroadcast, + getDelegatedAuthorizer, + resolveDelegatedCallerIdentity, + type AuthCaptureDelegatedAuthRecord, + type BindDisposition, + type DelegatedAuthorizer, +} from "./delegatedAuth"; +import { + chargeEscrowArgs, + chargeFeeFromCollectPayload, + defaultSubmittedFee, + parseAuthCaptureExtra, + resolveSettleTarget, + submittedFeeAmount, + validateSubmittedFee, + verifyCommon, + type NormalizedAuthCaptureExtra, + type SubmittedFee, +} from "../extra"; +import { + collectPayer, + facilitatorAddresses, + normalizePaymentState, + readPaymentStateForBalances, + resolveSubmitter, + simulateEscrowCall, + writeEscrowCall, + SAFETY_MARGIN_SECONDS, +} from "./utils"; + +/** + * Bound-collect `saltNonce`, if present. + * + * @param payload - Collect envelope. + * @returns The 32-byte nonce, or undefined when unbound. + */ +function collectSaltNonce(payload: AuthCaptureCollectPayload): `0x${string}` | undefined { + return "saltNonce" in payload ? payload.saltNonce : undefined; +} + +/** + * Partial-charge amount from a charge-completion collect payload. + * + * @param payload - Collect envelope. + * @returns Atomic amount, or undefined when the field is absent. + */ +function collectChargeAmount(payload: AuthCaptureCollectPayload): string | undefined { + return "amount" in payload && typeof payload.amount === "string" ? payload.amount : undefined; +} + +/** + * Authorizer signature from a charge-completion collect payload. + * + * @param payload - Collect envelope. + * @returns The signature, or undefined when absent (delegated authorizer). + */ +function collectAuthorizerSignature(payload: AuthCaptureCollectPayload): `0x${string}` | undefined { + return "authorizerSignature" in payload ? payload.authorizerSignature : undefined; +} + +/** + * EIP-712 Charge digest for the deployment's fee encoding. + * + * @param paymentInfoHash - Escrow payment identifier. + * @param amount - Charged amount. + * @param unpacked - Collector and collector data from the collect payload. + * @param unpacked.tokenCollector - Canonical collector for the asset-transfer method. + * @param unpacked.collectorData - Raw signature bytes forwarded to the collector. + * @param fee - Submitted fee (`feeBps` or `feeAmount` per deployment). + * @returns Digest to sign or verify. + */ +function chargeDigestFor( + paymentInfoHash: `0x${string}`, + amount: bigint, + unpacked: { tokenCollector: `0x${string}`; collectorData: `0x${string}` }, + fee: SubmittedFee, +): ChargeDigest { + const base = { + paymentInfoHash, + amount, + tokenCollector: unpacked.tokenCollector, + collectorData: unpacked.collectorData, + feeReceiver: fee.feeReceiver, + }; + return fee.version === "v1.0" + ? { ...base, feeBps: fee.feeBps } + : { ...base, feeAmount: fee.feeAmount }; +} + +/** + * Reason a payer signature failed verification. A signature that is not 65-byte ECDSA, from a + * payer with no code and no ERC-6492 envelope, is an undeployed smart wallet whose signature + * cannot be checked; anything else is simply invalid. + * + * @param isDeployedAtPayer - Whether the payer address has code. + * @param innerSignature - Signature with any ERC-6492 wrapper removed. + * @returns The stable `invalidReason`. + */ +function invalidPayerSignatureReason( + isDeployedAtPayer: boolean, + innerSignature: `0x${string}`, +): string { + return !isDeployedAtPayer && size(innerSignature) !== 65 + ? Errors.ErrUndeployedSmartWallet + : Errors.ErrInvalidAuthCaptureSignature; +} + +/** + * Verify a collect (authorize / charge) payload. + * + * @param signers - Facilitator signer set. + * @param config - Facilitator config. + * @param payload - Wire payment envelope. + * @param requirements - Published requirements. + * @param wirePayload - Narrowed collect payload. + * @param dataSuffix - Optional settlement suffix included in custom-operator simulation. + * @param requireChargeCompletion - Require the server-authored fields present at settle time. + * @returns VerifyResponse. + */ +export async function verifyCollect( + signers: readonly FacilitatorEvmSigner[], + config: AuthCaptureFacilitatorConfig | undefined, + payload: PaymentPayload, + requirements: PaymentRequirements, + wirePayload: AuthCaptureCollectPayload, + dataSuffix?: `0x${string}`, + requireChargeCompletion = false, +): Promise { + const payer = collectPayer(wirePayload); + const common = verifyCommon( + payload.accepted.scheme, + payload.accepted.network, + requirements, + AUTH_CAPTURE_SCHEME, + facilitatorAddresses(signers), + config, + false, + ); + if ("error" in common) { + return { isValid: false, invalidReason: common.error, payer }; + } + const extra = common.extra; + const submitter = resolveSubmitter(signers, extra); + if (!submitter) { + return { isValid: false, invalidReason: Errors.ErrOperatorNotAdmitted, payer }; + } + const bindOn = isSaltBindingOn(extra); + const saltNonce = collectSaltNonce(wirePayload); + + if (bindOn && saltNonce === undefined) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadFormat, payer }; + } + if (!bindOn && saltNonce !== undefined) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadFormat, payer }; + } + + const hasChargeCompletion = collectChargeAmount(wirePayload) !== undefined; + const isCharge = extra.paymentFlow === "authorization"; + + if ( + (!isCharge && hasChargeCompletion) || + (isCharge && requireChargeCompletion && !hasChargeCompletion) + ) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadFormat, payer }; + } + + if (extra.assetTransferMethod === "eip3009" && !isEip3009Payload(wirePayload)) { + return { isValid: false, invalidReason: Errors.ErrPayloadMethodMismatch, payer }; + } + if (extra.assetTransferMethod === "permit2" && !isPermit2Payload(wirePayload)) { + return { isValid: false, invalidReason: Errors.ErrPayloadMethodMismatch, payer }; + } + + const now = Math.floor(Date.now() / 1000); + if (extra.captureDeadline <= now + SAFETY_MARGIN_SECONDS) { + return { isValid: false, invalidReason: Errors.ErrCaptureDeadlineExpired, payer }; + } + if (extra.refundDeadline < extra.captureDeadline) { + return { isValid: false, invalidReason: Errors.ErrInvalidDeadlineOrdering, payer }; + } + if (now + requirements.maxTimeoutSeconds > extra.captureDeadline) { + return { isValid: false, invalidReason: Errors.ErrInvalidDeadlineOrdering, payer }; + } + + const chainId = getEvmChainId(requirements.network); + const unpacked = unpackForSettle(wirePayload, extra.assetTransferMethod, extra.deployment); + + if (unpacked.preApprovalExpiry <= now + SAFETY_MARGIN_SECONDS) { + return { isValid: false, invalidReason: Errors.ErrAuthorizationExpired, payer }; + } + if (unpacked.preApprovalExpiry > extra.captureDeadline) { + return { isValid: false, invalidReason: Errors.ErrInvalidDeadlineOrdering, payer }; + } + + if (extra.assetTransferMethod === "eip3009") { + const eipPayload = wirePayload as Eip3009Payload; + if (Number(eipPayload.authorization.validAfter) > now) { + return { isValid: false, invalidReason: Errors.ErrAuthorizationNotYetValid, payer }; + } + if ( + eipPayload.authorization.to.toLowerCase() !== extra.deployment.eip3009Collector.toLowerCase() + ) { + return { isValid: false, invalidReason: Errors.ErrTokenCollectorMismatch, payer }; + } + } else { + const permitPayload = wirePayload as Permit2Payload; + if ( + permitPayload.permit2Authorization.spender.toLowerCase() !== + extra.deployment.permit2Collector.toLowerCase() + ) { + return { isValid: false, invalidReason: Errors.ErrTokenCollectorMismatch, payer }; + } + if ( + permitPayload.permit2Authorization.permitted.token.toLowerCase() !== + requirements.asset.toLowerCase() + ) { + return { isValid: false, invalidReason: Errors.ErrTokenMismatch, payer }; + } + } + + // The canonical collectors strip the ERC-6492 wrapper onchain, so pre-verify checks the + // inner signature while settlement forwards the wrapper untouched. + const { isCounterfactual, isDeployedAtPayer, innerSignature, eip6492Deployment } = + await classifyErc6492Payer(submitter, wirePayload.signature, payer); + + if (isCounterfactual) { + // An undeployed wallet has no isValidSignature to call, so the simulation below is the + // only sound check: the collector deploys the wallet before the token validates the + // inner signature. Gate the preparation target on the allowlist first. + const factory = eip6492Deployment?.factoryAddress; + const factoryAllowed = + !!factory && + (config?.eip6492AllowedFactories ?? []).some( + allowed => allowed.trim().toLowerCase() === factory.toLowerCase(), + ); + if (!factoryAllowed) { + return { isValid: false, invalidReason: Errors.ErrErc6492FactoryNotAllowed, payer }; + } + } else { + const signatureValid = + extra.assetTransferMethod === "eip3009" + ? await verifyERC3009Signature( + submitter, + (wirePayload as Eip3009Payload).authorization, + innerSignature, + { ...extra, chainId }, + requirements.asset as `0x${string}`, + ) + : await verifyPermit2Signature( + submitter, + (wirePayload as Permit2Payload).permit2Authorization, + innerSignature, + chainId, + ); + + if (!signatureValid) { + return { + isValid: false, + invalidReason: invalidPayerSignatureReason(isDeployedAtPayer, innerSignature), + payer, + }; + } + } + + const originalMax = payload.accepted.amount; + if (unpacked.amount !== BigInt(originalMax)) { + return { isValid: false, invalidReason: Errors.ErrAmountMismatch, payer }; + } + + let settleAmount = unpacked.amount; + let fee = defaultSubmittedFee(extra, settleAmount); + + if (hasChargeCompletion) { + const chargeAmount = collectChargeAmount(wirePayload); + if (chargeAmount === undefined) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadFormat, payer }; + } + settleAmount = BigInt(chargeAmount); + if (settleAmount <= 0n || settleAmount > unpacked.amount) { + return { isValid: false, invalidReason: Errors.ErrAmountMismatch, payer }; + } + const chargeFee = chargeFeeFromCollectPayload(extra, wirePayload as Record); + if (!chargeFee) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadFormat, payer }; + } + fee = chargeFee; + const feeError = validateSubmittedFee(extra, settleAmount, fee); + if (feeError) { + return { isValid: false, invalidReason: feeError, payer }; + } + } + + if (bindOn && saltNonce !== undefined) { + const expectedSalt = deriveBoundSalt(extra.receiverAuthorizer, extra.policy, saltNonce); + if (BigInt(wirePayload.salt) !== BigInt(expectedSalt)) { + return { isValid: false, invalidReason: Errors.ErrSaltBindingMismatch, payer }; + } + } + + const paymentInfo = reconstructPaymentInfo( + payer, + unpacked.preApprovalExpiry, + wirePayload.salt, + { ...requirements, amount: originalMax }, + extra, + originalMax, + ); + const expectedNonce = computePayerAgnosticPaymentInfoHash( + chainId, + paymentInfo, + extra.deployment.escrow, + ); + + if (extra.assetTransferMethod === "eip3009") { + const wireNonce = (wirePayload as Eip3009Payload).authorization.nonce; + if (wireNonce.toLowerCase() !== expectedNonce.toLowerCase()) { + return { isValid: false, invalidReason: Errors.ErrNonceMismatch, payer }; + } + } else { + const wireNonce = BigInt((wirePayload as Permit2Payload).permit2Authorization.nonce); + if (wireNonce !== hexToBigInt(expectedNonce)) { + return { isValid: false, invalidReason: Errors.ErrNonceMismatch, payer }; + } + } + + if (hasChargeCompletion) { + const authorizerSignature = collectAuthorizerSignature(wirePayload); + // A delegated authorizer signs at settle time, after authenticating the caller. + if (authorizerSignature) { + const ok = await verifyCharge( + submitter, + extra.receiverAuthorizer, + chainId, + extra.captureAuthorizer, + extra.deployment, + chargeDigestFor( + computePaymentInfoHash(chainId, paymentInfo, extra.deployment.escrow), + settleAmount, + unpacked, + fee, + ), + authorizerSignature, + ); + if (!ok) { + return { isValid: false, invalidReason: Errors.ErrAuthorizerSignature, payer }; + } + } else if (!getDelegatedAuthorizer(config, extra.receiverAuthorizer)) { + return { isValid: false, invalidReason: Errors.ErrAuthorizerSignature, payer }; + } + } + + const simulateResult = await simulateCollect( + submitter, + config, + extra, + paymentInfo, + settleAmount, + unpacked.tokenCollector, + unpacked.collectorData, + fee, + chainId, + dataSuffix, + ); + if (simulateResult !== "ok") { + try { + const balance = (await submitter.readContract({ + address: requirements.asset as `0x${string}`, + abi: ERC20_BALANCE_OF_ABI, + functionName: "balanceOf", + args: [payer], + })) as bigint; + if (balance < unpacked.amount) { + return { isValid: false, invalidReason: Errors.ErrInsufficientBalance, payer }; + } + } catch { + /* ignore: fall through */ + } + return { isValid: false, invalidReason: simulateResult, payer }; + } + + return { isValid: true, payer }; +} + +type CollectBalanceCheck = { + before: CollectBalanceSnapshot; + tokenStore: `0x${string}`; + facilitator: `0x${string}`; +}; + +type CollectSettleExecution = { + submitter: FacilitatorEvmSigner; + extra: NormalizedAuthCaptureExtra; + payer: `0x${string}`; + network: Network; + paymentInfo: PaymentInfoStruct; + paymentInfoHash: `0x${string}`; + functionName: "authorize" | "charge"; + settleAmount: bigint; + fee: SubmittedFee; + tokenCollector: `0x${string}`; +}; + +/** + * Waits for a collect broadcast receipt and runs post-confirm checks, with + * pending-settlement store bookkeeping. + * + * @param store - Pending-settlement store keyed by the collect signature. + * @param pendingKey - Store lookup key (the collect signature). + * @param tx - Broadcast transaction hash to await. + * @param execution - Parsed collect execution context. + * @param customBalanceCheck - Pre-broadcast balance snapshot for custom operators. + * @param isReconcile - True on a cache-hit retry (skips balance-delta checks). + * @returns SettleResponse after receipt confirmation or a retryable pending failure. + */ +async function awaitCollectSettlement( + store: PendingSettlementStore, + pendingKey: string | undefined, + tx: `0x${string}`, + execution: CollectSettleExecution, + customBalanceCheck: CollectBalanceCheck | undefined, + isReconcile: boolean, +): Promise { + const { + submitter, + extra, + payer, + network, + paymentInfo, + paymentInfoHash, + functionName, + settleAmount, + fee, + tokenCollector, + } = execution; + const expectedCapturable = functionName === "authorize" ? settleAmount : 0n; + const expectedRefundable = functionName === "charge" ? settleAmount : 0n; + + return withPendingSettlementStore( + store, + pendingKey, + () => + waitAndReturnSettleResponse(submitter, tx, network, payer, { + failedStatusReason: Errors.ErrTransactionReverted, + validateReceipt: receipt => { + if (extra.operatorType !== "custom") { + return undefined; + } + const eventOk = + functionName === "charge" + ? verifyPaymentChargedEvent(receipt.logs, extra.deployment, { + paymentInfoHash, + amount: settleAmount, + tokenCollector, + operator: extra.captureAuthorizer, + fee, + }) + : verifyPaymentAuthorizedEvent(receipt.logs, extra.deployment.escrow, { + paymentInfoHash, + amount: settleAmount, + tokenCollector, + operator: extra.captureAuthorizer, + }); + if (!eventOk) { + return { + success: false, + errorReason: Errors.ErrSimulationFailed, + transaction: tx, + network, + payer, + }; + } + return undefined; + }, + onSuccess: async () => { + const { state, readFailed } = await readPaymentStateForBalances( + submitter, + paymentInfoHash, + expectedCapturable, + expectedRefundable, + extra.deployment.escrow, + ); + if (readFailed) { + throw new Error("payment state read failed after confirmed collect transaction"); + } + if ( + !state || + !state.hasCollectedPayment || + state.capturableAmount !== expectedCapturable || + state.refundableAmount !== expectedRefundable + ) { + return { + success: false, + errorReason: Errors.ErrUnexpectedPaymentState, + transaction: tx, + network, + payer, + }; + } + + // Cache-hit reconcile has no pre-broadcast balance snapshot; still require escrow + // events (above) and post-confirm paymentState, but skip the delta check. + if (customBalanceCheck && !isReconcile) { + const after = await readCollectBalanceSnapshot( + submitter, + paymentInfo.token, + payer, + customBalanceCheck.tokenStore, + customBalanceCheck.facilitator, + functionName, + paymentInfo.receiver, + fee.feeReceiver, + ); + if (!after) { + throw new Error("balance read failed after confirmed collect transaction"); + } + if ( + !hasExpectedCollectBalanceChanges( + customBalanceCheck.before, + after, + functionName, + settleAmount, + fee, + ) + ) { + return { + success: false, + errorReason: Errors.ErrSimulationFailed, + transaction: tx, + network, + payer, + }; + } + } + + return { + success: true, + transaction: tx, + network, + payer, + amount: settleAmount.toString(), + }; + }, + }), + Errors.ErrTransactionReverted, + ); +} + +/** + * Build collect execution context from a verified or reconciled payload. + * + * @param signers - Facilitator signer set. + * @param payload - Wire payment envelope. + * @param requirements - Published requirements. + * @param wirePayload - Narrowed collect payload. + * @param payerOverride - Payer when verify was skipped (cache-hit reconcile). + * @returns Execution context or a terminal SettleResponse. + */ +async function buildCollectSettleExecution( + signers: readonly FacilitatorEvmSigner[], + payload: PaymentPayload, + requirements: PaymentRequirements, + wirePayload: AuthCaptureCollectPayload, + payerOverride?: string, +): Promise { + const parsed = parseAuthCaptureExtra(requirements.extra); + if ("error" in parsed) { + return { + success: false, + errorReason: parsed.error, + transaction: "", + network: requirements.network, + payer: payerOverride ?? collectPayer(wirePayload), + }; + } + const extra = parsed.extra; + const submitter = resolveSubmitter(signers, extra); + if (!submitter) { + return { + success: false, + errorReason: Errors.ErrOperatorNotAdmitted, + transaction: "", + network: requirements.network, + payer: payerOverride ?? collectPayer(wirePayload), + }; + } + + const payer = (payerOverride ?? collectPayer(wirePayload)) as `0x${string}`; + const unpacked = unpackForSettle(wirePayload, extra.assetTransferMethod, extra.deployment); + const originalMax = payload.accepted.amount; + const paymentInfo = reconstructPaymentInfo( + payer, + unpacked.preApprovalExpiry, + wirePayload.salt, + { ...requirements, amount: originalMax }, + extra, + originalMax, + ); + + const needsChargeCompletion = extra.paymentFlow === "authorization"; + const functionName = extra.paymentFlow === "authorization" ? "charge" : "authorize"; + let settleAmount = unpacked.amount; + let fee = defaultSubmittedFee(extra, settleAmount); + if (needsChargeCompletion) { + settleAmount = BigInt(collectChargeAmount(wirePayload) ?? originalMax); + const chargeFee = chargeFeeFromCollectPayload(extra, wirePayload as Record); + if (chargeFee) { + fee = chargeFee; + } + } + + const chainId = getEvmChainId(requirements.network); + const paymentInfoHash = computePaymentInfoHash(chainId, paymentInfo, extra.deployment.escrow); + + return { + submitter, + extra, + payer, + network: requirements.network, + paymentInfo, + paymentInfoHash, + functionName, + settleAmount, + fee, + tokenCollector: unpacked.tokenCollector, + }; +} + +/** + * Snapshot payment-token balances before a custom-operator collect broadcast. + * + * @param submitter - Facilitator submitter. + * @param execution - Parsed collect execution context. + * @returns Balance snapshot context or a terminal SettleResponse. + */ +async function snapshotCustomOperatorBalances( + submitter: FacilitatorEvmSigner, + execution: CollectSettleExecution, +): Promise { + if (execution.extra.operatorType !== "custom") { + return undefined; + } + + const { extra, payer, paymentInfo, functionName, fee } = execution; + const facilitator = submitter.getAddresses()[0]; + if (!facilitator) { + return { + success: false, + errorReason: Errors.ErrSimulationFailed, + transaction: "", + network: execution.network, + payer, + }; + } + + let tokenStore: `0x${string}`; + try { + tokenStore = (await submitter.readContract({ + address: extra.deployment.escrow, + abi: ESCROW_VIEW_ABI, + functionName: "getTokenStore", + args: [extra.captureAuthorizer], + })) as `0x${string}`; + } catch { + return { + success: false, + errorReason: Errors.ErrSimulationFailed, + transaction: "", + network: execution.network, + payer, + }; + } + + const before = await readCollectBalanceSnapshot( + submitter, + paymentInfo.token, + payer, + tokenStore, + facilitator, + functionName, + paymentInfo.receiver, + fee.feeReceiver, + ); + if (!before) { + return { + success: false, + errorReason: Errors.ErrSimulationFailed, + transaction: "", + network: execution.network, + payer, + }; + } + + return { before, tokenStore, facilitator }; +} + +/** + * Re-verify and settle a collect payload. + * + * @param signers - Facilitator signer set. + * @param config - Facilitator config. + * @param payload - Wire payment envelope. + * @param requirements - Published requirements. + * @param wirePayload - Narrowed collect payload. + * @param store - Pending-settlement store keyed by the collect signature. + * @param context - Optional facilitator context for extension hooks. + * @returns SettleResponse, including the settled amount. + */ +export async function settleCollect( + signers: readonly FacilitatorEvmSigner[], + config: AuthCaptureFacilitatorConfig | undefined, + payload: PaymentPayload, + requirements: PaymentRequirements, + wirePayload: AuthCaptureCollectPayload, + store: PendingSettlementStore, + context?: FacilitatorContext, +): Promise { + const pendingKey = wirePayload.signature; + const payer = collectPayer(wirePayload); + + if (pendingKey) { + const cachedTx = await store.get(pendingKey); + if (cachedTx) { + const execution = await buildCollectSettleExecution( + signers, + payload, + requirements, + wirePayload, + payer, + ); + if ("success" in execution) { + return { + success: false, + errorReason: Errors.ErrSettlementPending, + transaction: cachedTx, + network: requirements.network, + payer, + }; + } + await store.delete(pendingKey); + return awaitCollectSettlement( + store, + pendingKey, + cachedTx as `0x${string}`, + execution, + undefined, + true, + ); + } + } + + const dataSuffix = await resolveDataSuffix(context, { + paymentPayload: payload, + paymentRequirements: requirements, + }); + const verification = await verifyCollect( + signers, + config, + payload, + requirements, + wirePayload, + dataSuffix, + true, + ); + if (!verification.isValid) { + return { + success: false, + errorReason: verification.invalidReason ?? Errors.ErrVerificationFailed, + transaction: "", + network: requirements.network, + payer: verification.payer, + }; + } + + const execution = await buildCollectSettleExecution( + signers, + payload, + requirements, + wirePayload, + verification.payer, + ); + if ("success" in execution) { + return execution; + } + + const delegated = getDelegatedAuthorizer(config, execution.extra.receiverAuthorizer); + if (delegated) { + const authenticated = await authenticateDelegatedCollect( + delegated, + config, + payload, + requirements, + wirePayload, + execution, + context, + ); + if ("success" in authenticated) { + return authenticated; + } + const broadcast = () => + broadcastCollect(config, wirePayload, execution, store, pendingKey, dataSuffix); + if (!authenticated.bindRecord) { + return (await broadcast()).value; + } + const bound = await bindThenBroadcast({ + delegated, + record: authenticated.bindRecord, + broadcast, + }); + if (!bound.ok) { + return { + success: false, + errorReason: + bound.reason === "conflict" + ? Errors.ErrUnauthenticatedAuthorizerRequest + : Errors.ErrDelegatedAuthUnavailable, + ...(bound.reason === "unavailable" + ? { + errorMessage: `failed to bind delegated caller: ${truncateErrorMessage(bound.error instanceof Error ? bound.error.message : String(bound.error))}`, + } + : {}), + transaction: "", + network: requirements.network, + payer: execution.payer, + }; + } + return bound.value; + } + + return (await broadcastCollect(config, wirePayload, execution, store, pendingKey, dataSuffix)) + .value; +} + +/** + * Authenticate a collect whose authorizer is delegated to this facilitator, produce the + * `Charge` signature the server omitted, and decide whether the caller must be bound. + * + * @param delegated - Delegated-authorizer wiring. + * @param config - Facilitator config. + * @param payload - Wire payment envelope. + * @param requirements - Published requirements. + * @param wirePayload - Narrowed collect payload. + * @param execution - Parsed collect execution context. + * @param context - Optional facilitator context passed to `resolveCallerIdentity`. + * @returns A terminal failure, or the binding to write before broadcast (when one is needed). + */ +async function authenticateDelegatedCollect( + delegated: DelegatedAuthorizer, + config: AuthCaptureFacilitatorConfig | undefined, + payload: PaymentPayload, + requirements: PaymentRequirements, + wirePayload: AuthCaptureCollectPayload, + execution: CollectSettleExecution, + context?: FacilitatorContext, +): Promise { + const { extra, payer, network, paymentInfoHash, functionName } = execution; + const fail = (errorReason: string): SettleResponse => ({ + success: false, + errorReason, + transaction: "", + network, + payer, + }); + + const identity = await resolveDelegatedCallerIdentity(delegated, { + step: functionName, + paymentInfoHash, + network, + payer, + payload, + requirements, + facilitatorContext: context, + }); + if (!identity) { + return fail(Errors.ErrUnauthenticatedAuthorizerRequest); + } + + if (functionName === "charge" && !collectAuthorizerSignature(wirePayload)) { + const chainId = getEvmChainId(requirements.network); + const unpacked = unpackForSettle(wirePayload, extra.assetTransferMethod, extra.deployment); + const digest = chargeDigestFor( + paymentInfoHash, + execution.settleAmount, + unpacked, + execution.fee, + ); + const signature = await signCharge( + delegated.signer, + chainId, + extra.captureAuthorizer, + extra.deployment, + digest, + ); + const ok = await verifyCharge( + execution.submitter, + extra.receiverAuthorizer, + chainId, + extra.captureAuthorizer, + extra.deployment, + digest, + signature, + ); + if (!ok) { + return fail(Errors.ErrAuthorizerSignature); + } + } + + // Only payments whose later steps the facilitator can still relay need a binding. + // A custom operator's lifecycle is never relayed, and a charge is only refundable + // through the facilitator when refunds are funded. + const relayable = + extra.operatorType === "delegated" && + (functionName === "authorize" || config?.refundFunding === true); + if (!relayable) { + return {}; + } + return { + bindRecord: { + network, + paymentInfoHash, + callerIdentity: identity, + expiresAt: extra.refundDeadline, + }, + }; +} + +/** + * Broadcast the collect and await its receipt. + * + * @param config - Facilitator config. + * @param wirePayload - Narrowed collect payload. + * @param execution - Parsed collect execution context. + * @param store - Pending-settlement store keyed by the collect signature. + * @param pendingKey - Store lookup key (the collect signature). + * @param dataSuffix - Optional settlement suffix appended to the calldata. + * @returns The settle response, and whether a caller binding written for it should be kept: + * it is reverted only when no transaction can have landed. + */ +async function broadcastCollect( + config: AuthCaptureFacilitatorConfig | undefined, + wirePayload: AuthCaptureCollectPayload, + execution: CollectSettleExecution, + store: PendingSettlementStore, + pendingKey: string | undefined, + dataSuffix: `0x${string}` | undefined, +): Promise<{ value: SettleResponse; disposition: BindDisposition }> { + const customBalanceSnapshot = await snapshotCustomOperatorBalances( + execution.submitter, + execution, + ); + if (customBalanceSnapshot && "success" in customBalanceSnapshot) { + return { value: customBalanceSnapshot, disposition: "revert" }; + } + + const tuple = paymentInfoToContractTuple(execution.paymentInfo); + const unpackedSettle = unpackForSettle( + wirePayload, + execution.extra.assetTransferMethod, + execution.extra.deployment, + ); + const args = + execution.functionName === "charge" + ? chargeEscrowArgs( + tuple, + execution.settleAmount, + execution.tokenCollector, + unpackedSettle.collectorData, + execution.fee, + ) + : ([ + tuple, + execution.settleAmount, + execution.tokenCollector, + unpackedSettle.collectorData, + ] as const); + + const settleTarget = resolveSettleTarget(execution.extra); + const customGasLimit = + execution.extra.operatorType === "custom" + ? (config?.customOperatorAuthorizeGasLimit ?? DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT) + : undefined; + + const written = await writeEscrowCall( + execution.submitter, + settleTarget, + execution.functionName, + args, + execution.extra.deployment, + { + gas: customGasLimit, + dataSuffix, + }, + ); + if ("error" in written) { + return { + value: { + success: false, + errorReason: written.error, + transaction: "", + network: execution.network, + payer: execution.payer, + }, + disposition: "revert", + }; + } + + const value = await awaitCollectSettlement( + store, + pendingKey, + written.txHash, + execution, + customBalanceSnapshot, + false, + ); + // A pending receipt or any other post-broadcast outcome may still have landed onchain. + return { + value, + disposition: value.errorReason === Errors.ErrTransactionReverted ? "revert" : "keep", + }; +} + +/** + * Simulate authorize or charge against the resolved settle target. + * + * @param signer - Facilitator signer. + * @param config - Facilitator config. + * @param extra - Normalized extra. + * @param paymentInfo - Reconstructed PaymentInfo. + * @param amount - Amount to collect. + * @param tokenCollector - Canonical collector for the asset-transfer method. + * @param collectorData - Raw signature bytes. + * @param fee - Submitted fee for charge (`feeBps` or `feeAmount` per deployment); ignored for authorize. + * @param chainId - EVM chain id for paymentInfoHash. + * @param dataSuffix - Optional settlement suffix appended to the simulated calldata. + * @returns `"ok"` or a stable invalidReason. + */ +async function simulateCollect( + signer: FacilitatorEvmSigner, + config: AuthCaptureFacilitatorConfig | undefined, + extra: NormalizedAuthCaptureExtra, + paymentInfo: PaymentInfoStruct, + amount: bigint, + tokenCollector: `0x${string}`, + collectorData: `0x${string}`, + fee: SubmittedFee, + chainId: number, + dataSuffix?: `0x${string}`, +): Promise<"ok" | string> { + const functionName = extra.paymentFlow === "authorization" ? "charge" : "authorize"; + const tuple = paymentInfoToContractTuple(paymentInfo); + const args = + functionName === "charge" + ? chargeEscrowArgs(tuple, amount, tokenCollector, collectorData, fee) + : ([tuple, amount, tokenCollector, collectorData] as const); + + if (extra.operatorType === "delegated") { + // Delegated collect calls the escrow directly; eth_call success is sufficient preflight. + const settleTarget = resolveSettleTarget(extra); + return simulateEscrowCall( + signer, + settleTarget, + functionName, + args, + extra.captureAuthorizer, + extra.deployment, + ); + } + + // Custom operators relay through captureAuthorizer — require eth_simulateV1 outcome checks. + if (!signer.simulateCalls) { + return Errors.ErrSimulationFailed; + } + + const gasLimit = + config?.customOperatorAuthorizeGasLimit ?? DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT; + const facilitator = signer.getAddresses()[0]!; + const paymentInfoHash = computePaymentInfoHash(chainId, paymentInfo, extra.deployment.escrow); + const operator = extra.captureAuthorizer; + const token = paymentInfo.token; + const payer = paymentInfo.payer; + const receiver = paymentInfo.receiver; + const feeReceiver = fee.feeReceiver; + const escrowAddress = extra.deployment.escrow; + + // Token store is CREATE2-predictable from the operator even before deployment. + let tokenStore: `0x${string}`; + try { + tokenStore = (await signer.readContract({ + address: escrowAddress, + abi: ESCROW_VIEW_ABI, + functionName: "getTokenStore", + args: [operator], + })) as `0x${string}`; + } catch { + return Errors.ErrSimulationFailed; + } + + const balanceCall = (account: `0x${string}`) => + ({ + to: token, + abi: ERC20_BALANCE_OF_ABI, + functionName: "balanceOf", + args: [account], + }) as const; + + const paymentStateCall = () => + ({ + to: escrowAddress, + abi: ESCROW_VIEW_ABI, + functionName: "paymentState", + args: [paymentInfoHash], + }) as const; + + const preCalls = [ + paymentStateCall(), + balanceCall(payer), + balanceCall(tokenStore), + balanceCall(facilitator), + ...(functionName === "charge" ? [balanceCall(receiver), balanceCall(feeReceiver)] : []), + ]; + + const forwardedData = appendDataSuffix( + encodeFunctionData({ + abi: escrowAbiWithErrorsForDeployment(extra.deployment), + functionName, + args: args as never, + }), + dataSuffix, + ); + + // One batch: snapshot pre-state, simulate the operator relay (gas-capped), then re-read deltas. + const forwardedCall = { + to: operator, + data: forwardedData, + gas: gasLimit, + } as const; + + const postCalls = [ + paymentStateCall(), + balanceCall(payer), + balanceCall(tokenStore), + balanceCall(facilitator), + ...(functionName === "charge" ? [balanceCall(receiver), balanceCall(feeReceiver)] : []), + ]; + + const calls = [...preCalls, forwardedCall, ...postCalls]; + const forwardedIndex = preCalls.length; + + let results: Awaited>>["results"]; + try { + ({ results } = await signer.simulateCalls({ account: facilitator, calls })); + } catch { + return Errors.ErrSimulationFailed; + } + + if (results.length !== calls.length) { + return Errors.ErrSimulationFailed; + } + + // Every view call in the batch must succeed so deltas are readable. + for (let i = 0; i < results.length; i++) { + if (i === forwardedIndex) continue; + if (results[i]?.status !== "success") { + return Errors.ErrSimulationFailed; + } + } + + const forwarded = results[forwardedIndex]; + if (!forwarded || forwarded.status !== "success") { + return Errors.ErrSimulationFailed; + } + // Reject over-limit gas even when the call returns success under the sim cap. + if (forwarded.gasUsed !== undefined && forwarded.gasUsed > gasLimit) { + return Errors.ErrSimulationFailed; + } + + // Top-level operator success is not enough — require a canonical escrow event + // (operator-emitted logs are ignored by filtering on escrow address). + const eventOk = + functionName === "charge" + ? verifyPaymentChargedEvent(forwarded.logs, extra.deployment, { + paymentInfoHash, + amount, + tokenCollector, + operator, + fee, + }) + : verifyPaymentAuthorizedEvent(forwarded.logs, escrowAddress, { + paymentInfoHash, + amount, + tokenCollector, + operator, + }); + if (!eventOk) { + return Errors.ErrSimulationFailed; + } + + const preState = normalizePaymentState(results[0]?.result); + const postState = normalizePaymentState(results[forwardedIndex + 1]?.result); + // Payment must start uncollected; a non-zero pre-state means reuse or stale sim state. + if ( + !preState || + !postState || + preState.hasCollectedPayment || + preState.capturableAmount !== 0n || + preState.refundableAmount !== 0n + ) { + return Errors.ErrSimulationFailed; + } + + const readBalance = (index: number): bigint | undefined => { + const result = results[index]?.result; + if (result === undefined || result === null) return undefined; + return BigInt(result as bigint | number | string); + }; + + const prePayer = readBalance(1); + const preTokenStore = readBalance(2); + const preFacilitator = readBalance(3); + const postPayer = readBalance(forwardedIndex + 2); + const postTokenStore = readBalance(forwardedIndex + 3); + const postFacilitator = readBalance(forwardedIndex + 4); + + if ( + prePayer === undefined || + preTokenStore === undefined || + preFacilitator === undefined || + postPayer === undefined || + postTokenStore === undefined || + postFacilitator === undefined + ) { + return Errors.ErrSimulationFailed; + } + + const beforeBalances: CollectBalanceSnapshot = { + payer: prePayer, + tokenStore: preTokenStore, + facilitator: preFacilitator, + }; + const afterBalances: CollectBalanceSnapshot = { + payer: postPayer, + tokenStore: postTokenStore, + facilitator: postFacilitator, + }; + if (functionName === "authorize") { + // Authorize: escrow hold (capturable=amount); payer debited into the operator token store. + if ( + !postState.hasCollectedPayment || + postState.capturableAmount !== amount || + postState.refundableAmount !== 0n || + !hasExpectedCollectBalanceChanges(beforeBalances, afterBalances, functionName, amount, fee) + ) { + return Errors.ErrSimulationFailed; + } + return "ok"; + } + + const preReceiver = readBalance(4); + const preFeeReceiver = readBalance(5); + const postReceiver = readBalance(forwardedIndex + 5); + const postFeeReceiver = readBalance(forwardedIndex + 6); + + if ( + preReceiver === undefined || + preFeeReceiver === undefined || + postReceiver === undefined || + postFeeReceiver === undefined + ) { + return Errors.ErrSimulationFailed; + } + + beforeBalances.receiver = preReceiver; + beforeBalances.feeReceiver = preFeeReceiver; + afterBalances.receiver = postReceiver; + afterBalances.feeReceiver = postFeeReceiver; + + // Charge: payer debited, token store net zero, receiver/feeReceiver split matches _distributeTokens. + if ( + !postState.hasCollectedPayment || + postState.capturableAmount !== 0n || + postState.refundableAmount !== amount || + !hasExpectedCollectBalanceChanges(beforeBalances, afterBalances, functionName, amount, fee) + ) { + return Errors.ErrSimulationFailed; + } + + return "ok"; +} + +type CollectBalanceSnapshot = { + payer: bigint; + tokenStore: bigint; + facilitator: bigint; + receiver?: bigint; + feeReceiver?: bigint; +}; + +/** + * Read the payment-token balances used to verify a custom collect outcome. + * + * @param signer - Facilitator signer used for reads. + * @param token - Payment token. + * @param payer - Payer address. + * @param tokenStore - Operator's escrow token store. + * @param facilitator - Facilitator submitter address. + * @param functionName - Collect operation. + * @param receiver - Payment receiver. + * @param feeReceiver - Charge fee recipient. + * @returns A balance snapshot, or undefined when any read fails. + */ +async function readCollectBalanceSnapshot( + signer: FacilitatorEvmSigner, + token: `0x${string}`, + payer: `0x${string}`, + tokenStore: `0x${string}`, + facilitator: `0x${string}`, + functionName: "authorize" | "charge", + receiver: `0x${string}`, + feeReceiver: `0x${string}`, +): Promise { + const readBalance = async (account: `0x${string}`): Promise => + BigInt( + (await signer.readContract({ + address: token, + abi: ERC20_BALANCE_OF_ABI, + functionName: "balanceOf", + args: [account], + account: facilitator, + })) as bigint | number | string, + ); + + try { + const common = await Promise.all([ + readBalance(payer), + readBalance(tokenStore), + readBalance(facilitator), + ]); + if (functionName === "authorize") { + return { payer: common[0], tokenStore: common[1], facilitator: common[2] }; + } + const [receiverBalance, feeReceiverBalance] = await Promise.all([ + readBalance(receiver), + readBalance(feeReceiver), + ]); + return { + payer: common[0], + tokenStore: common[1], + facilitator: common[2], + receiver: receiverBalance, + feeReceiver: feeReceiverBalance, + }; + } catch { + return undefined; + } +} + +/** + * Check actual payment-token balance changes against the simulated collect invariants. + * + * @param before - Balances immediately before submission. + * @param after - Balances after the transaction confirms. + * @param functionName - Collect operation. + * @param amount - Settled amount. + * @param fee - Submitted fee for charge (`feeBps` or `feeAmount` per deployment). + * @returns True when all expected deltas match. + */ +function hasExpectedCollectBalanceChanges( + before: CollectBalanceSnapshot, + after: CollectBalanceSnapshot, + functionName: "authorize" | "charge", + amount: bigint, + fee: SubmittedFee, +): boolean { + if (after.facilitator < before.facilitator || after.payer !== before.payer - amount) { + return false; + } + if (functionName === "authorize") { + return after.tokenStore === before.tokenStore + amount; + } + if ( + before.receiver === undefined || + before.feeReceiver === undefined || + after.receiver === undefined || + after.feeReceiver === undefined + ) { + return false; + } + const feeAmount = submittedFeeAmount(fee, amount); + return ( + after.tokenStore === before.tokenStore && + after.receiver === before.receiver + (amount - feeAmount) && + after.feeReceiver === before.feeReceiver + feeAmount + ); +} + +/** + * Filter receipt or simulation logs down to the canonical escrow address. + * + * @param logs - Logs from the forwarded call or mined receipt. + * @param escrowAddress - Canonical AuthCaptureEscrow address. + * @returns Logs emitted by the escrow. + */ +function escrowEventLogs(logs: readonly Log[] | undefined, escrowAddress: `0x${string}`): Log[] { + return (logs ?? []).filter(log => isAddressEqual(log.address, escrowAddress)); +} + +/** + * Whether logs contain a PaymentAuthorized event matching the expected collect. + * + * @param logs - Logs from the forwarded call or mined receipt. + * @param escrowAddress - Canonical AuthCaptureEscrow address. + * @param expected - Expected event fields. + * @param expected.paymentInfoHash - Escrow payment identifier. + * @param expected.amount - Authorized amount. + * @param expected.tokenCollector - Collector used for the collect. + * @param expected.operator - PaymentInfo.operator. + * @returns True when a matching event is present. + */ +function verifyPaymentAuthorizedEvent( + logs: readonly Log[] | undefined, + escrowAddress: `0x${string}`, + expected: { + paymentInfoHash: `0x${string}`; + amount: bigint; + tokenCollector: `0x${string}`; + operator: `0x${string}`; + }, +): boolean { + const parsed = parseEventLogs({ + abi: escrowEventsAbiForDeployment(AUTH_CAPTURE_DEPLOYMENT_V1_1), + eventName: "PaymentAuthorized", + logs: escrowEventLogs(logs, escrowAddress), + }); + return parsed.some( + event => + event.args.paymentInfoHash?.toLowerCase() === expected.paymentInfoHash.toLowerCase() && + event.args.amount === expected.amount && + isAddressEqual(event.args.tokenCollector, expected.tokenCollector) && + isAddressEqual(event.args.paymentInfo.operator, expected.operator), + ); +} + +/** + * Whether logs contain a PaymentCharged event matching the expected collect. + * + * @param logs - Logs from the forwarded call or mined receipt. + * @param deployment - Resolved AuthCaptureEscrow deployment. + * @param expected - Expected event fields. + * @param expected.paymentInfoHash - Escrow payment identifier. + * @param expected.amount - Charged amount. + * @param expected.tokenCollector - Collector used for the collect. + * @param expected.operator - PaymentInfo.operator. + * @param expected.fee - Submitted fee (`feeBps` or `feeAmount` per deployment). + * @returns True when a matching event is present. + */ +function verifyPaymentChargedEvent( + logs: readonly Log[] | undefined, + deployment: AuthCaptureDeployment, + expected: { + paymentInfoHash: `0x${string}`; + amount: bigint; + tokenCollector: `0x${string}`; + operator: `0x${string}`; + fee: SubmittedFee; + }, +): boolean { + const parsed = parseEventLogs({ + abi: escrowEventsAbiForDeployment(deployment), + eventName: "PaymentCharged", + logs: escrowEventLogs(logs, deployment.escrow), + }); + return parsed.some(event => { + const eventArgs = event.args as { + paymentInfoHash?: `0x${string}`; + amount?: bigint; + tokenCollector?: `0x${string}`; + paymentInfo?: { operator?: `0x${string}` }; + feeReceiver?: `0x${string}`; + feeBps?: number; + feeAmount?: bigint; + }; + const feeMatches = + expected.fee.version === "v1.0" + ? eventArgs.feeBps === expected.fee.feeBps + : eventArgs.feeAmount === BigInt(expected.fee.feeAmount); + return ( + eventArgs.paymentInfoHash?.toLowerCase() === expected.paymentInfoHash.toLowerCase() && + eventArgs.amount === expected.amount && + eventArgs.tokenCollector !== undefined && + isAddressEqual(eventArgs.tokenCollector, expected.tokenCollector) && + eventArgs.paymentInfo?.operator !== undefined && + isAddressEqual(eventArgs.paymentInfo.operator, expected.operator) && + feeMatches && + eventArgs.feeReceiver !== undefined && + isAddressEqual(eventArgs.feeReceiver, expected.fee.feeReceiver) + ); + }); +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/delegatedAuth.ts b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/delegatedAuth.ts new file mode 100644 index 0000000000..0a1f52e471 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/delegatedAuth.ts @@ -0,0 +1,328 @@ +/** + * Facilitator-delegated receiver authorizer: caller-identity binding. + * + * When the facilitator holds the `receiverAuthorizer` key, the `authorizerSignature` it + * produces is no longer evidence of the server's intent, so the request is authenticated + * out of band instead. The first authenticated caller to settle a payment is bound to its + * `paymentInfoHash`; every later facilitator-signed step must come from the same identity. + * The binding is written after re-verify and before broadcast, and fails closed. + */ + +import type { + FacilitatorContext, + Network, + PaymentPayload, + PaymentRequirements, +} from "@x402/core/types"; +import { isAddressEqual } from "viem"; +import type { AuthCaptureFacilitatorConfig, AuthorizerSigner } from "../types"; + +export type DelegatedStep = "authorize" | "charge" | "capture" | "void" | "refund"; + +/** Context passed to {@link AuthCaptureFacilitatorConfig.resolveCallerIdentity}. */ +export type DelegatedSettleContext = { + step: DelegatedStep; + paymentInfoHash: `0x${string}`; + network: Network; + payer: string; + payload: PaymentPayload; + requirements: PaymentRequirements; + facilitatorContext?: FacilitatorContext; +}; + +/** Caller binding for one payment. */ +export interface AuthCaptureDelegatedAuthRecord { + network: Network; + paymentInfoHash: `0x${string}`; + /** Stable identity returned by `resolveCallerIdentity`. */ + callerIdentity: string; + /** Unix seconds (`refundDeadline`). An expired row is treated as absent. */ + expiresAt: number; +} + +/** + * Result of {@link AuthCaptureDelegatedAuthStorage.bind}. `revertToken` is empty when the + * row already existed, so a later revert leaves that row alone. + */ +export interface AuthCaptureDelegatedAuthWrite { + /** Row as stored after the write. */ + record: AuthCaptureDelegatedAuthRecord; + /** Opaque host-defined token. Empty when this call did not create the row. */ + revertToken: string; +} + +/** Pluggable storage for facilitator-delegated caller bindings. */ +export interface AuthCaptureDelegatedAuthStorage { + /** + * Insert the row when it is absent (an expired row counts as absent). The check and the + * insert MUST be one atomic operation under concurrent callers, for example a unique key + * on `network` and `paymentInfoHash` with insert-on-conflict. + * + * When a live row exists, leave its `callerIdentity` and `expiresAt` untouched and return + * it with an empty `revertToken`. A different identity is rejected by the SDK through + * {@link bindThenBroadcast}. A matching identity is an idempotent success; hosts SHOULD + * rotate the stored token so a concurrent creator's revert no longer matches. + * + * @param record - Binding to store + * @returns The stored row and a revert token when this call created it + */ + bind(record: AuthCaptureDelegatedAuthRecord): Promise; + /** + * Delete the row only when `write.revertToken` is non-empty and still matches. + * + * @param write - The {@link AuthCaptureDelegatedAuthStorage.bind} result + */ + revertBind(write: AuthCaptureDelegatedAuthWrite): Promise; + /** + * Read one live row. + * + * @param network - CAIP-2 network + * @param paymentInfoHash - Escrow payment identifier + * @returns The stored row, or undefined when absent or expired + */ + get( + network: Network, + paymentInfoHash: `0x${string}`, + ): Promise; + /** + * Remove a row. Hosts SHOULD also purge expired rows (for example a TTL index on `expiresAt`). + * + * @param network - CAIP-2 network + * @param paymentInfoHash - Escrow payment identifier + */ + delete(network: Network, paymentInfoHash: `0x${string}`): Promise; +} + +/** Reported when a binding revert or early delete fails. Must not replace the settle result. */ +export type OnDelegatedAuthStorageError = ( + error: unknown, + network: string, + paymentInfoHash: string, +) => void; + +/** First writer already bound this payment to a different caller identity. */ +export class AuthCaptureCallerIdentityConflictError extends Error { + /** Create the first-writer-wins identity conflict. */ + constructor() { + super("delegated auth binding already exists for a different identity"); + this.name = "AuthCaptureCallerIdentityConflictError"; + } +} + +/** Resolved delegated-authorizer wiring for one request. */ +export type DelegatedAuthorizer = { + signer: AuthorizerSigner; + resolveCallerIdentity: NonNullable; + storage: AuthCaptureDelegatedAuthStorage; + onStorageError: OnDelegatedAuthStorageError; +}; + +/** + * The delegated authorizer, when `receiverAuthorizer` is the key this facilitator holds. + * + * @param config - Facilitator config with delegation fully wired + * @param receiverAuthorizer - `extra.receiverAuthorizer` + * @returns The wiring, or undefined when the authorizer is not facilitator-delegated + */ +export function getDelegatedAuthorizer( + config: AuthCaptureFacilitatorConfig | undefined, + receiverAuthorizer: `0x${string}`, +): DelegatedAuthorizer | undefined { + const signer = config?.authorizerSigner; + const resolveCallerIdentity = config?.resolveCallerIdentity; + const storage = config?.delegatedAuthStorage; + const onStorageError = config?.onStorageError; + if (!signer || !resolveCallerIdentity || !storage || !onStorageError) return undefined; + if (!isAddressEqual(signer.address, receiverAuthorizer)) return undefined; + return { signer, resolveCallerIdentity, storage, onStorageError }; +} + +/** + * Resolve a delegated settle's caller identity. Throws and empty results are treated as + * unauthenticated. + * + * @param delegated - Delegated-authorizer wiring + * @param ctx - Settle context passed to the operator resolver + * @returns Stable identity, or undefined when the caller is unauthenticated + */ +export async function resolveDelegatedCallerIdentity( + delegated: DelegatedAuthorizer, + ctx: DelegatedSettleContext, +): Promise { + try { + const identity = await delegated.resolveCallerIdentity(ctx); + return typeof identity === "string" && identity.length > 0 ? identity : undefined; + } catch { + return undefined; + } +} + +/** + * Report a storage failure that must not replace the settle result. + * + * @param delegated - Delegated-authorizer wiring + * @param error - Storage error + * @param network - CAIP-2 network + * @param paymentInfoHash - Escrow payment identifier + */ +export function reportDelegatedStorageError( + delegated: DelegatedAuthorizer, + error: unknown, + network: string, + paymentInfoHash: string, +): void { + delegated.onStorageError(error, network, paymentInfoHash); +} + +/** How a broadcast ended, for {@link bindThenBroadcast}. */ +export type BindDisposition = "keep" | "revert"; + +export type BindThenBroadcastResult = + | { ok: true; value: T } + | { ok: false; reason: "conflict" | "unavailable"; error: unknown }; + +/** + * Bind the caller to the payment, then run `broadcast`. + * + * The bind is fail-closed: a storage error or an identity conflict returns without calling + * `broadcast`. A binding this call created is reverted when `broadcast` returns `revert`; + * `keep` leaves it, as does a thrown `broadcast` error because the outcome is then unknown. + * A revert error goes to `onStorageError` and never replaces the broadcast result. + * + * @param args - Delegated wiring, the record to bind, and the broadcast + * @param args.delegated - Delegated-authorizer wiring + * @param args.record - Binding to write before broadcast + * @param args.broadcast - Sends or reconciles the transaction + * @returns The broadcast value, or the reason the bind failed closed + */ +export async function bindThenBroadcast(args: { + delegated: DelegatedAuthorizer; + record: AuthCaptureDelegatedAuthRecord; + broadcast: () => Promise<{ value: T; disposition: BindDisposition }>; +}): Promise> { + const { delegated, record } = args; + let write: AuthCaptureDelegatedAuthWrite; + try { + write = await delegated.storage.bind(record); + } catch (error) { + return { ok: false, reason: "unavailable", error }; + } + if (write.record.callerIdentity !== record.callerIdentity) { + return { ok: false, reason: "conflict", error: new AuthCaptureCallerIdentityConflictError() }; + } + + const outcome = await args.broadcast(); + if (outcome.disposition === "revert") { + try { + await delegated.storage.revertBind(write); + } catch (error) { + reportDelegatedStorageError(delegated, error, record.network, record.paymentInfoHash); + } + } + return { ok: true, value: outcome.value }; +} + +/** + * Reject a value that does not implement {@link AuthCaptureDelegatedAuthStorage}. + * + * @param storage - Configured delegated-auth storage + */ +export function assertDelegatedAuthStorage( + storage: object, +): asserts storage is AuthCaptureDelegatedAuthStorage { + const candidate = storage as Partial; + if ( + typeof candidate.bind !== "function" || + typeof candidate.revertBind !== "function" || + typeof candidate.get !== "function" || + typeof candidate.delete !== "function" + ) { + throw new Error("delegatedAuthStorage must implement bind, revertBind, get, and delete"); + } +} + +/** + * In-memory {@link AuthCaptureDelegatedAuthStorage}. A per-row counter is the revert token: + * a matching-identity bind on a live row rotates it and returns an empty token, so the + * creator's revert no longer matches. Expired rows are purged lazily on `bind` and `get`. + * Bindings are lost on restart, which fails closed. + */ +export class InMemoryAuthCaptureDelegatedAuthStorage implements AuthCaptureDelegatedAuthStorage { + private readonly rows = new Map< + string, + { record: AuthCaptureDelegatedAuthRecord; revertToken: string } + >(); + private nextToken = 0; + + /** @inheritdoc */ + async bind(record: AuthCaptureDelegatedAuthRecord): Promise { + this.purgeExpired(); + const key = rowKey(record.network, record.paymentInfoHash); + const existing = this.rows.get(key); + const revertToken = String(++this.nextToken); + if (!existing) { + this.rows.set(key, { record: { ...record }, revertToken }); + return { record: { ...record }, revertToken }; + } + if (existing.record.callerIdentity === record.callerIdentity) { + existing.revertToken = revertToken; + } + return { record: { ...existing.record }, revertToken: "" }; + } + + /** @inheritdoc */ + async revertBind(write: AuthCaptureDelegatedAuthWrite): Promise { + if (write.revertToken === "") return; + const key = rowKey(write.record.network, write.record.paymentInfoHash); + if (this.rows.get(key)?.revertToken !== write.revertToken) return; + this.rows.delete(key); + } + + /** @inheritdoc */ + async get( + network: Network, + paymentInfoHash: `0x${string}`, + ): Promise { + const key = rowKey(network, paymentInfoHash); + const existing = this.rows.get(key); + if (!existing) return undefined; + if (isExpired(existing.record)) { + this.rows.delete(key); + return undefined; + } + return { ...existing.record }; + } + + /** @inheritdoc */ + async delete(network: Network, paymentInfoHash: `0x${string}`): Promise { + this.rows.delete(rowKey(network, paymentInfoHash)); + } + + /** Drop every expired row. */ + private purgeExpired(): void { + for (const [key, row] of this.rows) { + if (isExpired(row.record)) this.rows.delete(key); + } + } +} + +/** + * Whether a binding is past `expiresAt`. + * + * @param record - Stored binding + * @returns True when the binding no longer authorizes anything + */ +function isExpired(record: AuthCaptureDelegatedAuthRecord): boolean { + return record.expiresAt <= Math.floor(Date.now() / 1000); +} + +/** + * Composite key so the same hash on two networks cannot collide. + * + * @param network - CAIP-2 network + * @param paymentInfoHash - Escrow payment identifier + * @returns Map key + */ +function rowKey(network: string, paymentInfoHash: string): string { + return `${network}\0${paymentInfoHash.toLowerCase()}`; +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/index.ts b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/index.ts new file mode 100644 index 0000000000..b49c7163ba --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/index.ts @@ -0,0 +1,15 @@ +export { AuthCaptureEvmScheme } from "./scheme"; +export type { AuthCaptureFacilitatorConfig } from "./scheme"; +export { facilitatorAddresses, selectSubmitter } from "./utils"; +export { + AuthCaptureCallerIdentityConflictError, + InMemoryAuthCaptureDelegatedAuthStorage, +} from "./delegatedAuth"; +export type { + AuthCaptureDelegatedAuthRecord, + AuthCaptureDelegatedAuthStorage, + AuthCaptureDelegatedAuthWrite, + DelegatedSettleContext, + DelegatedStep, + OnDelegatedAuthStorageError, +} from "./delegatedAuth"; diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/lifecycle.ts b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/lifecycle.ts new file mode 100644 index 0000000000..ef1780ead8 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/lifecycle.ts @@ -0,0 +1,1103 @@ +import type { + FacilitatorContext, + Network, + PaymentPayload, + PaymentRequirements, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import type { PendingSettlementStore } from "@x402/core/facilitator"; +import { isAddressEqual } from "viem"; +import type { FacilitatorEvmSigner } from "../../signer"; +import { AUTH_CAPTURE_SCHEME } from "../constants"; +import { computePaymentInfoHash, deriveBoundSalt } from "../nonce"; +import { resolveDataSuffix } from "../../shared/extensions"; +import { + waitAndReturnSettleResponse, + withPendingSettlementStore, +} from "../../shared/settleReceipt"; +import { getEvmChainId } from "../../utils"; +import { paymentInfoToContractTuple } from "../utils"; +import { + signCapture, + signRefund, + signVoid, + verifyCapture, + verifyRefund, + verifyVoid, + type CaptureDigest, +} from "../authorizerSigner"; +import type { + AuthCaptureFacilitatorConfig, + AuthCaptureLifecyclePayload, + CapturePayload, + PaymentInfoStruct, + PaymentState, + RefundPayload, + SignedLifecyclePayload, + VoidPayload, +} from "../types"; +import { isCapturePayload, isRefundPayload, isVoidPayload } from "../types"; +import * as Errors from "../errors"; +import { + captureEscrowArgs, + captureFeeFromPayload, + parseAuthCaptureExtra, + resolveSettleTarget, + validateSubmittedFee, + verifyCommon, + type NormalizedAuthCaptureExtra, + type SubmittedFee, +} from "../extra"; +import { + getDelegatedAuthorizer, + reportDelegatedStorageError, + resolveDelegatedCallerIdentity, + type DelegatedAuthorizer, +} from "./delegatedAuth"; +import { + facilitatorAddresses, + readPaymentStateForBalances, + readPaymentStateOnce, + resolveSubmitter, + simulateEscrowCall, + submitEscrowCall, + writeEscrowCall, +} from "./utils"; + +/** + * Verify a lifecycle (capture / void / refund) payload. + * + * @param signers - Facilitator signer set. + * @param config - Facilitator config. + * @param payload - Wire payment envelope. + * @param requirements - Published requirements. + * @param wirePayload - Payload with a lifecycle `type`. + * @param context - Optional facilitator context passed to `resolveCallerIdentity`. + * @returns VerifyResponse. + */ +export async function verifyLifecycle( + signers: readonly FacilitatorEvmSigner[], + config: AuthCaptureFacilitatorConfig | undefined, + payload: PaymentPayload, + requirements: PaymentRequirements, + wirePayload: AuthCaptureLifecyclePayload, + context?: FacilitatorContext, +): Promise { + if (wirePayload.type === "capture" && !isCapturePayload(wirePayload)) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadFormat }; + } + if (wirePayload.type === "void") { + if (!isVoidPayload(wirePayload)) { + if ( + typeof wirePayload === "object" && + wirePayload !== null && + "voidAuthorizerSignature" in wirePayload && + (wirePayload as Record).voidAuthorizerSignature !== undefined + ) { + return { isValid: false, invalidReason: Errors.ErrVoidAuthorizerSignature }; + } + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadFormat }; + } + } + if (wirePayload.type === "refund") { + if (!isRefundPayload(wirePayload)) { + if ( + typeof wirePayload === "object" && + wirePayload !== null && + "voidAuthorizerSignature" in wirePayload && + (wirePayload as Record).voidAuthorizerSignature !== undefined + ) { + return { isValid: false, invalidReason: Errors.ErrVoidAuthorizerSignature }; + } + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadFormat }; + } + } + + const common = verifyCommon( + payload.accepted.scheme, + payload.accepted.network, + requirements, + AUTH_CAPTURE_SCHEME, + facilitatorAddresses(signers), + config, + true, + ); + if ("error" in common) { + return { isValid: false, invalidReason: common.error }; + } + const extra = common.extra; + const submitter = resolveSubmitter(signers, extra); + if (!submitter) { + return { isValid: false, invalidReason: Errors.ErrOperatorNotAdmitted }; + } + + if ( + (wirePayload.type === "capture" || wirePayload.type === "void") && + extra.paymentFlow !== "escrow" + ) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadType }; + } + + if ( + wirePayload.type === "refund" && + extra.operatorType === "delegated" && + !config?.refundFunding + ) { + return { isValid: false, invalidReason: Errors.ErrRefundFundingUnavailable }; + } + + const paymentInfo = wirePayload.paymentInfo; + if (!isAddressEqual(paymentInfo.operator, extra.captureAuthorizer)) { + return { isValid: false, invalidReason: Errors.ErrOperatorMismatch }; + } + + const expectedSalt = deriveBoundSalt( + extra.receiverAuthorizer, + extra.policy, + wirePayload.saltNonce, + ); + if (BigInt(paymentInfo.salt) !== BigInt(expectedSalt)) { + return { isValid: false, invalidReason: Errors.ErrSaltBindingMismatch }; + } + + if (!paymentInfoMatchesRequirements(paymentInfo, payload.accepted, extra)) { + return { isValid: false, invalidReason: Errors.ErrPaymentInfoMismatch }; + } + + const chainId = getEvmChainId(requirements.network); + const paymentInfoHash = computePaymentInfoHash(chainId, paymentInfo, extra.deployment.escrow); + const now = Math.floor(Date.now() / 1000); + + const delegated = getDelegatedAuthorizer(config, extra.receiverAuthorizer); + const signed = hasAuthorizerSignature(wirePayload) + ? { payload: wirePayload } + : delegated + ? await signDelegatedLifecycle({ + delegated, + extra, + chainId, + paymentInfoHash, + payload, + requirements, + wirePayload, + context, + }) + : { failure: { reason: Errors.ErrAuthorizerSignature } }; + if ("failure" in signed) { + return { + isValid: false, + invalidReason: signed.failure.reason, + invalidMessage: signed.failure.message, + }; + } + const signedPayload = signed.payload; + + if (signedPayload.type === "capture") { + return verifyCapturePayload( + submitter, + extra, + chainId, + paymentInfo, + paymentInfoHash, + signedPayload, + now, + ); + } + if (signedPayload.type === "void") { + return verifyVoidPayload( + submitter, + extra, + chainId, + paymentInfo, + paymentInfoHash, + signedPayload, + ); + } + return verifyRefundPayload( + submitter, + extra, + chainId, + paymentInfo, + paymentInfoHash, + signedPayload, + now, + ); +} + +/** + * Whether the payload carries the authorizer signature. + * + * @param wirePayload - Lifecycle payload. + * @returns True when `authorizerSignature` is present. + */ +function hasAuthorizerSignature( + wirePayload: AuthCaptureLifecyclePayload, +): wirePayload is SignedLifecyclePayload { + return Boolean(wirePayload.authorizerSignature); +} + +type DelegatedLifecycleFailure = { reason: string; message?: string }; + +/** + * Authenticate a lifecycle request whose authorizer is delegated to this facilitator and + * sign it. The caller must resolve to the identity bound to the payment at `authorize` (or + * `charge`) time. Nothing is bound here. + * + * @param args - Request context. + * @param args.delegated - Delegated-authorizer wiring. + * @param args.extra - Normalized extra. + * @param args.chainId - EVM chain id. + * @param args.paymentInfoHash - Escrow payment identifier. + * @param args.payload - Wire payment envelope. + * @param args.requirements - Published requirements. + * @param args.wirePayload - Unsigned lifecycle payload. + * @param args.context - Optional facilitator context passed to `resolveCallerIdentity`. + * @returns The payload with the facilitator's signature(s), or why the request was refused. + */ +async function signDelegatedLifecycle(args: { + delegated: DelegatedAuthorizer; + extra: NormalizedAuthCaptureExtra; + chainId: number; + paymentInfoHash: `0x${string}`; + payload: PaymentPayload; + requirements: PaymentRequirements; + wirePayload: AuthCaptureLifecyclePayload; + context?: FacilitatorContext; +}): Promise<{ payload: SignedLifecyclePayload } | { failure: DelegatedLifecycleFailure }> { + const { delegated, extra, chainId, paymentInfoHash, wirePayload } = args; + const network = args.requirements.network; + + const identity = await resolveDelegatedCallerIdentity(delegated, { + step: wirePayload.type, + paymentInfoHash, + network, + payer: wirePayload.paymentInfo.payer, + payload: args.payload, + requirements: args.requirements, + facilitatorContext: args.context, + }); + if (!identity) { + return { failure: { reason: Errors.ErrUnauthenticatedAuthorizerRequest } }; + } + + try { + const binding = await delegated.storage.get(network, paymentInfoHash); + const bound = + binding !== undefined && + binding.expiresAt > Math.floor(Date.now() / 1000) && + binding.callerIdentity === identity; + if (!bound) { + return { failure: { reason: Errors.ErrUnauthenticatedAuthorizerRequest } }; + } + } catch (error) { + return { + failure: { + reason: Errors.ErrDelegatedAuthUnavailable, + message: `failed to read delegated auth binding: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }; + } + + const domainArgs = [delegated.signer, chainId, extra.captureAuthorizer] as const; + switch (wirePayload.type) { + case "void": { + const authorizerSignature = await signVoid(...domainArgs, paymentInfoHash); + return { payload: { ...wirePayload, authorizerSignature } }; + } + case "refund": { + const authorizerSignature = await signRefund(...domainArgs, { + paymentInfoHash, + amount: wirePayload.amount, + tokenCollector: extra.deployment.operatorRefundCollector, + expectedCapturableAmount: wirePayload.expectedCapturableAmount, + expectedRefundableAmount: wirePayload.expectedRefundableAmount, + }); + return { payload: { ...wirePayload, authorizerSignature } }; + } + case "capture": { + const captureFee = captureFeeFromPayload(extra, wirePayload); + if (!captureFee) { + return { failure: { reason: Errors.ErrInvalidPayloadFormat } }; + } + const authorizerSignature = await signCapture( + ...domainArgs, + extra.deployment, + captureDigestFor(captureFee, paymentInfoHash, wirePayload), + ); + const { voidRemainder, ...capture } = wirePayload; + return { + payload: { + ...capture, + authorizerSignature, + ...(voidRemainder + ? { voidAuthorizerSignature: await signVoid(...domainArgs, paymentInfoHash) } + : {}), + }, + }; + } + default: { + const exhaustive: never = wirePayload; + throw new Error(`unexpected lifecycle payload ${String(exhaustive)}`); + } + } +} + +/** + * EIP-712 Capture digest for the deployment's fee encoding. + * + * @param captureFee - Submitted capture fee. + * @param paymentInfoHash - Escrow payment identifier. + * @param wirePayload - Capture envelope. + * @returns Digest to sign or verify. + */ +function captureDigestFor( + captureFee: SubmittedFee, + paymentInfoHash: `0x${string}`, + wirePayload: CapturePayload, +): CaptureDigest { + const base = { + paymentInfoHash, + amount: wirePayload.amount, + feeReceiver: captureFee.feeReceiver, + expectedCapturableAmount: wirePayload.expectedCapturableAmount, + expectedRefundableAmount: wirePayload.expectedRefundableAmount, + }; + return captureFee.version === "v1.0" + ? { ...base, feeBps: captureFee.feeBps } + : { ...base, feeAmount: captureFee.feeAmount }; +} + +/** + * Wait for a lifecycle broadcast receipt with pending-settlement bookkeeping. + * + * @param store - Pending-settlement store keyed by authorizerSignature. + * @param pendingKey - Store lookup key. + * @param submitter - Facilitator submitter. + * @param tx - Broadcast transaction hash. + * @param network - CAIP-2 network. + * @param payer - PaymentInfo.payer. + * @param amount - Settled atomic amount on success. + * @returns SettleResponse after receipt confirmation or a retryable pending failure. + */ +async function awaitLifecycleSettlement( + store: PendingSettlementStore, + pendingKey: string | undefined, + submitter: FacilitatorEvmSigner, + tx: `0x${string}`, + network: Network, + payer: string, + amount: string, +): Promise { + return withPendingSettlementStore( + store, + pendingKey, + () => + waitAndReturnSettleResponse(submitter, tx, network, payer, { + failedStatusReason: Errors.ErrTransactionReverted, + amount, + }), + Errors.ErrTransactionReverted, + ); +} + +/** + * Re-verify and settle a lifecycle payload. A delegated authorizer's signature is produced + * first, so the pending-settlement key derives from it and a retry reconciles. + * + * @param signers - Facilitator signer set. + * @param config - Facilitator config. + * @param payload - Wire payment envelope. + * @param requirements - Published requirements. + * @param wirePayload - Payload with a lifecycle `type`. + * @param store - Pending-settlement store keyed by authorizerSignature. + * @param context - Optional facilitator context for extension hooks. + * @returns SettleResponse. + */ +export async function settleLifecycle( + signers: readonly FacilitatorEvmSigner[], + config: AuthCaptureFacilitatorConfig | undefined, + payload: PaymentPayload, + requirements: PaymentRequirements, + wirePayload: AuthCaptureLifecyclePayload, + store: PendingSettlementStore, + context?: FacilitatorContext, +): Promise { + const parsed = parseAuthCaptureExtra(requirements.extra); + const extra = "extra" in parsed ? parsed.extra : undefined; + const delegated = extra ? getDelegatedAuthorizer(config, extra.receiverAuthorizer) : undefined; + + let settleable = wirePayload; + if (!hasAuthorizerSignature(wirePayload) && extra && delegated) { + const chainId = getEvmChainId(requirements.network); + const signed = await signDelegatedLifecycle({ + delegated, + extra, + chainId, + paymentInfoHash: computePaymentInfoHash( + chainId, + wirePayload.paymentInfo, + extra.deployment.escrow, + ), + payload, + requirements, + wirePayload, + context, + }); + if ("failure" in signed) { + return { + success: false, + errorReason: signed.failure.reason, + errorMessage: signed.failure.message, + transaction: "", + network: requirements.network, + payer: wirePayload.paymentInfo.payer, + }; + } + settleable = signed.payload; + } + + const result = await settleVerifiedLifecycle( + signers, + config, + payload, + requirements, + settleable, + store, + context, + ); + if (result.success && extra && delegated && hasAuthorizerSignature(settleable)) { + await releaseTerminalBinding(signers, delegated, extra, requirements, settleable); + } + return result; +} + +/** + * Delete the caller binding once the payment has nothing left to relay: no capturable hold + * and nothing refundable. Best effort, so a storage failure never fails a confirmed settle. + * Balances come from the signed expectations; a void leg is not known locally, so those + * cases read `paymentState` once, and a stale read just leaves the row to expire. + * + * @param signers - Facilitator signer set. + * @param delegated - Delegated-authorizer wiring. + * @param extra - Normalized extra. + * @param requirements - Published requirements. + * @param settled - The lifecycle payload that just settled. + */ +async function releaseTerminalBinding( + signers: readonly FacilitatorEvmSigner[], + delegated: DelegatedAuthorizer, + extra: NormalizedAuthCaptureExtra, + requirements: PaymentRequirements, + settled: SignedLifecyclePayload, +): Promise { + const chainId = getEvmChainId(requirements.network); + const paymentInfoHash = computePaymentInfoHash( + chainId, + settled.paymentInfo, + extra.deployment.escrow, + ); + + let remaining: { capturableAmount: bigint; refundableAmount: bigint } | undefined; + if (settled.type === "refund") { + remaining = { + capturableAmount: BigInt(settled.expectedCapturableAmount), + refundableAmount: BigInt(settled.expectedRefundableAmount) - BigInt(settled.amount), + }; + } else if (settled.type === "capture" && !settled.voidAuthorizerSignature) { + remaining = { + capturableAmount: BigInt(settled.expectedCapturableAmount) - BigInt(settled.amount), + refundableAmount: BigInt(settled.expectedRefundableAmount) + BigInt(settled.amount), + }; + } else { + const submitter = resolveSubmitter(signers, extra); + remaining = + submitter && + (await readPaymentStateOnce(submitter, paymentInfoHash, extra.deployment.escrow)); + } + if (!remaining || remaining.capturableAmount !== 0n || remaining.refundableAmount !== 0n) { + return; + } + + try { + await delegated.storage.delete(requirements.network, paymentInfoHash); + } catch (error) { + reportDelegatedStorageError(delegated, error, requirements.network, paymentInfoHash); + } +} + +/** + * Pending-settlement key of a lifecycle payload: its authorizer signature, except when the + * authorizer is delegated to this facilitator. The facilitator produced that signature, and a + * non-deterministic signer may produce a different one on a retry, so the key is instead what + * the payload does to which payment: network, paymentInfoHash, type, amount and expected + * balances. + * + * @param config - Facilitator config. + * @param requirements - Published requirements. + * @param wirePayload - Signed lifecycle payload. + * @returns The store key, or undefined when none can be derived. + */ +function lifecyclePendingKey( + config: AuthCaptureFacilitatorConfig | undefined, + requirements: PaymentRequirements, + wirePayload: AuthCaptureLifecyclePayload, +): string | undefined { + const parsed = parseAuthCaptureExtra(requirements.extra); + if ("error" in parsed || !getDelegatedAuthorizer(config, parsed.extra.receiverAuthorizer)) { + return wirePayload.authorizerSignature; + } + const paymentInfoHash = computePaymentInfoHash( + getEvmChainId(requirements.network), + wirePayload.paymentInfo, + parsed.extra.deployment.escrow, + ); + const balances = + wirePayload.type === "void" + ? ["", "", ""] + : [ + wirePayload.amount, + wirePayload.expectedCapturableAmount, + wirePayload.expectedRefundableAmount, + ]; + return [requirements.network, paymentInfoHash.toLowerCase(), wirePayload.type, ...balances].join( + "|", + ); +} + +/** + * Re-verify and settle a lifecycle payload whose authorizer signature is present. + * + * @param signers - Facilitator signer set. + * @param config - Facilitator config. + * @param payload - Wire payment envelope. + * @param requirements - Published requirements. + * @param wirePayload - Payload with a lifecycle `type`. + * @param store - Pending-settlement store keyed by authorizerSignature. + * @param context - Optional facilitator context for extension hooks. + * @returns SettleResponse. + */ +async function settleVerifiedLifecycle( + signers: readonly FacilitatorEvmSigner[], + config: AuthCaptureFacilitatorConfig | undefined, + payload: PaymentPayload, + requirements: PaymentRequirements, + wirePayload: AuthCaptureLifecyclePayload, + store: PendingSettlementStore, + context?: FacilitatorContext, +): Promise { + const pendingKey = lifecyclePendingKey(config, requirements, wirePayload); + const payer = wirePayload.paymentInfo.payer; + + if (pendingKey) { + const cachedTx = await store.get(pendingKey); + if (cachedTx) { + const parsed = parseAuthCaptureExtra(requirements.extra); + if ("error" in parsed) { + return { + success: false, + errorReason: Errors.ErrSettlementPending, + transaction: cachedTx, + network: requirements.network, + payer, + }; + } + const submitter = resolveSubmitter(signers, parsed.extra); + if (!submitter) { + return { + success: false, + errorReason: Errors.ErrSettlementPending, + transaction: cachedTx, + network: requirements.network, + payer, + }; + } + await store.delete(pendingKey); + const amount = + wirePayload.type === "refund" || wirePayload.type === "capture" ? wirePayload.amount : "0"; + const result = await awaitLifecycleSettlement( + store, + pendingKey, + submitter, + cachedTx as `0x${string}`, + requirements.network, + payer, + amount, + ); + if (result.success && wirePayload.type === "capture" && wirePayload.voidAuthorizerSignature) { + const tuple = paymentInfoToContractTuple(wirePayload.paymentInfo); + const settleTarget = resolveSettleTarget(parsed.extra); + const dataSuffix = await resolveDataSuffix(context, { + paymentPayload: payload, + paymentRequirements: requirements, + }); + await submitEscrowCall(submitter, settleTarget, "void", [tuple], parsed.extra.deployment, { + dataSuffix, + }); + } + return result; + } + } + + const verification = await verifyLifecycle( + signers, + config, + payload, + requirements, + wirePayload, + context, + ); + if (!verification.isValid) { + return { + success: false, + errorReason: verification.invalidReason ?? Errors.ErrVerificationFailed, + transaction: "", + network: requirements.network, + payer: verification.payer ?? wirePayload.paymentInfo.payer, + }; + } + + const parsed = parseAuthCaptureExtra(requirements.extra); + if ("error" in parsed) { + return { + success: false, + errorReason: parsed.error, + transaction: "", + network: requirements.network, + payer: wirePayload.paymentInfo.payer, + }; + } + const extra = parsed.extra; + const submitter = resolveSubmitter(signers, extra); + if (!submitter) { + return { + success: false, + errorReason: Errors.ErrOperatorNotAdmitted, + transaction: "", + network: requirements.network, + payer: wirePayload.paymentInfo.payer, + }; + } + const tuple = paymentInfoToContractTuple(wirePayload.paymentInfo); + const settleTarget = resolveSettleTarget(extra); + const dataSuffix = await resolveDataSuffix(context, { + paymentPayload: payload, + paymentRequirements: requirements, + }); + + if (wirePayload.type === "void") { + const written = await writeEscrowCall( + submitter, + settleTarget, + "void", + [tuple], + extra.deployment, + { + dataSuffix, + }, + ); + if ("error" in written) { + return { + success: false, + errorReason: written.error, + transaction: "", + network: requirements.network, + payer, + }; + } + return awaitLifecycleSettlement( + store, + pendingKey, + submitter, + written.txHash, + requirements.network, + payer, + "0", + ); + } + + if (wirePayload.type === "refund") { + const amount = BigInt(wirePayload.amount); + const written = await writeEscrowCall( + submitter, + settleTarget, + "refund", + [tuple, amount, extra.deployment.operatorRefundCollector, "0x"], + extra.deployment, + { dataSuffix }, + ); + if ("error" in written) { + return { + success: false, + errorReason: written.error, + transaction: "", + network: requirements.network, + payer, + }; + } + return awaitLifecycleSettlement( + store, + pendingKey, + submitter, + written.txHash, + requirements.network, + payer, + amount.toString(), + ); + } + + const capture = wirePayload; + const amount = BigInt(capture.amount); + const captureFee = captureFeeFromPayload(extra, capture); + if (!captureFee) { + return { + success: false, + errorReason: Errors.ErrInvalidPayloadFormat, + transaction: "", + network: requirements.network, + payer, + amount: amount.toString(), + }; + } + const written = await writeEscrowCall( + submitter, + settleTarget, + "capture", + captureEscrowArgs(tuple, amount, captureFee), + extra.deployment, + { dataSuffix }, + ); + if ("error" in written) { + return { + success: false, + errorReason: written.error, + transaction: "", + network: requirements.network, + payer, + amount: amount.toString(), + }; + } + + const result = await awaitLifecycleSettlement( + store, + pendingKey, + submitter, + written.txHash, + requirements.network, + payer, + amount.toString(), + ); + if (!result.success) { + return result; + } + + if (capture.voidAuthorizerSignature) { + // Releasing the remainder is best effort: the capture already moved funds, so this settle + // succeeded whatever the void does. A remainder left behind — by a race that emptied the + // hold, or by an RPC failure — stays voidable until authorizationExpiry, after which the + // payer can reclaim it. + await submitEscrowCall(submitter, settleTarget, "void", [tuple], extra.deployment, { + dataSuffix, + }); + } + + return result; +} + +/** + * Whether payload.paymentInfo matches the published requirements and extra. + * + * @param paymentInfo - Struct from the lifecycle payload. + * @param requirements - Published requirements. + * @param extra - Normalized extra. + * @returns True when every committed field matches. + */ +function paymentInfoMatchesRequirements( + paymentInfo: PaymentInfoStruct, + requirements: PaymentRequirements, + extra: NormalizedAuthCaptureExtra, +): boolean { + return ( + paymentInfo.receiver.toLowerCase() === requirements.payTo.toLowerCase() && + paymentInfo.token.toLowerCase() === requirements.asset.toLowerCase() && + paymentInfo.maxAmount === requirements.amount && + paymentInfo.authorizationExpiry === extra.captureDeadline && + paymentInfo.refundExpiry === extra.refundDeadline && + paymentInfo.minFeeBps === extra.minFeeBps && + paymentInfo.maxFeeBps === extra.maxFeeBps && + isAddressEqual(paymentInfo.feeReceiver, extra.feeRecipient) + ); +} + +/** + * Single-use check: signed expected balances must equal onchain state. + * + * @param state - Escrow paymentState. + * @param expectedCapturable - Signed expectedCapturableAmount. + * @param expectedRefundable - Signed expectedRefundableAmount. + * @returns True when both balances match. + */ +function balancesMatch( + state: PaymentState, + expectedCapturable: bigint, + expectedRefundable: bigint, +): boolean { + return ( + state.capturableAmount === expectedCapturable && state.refundableAmount === expectedRefundable + ); +} + +/** + * Verify a capture payload: authorizer signatures, fees, deadlines, paymentState, simulation. + * + * @param signer - Facilitator signer. + * @param extra - Normalized extra. + * @param chainId - EVM chain id. + * @param paymentInfo - Payload PaymentInfo. + * @param paymentInfoHash - Escrow payment identifier. + * @param wirePayload - Capture envelope. + * @param now - Unix seconds. + * @returns VerifyResponse. + */ +async function verifyCapturePayload( + signer: FacilitatorEvmSigner, + extra: NormalizedAuthCaptureExtra, + chainId: number, + paymentInfo: PaymentInfoStruct, + paymentInfoHash: `0x${string}`, + wirePayload: CapturePayload & { authorizerSignature: `0x${string}` }, + now: number, +): Promise { + const payer = paymentInfo.payer; + const captureFee = captureFeeFromPayload(extra, wirePayload); + if (!captureFee) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadFormat, payer }; + } + + const ok = await verifyCapture( + signer, + extra.receiverAuthorizer, + chainId, + extra.captureAuthorizer, + extra.deployment, + captureDigestFor(captureFee, paymentInfoHash, wirePayload), + wirePayload.authorizerSignature, + ); + if (!ok) { + return { isValid: false, invalidReason: Errors.ErrAuthorizerSignature, payer }; + } + + if (wirePayload.voidAuthorizerSignature) { + const voidOk = await verifyVoid( + signer, + extra.receiverAuthorizer, + chainId, + extra.captureAuthorizer, + paymentInfoHash, + wirePayload.voidAuthorizerSignature, + ); + if (!voidOk) { + return { isValid: false, invalidReason: Errors.ErrVoidAuthorizerSignature, payer }; + } + } + + const feeError = validateSubmittedFee(extra, wirePayload.amount, captureFee); + if (feeError) { + return { isValid: false, invalidReason: feeError, payer }; + } + + if (now >= paymentInfo.authorizationExpiry) { + return { isValid: false, invalidReason: Errors.ErrCaptureDeadlineExpired, payer }; + } + + const expectedCapturable = BigInt(wirePayload.expectedCapturableAmount); + const expectedRefundable = BigInt(wirePayload.expectedRefundableAmount); + const { state } = await readPaymentStateForBalances( + signer, + paymentInfoHash, + expectedCapturable, + expectedRefundable, + extra.deployment.escrow, + ); + if (!state) { + return { isValid: false, invalidReason: Errors.ErrUnexpectedPaymentState, payer }; + } + if (!balancesMatch(state, expectedCapturable, expectedRefundable)) { + return { isValid: false, invalidReason: Errors.ErrUnexpectedPaymentState, payer }; + } + + const amount = BigInt(wirePayload.amount); + if (amount <= 0n || amount > state.capturableAmount) { + return { isValid: false, invalidReason: Errors.ErrAmountMismatch, payer }; + } + if (wirePayload.voidAuthorizerSignature) { + if (amount >= state.capturableAmount) { + return { isValid: false, invalidReason: Errors.ErrVoidRemainderFullCapture, payer }; + } + } + + const tuple = paymentInfoToContractTuple(paymentInfo); + const captureSim = await simulateEscrowCall( + signer, + resolveSettleTarget(extra), + "capture", + captureEscrowArgs(tuple, amount, captureFee), + extra.captureAuthorizer, + extra.deployment, + ); + if (captureSim !== "ok") { + return { isValid: false, invalidReason: captureSim, payer }; + } + if (wirePayload.voidAuthorizerSignature) { + const voidSim = await simulateEscrowCall( + signer, + resolveSettleTarget(extra), + "void", + [tuple], + extra.captureAuthorizer, + extra.deployment, + ); + if (voidSim !== "ok") { + return { isValid: false, invalidReason: voidSim, payer }; + } + } + + return { isValid: true, payer }; +} + +/** + * Verify a void payload: authorizer signature, remaining hold, simulation. + * + * @param signer - Facilitator signer. + * @param extra - Normalized extra. + * @param chainId - EVM chain id. + * @param paymentInfo - Payload PaymentInfo. + * @param paymentInfoHash - Escrow payment identifier. + * @param wirePayload - Void envelope. + * @returns VerifyResponse. + */ +async function verifyVoidPayload( + signer: FacilitatorEvmSigner, + extra: NormalizedAuthCaptureExtra, + chainId: number, + paymentInfo: PaymentInfoStruct, + paymentInfoHash: `0x${string}`, + wirePayload: VoidPayload & { authorizerSignature: `0x${string}` }, +): Promise { + const payer = paymentInfo.payer; + const ok = await verifyVoid( + signer, + extra.receiverAuthorizer, + chainId, + extra.captureAuthorizer, + paymentInfoHash, + wirePayload.authorizerSignature, + ); + if (!ok) { + return { isValid: false, invalidReason: Errors.ErrAuthorizerSignature, payer }; + } + + const state = await readPaymentStateOnce(signer, paymentInfoHash, extra.deployment.escrow); + if (!state) { + return { isValid: false, invalidReason: Errors.ErrUnexpectedPaymentState, payer }; + } + if (state.capturableAmount === 0n) { + return { isValid: false, invalidReason: Errors.ErrZeroAuthorization, payer }; + } + + const tuple = paymentInfoToContractTuple(paymentInfo); + const sim = await simulateEscrowCall( + signer, + resolveSettleTarget(extra), + "void", + [tuple], + extra.captureAuthorizer, + extra.deployment, + ); + if (sim !== "ok") { + return { isValid: false, invalidReason: sim, payer }; + } + return { isValid: true, payer }; +} + +/** + * Verify a refund payload: authorizer signature, deadline, paymentState, simulation. + * + * @param signer - Facilitator signer. + * @param extra - Normalized extra. + * @param chainId - EVM chain id. + * @param paymentInfo - Payload PaymentInfo. + * @param paymentInfoHash - Escrow payment identifier. + * @param wirePayload - Refund envelope. + * @param now - Unix seconds. + * @returns VerifyResponse. + */ +async function verifyRefundPayload( + signer: FacilitatorEvmSigner, + extra: NormalizedAuthCaptureExtra, + chainId: number, + paymentInfo: PaymentInfoStruct, + paymentInfoHash: `0x${string}`, + wirePayload: RefundPayload & { authorizerSignature: `0x${string}` }, + now: number, +): Promise { + const payer = paymentInfo.payer; + const ok = await verifyRefund( + signer, + extra.receiverAuthorizer, + chainId, + extra.captureAuthorizer, + { + paymentInfoHash, + amount: wirePayload.amount, + tokenCollector: extra.deployment.operatorRefundCollector, + expectedCapturableAmount: wirePayload.expectedCapturableAmount, + expectedRefundableAmount: wirePayload.expectedRefundableAmount, + }, + wirePayload.authorizerSignature, + ); + if (!ok) { + return { isValid: false, invalidReason: Errors.ErrAuthorizerSignature, payer }; + } + + if (now >= paymentInfo.refundExpiry) { + return { isValid: false, invalidReason: Errors.ErrRefundDeadlineExpired, payer }; + } + + const expectedCapturable = BigInt(wirePayload.expectedCapturableAmount); + const expectedRefundable = BigInt(wirePayload.expectedRefundableAmount); + const { state } = await readPaymentStateForBalances( + signer, + paymentInfoHash, + expectedCapturable, + expectedRefundable, + extra.deployment.escrow, + ); + if (!state) { + return { isValid: false, invalidReason: Errors.ErrUnexpectedPaymentState, payer }; + } + if (!balancesMatch(state, expectedCapturable, expectedRefundable)) { + return { isValid: false, invalidReason: Errors.ErrUnexpectedPaymentState, payer }; + } + + const amount = BigInt(wirePayload.amount); + if (amount <= 0n || amount > state.refundableAmount) { + return { isValid: false, invalidReason: Errors.ErrRefundExceedsCapture, payer }; + } + + const tuple = paymentInfoToContractTuple(paymentInfo); + const sim = await simulateEscrowCall( + signer, + resolveSettleTarget(extra), + "refund", + [tuple, amount, extra.deployment.operatorRefundCollector, "0x"], + extra.captureAuthorizer, + extra.deployment, + ); + if (sim !== "ok") { + return { isValid: false, invalidReason: sim, payer }; + } + return { isValid: true, payer }; +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts new file mode 100644 index 0000000000..8190ed450a --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts @@ -0,0 +1,240 @@ +/** + * AuthCapture Scheme - Facilitator + * Handles verification and settlement of auth-capture payments. + * + * Implements x402's SchemeNetworkFacilitator interface so the auth-capture scheme + * is a drop-in for the x402 facilitator, just like ExactEvmScheme. + * + * Dispatches on `payload.type`: collect (authorize/charge) when absent, lifecycle + * (capture/void/refund) when present. Operator type, not bytecode, selects the + * settle target. + */ + +import type { + FacilitatorContext, + PaymentPayload, + PaymentRequirements, + SchemeNetworkFacilitator, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import { InMemoryPendingSettlementStore, PendingSettlementStore } from "@x402/core/facilitator"; +import { getAddress } from "viem"; +import type { FacilitatorEvmSigner } from "../../signer"; +import { resolveDataSuffix } from "../../shared/extensions"; +import { AUTH_CAPTURE_SCHEME } from "../constants"; +import type { AuthCaptureFacilitatorConfig } from "../types"; +import { isAuthCaptureCollectPayload, isLifecyclePayload } from "../types"; +import * as Errors from "../errors"; +import { facilitatorAddresses, pickCaptureAuthorizer, resolveCaptureAuthorizerPool } from "./utils"; +import { verifyCollect, settleCollect } from "./collect"; +import { verifyLifecycle, settleLifecycle } from "./lifecycle"; +import { assertDelegatedAuthStorage } from "./delegatedAuth"; + +export type { AuthCaptureFacilitatorConfig } from "../types"; + +/** + * Fail fast when facilitator-delegated receiver authorization is only partially configured. + * + * @param config - Facilitator scheme config. + * @throws When any delegation field is set without the full quartet. + */ +function assertDelegatedReceiverAuthorizerConfig(config?: AuthCaptureFacilitatorConfig): void { + if (!config) return; + + const hasAuthorizerSigner = config.authorizerSigner !== undefined; + const hasResolveCallerIdentity = config.resolveCallerIdentity !== undefined; + const delegatedAuthStorage = config.delegatedAuthStorage; + const hasDelegatedAuthStorage = delegatedAuthStorage !== undefined; + const hasOnStorageError = config.onStorageError !== undefined; + const anyDelegation = + hasAuthorizerSigner || hasResolveCallerIdentity || hasDelegatedAuthStorage || hasOnStorageError; + + if (!anyDelegation) return; + + if (!hasAuthorizerSigner) { + throw new Error("facilitator-delegated receiver authorization requires authorizerSigner"); + } + if (!hasResolveCallerIdentity) { + throw new Error("authorizerSigner requires resolveCallerIdentity"); + } + if (delegatedAuthStorage === undefined) { + throw new Error("authorizerSigner requires delegatedAuthStorage"); + } + assertDelegatedAuthStorage(delegatedAuthStorage); + if (!hasOnStorageError) { + throw new Error("authorizerSigner requires onStorageError"); + } +} + +/** + * AuthCapture Facilitator Scheme - implements x402's SchemeNetworkFacilitator. + * + * Settle dispatch: + * - no `payload.type` + extra.paymentFlow escrow (default) → escrow.authorize() + * - no `payload.type` + extra.paymentFlow authorization → escrow.charge() + * - payload.type capture / void / refund → lifecycle + * + * Asset-transfer dispatch (extra.assetTransferMethod): + * - 'eip3009' (default) → ERC-3009 ReceiveWithAuthorization, EIP3009_TOKEN_COLLECTOR + * - 'permit2' → Permit2 PermitTransferFrom, PERMIT2_TOKEN_COLLECTOR + */ +export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { + readonly scheme = AUTH_CAPTURE_SCHEME; + readonly caipFamily = "eip155:*"; + private readonly signers: readonly FacilitatorEvmSigner[]; + private readonly pendingStore: PendingSettlementStore; + private readonly config: AuthCaptureFacilitatorConfig | undefined; + private readonly captureAuthorizers: readonly `0x${string}`[]; + + /** + * Construct a facilitator-side auth-capture scheme bound to one or more signers. + * Pass an array of single-address `toFacilitatorEvmSigner` results to rotate + * submitters — do not register the scheme twice on the same network. + * + * @param signer - Facilitator signer, or a set of signers to rotate across. + * @param config - Optional fee terms, operator allowlist, delegated refund funding, and + * the facilitator-delegated receiver authorizer. + * @throws If facilitator-delegated receiver authorization is only partially configured, or + * `captureAuthorizers` names an address no signer holds. + */ + constructor( + signer: FacilitatorEvmSigner | readonly FacilitatorEvmSigner[], + config?: AuthCaptureFacilitatorConfig, + ) { + assertDelegatedReceiverAuthorizerConfig(config); + this.signers = Array.isArray(signer) ? [...signer] : [signer]; + this.pendingStore = config?.pendingSettlementStore ?? new InMemoryPendingSettlementStore(); + this.config = config; + this.captureAuthorizers = resolveCaptureAuthorizerPool(this.signers, config); + } + + /** + * Return the flattened, deduped addresses this facilitator submits from. + * + * @param _ - Unused network argument (interface compatibility). + * @returns The facilitator's submitter address(es) on this network. + */ + getSigners(_: string): string[] { + return [...facilitatorAddresses(this.signers)]; + } + + /** + * Facilitator-injected `extra` fields for `/supported`: a `captureAuthorizer` picked from + * the pool (randomly, or by `selectCaptureAuthorizer`), the receiver authorizer when this + * facilitator can authenticate delegated requests, grouped fee terms, and the + * custom-operator allowlist. + * + * @param network - CAIP-2 network being advertised. + * @returns Extra to merge into payment requirements, or undefined when empty. + */ + getExtra(network: string): Record | undefined { + const extra: Record = {}; + const captureAuthorizer = pickCaptureAuthorizer( + this.captureAuthorizers, + network, + this.config?.selectCaptureAuthorizer, + ); + if (captureAuthorizer) { + extra.captureAuthorizer = captureAuthorizer; + } + if (this.config?.authorizerSigner) { + extra.receiverAuthorizer = getAddress(this.config.authorizerSigner.address); + } + if (this.config?.feeTerms) { + extra.feeRecipient = this.config.feeTerms.feeRecipient; + extra.minFeeBps = this.config.feeTerms.minFeeBps; + extra.maxFeeBps = this.config.feeTerms.maxFeeBps; + } + if ( + this.config?.operators && + this.config.operators.length > 0 && + this.signers.length > 0 && + this.signers.every(member => member.simulateCalls) + ) { + extra.operators = this.config.operators; + } + return Object.keys(extra).length > 0 ? extra : undefined; + } + + /** + * Verify a payment payload against the published requirements without + * touching state. + * + * @param payload - The wire payload from the payer or resource server. + * @param requirements - The server's published payment requirements. + * @param context - Optional facilitator context for extension hooks. + * @returns A `VerifyResponse` with `isValid` and, on failure, a stable `invalidReason`. + */ + async verify( + payload: PaymentPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + ): Promise { + const raw = payload.payload; + if (isLifecyclePayload(raw)) { + return verifyLifecycle(this.signers, this.config, payload, requirements, raw, context); + } + if (isAuthCaptureCollectPayload(raw)) { + const dataSuffix = await resolveDataSuffix(context, { + paymentPayload: payload, + paymentRequirements: requirements, + }); + return verifyCollect(this.signers, this.config, payload, requirements, raw, dataSuffix); + } + if (typeof raw === "object" && raw !== null && "type" in raw) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadType }; + } + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadFormat }; + } + + /** + * Verify-then-settle. Re-runs `verify()` against the payload, then submits + * the collect or lifecycle call. + * + * @param payload - The wire payload from the payer or resource server. + * @param requirements - The server's published payment requirements. + * @param context - Optional facilitator context for extension hooks (e.g. + * builder-code calldata suffixes). + * @returns A `SettleResponse` with `success`, the transaction hash (on + * success), and a stable `errorReason` (on failure). + */ + async settle( + payload: PaymentPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + ): Promise { + const raw = payload.payload; + if (isLifecyclePayload(raw)) { + return settleLifecycle( + this.signers, + this.config, + payload, + requirements, + raw, + this.pendingStore, + context, + ); + } + if (isAuthCaptureCollectPayload(raw)) { + return settleCollect( + this.signers, + this.config, + payload, + requirements, + raw, + this.pendingStore, + context, + ); + } + return { + success: false, + errorReason: + typeof raw === "object" && raw !== null && "type" in raw + ? Errors.ErrInvalidPayloadType + : Errors.ErrInvalidPayloadFormat, + transaction: "", + network: requirements.network, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/utils.ts b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/utils.ts new file mode 100644 index 0000000000..8aa6088357 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/utils.ts @@ -0,0 +1,444 @@ +import { + BaseError, + ContractFunctionRevertedError, + getAddress, + isAddress, + isAddressEqual, + type Log, +} from "viem"; +import type { FacilitatorEvmSigner } from "../../signer"; +import { ESCROW_VIEW_ABI, escrowAbiWithErrorsForDeployment } from "../abi"; +import type { AuthCaptureDeployment } from "../constants"; +import { isNonZeroAddress } from "../nonce"; +import type { + AuthCaptureCollectPayload, + AuthCaptureFacilitatorConfig, + PaymentState, +} from "../types"; +import { isEip3009Payload } from "../types"; +import { ESCROW_ERROR_TO_INVALID_REASON, ErrSimulationFailed } from "../errors"; + +export const SAFETY_MARGIN_SECONDS = 6; + +/** + * Flatten and dedupe facilitator submitter addresses across a signer set. + * + * @param signers - Facilitator signers (each may expose one or more addresses). + * @returns Unique addresses in first-seen order. + */ +export function facilitatorAddresses( + signers: readonly FacilitatorEvmSigner[], +): readonly `0x${string}`[] { + const seen: `0x${string}`[] = []; + for (const signer of signers) { + for (const address of signer.getAddresses()) { + if (!seen.some(existing => isAddressEqual(existing, address))) { + seen.push(address); + } + } + } + return seen; +} + +/** + * Resolve the deduplicated pool of `captureAuthorizer` addresses to advertise, checked + * against the signer set. Defaults to every signer address. + * + * @param signers - Facilitator signers. + * @param config - Facilitator config carrying the optional `captureAuthorizers`. + * @returns Checksummed pool addresses in first-seen order. + * @throws When a configured address is invalid, zero, or not held by any signer. + */ +export function resolveCaptureAuthorizerPool( + signers: readonly FacilitatorEvmSigner[], + config: AuthCaptureFacilitatorConfig | undefined, +): readonly `0x${string}`[] { + const configured = config?.captureAuthorizers; + if (!configured || configured.length === 0) { + return facilitatorAddresses(signers).map(address => getAddress(address)); + } + + const pool: `0x${string}`[] = []; + for (const candidate of configured) { + if (!isAddress(candidate, { strict: false }) || !isNonZeroAddress(candidate)) { + throw new Error(`captureAuthorizers contains an empty or zero address: "${candidate}"`); + } + if (!selectSubmitter(signers, candidate)) { + throw new Error(`captureAuthorizer ${candidate} is not one of the signers' addresses`); + } + const checksummed = getAddress(candidate); + if (!pool.some(existing => isAddressEqual(existing, checksummed))) { + pool.push(checksummed); + } + } + return pool; +} + +/** + * Pick the `captureAuthorizer` one `/supported` response advertises. + * + * @param pool - Pool from {@link resolveCaptureAuthorizerPool}. + * @param network - CAIP-2 network being advertised. + * @param select - Optional config selector. + * @returns A pool member, or undefined for an empty pool. + */ +export function pickCaptureAuthorizer( + pool: readonly `0x${string}`[], + network: string, + select: AuthCaptureFacilitatorConfig["selectCaptureAuthorizer"], +): `0x${string}` | undefined { + if (pool.length <= 1) return pool[0]; + const selected = select?.(network, [...pool]); + if (selected && isAddress(selected, { strict: false })) { + const member = pool.find(candidate => isAddressEqual(candidate, selected)); + if (member) return member; + } + return pool[Math.floor(Math.random() * pool.length)]; +} + +/** + * Find the signer that can submit from `address`. + * + * Comparison is checksum-insensitive so a parsed `extra.captureAuthorizer` + * still matches a lowercase `getAddresses()` entry. + * + * @param signers - Facilitator signers to scan. + * @param address - Submitter to look up. + * @returns The owning signer, or undefined when no member controls `address`. + */ +export function selectSubmitter( + signers: readonly FacilitatorEvmSigner[], + address: `0x${string}`, +): FacilitatorEvmSigner | undefined { + return signers.find(signer => + signer.getAddresses().some(owned => isAddressEqual(owned, address)), + ); +} + +/** + * Resolve the signer that will simulate and submit this request. + * Delegated payments use `extra.captureAuthorizer`; custom operators may be + * submitted by any facilitator address, so the first signer is used. + * + * @param signers - Facilitator signer set. + * @param extra - Normalized extra after `verifyCommon`. + * @param extra.operatorType - `"delegated"` or `"custom"`. + * @param extra.captureAuthorizer - Operator address from extra. + * @returns The submitter, or undefined when the set is empty. + */ +export function resolveSubmitter( + signers: readonly FacilitatorEvmSigner[], + extra: { operatorType: "delegated" | "custom"; captureAuthorizer: `0x${string}` }, +): FacilitatorEvmSigner | undefined { + if (extra.operatorType === "delegated") { + return selectSubmitter(signers, extra.captureAuthorizer); + } + return signers[0]; +} + +/** Backoff delays between paymentState eth_call retries (RPC index lag after authorize). */ +export const PAYMENT_STATE_RETRY_DELAYS_MS = [200, 400, 800, 1600] as const; + +/** Initial read plus one attempt per retry delay. */ +export const PAYMENT_STATE_MAX_ATTEMPTS = PAYMENT_STATE_RETRY_DELAYS_MS.length + 1; + +/** + * Walk a viem error chain looking for a decoded custom-error name, then map + * known names to a stable `invalidReason` via `ESCROW_ERROR_TO_INVALID_REASON`. + * Anything unmapped returns `ErrSimulationFailed` so the wire never leaks raw + * selectors. + * + * @param err - The error thrown by `readContract` / `simulateContract`. + * @returns A stable wire-level `invalidReason` string. + */ +export function decodeRevertReason(err: unknown): string { + if (err instanceof BaseError) { + const revert = err.walk( + (e): e is ContractFunctionRevertedError => e instanceof ContractFunctionRevertedError, + ); + if (revert instanceof ContractFunctionRevertedError) { + const errorName = revert.data?.errorName; + if (errorName && errorName in ESCROW_ERROR_TO_INVALID_REASON) { + return ESCROW_ERROR_TO_INVALID_REASON[errorName]; + } + } + } + return ErrSimulationFailed; +} + +/** + * Collect-payload payer address. + * + * @param wirePayload - EIP-3009 or Permit2 collect payload. + * @returns The `from` address. + */ +export function collectPayer(wirePayload: AuthCaptureCollectPayload): `0x${string}` { + return isEip3009Payload(wirePayload) + ? wirePayload.authorization.from + : wirePayload.permit2Authorization.from; +} + +/** + * Simulate an escrow call via `eth_call` as the facilitator submitter. + * + * @param signer - Facilitator signer. + * @param target - Escrow or custom operator. + * @param functionName - Escrow ABI function. + * @param args - Encoded arguments. + * @param account - msg.sender for the eth_call. + * @param deployment - Resolved escrow addresses and version. + * @returns `"ok"` or a stable invalidReason. + */ +export async function simulateEscrowCall( + signer: FacilitatorEvmSigner, + target: `0x${string}`, + functionName: "authorize" | "charge" | "capture" | "void" | "refund", + args: readonly unknown[], + account: `0x${string}`, + deployment: AuthCaptureDeployment, +): Promise<"ok" | string> { + try { + await signer.readContract({ + address: target, + abi: escrowAbiWithErrorsForDeployment(deployment), + functionName, + args, + account, + }); + return "ok"; + } catch (err) { + return decodeRevertReason(err); + } +} + +/** + * Broadcast an escrow call without waiting for confirmation. + * + * @param signer - Facilitator signer. + * @param target - Contract to call. + * @param functionName - Escrow ABI function. + * @param args - Encoded arguments. + * @param deployment - Resolved escrow addresses and version. + * @param options - Optional gas cap and extension calldata suffix. + * @param options.gas - Hard gas limit for the write. + * @param options.dataSuffix - Builder-code suffix appended to calldata. + * @returns Transaction hash, or a write failure reason with no hash. + */ +export async function writeEscrowCall( + signer: FacilitatorEvmSigner, + target: `0x${string}`, + functionName: "authorize" | "charge" | "capture" | "void" | "refund", + args: readonly unknown[], + deployment: AuthCaptureDeployment, + options?: { gas?: bigint; dataSuffix?: `0x${string}` }, +): Promise<{ txHash: `0x${string}` } | { error: string }> { + try { + const txHash = await signer.writeContract({ + address: target, + abi: escrowAbiWithErrorsForDeployment(deployment), + functionName, + args, + gas: options?.gas, + dataSuffix: options?.dataSuffix, + }); + return { txHash }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Settlement failed" }; + } +} + +/** + * Broadcast an escrow call and wait for a successful receipt. Used for + * best-effort trailing lifecycle calls whose outcome is ignored. + * + * @param signer - Facilitator signer. + * @param target - Contract to call. + * @param functionName - Escrow ABI function. + * @param args - Encoded arguments. + * @param deployment - Resolved escrow addresses and version. + * @param options - Optional gas cap and extension calldata suffix. + * @param options.gas - Hard gas limit for the write. + * @param options.dataSuffix - Builder-code suffix appended to calldata. + * @returns Transaction hash, or a failure reason. + */ +export async function submitEscrowCall( + signer: FacilitatorEvmSigner, + target: `0x${string}`, + functionName: "authorize" | "charge" | "capture" | "void" | "refund", + args: readonly unknown[], + deployment: AuthCaptureDeployment, + options?: { gas?: bigint; dataSuffix?: `0x${string}` }, +): Promise< + { txHash: `0x${string}`; logs?: readonly Log[] } | { error: string; txHash?: `0x${string}` } +> { + const written = await writeEscrowCall(signer, target, functionName, args, deployment, options); + if ("error" in written) { + return { error: written.error }; + } + + try { + const receipt = await signer.waitForTransactionReceipt({ hash: written.txHash }); + if (receipt.status !== "success") { + return { error: "reverted", txHash: written.txHash }; + } + return { txHash: written.txHash, logs: receipt.logs }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Settlement failed" }; + } +} + +/** + * Normalize viem `paymentState` return shapes to a flat PaymentState. + * + * @param raw - Value from `readContract(paymentState)`. + * @returns Parsed state, or undefined when unrecognized. + */ +export function normalizePaymentState(raw: unknown): PaymentState | undefined { + if (raw === null || raw === undefined) return undefined; + + if (typeof raw === "object" && !Array.isArray(raw) && "state" in raw) { + return normalizePaymentState((raw as { state: unknown }).state); + } + + if (Array.isArray(raw)) { + if (raw.length < 3) return undefined; + return { + hasCollectedPayment: Boolean(raw[0]), + capturableAmount: BigInt(raw[1] as bigint | number | string), + refundableAmount: BigInt(raw[2] as bigint | number | string), + }; + } + + if (typeof raw === "object") { + const s = raw as Record; + if ( + s.capturableAmount === undefined && + s.refundableAmount === undefined && + s.hasCollectedPayment === undefined + ) { + return undefined; + } + return { + hasCollectedPayment: Boolean(s.hasCollectedPayment), + capturableAmount: BigInt(s.capturableAmount as bigint | number | string), + refundableAmount: BigInt(s.refundableAmount as bigint | number | string), + }; + } + + return undefined; +} + +/** + * Read AuthCaptureEscrow.paymentState once. + * + * @param signer - Facilitator signer. + * @param paymentInfoHash - Escrow payment identifier. + * @param escrowAddress - AuthCaptureEscrow to query. + * @returns Onchain balances, or undefined when the read fails. + */ +export async function readPaymentStateOnce( + signer: FacilitatorEvmSigner, + paymentInfoHash: `0x${string}`, + escrowAddress: `0x${string}`, +): Promise { + try { + const raw = await signer.readContract({ + address: escrowAddress, + abi: ESCROW_VIEW_ABI, + functionName: "paymentState", + args: [paymentInfoHash], + }); + return normalizePaymentState(raw); + } catch { + return undefined; + } +} + +/** + * Delay for a fixed duration (paymentState RPC retry backoff). + * + * @param ms - Sleep duration in milliseconds. + * @returns A promise that resolves after `ms`. + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Compare onchain paymentState balances to signed lifecycle expectations. + * + * @param state - Escrow paymentState read from chain. + * @param expectedCapturable - Signed expected capturable balance. + * @param expectedRefundable - Signed expected refundable balance. + * @returns True when both balances match exactly. + */ +function paymentStateBalancesMatch( + state: PaymentState, + expectedCapturable: bigint, + expectedRefundable: bigint, +): boolean { + return ( + state.capturableAmount === expectedCapturable && state.refundableAmount === expectedRefundable + ); +} + +/** + * Detect empty paymentState that likely reflects RPC index lag after collect. + * + * Authorize expects a capturable hold; charge expects a refundable balance. + * Public RPCs often still return the uncollected zero struct right after the + * receipt, so both cases retry rather than treating zeros as a genuine mismatch. + * + * @param state - Escrow paymentState read from chain. + * @param expectedCapturable - Signed expected capturable balance. + * @param expectedRefundable - Signed expected refundable balance. + * @returns True when the read looks like a stale zero state despite an expected collect. + */ +function isLikelyStalePaymentState( + state: PaymentState, + expectedCapturable: bigint, + expectedRefundable: bigint, +): boolean { + return ( + (expectedCapturable > 0n || expectedRefundable > 0n) && + !state.hasCollectedPayment && + state.capturableAmount === 0n && + state.refundableAmount === 0n + ); +} + +/** + * Read paymentState with exponential backoff when the RPC may not yet reflect a fresh collect. + * + * @param signer - Facilitator signer. + * @param paymentInfoHash - Escrow payment identifier. + * @param expectedCapturable - Signed expected capturable balance. + * @param expectedRefundable - Signed expected refundable balance. + * @param escrowAddress - AuthCaptureEscrow to query. + * @returns Parsed state and read metadata. + */ +export async function readPaymentStateForBalances( + signer: FacilitatorEvmSigner, + paymentInfoHash: `0x${string}`, + expectedCapturable: bigint, + expectedRefundable: bigint, + escrowAddress: `0x${string}`, +): Promise<{ state?: PaymentState; readFailed: boolean; attempts: number }> { + for (let attempt = 0; ; attempt++) { + const state = await readPaymentStateOnce(signer, paymentInfoHash, escrowAddress); + + if (state && paymentStateBalancesMatch(state, expectedCapturable, expectedRefundable)) { + return { state, readFailed: false, attempts: attempt + 1 }; + } + + if (state && !isLikelyStalePaymentState(state, expectedCapturable, expectedRefundable)) { + return { state, readFailed: false, attempts: attempt + 1 }; + } + + if (attempt === PAYMENT_STATE_RETRY_DELAYS_MS.length) { + return { readFailed: true, attempts: attempt + 1 }; + } + + await sleep(PAYMENT_STATE_RETRY_DELAYS_MS[attempt]!); + } +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/lifecyclePayload.ts b/typescript/packages/mechanisms/evm/src/auth-capture/lifecyclePayload.ts new file mode 100644 index 0000000000..a5d4bb6e6d --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/lifecyclePayload.ts @@ -0,0 +1,492 @@ +import type { PaymentRequirements } from "@x402/core/types"; +import { feeAmountFromBps, resolveAuthCaptureDeployment } from "./constants"; +import { computePaymentInfoHash } from "./nonce"; +import { signCapture, signCharge, signRefund, signVoid } from "./authorizerSigner"; +import { defaultSubmittedFee, type NormalizedAuthCaptureExtra, type SubmittedFee } from "./extra"; +import { reconstructPaymentInfo, unpackForSettle } from "./utils"; +import type { + AuthCaptureCollectPayload, + AuthCaptureExtra, + AuthorizerSigner, + CapturePayload, + PaymentInfoStruct, + RefundPayload, + VoidPayload, +} from "./types"; +import { isEip3009Payload } from "./types"; + +/** + * Reconstruct PaymentInfo from a collect payload and published requirements. + * + * @param collect - Client collect payload. + * @param requirements - Payment requirements. + * @param extra - Auth-capture extra. + * @returns PaymentInfo struct. + */ +export function paymentInfoFromCollect( + collect: AuthCaptureCollectPayload, + requirements: PaymentRequirements, + extra: AuthCaptureExtra, +): PaymentInfoStruct { + const deployment = resolveAuthCaptureDeployment(extra.authCaptureEscrow)!; + const unpacked = unpackForSettle(collect, extra.assetTransferMethod ?? "eip3009", deployment); + const payer = isEip3009Payload(collect) + ? collect.authorization.from + : collect.permit2Authorization.from; + return reconstructPaymentInfo( + payer, + unpacked.preApprovalExpiry, + collect.salt, + requirements, + extra, + unpacked.amount.toString(), + ); +} + +/** + * Capture fields for the wire. Without a local signer (authorizer delegated to the + * facilitator) the signatures are omitted and `voidRemainder` asks the facilitator to sign + * the `Void` leg. + */ +export type CaptureFields = { + amount: string; + feeReceiver: `0x${string}`; + expectedCapturableAmount: string; + expectedRefundableAmount: string; + authorizerSignature?: `0x${string}`; + voidAuthorizerSignature?: `0x${string}`; + voidRemainder?: true; +} & ({ feeBps: number; feeAmount?: never } | { feeAmount: string; feeBps?: never }); + +/** + * Merge a submitted fee into capture fields for the deployment version. + * + * @param fee - Submitted fee (`feeBps` or `feeAmount`). + * @param fields - Capture fields excluding the version-specific fee amount. + * @returns Capture fields with the fee encoding for this deployment. + */ +function captureFieldsFromFee( + fee: SubmittedFee, + fields: Omit & { + feeReceiver: `0x${string}`; + }, +): CaptureFields { + if (fee.version === "v1.0") { + return { ...fields, feeBps: fee.feeBps, feeReceiver: fee.feeReceiver }; + } + return { ...fields, feeAmount: fee.feeAmount, feeReceiver: fee.feeReceiver }; +} + +/** + * Build capture fields, signing the capture (and optionally void-remainder) authorizer + * digests when a signer is given. Shared by sync enrichment and deferred helper settles. + * + * @param params - Capture context and optional explicit void-remainder flag. + * @param params.signer - Authorizer key used to sign capture and void digests; omit when the + * authorizer is delegated to the facilitator. + * @param params.chainId - EVM chain id for EIP-712 domain separation. + * @param params.extra - Normalized auth-capture extra (authorizer, fee bounds). + * @param params.paymentInfoHash - Hash of the escrow PaymentInfo struct. + * @param params.amount - Atomic capture amount. + * @param params.capturable - Expected capturable balance before capture. + * @param params.refundable - Expected refundable balance before capture. + * @param params.feeBps - Optional submitted fee basis points (v1.0). + * @param params.feeAmount - Optional submitted absolute fee (v1.1). + * @param params.feeReceiver - Optional fee recipient address. + * @param params.voidRemainder - When true, sign a void digest for the remaining hold. + * @param params.voidOnPartialCapture - When true and amount is below capturable, sign void. + * @returns Signed capture fields for wire payloads or enrichment. + */ +export async function buildCaptureFields(params: { + signer?: AuthorizerSigner; + chainId: number; + extra: NormalizedAuthCaptureExtra; + paymentInfoHash: `0x${string}`; + amount: string; + capturable: string; + refundable: string; + feeBps?: number; + feeAmount?: string; + feeReceiver?: `0x${string}`; + /** Explicit void-remainder for deferred helper settles. */ + voidRemainder?: boolean; + /** Sync enrichment: attach void sig when amount is below capturable. */ + voidOnPartialCapture?: boolean; +}): Promise { + const defaultFee = defaultSubmittedFee(params.extra, params.amount); + const fee: SubmittedFee = + params.feeBps !== undefined && params.feeReceiver !== undefined + ? params.extra.deployment.version === "v1.0" + ? { version: "v1.0", feeBps: params.feeBps, feeReceiver: params.feeReceiver } + : { + version: "v1.1", + feeAmount: + params.feeAmount ?? + (defaultFee.version === "v1.1" + ? defaultFee.feeAmount + : feeAmountFromBps(BigInt(params.amount), params.feeBps).toString()), + feeReceiver: params.feeReceiver, + } + : params.feeAmount !== undefined && params.feeReceiver !== undefined + ? { version: "v1.1", feeAmount: params.feeAmount, feeReceiver: params.feeReceiver } + : defaultFee; + + const fields = { + amount: params.amount, + expectedCapturableAmount: params.capturable, + expectedRefundableAmount: params.refundable, + feeReceiver: fee.feeReceiver, + }; + const needsVoid = + params.voidRemainder === true || + (params.voidOnPartialCapture === true && BigInt(params.amount) < BigInt(params.capturable)); + + if (!params.signer) { + return captureFieldsFromFee(fee, { ...fields, ...(needsVoid ? { voidRemainder: true } : {}) }); + } + + const captureDigest = + fee.version === "v1.0" + ? { + paymentInfoHash: params.paymentInfoHash, + ...fields, + feeBps: fee.feeBps, + } + : { + paymentInfoHash: params.paymentInfoHash, + ...fields, + feeAmount: fee.feeAmount, + }; + const authorizerSignature = await signCapture( + params.signer, + params.chainId, + params.extra.captureAuthorizer, + params.extra.deployment, + captureDigest, + ); + const result = captureFieldsFromFee(fee, { ...fields, authorizerSignature }); + if (needsVoid) { + result.voidAuthorizerSignature = await signVoid( + params.signer, + params.chainId, + params.extra.captureAuthorizer, + params.paymentInfoHash, + ); + } + return result; +} + +/** + * Build additive capture enrichment for sync after-handler settle. Does not + * re-emit `saltNonce`. + * + * @param params - Collect payload, requirements, stored balances, and signer. + * @param params.collect - Client collect payload from the authorize settle. + * @param params.requirements - Payment requirements for reconstruction. + * @param params.extra - Normalized auth-capture extra. + * @param params.signer - Authorizer signer for capture digests; omit when delegated. + * @param params.chainId - EVM chain id for hashing and signing. + * @param params.capturable - Current capturable balance on the hold. + * @param params.refundable - Current refundable balance on the hold. + * @param params.amount - Capture amount for this settle. + * @param params.paymentInfo - Server-trusted PaymentInfo; when omitted, reconstructed from collect + requirements. + * @returns Fields to merge into the client payload. + */ +export async function buildCaptureEnrichment(params: { + collect: AuthCaptureCollectPayload; + requirements: PaymentRequirements; + extra: NormalizedAuthCaptureExtra; + signer?: AuthorizerSigner; + chainId: number; + capturable: string; + refundable: string; + amount: string; + paymentInfo?: PaymentInfoStruct; +}): Promise> { + const paymentInfo = + params.paymentInfo ?? paymentInfoFromCollect(params.collect, params.requirements, params.extra); + const paymentInfoHash = computePaymentInfoHash( + params.chainId, + paymentInfo, + params.extra.deployment.escrow, + ); + const signed = await buildCaptureFields({ + signer: params.signer, + chainId: params.chainId, + extra: params.extra, + paymentInfoHash, + amount: params.amount, + capturable: params.capturable, + refundable: params.refundable, + voidOnPartialCapture: true, + }); + return { + type: "capture", + paymentInfo, + ...signed, + }; +} + +/** + * Build a signed capture lifecycle payload for deferred helper settles. + * + * @param params - Stored payment record fields and capture options. + * @param params.record - Stored authorized-payment fields needed for capture. + * @param params.record.paymentInfo - On-chain PaymentInfo struct from storage. + * @param params.record.paymentInfoHash - Storage key for the authorized payment. + * @param params.record.capturableAmount - Capturable balance at settle time. + * @param params.record.refundableAmount - Refundable balance at settle time. + * @param params.record.saltNonce - Bound salt nonce for lifecycle settles. + * @param params.extra - Normalized auth-capture extra. + * @param params.signer - Authorizer signer for capture digests; omit when delegated. + * @param params.chainId - EVM chain id for hashing and signing. + * @param params.amount - Atomic capture amount. + * @param params.feeBps - Optional submitted fee basis points (v1.0). + * @param params.feeAmount - Optional submitted absolute fee (v1.1). + * @param params.feeReceiver - Optional fee recipient address. + * @param params.voidRemainder - When true, void the remaining hold after capture. + * @returns Capture payload ready for facilitator `/settle`. + */ +export async function buildCapturePayload(params: { + record: { + paymentInfo: PaymentInfoStruct; + paymentInfoHash: `0x${string}`; + capturableAmount: string; + refundableAmount: string; + saltNonce: `0x${string}`; + }; + extra: NormalizedAuthCaptureExtra; + signer?: AuthorizerSigner; + chainId: number; + amount: string; + feeBps?: number; + feeAmount?: string; + feeReceiver?: `0x${string}`; + voidRemainder?: boolean; +}): Promise { + const signed = await buildCaptureFields({ + signer: params.signer, + chainId: params.chainId, + extra: params.extra, + paymentInfoHash: params.record.paymentInfoHash, + amount: params.amount, + capturable: params.record.capturableAmount, + refundable: params.record.refundableAmount, + feeBps: params.feeBps, + feeAmount: params.feeAmount, + feeReceiver: params.feeReceiver, + voidRemainder: params.voidRemainder, + }); + return { + type: "capture", + paymentInfo: params.record.paymentInfo, + saltNonce: params.record.saltNonce, + ...signed, + }; +} + +/** + * Build void enrichment for cancel-phase settle. + * + * @param params - Collect-derived payment info and signer. + * @param params.paymentInfo - PaymentInfo struct reconstructed from collect. + * @param params.extra - Normalized auth-capture extra. + * @param params.signer - Authorizer signer for void digests; omit when delegated. + * @param params.chainId - EVM chain id for hashing and signing. + * @param params.paymentInfoHash - Hash of the PaymentInfo struct. + * @returns Void fields to merge into the payload. + */ +export async function buildVoidEnrichment(params: { + paymentInfo: PaymentInfoStruct; + extra: NormalizedAuthCaptureExtra; + signer?: AuthorizerSigner; + chainId: number; + paymentInfoHash: `0x${string}`; +}): Promise> { + const authorizerSignature = await signVoidIfLocal(params); + return { + type: "void", + paymentInfo: params.paymentInfo, + ...(authorizerSignature ? { authorizerSignature } : {}), + }; +} + +/** + * Sign a `Void` digest when a local signer is configured. + * + * @param params - Signer and digest context. + * @param params.signer - Authorizer signer; undefined when the authorizer is delegated. + * @param params.chainId - EVM chain id for hashing and signing. + * @param params.extra - Normalized auth-capture extra. + * @param params.paymentInfoHash - Escrow payment identifier. + * @returns The signature, or undefined when delegated. + */ +async function signVoidIfLocal(params: { + signer?: AuthorizerSigner; + chainId: number; + extra: NormalizedAuthCaptureExtra; + paymentInfoHash: `0x${string}`; +}): Promise<`0x${string}` | undefined> { + if (!params.signer) return undefined; + return signVoid( + params.signer, + params.chainId, + params.extra.captureAuthorizer, + params.paymentInfoHash, + ); +} + +/** + * Build a void lifecycle payload, signed when a local signer is given. + * + * @param params - Stored payment record and signer. + * @param params.record - Stored authorized-payment fields needed for void. + * @param params.record.paymentInfo - On-chain PaymentInfo struct from storage. + * @param params.record.paymentInfoHash - Storage key for the authorized payment. + * @param params.record.saltNonce - Bound salt nonce for lifecycle settles. + * @param params.extra - Normalized auth-capture extra. + * @param params.signer - Authorizer signer for void digests; omit when delegated. + * @param params.chainId - EVM chain id for hashing and signing. + * @returns Void payload ready for facilitator `/settle`. + */ +export async function buildVoidPayload(params: { + record: { + paymentInfo: PaymentInfoStruct; + paymentInfoHash: `0x${string}`; + saltNonce: `0x${string}`; + }; + extra: NormalizedAuthCaptureExtra; + signer?: AuthorizerSigner; + chainId: number; +}): Promise { + const authorizerSignature = await signVoidIfLocal({ + signer: params.signer, + chainId: params.chainId, + extra: params.extra, + paymentInfoHash: params.record.paymentInfoHash, + }); + return { + type: "void", + paymentInfo: params.record.paymentInfo, + saltNonce: params.record.saltNonce, + ...(authorizerSignature ? { authorizerSignature } : {}), + }; +} + +/** + * Build a refund lifecycle payload, signed when a local signer is given. + * + * @param params - Stored payment record, refund amount, and signer. + * @param params.record - Stored authorized-payment fields needed for refund. + * @param params.record.paymentInfo - On-chain PaymentInfo struct from storage. + * @param params.record.paymentInfoHash - Storage key for the authorized payment. + * @param params.record.capturableAmount - Capturable balance at settle time. + * @param params.record.refundableAmount - Refundable balance at settle time. + * @param params.record.saltNonce - Bound salt nonce for lifecycle settles. + * @param params.extra - Normalized auth-capture extra. + * @param params.signer - Authorizer signer for refund digests; omit when delegated. + * @param params.chainId - EVM chain id for hashing and signing. + * @param params.amount - Atomic refund amount. + * @returns Refund payload ready for facilitator `/settle`. + */ +export async function buildRefundPayload(params: { + record: { + paymentInfo: PaymentInfoStruct; + paymentInfoHash: `0x${string}`; + capturableAmount: string; + refundableAmount: string; + saltNonce: `0x${string}`; + }; + extra: NormalizedAuthCaptureExtra; + signer?: AuthorizerSigner; + chainId: number; + amount: string; +}): Promise { + const authorizerSignature = params.signer + ? await signRefund(params.signer, params.chainId, params.extra.captureAuthorizer, { + paymentInfoHash: params.record.paymentInfoHash, + amount: params.amount, + tokenCollector: params.extra.deployment.operatorRefundCollector, + expectedCapturableAmount: params.record.capturableAmount, + expectedRefundableAmount: params.record.refundableAmount, + }) + : undefined; + return { + type: "refund", + paymentInfo: params.record.paymentInfo, + saltNonce: params.record.saltNonce, + amount: params.amount, + expectedCapturableAmount: params.record.capturableAmount, + expectedRefundableAmount: params.record.refundableAmount, + ...(authorizerSignature ? { authorizerSignature } : {}), + }; +} + +/** + * Build additive charge-completion enrichment for bound authorization routes. Without a + * signer (authorizer delegated to the facilitator) the signature is omitted. + * + * @param params - Collect payload, requirements, and signer. + * @param params.collect - Client collect payload from the authorize settle. + * @param params.requirements - Payment requirements for reconstruction. + * @param params.extra - Normalized auth-capture extra. + * @param params.signer - Authorizer signer for charge digests; omit when delegated. + * @param params.chainId - EVM chain id for hashing and signing. + * @param params.amount - Atomic charge amount. + * @returns Charge completion fields to merge into the payload. + */ +export async function buildChargeCompletionEnrichment(params: { + collect: AuthCaptureCollectPayload; + requirements: PaymentRequirements; + extra: NormalizedAuthCaptureExtra; + signer?: AuthorizerSigner; + chainId: number; + amount: string; +}): Promise> { + const paymentInfo = paymentInfoFromCollect(params.collect, params.requirements, params.extra); + const unpacked = unpackForSettle( + params.collect, + params.extra.assetTransferMethod, + params.extra.deployment, + ); + const fee = defaultSubmittedFee(params.extra, params.amount); + const paymentInfoHash = computePaymentInfoHash( + params.chainId, + paymentInfo, + params.extra.deployment.escrow, + ); + const completion = + fee.version === "v1.0" + ? { amount: params.amount, feeBps: fee.feeBps, feeReceiver: fee.feeReceiver } + : { amount: params.amount, feeAmount: fee.feeAmount, feeReceiver: fee.feeReceiver }; + if (!params.signer) { + return completion; + } + + const chargeDigest = + fee.version === "v1.0" + ? { + paymentInfoHash, + amount: params.amount, + tokenCollector: unpacked.tokenCollector, + collectorData: unpacked.collectorData, + feeBps: fee.feeBps, + feeReceiver: fee.feeReceiver, + } + : { + paymentInfoHash, + amount: params.amount, + tokenCollector: unpacked.tokenCollector, + collectorData: unpacked.collectorData, + feeAmount: fee.feeAmount, + feeReceiver: fee.feeReceiver, + }; + const authorizerSignature = await signCharge( + params.signer, + params.chainId, + params.extra.captureAuthorizer, + params.extra.deployment, + chargeDigest, + ); + return { ...completion, authorizerSignature }; +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/server/index.ts b/typescript/packages/mechanisms/evm/src/auth-capture/server/index.ts new file mode 100644 index 0000000000..9e8e79757a --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/server/index.ts @@ -0,0 +1,11 @@ +export { AuthCaptureEvmScheme } from "./scheme"; +export type { AuthCaptureServerConfig } from "./scheme"; +export { AuthCaptureLifecycleManager } from "./lifecycleManager"; +export type { AuthCaptureLifecycleManagerConfig } from "./lifecycleManager"; +export { InMemoryAuthorizedPaymentStorage } from "./storage"; +export type { + AuthorizedPayment, + AuthorizedPaymentStorage, + AuthorizedPaymentUpdateResult, +} from "./storage"; +export type { AuthorizerSigner, AuthCaptureRouteExtra, CaptureOptions } from "../types"; diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/server/lifecycleManager.ts b/typescript/packages/mechanisms/evm/src/auth-capture/server/lifecycleManager.ts new file mode 100644 index 0000000000..975de3f0bd --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/server/lifecycleManager.ts @@ -0,0 +1,298 @@ +import type { PaymentPayload, PaymentRequirements, SettleResponse } from "@x402/core/types"; +import type { FacilitatorClient } from "@x402/core/server"; +import { getEvmChainId } from "../../utils"; +import { AUTH_CAPTURE_SCHEME } from "../constants"; +import * as Errors from "../errors"; +import { authorizerMode, parseAuthCaptureExtra, type NormalizedAuthCaptureExtra } from "../extra"; +import { buildCapturePayload, buildRefundPayload, buildVoidPayload } from "../lifecyclePayload"; +import type { + AuthorizerSigner, + CaptureOptions, + CapturePayload, + RefundPayload, + VoidPayload, +} from "../types"; +import { applyCaptureBalances, type AuthorizedPayment } from "./storage"; +import type { AuthCaptureEvmScheme } from "./scheme"; + +export interface AuthCaptureLifecycleManagerConfig { + scheme: AuthCaptureEvmScheme; + facilitator: FacilitatorClient; +} + +/** + * Out-of-band capture / void / refund against stored authorized payments. + * Storage and the receiver-authorizer signer are read through the scheme. Payments whose + * authorizer is delegated to the facilitator are sent unsigned. + */ +export class AuthCaptureLifecycleManager { + /** + * Create a facilitator-bound lifecycle manager. + * + * @param config - Scheme (storage + signer) and facilitator client. + */ + constructor(private readonly config: AuthCaptureLifecycleManagerConfig) {} + + /** + * Capture a stored authorized payment through the facilitator. + * + * @param paymentInfoHash - Escrow payment identifier. + * @param opts - Capture amount, fees, and optional void-remainder. + * @returns Facilitator settle response. + */ + async capture(paymentInfoHash: `0x${string}`, opts?: CaptureOptions): Promise { + const { record, saltNonce } = await this.resolve(paymentInfoHash, false); + const extra = extraFromRecord(record); + const chainId = getEvmChainId(record.network); + const amount = opts?.amount ?? record.capturableAmount; + const payload = await buildCapturePayload({ + record: { + paymentInfo: record.paymentInfo, + paymentInfoHash: record.paymentInfoHash, + capturableAmount: record.capturableAmount, + refundableAmount: record.refundableAmount, + saltNonce, + }, + extra, + signer: this.signerFor(record), + chainId, + amount, + feeBps: opts?.feeBps, + feeAmount: opts?.feeAmount, + feeReceiver: opts?.feeReceiver, + voidRemainder: opts?.voidRemainder, + }); + const response = await this.settleLifecycle(record, payload); + if (response.success) { + await applyCaptureBalances( + this.config.scheme.getStorage(), + record.paymentInfoHash, + amount, + Boolean(opts?.voidRemainder), + ); + } + return response; + } + + /** + * Void the remaining hold on a stored authorized payment. + * + * @param paymentInfoHash - Escrow payment identifier. + * @returns Facilitator settle response. + */ + async voidPayment(paymentInfoHash: `0x${string}`): Promise { + const { record, saltNonce } = await this.resolve(paymentInfoHash, false); + const extra = extraFromRecord(record); + const payload = await buildVoidPayload({ + record: { + paymentInfo: record.paymentInfo, + paymentInfoHash: record.paymentInfoHash, + saltNonce, + }, + extra, + signer: this.signerFor(record), + chainId: getEvmChainId(record.network), + }); + const response = await this.settleLifecycle(record, payload); + if (response.success) { + await this.config.scheme + .getStorage() + .update(record.paymentInfoHash, current => + current ? { ...current, capturableAmount: "0" } : current, + ); + } + return response; + } + + /** + * Refund captured funds on a stored authorized payment. + * + * @param paymentInfoHash - Escrow payment identifier. + * @param opts - Refund amount. + * @param opts.amount - Atomic refund amount in token base units. + * @returns Facilitator settle response. + */ + async refund(paymentInfoHash: `0x${string}`, opts: { amount: string }): Promise { + const { record, saltNonce } = await this.resolve(paymentInfoHash, true); + const extra = extraFromRecord(record); + const payload = await buildRefundPayload({ + record: { + paymentInfo: record.paymentInfo, + paymentInfoHash: record.paymentInfoHash, + capturableAmount: record.capturableAmount, + refundableAmount: record.refundableAmount, + saltNonce, + }, + extra, + signer: this.signerFor(record), + chainId: getEvmChainId(record.network), + amount: opts.amount, + }); + const response = await this.settleLifecycle(record, payload); + if (response.success) { + await this.config.scheme.getStorage().update(record.paymentInfoHash, current => { + if (!current) return current; + const next = BigInt(current.refundableAmount) - BigInt(opts.amount); + return { ...current, refundableAmount: next.toString() }; + }); + } + return response; + } + + /** + * Read a stored authorized payment. + * + * @param paymentInfoHash - Escrow payment identifier. + * @returns The record, or undefined. + */ + async getAuthorizedPayment( + paymentInfoHash: `0x${string}`, + ): Promise { + return this.config.scheme.getStorage().get(paymentInfoHash); + } + + /** + * List stored authorized payments. + * + * @returns All records in storage. + */ + async listAuthorizedPayments(): Promise { + return this.config.scheme.getStorage().list(); + } + + /** + * POST a signed lifecycle payload to the configured facilitator client. + * + * @param record - Stored payment. + * @param payload - Capture, void, or refund envelope. + * @returns Facilitator settle response. + */ + private async settleLifecycle( + record: AuthorizedPayment, + payload: CapturePayload | VoidPayload | RefundPayload, + ): Promise { + const requirements = buildRequirements(record); + const paymentPayload: PaymentPayload = { + x402Version: 2, + accepted: requirements, + payload: payload as unknown as Record, + }; + return this.config.facilitator.settle(paymentPayload, requirements); + } + + /** + * Load a stored payment and refuse the operations the facilitator would never relay for it: + * custom operators (lifecycle is out of band), capture / void on an authorization-flow + * payment (already a terminal charge, only a refund applies), and records without the + * `saltNonce` lifecycle payloads need. + * + * @param paymentInfoHash - Storage key. + * @param refund - True for a refund, the only operation an authorization-flow payment allows. + * @returns The record and its `saltNonce`. + */ + private async resolve( + paymentInfoHash: `0x${string}`, + refund: boolean, + ): Promise<{ record: AuthorizedPayment; saltNonce: `0x${string}` }> { + const record = await this.config.scheme.getStorage().get(paymentInfoHash); + if (!record) { + throw new Error(`AuthCapture: no authorized payment ${paymentInfoHash}`); + } + if (record.operatorType === "custom") { + throw new Error( + `${Errors.ErrServerLifecycleUnavailable}: the custom operator performs capture, void and refund itself`, + ); + } + if (record.paymentFlow === "authorization" && !refund) { + throw new Error( + `${Errors.ErrServerLifecycleUnavailable}: an authorization-flow payment is already charged, only a refund applies`, + ); + } + if (!record.saltNonce) { + throw new Error( + `${Errors.ErrServerLifecycleUnavailable}: the record has no saltNonce, which lifecycle payloads need`, + ); + } + return { record, saltNonce: record.saltNonce }; + } + + /** + * Signer for a stored payment's lifecycle payloads: the scheme signer when it is the + * payment's authorizer, none when the authorizer is delegated to the facilitator (which + * signs the unsigned payload), and an error when the payment is collect-only. + * + * @param record - Stored payment. + * @returns The authorizer signer, or undefined to send unsigned payloads. + */ + private signerFor(record: AuthorizedPayment): AuthorizerSigner | undefined { + const signer = this.config.scheme.getReceiverAuthorizerSigner(); + const mode = authorizerMode(record.receiverAuthorizer, signer); + switch (mode) { + case "self": + return signer; + case "delegated": + return undefined; + case "collect-only": + throw new Error( + `${Errors.ErrServerLifecycleUnavailable}: payment ${record.paymentInfoHash} is collect-only ` + + "(zero receiverAuthorizer); its lifecycle is out of band", + ); + default: { + const exhaustive: never = mode; + throw new Error(`unexpected authorizer mode ${String(exhaustive)}`); + } + } + } +} + +/** + * Rebuild extra from a stored authorized-payment record. + * + * @param record - Stored payment. + * @returns Extra sufficient to reconstruct and settle lifecycle payloads. + */ +function extraFromRecord(record: AuthorizedPayment): NormalizedAuthCaptureExtra { + const parsed = parseAuthCaptureExtra({ + captureAuthorizer: record.paymentInfo.operator, + captureDeadline: record.paymentInfo.authorizationExpiry, + refundDeadline: record.paymentInfo.refundExpiry, + feeRecipient: record.paymentInfo.feeReceiver, + minFeeBps: record.paymentInfo.minFeeBps, + maxFeeBps: record.paymentInfo.maxFeeBps, + name: record.name, + version: record.version, + paymentFlow: record.paymentFlow, + operatorType: record.operatorType, + assetTransferMethod: record.assetTransferMethod, + receiverAuthorizer: record.receiverAuthorizer, + policy: record.policy, + captureMode: record.paymentFlow === "escrow" ? "deferred" : "sync", + authCaptureEscrow: record.authCaptureEscrow, + }); + if ("error" in parsed) { + throw new Error(`AuthCapture: invalid stored extra: ${parsed.error}`); + } + return parsed.extra; +} + +/** + * PaymentRequirements for a facilitator lifecycle settle from a stored record. + * + * @param record - Stored payment. + * @returns Requirements whose extra matches the original collect. + */ +function buildRequirements(record: AuthorizedPayment): PaymentRequirements { + const { captureMode, ...extra } = extraFromRecord(record); + return { + scheme: AUTH_CAPTURE_SCHEME, + network: record.network, + asset: record.paymentInfo.token, + amount: record.paymentInfo.maxAmount, + payTo: record.paymentInfo.receiver, + maxTimeoutSeconds: 1, + // captureMode is escrow-only: the spec forbids it on an authorization route. + extra: (record.paymentFlow === "escrow" + ? { ...extra, captureMode } + : extra) as unknown as Record, + }; +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/server/scheme.ts b/typescript/packages/mechanisms/evm/src/auth-capture/server/scheme.ts new file mode 100644 index 0000000000..5d01b8023e --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/server/scheme.ts @@ -0,0 +1,507 @@ +/** + * AuthCapture Scheme - Server + * Handles price parsing, requirement enhancement, and payment-flow selection. + */ + +import type { + AssetAmount, + MoneyParser, + Network, + PaymentFlowConfig, + PaymentRequirements, + Price, + SchemeNetworkServer, + SchemeServerHooks, + SupportedKind, +} from "@x402/core/types"; +import type { FacilitatorClient, VerifiedPaymentCanceledContext } from "@x402/core/server"; +import { convertToTokenAmount, parseMoney } from "@x402/core/utils"; +import { getAddress, isAddressEqual, zeroAddress } from "viem"; +import { findDefaultAsset, getDefaultAsset } from "../../defaultAssets"; +import type { AssetTransferMethod } from "../../types"; +import { AUTH_CAPTURE_SCHEME } from "../constants"; +import { canonicalAuthCaptureEscrow } from "../extra"; +import { isNonZeroAddress } from "../nonce"; +import type { AuthorizerSigner } from "../types"; +import { AuthCaptureLifecycleManager } from "./lifecycleManager"; +import { AuthCaptureSettlementHooks } from "./settlementHooks"; +import { InMemoryAuthorizedPaymentStorage, type AuthorizedPaymentStorage } from "./storage"; + +export interface AuthCaptureServerConfig { + storage?: AuthorizedPaymentStorage; + receiverAuthorizerSigner?: AuthorizerSigner; + /** + * When true, skip startup checks that the facilitator advertises + * `extra.receiverAuthorizer` when no local signer is configured. Use only when + * every route is collect-only (escrow + deferred + explicit `receiverAuthorizer: + * zeroAddress`). + */ + collectOnlyRoutes?: boolean; +} + +/** Align relative deadline conversion so repeated 402s in the same minute share values. */ +const DEADLINE_OFFSET_BUCKET_SECONDS = 60; + +const AUTH_CAPTURE_MERCHANT_FIELD_HINTS: Record = { + captureAuthorizer: + ' For operatorType "delegated", omit extra.captureAuthorizer so the scheme copies it from ' + + "the facilitator's /supported extra.captureAuthorizer.", + captureDeadline: + " Set extra.captureDeadlineSeconds (relative, recommended) or extra.captureDeadline (absolute).", + refundDeadline: + " Set extra.refundDeadlineSeconds (relative, recommended) or extra.refundDeadline (absolute).", +}; + +/** + * Validate a relative-offset extras key and resolve it to an absolute Unix + * second. Returns `undefined` when the key is absent. Throws on a present- + * but-invalid value so the merchant gets a clear error at the layer they + * configured it, rather than a downstream facilitator rejection with a + * cryptic reason. + * + * @param extras - Merged `extra` map being assembled for publication. + * @param key - The relative-offset key to read (e.g. `"captureDeadlineSeconds"`). + * @param now - Unix-second clock value used for the conversion. + * @returns Absolute Unix-second deadline, or `undefined` if the key wasn't set. + * @throws If `extras[key]` is present but not a finite positive number. + */ +function resolveOffsetToDeadline( + extras: Record, + key: string, + now: number, +): number | undefined { + const raw = extras[key]; + if (raw === undefined) return undefined; + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { + throw new Error( + `extra.${key} must be a positive finite number of seconds-from-now (got ${String(raw)})`, + ); + } + return now + raw; +} + +/** + * Assert that the merged `extra` carries every field that comes from the + * merchant's route config. + * + * @param extra - The merged `extra` map about to be returned by `enhancePaymentRequirements`. + * @throws With a message naming the first missing or wrongly-typed merchant field. + */ +function assertAuthCaptureMerchantExtraComplete(extra: Record): void { + const required: Array<[string, "string" | "number"]> = [ + ["captureAuthorizer", "string"], + ["captureDeadline", "number"], + ["refundDeadline", "number"], + ["feeRecipient", "string"], + ["minFeeBps", "number"], + ["maxFeeBps", "number"], + ]; + for (const [key, expectedType] of required) { + if (typeof extra[key] !== expectedType) { + const hint = AUTH_CAPTURE_MERCHANT_FIELD_HINTS[key] ?? ""; + throw new Error(`AuthCapture requires extra.${key} (${expectedType}).${hint}`); + } + } +} + +/** + * A non-empty string `extra` value as a checksummed address. + * + * @param value - Untrusted `extra` field. + * @returns The address, or undefined when absent or empty. + */ +function nonEmptyAddress(value: unknown): `0x${string}` | undefined { + return typeof value === "string" && value.length > 0 ? getAddress(value) : undefined; +} + +/** + * Server-side implementation of the auth-capture scheme. + */ +export class AuthCaptureEvmScheme implements SchemeNetworkServer { + readonly scheme = AUTH_CAPTURE_SCHEME; + readonly dynamicExtraFields = ["captureDeadline", "refundDeadline"]; + readonly defaultAssetTransferMethod: AssetTransferMethod = "eip3009"; + readonly paymentFlows = { + eip3009: { supported: ["escrow", "authorization"], default: "escrow" }, + permit2: { supported: ["escrow", "authorization"], default: "escrow" }, + } as const satisfies Record; + readonly schemeHooks: SchemeServerHooks; + readonly enrichSettlementPayload: ( + ctx: Parameters[0], + ) => Promise | void>; + + private moneyParsers: MoneyParser[] = []; + private readonly storage: AuthorizedPaymentStorage; + private readonly receiverAuthorizerSigner: AuthorizerSigner | undefined; + private readonly collectOnlyRoutes: boolean; + private readonly settlementHooks: AuthCaptureSettlementHooks; + + /** + * Construct a server-side auth-capture scheme. + * + * @param config - Optional storage and receiver-authorizer signer. + */ + constructor(config?: AuthCaptureServerConfig) { + this.storage = config?.storage ?? new InMemoryAuthorizedPaymentStorage(); + this.receiverAuthorizerSigner = config?.receiverAuthorizerSigner; + this.collectOnlyRoutes = config?.collectOnlyRoutes === true; + this.settlementHooks = new AuthCaptureSettlementHooks({ + storage: this.storage, + receiverAuthorizerSigner: this.receiverAuthorizerSigner, + }); + this.schemeHooks = { + onBeforeSettle: ctx => this.settlementHooks.handleBeforeSettle(ctx), + onAfterSettle: ctx => this.settlementHooks.handleAfterSettle(ctx), + }; + this.enrichSettlementPayload = ctx => this.settlementHooks.enrichSettlementPayload(ctx); + } + + /** + * Add a custom money parser to the chain. Parsers run in registration order; + * the first one to return a non-null `AssetAmount` wins. + * + * @param parser - Function that maps a decimal amount to an `AssetAmount`, or `null` to defer. + * @returns This server scheme instance, for fluent chaining. + */ + registerMoneyParser(parser: MoneyParser): AuthCaptureEvmScheme { + this.moneyParsers.push(parser); + return this; + } + + /** + * Decimals for a known default asset, or undefined. + * + * @param asset - Asset address or symbol + * @param network - Target network + * @returns Decimals when the asset is a known default; otherwise undefined + */ + getAssetDecimals(asset: string, network: Network): number | undefined { + return findDefaultAsset(asset, network)?.decimals; + } + + /** + * Translate a merchant-supplied `Price` into a fully-resolved `AssetAmount`. + * + * @param price - `"$0.01"` / `0.01` / `{ asset, amount }`. + * @param network - CAIP-2 network identifier used for default-asset lookup. + * @returns The resolved `AssetAmount` containing token address and base units. + */ + async parsePrice(price: Price, network: Network): Promise { + if (typeof price === "object" && price !== null && "amount" in price) { + if (!price.asset) { + throw new Error(`Asset address must be specified for AssetAmount on network ${network}`); + } + return { + amount: price.amount, + asset: price.asset, + extra: price.extra || {}, + }; + } + + const { amount, symbol } = parseMoney(price); + + for (const parser of this.moneyParsers) { + const result = await parser(amount, network); + if (result !== null) { + return result; + } + } + + return this.defaultMoneyConversion(amount, network, symbol); + } + + /** + * Merge facilitator-advertised `extra` into the merchant's payment + * requirements, resolve relative deadline offsets into absolute deadlines, + * write the resolved `paymentFlow` / `captureMode`, and fail-fast on + * misconfiguration. + * + * @param requirements - The merchant-authored payment requirements. + * @param supportedKind - The facilitator's advertised support entry. + * @param supportedKind.x402Version - Protocol version the facilitator advertises. + * @param supportedKind.scheme - Scheme identifier (`"auth-capture"`). + * @param supportedKind.network - CAIP-2 network identifier. + * @param supportedKind.extra - Facilitator-injected `extra` fields (lowest priority on collision). + * @param _ - Unused list of facilitator extensions (interface compatibility). + * @returns Enhanced `PaymentRequirements` with merged `extra` and resolved deadlines. + */ + async enhancePaymentRequirements( + requirements: PaymentRequirements, + supportedKind: { + x402Version: number; + scheme: string; + network: Network; + extra?: Record; + }, + _: string[], + ): Promise { + const merged: Record = { + ...supportedKind.extra, + ...requirements.extra, + }; + + if ("autoCapture" in merged) { + throw new Error( + "AuthCapture extra.autoCapture was removed in v1.1. Use extra.paymentFlow " + + '("escrow" or "authorization") instead.', + ); + } + + const now = Math.floor(Date.now() / 1000); + const deadlineBase = + Math.floor(now / DEADLINE_OFFSET_BUCKET_SECONDS) * DEADLINE_OFFSET_BUCKET_SECONDS; + const hasAbsCapture = typeof merged.captureDeadline === "number"; + const hasAbsRefund = typeof merged.refundDeadline === "number"; + const hasRelCapture = merged.captureDeadlineSeconds !== undefined; + const hasRelRefund = merged.refundDeadlineSeconds !== undefined; + const absPair = hasAbsCapture && hasAbsRefund; + const relPair = hasRelCapture && hasRelRefund; + + if (absPair && relPair) { + throw new Error( + "AuthCapture extra must use either both absolute deadlines (captureDeadline and " + + "refundDeadline) or both relative offsets (captureDeadlineSeconds and " + + "refundDeadlineSeconds), not a mix.", + ); + } + if ( + (hasAbsCapture !== hasAbsRefund || hasRelCapture !== hasRelRefund) && + !(absPair || relPair) + ) { + throw new Error( + "AuthCapture extra must use either both absolute deadlines (captureDeadline and " + + "refundDeadline) or both relative offsets (captureDeadlineSeconds and " + + "refundDeadlineSeconds), not a mix.", + ); + } + + const captureFromOffset = resolveOffsetToDeadline( + merged, + "captureDeadlineSeconds", + deadlineBase, + ); + const refundFromOffset = resolveOffsetToDeadline(merged, "refundDeadlineSeconds", deadlineBase); + delete merged.captureDeadlineSeconds; + delete merged.refundDeadlineSeconds; + + if (!absPair) { + if (captureFromOffset !== undefined) merged.captureDeadline = captureFromOffset; + if (refundFromOffset !== undefined) merged.refundDeadline = refundFromOffset; + } + + const operatorType = merged.operatorType ?? "delegated"; + if (operatorType === "custom") { + const merchantSet = requirements.extra?.captureAuthorizer; + if (typeof merchantSet !== "string" || merchantSet.length === 0) { + delete merged.captureAuthorizer; + } + } + + assertAuthCaptureMerchantExtraComplete(merged); + + merged.receiverAuthorizer = this.resolveReceiverAuthorizer( + requirements.extra?.receiverAuthorizer, + supportedKind.extra?.receiverAuthorizer, + ); + + const paymentFlow = merged.paymentFlow === "authorization" ? "authorization" : "escrow"; + merged.paymentFlow = paymentFlow; + + const collectOnly = !isNonZeroAddress(merged.receiverAuthorizer as `0x${string}`); + if (paymentFlow === "authorization") { + if (merged.captureMode !== undefined) { + throw new Error( + 'AuthCapture extra.captureMode is only valid with paymentFlow "escrow"; ' + + "authorization has no hold to finalize.", + ); + } + if (collectOnly) { + throw new Error( + 'AuthCapture paymentFlow "authorization" requires a non-zero receiverAuthorizer ' + + "(extra.receiverAuthorizer: zeroAddress is collect-only, valid only for escrow with " + + 'captureMode "deferred")', + ); + } + } else { + const captureMode = merged.captureMode === "deferred" ? "deferred" : "sync"; + merged.captureMode = captureMode; + if (captureMode === "sync" && operatorType === "custom") { + throw new Error('AuthCapture operatorType "custom" is collect-only'); + } + if (captureMode === "sync" && collectOnly) { + throw new Error( + "AuthCapture escrow sync routes require a non-zero receiverAuthorizer " + + '(extra.receiverAuthorizer: zeroAddress is collect-only, valid only with captureMode "deferred")', + ); + } + } + + // Facilitator-only allowlist from GET /supported — not part of the 402 wire. + delete merged.operators; + + merged.authCaptureEscrow = canonicalAuthCaptureEscrow( + typeof merged.authCaptureEscrow === "string" ? merged.authCaptureEscrow : undefined, + ); + + return { ...requirements, extra: merged }; + } + + /** + * On handler failure after a before-handler authorize, settle a void. + * + * @param context - Cancellation context. + * @returns Requirements to settle, or void when there is no hold to release. + */ + settleOnCancel(context: VerifiedPaymentCanceledContext): Promise { + return this.settlementHooks.settleOnCancel(context); + } + + /** + * Returns the authorized-payment storage backend. + * + * @returns The configured {@link AuthorizedPaymentStorage}. + */ + getStorage(): AuthorizedPaymentStorage { + return this.storage; + } + + /** + * Returns the receiver-authorizer signer, if configured. + * + * @returns Receiver-authorizer signer, or `undefined` when not set. + */ + getReceiverAuthorizerSigner(): AuthorizerSigner | undefined { + return this.receiverAuthorizerSigner; + } + + /** + * Create a facilitator-bound manager for out-of-band capture / void / refund. + * + * @param facilitator - Facilitator client used to POST lifecycle settles. + * @returns A lifecycle manager reading storage and the signer from this scheme. + */ + createLifecycleManager(facilitator: FacilitatorClient): AuthCaptureLifecycleManager { + return new AuthCaptureLifecycleManager({ scheme: this, facilitator }); + } + + /** + * Fail server startup when the facilitator does not advertise a usable + * `captureAuthorizer`, or when this server delegates receiver signing but the + * facilitator does not advertise a non-zero `receiverAuthorizer`. + * + * @param network - The network identifier being validated. + * @param supportedKind - The facilitator's advertised kind for this scheme/network. + * @param _ - Extensions advertised by the facilitator (unused). + * @returns A problem message when misconfigured, or void when valid. + */ + validateFacilitatorSupport( + network: Network, + supportedKind: SupportedKind, + _: string[], + ): string | void { + const captureAuthorizer = supportedKind.extra?.captureAuthorizer; + if (typeof captureAuthorizer !== "string" || !isNonZeroAddress(getAddress(captureAuthorizer))) { + return ( + `facilitator does not advertise a valid captureAuthorizer for auth-capture on ${network}; ` + + `delegated routes copy it from GET /supported extra.captureAuthorizer` + ); + } + + if (this.receiverAuthorizerSigner || this.collectOnlyRoutes) { + return; + } + + const advertised = supportedKind.extra?.receiverAuthorizer; + const hasValidReceiver = + typeof advertised === "string" && isNonZeroAddress(getAddress(advertised)); + + if (!hasValidReceiver) { + return ( + `no receiverAuthorizerSigner is configured and the facilitator does not advertise a ` + + `receiverAuthorizer on ${network}. Configure a receiverAuthorizerSigner, use a ` + + `facilitator that advertises one, or set collectOnlyRoutes: true when every route is ` + + `collect-only (escrow + deferred + extra.receiverAuthorizer zeroAddress).` + ); + } + } + + /** + * Resolve who authorizes facilitator-relayed `charge` and lifecycle, strictly and in order: + * the scheme signer (self-managed); else the route's own `receiverAuthorizer` (`zeroAddress` + * is explicit collect-only, the facilitator-advertised address is delegated, anything else + * has no way to be signed); else the facilitator-advertised address. Throws instead of + * silently falling back to collect-only. + * + * @param routeReceiverAuthorizer - `receiverAuthorizer` from the route's `extra`. + * @param advertisedReceiverAuthorizer - `receiverAuthorizer` from the facilitator's `/supported` extra. + * @returns The checksummed address to publish in `extra.receiverAuthorizer`. + * @throws If no signer, route value, or facilitator advertisement can produce signatures. + */ + private resolveReceiverAuthorizer( + routeReceiverAuthorizer: unknown, + advertisedReceiverAuthorizer: unknown, + ): `0x${string}` { + const route = nonEmptyAddress(routeReceiverAuthorizer); + const advertised = nonEmptyAddress(advertisedReceiverAuthorizer); + const advertisedNonZero = advertised && isNonZeroAddress(advertised) ? advertised : undefined; + + const signerAddress = this.receiverAuthorizerSigner?.address; + if (signerAddress) { + if (route && isNonZeroAddress(route) && !isAddressEqual(route, signerAddress)) { + throw new Error( + `AuthCapture extra.receiverAuthorizer (${route}) does not match the ` + + `scheme's receiverAuthorizerSigner (${signerAddress}).`, + ); + } + return getAddress(signerAddress); + } + + if (route) { + if (!isNonZeroAddress(route)) return zeroAddress; + if (advertisedNonZero && isAddressEqual(route, advertisedNonZero)) return route; + throw new Error( + `AuthCapture extra.receiverAuthorizer (${route}) can not be signed: the scheme has no ` + + "receiverAuthorizerSigner and the facilitator does not advertise that address. " + + "Configure a receiverAuthorizerSigner, omit the field to use the facilitator's " + + 'authorizer, or set it to zeroAddress for collect-only with captureMode "deferred".', + ); + } + + if (advertisedNonZero) return advertisedNonZero; + throw new Error( + "AuthCapture has no receiverAuthorizer: the route sets none, the scheme has no " + + "receiverAuthorizerSigner, and the facilitator advertises none. Fix one of: " + + "(1) configure a receiverAuthorizerSigner on the scheme, " + + "(2) use a facilitator that delegates the authorizer (advertises extra.receiverAuthorizer), " + + 'or (3) set extra.receiverAuthorizer to zeroAddress with captureMode "deferred" (collect-only).', + ); + } + + /** + * Fall-through converter: resolves a decimal amount against the default + * asset registered for the network in `getDefaultAsset`. + * + * @param amount - Decimal amount in the token's display units. + * @param network - CAIP-2 network identifier. + * @param symbol - Optional ticker from a suffixed price. + * @returns Resolved `AssetAmount` with the network's default asset. + */ + private defaultMoneyConversion(amount: string, network: Network, symbol?: string): AssetAmount { + const assetInfo = getDefaultAsset(network, symbol); + const tokenAmount = convertToTokenAmount(amount, assetInfo.decimals); + const includeEip712Domain = !assetInfo.assetTransferMethod || assetInfo.supportsEip2612; + return { + asset: assetInfo.asset, + amount: tokenAmount, + extra: { + ...(includeEip712Domain && { + name: assetInfo.name, + version: assetInfo.version, + }), + ...(assetInfo.assetTransferMethod && { + assetTransferMethod: assetInfo.assetTransferMethod, + }), + }, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/server/settlementHooks.ts b/typescript/packages/mechanisms/evm/src/auth-capture/server/settlementHooks.ts new file mode 100644 index 0000000000..0cdb6957f8 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/server/settlementHooks.ts @@ -0,0 +1,298 @@ +import type { + DeepReadonly, + PaymentPayload, + PaymentRequirements, + SettleResponse, +} from "@x402/core/types"; +import type { + SettleContext, + SettleResultContext, + VerifiedPaymentCanceledContext, +} from "@x402/core/server"; +import { getEvmChainId } from "../../utils"; +import { computePaymentInfoHash } from "../nonce"; +import { authorizerMode, parseAuthCaptureExtra, type NormalizedAuthCaptureExtra } from "../extra"; +import { + buildCaptureEnrichment, + buildChargeCompletionEnrichment, + buildVoidEnrichment, + paymentInfoFromCollect, +} from "../lifecyclePayload"; +import type { AuthCaptureCollectPayload, AuthorizerSigner, PaymentInfoStruct } from "../types"; +import { isAuthCaptureCollectPayload, isEip3009Payload } from "../types"; +import { + applyCaptureBalances, + type AuthorizedPayment, + type AuthorizedPaymentStorage, +} from "./storage"; + +export interface AuthCaptureSettlementHooksConfig { + storage: AuthorizedPaymentStorage; + receiverAuthorizerSigner?: AuthorizerSigner; +} + +/** + * Narrow a wire payload to a collect envelope, or undefined. + * + * @param payload - `PaymentPayload.payload`. + * @returns Collect payload when the shape matches. + */ +function asCollectPayload( + payload: DeepReadonly["payload"], +): AuthCaptureCollectPayload | undefined { + return isAuthCaptureCollectPayload(payload) ? payload : undefined; +} + +/** + * Client-signed authorize amount from a collect payload (EIP-3009 value or Permit2 permitted amount). + * + * @param collect - Verified collect envelope. + * @returns Atomic amount in token base units. + */ +function signedCollectAmount(collect: AuthCaptureCollectPayload): string { + return isEip3009Payload(collect) + ? collect.authorization.value + : collect.permit2Authorization.permitted.amount; +} + +/** + * In-request settlement hooks: payload enrichment, cancel void, persist, and + * deferred-settle skip. Storage and the receiver-authorizer signer only — + * out-of-band capture/void/refund live on `AuthCaptureLifecycleManager`. Without a local + * signer for the route's `receiverAuthorizer` the enrichments carry no signatures. + */ +export class AuthCaptureSettlementHooks { + private readonly authorizeResults = new WeakMap(); + + /** + * Create settlement hooks bound to storage and an optional authorizer signer. + * + * @param config - Storage and optional receiver-authorizer signer. + */ + constructor(private readonly config: AuthCaptureSettlementHooksConfig) {} + + /** + * Additive payload enrichment for sync capture, bound charge, and cancel void. + * + * @param ctx - Settle context from core. + * @returns Fields to merge into the client payload, or void when not applicable. + */ + async enrichSettlementPayload(ctx: SettleContext): Promise | void> { + if (ctx.phase === "before-handler") return; + const extraParsed = parseAuthCaptureExtra(ctx.requirements.extra); + if ("error" in extraParsed) return; + const extra = extraParsed.extra; + const collect = asCollectPayload(ctx.paymentPayload.payload); + if (!collect) return; + + // Delegated mode builds the same payloads without signatures; the facilitator signs them. + const mode = authorizerMode(extra.receiverAuthorizer, this.config.receiverAuthorizerSigner); + if (mode === "collect-only") return; + const signer = mode === "self" ? this.config.receiverAuthorizerSigner : undefined; + + if (ctx.phase === "cancel") { + if (extra.paymentFlow !== "escrow") return; + const requirements = ctx.requirements as PaymentRequirements; + const paymentInfo = paymentInfoFromCollect(collect, requirements, extra); + const chainId = getEvmChainId(ctx.requirements.network); + return buildVoidEnrichment({ + paymentInfo, + extra, + signer, + chainId, + paymentInfoHash: computePaymentInfoHash(chainId, paymentInfo, extra.deployment.escrow), + }); + } + + if (ctx.phase !== "after-handler") return; + + const chainId = getEvmChainId(ctx.requirements.network); + + if (extra.paymentFlow === "authorization") { + return buildChargeCompletionEnrichment({ + collect, + requirements: ctx.requirements as PaymentRequirements, + extra, + signer, + chainId, + amount: ctx.requirements.amount, + }); + } + + if (extra.paymentFlow === "escrow" && extra.captureMode !== "deferred") { + const requirements = ctx.requirements as PaymentRequirements; + const authorizeRequirements: PaymentRequirements = { + ...requirements, + amount: signedCollectAmount(collect), + }; + const paymentInfo = paymentInfoFromCollect(collect, authorizeRequirements, extra); + const paymentInfoHash = computePaymentInfoHash(chainId, paymentInfo, extra.deployment.escrow); + const stored = await this.config.storage.get(paymentInfoHash); + const trustedPaymentInfo = stored?.paymentInfo ?? paymentInfo; + return buildCaptureEnrichment({ + collect, + requirements: authorizeRequirements, + paymentInfo: trustedPaymentInfo, + extra, + signer, + chainId, + capturable: stored?.capturableAmount ?? trustedPaymentInfo.maxAmount, + refundable: stored?.refundableAmount ?? "0", + amount: requirements.amount, + }); + } + } + + /** + * On handler failure after a before-handler authorize, settle a void. + * + * @param context - Cancellation context. + * @returns Requirements to settle, or void when there is no hold to release. + */ + async settleOnCancel( + context: VerifiedPaymentCanceledContext, + ): Promise { + const extraParsed = parseAuthCaptureExtra(context.requirements.extra); + if ("error" in extraParsed) return; + const extra = extraParsed.extra; + if (extra.paymentFlow !== "escrow") return; + const mode = authorizerMode(extra.receiverAuthorizer, this.config.receiverAuthorizerSigner); + if (mode === "collect-only") return; + return context.requirements as PaymentRequirements; + } + + /** + * Skip the after-handler facilitator settle for deferred escrow; echo the authorize receipt. + * + * @param ctx - Settle context. + * @returns Skip directive with the prior authorize result, or void. + */ + async handleBeforeSettle( + ctx: SettleContext, + ): Promise { + if (ctx.phase !== "after-handler") return; + const extraParsed = parseAuthCaptureExtra(ctx.requirements.extra); + if ("error" in extraParsed) return; + if (extraParsed.extra.paymentFlow !== "escrow") return; + if (extraParsed.extra.captureMode !== "deferred") return; + const prior = this.authorizeResults.get(ctx.paymentPayload); + if (!prior) return; + return { skip: true, result: prior }; + } + + /** + * Persist authorized-payment records and update balances after a successful settle. + * + * @param ctx - Settle result context. + * @returns Nothing. + */ + async handleAfterSettle(ctx: SettleResultContext): Promise { + if (!ctx.result.success) return; + const extraParsed = parseAuthCaptureExtra(ctx.requirements.extra); + if ("error" in extraParsed) return; + const extra = extraParsed.extra; + const collect = asCollectPayload(ctx.paymentPayload.payload); + + if (ctx.phase === "before-handler" && collect) { + this.authorizeResults.set(ctx.paymentPayload, ctx.result as SettleResponse); + await this.persistCollect(collect, ctx, extra, "authorize"); + return; + } + if (ctx.phase === "after-handler" && extra.paymentFlow === "authorization" && collect) { + await this.persistCollect(collect, ctx, extra, "charge"); + return; + } + if ( + ctx.phase === "after-handler" && + extra.paymentFlow === "escrow" && + extra.captureMode !== "deferred" + ) { + const type = (ctx.paymentPayload.payload as Record).type; + if (type === "capture") { + const amount = ctx.result.amount ?? ctx.requirements.amount; + const voidRemainder = + (ctx.paymentPayload.payload as Record).voidAuthorizerSignature !== + undefined; + const paymentInfo = (ctx.paymentPayload.payload as Record).paymentInfo as + | PaymentInfoStruct + | undefined; + if (paymentInfo) { + const hash = computePaymentInfoHash( + getEvmChainId(ctx.requirements.network), + paymentInfo, + extra.deployment.escrow, + ); + await applyCaptureBalances(this.config.storage, hash, amount, voidRemainder); + } + } + } + if (ctx.phase === "cancel") { + const paymentInfo = (ctx.paymentPayload.payload as Record).paymentInfo as + | PaymentInfoStruct + | undefined; + if (paymentInfo) { + const hash = computePaymentInfoHash( + getEvmChainId(ctx.requirements.network), + paymentInfo, + extra.deployment.escrow, + ); + await this.config.storage.update(hash, current => + current ? { ...current, capturableAmount: "0" } : current, + ); + } + } + } + + /** + * Store a payment record after a successful collect settle. + * + * @param collect - Client collect payload. + * @param ctx - Settle result context. + * @param extra - Normalized extra used for reconstruction. + * @param operation - `"authorize"` (hold) or `"charge"` (already captured). + * @returns Nothing. + */ + private async persistCollect( + collect: AuthCaptureCollectPayload, + ctx: SettleResultContext, + extra: NormalizedAuthCaptureExtra, + operation: "authorize" | "charge", + ): Promise { + const paymentInfo = paymentInfoFromCollect( + collect, + ctx.requirements as PaymentRequirements, + extra, + ); + const chainId = getEvmChainId(ctx.requirements.network); + const paymentInfoHash = computePaymentInfoHash(chainId, paymentInfo, extra.deployment.escrow); + const signedAmount = isEip3009Payload(collect) + ? collect.authorization.value + : collect.permit2Authorization.permitted.amount; + const settledAmount = ctx.result.amount ?? signedAmount; + const saltNonce = "saltNonce" in collect ? collect.saltNonce : undefined; + + const record: AuthorizedPayment = { + paymentInfoHash, + paymentInfo, + ...(saltNonce ? { saltNonce } : {}), + receiverAuthorizer: extra.receiverAuthorizer, + policy: extra.policy, + network: ctx.requirements.network, + capturableAmount: operation === "authorize" ? signedAmount : "0", + refundableAmount: operation === "charge" ? settledAmount : "0", + collectTransaction: ctx.result.transaction, + createdAt: Date.now(), + name: extra.name, + version: extra.version, + paymentFlow: extra.paymentFlow, + operatorType: extra.operatorType, + assetTransferMethod: extra.assetTransferMethod, + authCaptureEscrow: extra.authCaptureEscrow, + }; + // First write wins. A second collect for the same paymentInfoHash cannot succeed onchain + // (the escrow marks it collected), so reaching here twice means a retry, and the stored + // balances are then the authoritative ones — out-of-band capture/refund or a cancel void + // may already have moved them. + await this.config.storage.update(paymentInfoHash, current => current ?? record); + } +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/server/storage.ts b/typescript/packages/mechanisms/evm/src/auth-capture/server/storage.ts new file mode 100644 index 0000000000..5511a77afa --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/server/storage.ts @@ -0,0 +1,183 @@ +import type { Network } from "@x402/core/types"; +import type { AuthCaptureOperatorType, AuthCapturePaymentFlow, PaymentInfoStruct } from "../types"; +import type { AssetTransferMethod } from "../../types"; + +/** + * Per-key async mutex. Concurrent calls with the same key are serialized; + * different keys run in parallel. + * + * The in-memory backend only provides this guarantee inside one JS runtime; + * production multi-instance deployments need storage with backend-level atomic + * conditional mutation, such as Redis/Valkey Lua scripts, SQL transactions, or + * Durable Objects. + * + * @returns A `withLock(key, fn)` function. + */ +function createKeyedLock(): (key: string, fn: () => Promise) => Promise { + const locks = new Map>(); + + return async function withLock(key: string, fn: () => Promise): Promise { + const previous = locks.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise(resolve => { + release = resolve; + }); + const next = previous.catch(() => {}).then(() => current); + locks.set(key, next); + + await previous.catch(() => {}); + try { + return await fn(); + } finally { + release(); + if (locks.get(key) === next) { + locks.delete(key); + } + } + }; +} + +export interface AuthorizedPayment { + paymentInfoHash: `0x${string}`; + paymentInfo: PaymentInfoStruct; + saltNonce?: `0x${string}`; + receiverAuthorizer: `0x${string}`; + policy: `0x${string}`; + network: Network; + capturableAmount: string; + refundableAmount: string; + collectTransaction: string; + createdAt: number; + name: string; + version: string; + paymentFlow: AuthCapturePaymentFlow; + operatorType: Exclude; + assetTransferMethod: AssetTransferMethod; + authCaptureEscrow: `0x${string}`; +} + +export interface AuthorizedPaymentUpdateResult { + payment: AuthorizedPayment | undefined; + status: "updated" | "unchanged" | "deleted"; +} + +export interface AuthorizedPaymentStorage { + get(paymentInfoHash: string): Promise; + list(): Promise; + /** + * Atomically inspects and mutates a payment record. + * + * Implementations must guarantee that no concurrent mutation can interleave + * between reading `current` and writing the callback result for all + * application instances that share the backend. The in-memory backend only + * provides this guarantee inside one JS runtime; production multi-instance + * deployments need storage with backend-level atomic conditional mutation. + * + * @param paymentInfoHash - Storage key = AuthCaptureEscrow.getHash(paymentInfo). + * @param update - Mutation callback. Return `undefined` to delete, or `current` to leave unchanged. + * @returns The final stored payment and whether storage updated, stayed unchanged, or deleted. + */ + update( + paymentInfoHash: string, + update: (current: AuthorizedPayment | undefined) => AuthorizedPayment | undefined, + ): Promise; +} + +/** + * Lowercase a paymentInfoHash so Map keys are case-insensitive. + * + * @param hash - Escrow payment identifier. + * @returns Lowercase hash string. + */ +function normalizeHash(hash: string): string { + return hash.toLowerCase(); +} + +/** + * In-memory {@link AuthorizedPaymentStorage} backed by a Map keyed by paymentInfoHash. + * + * Atomicity is guaranteed only inside one JS runtime. Deferred captures are lost + * on restart; the payer's protection in that case is `reclaim` after the capture + * deadline. + */ +export class InMemoryAuthorizedPaymentStorage implements AuthorizedPaymentStorage { + private readonly payments = new Map(); + private readonly withLock = createKeyedLock(); + + /** + * Returns the payment record for a hash, if present. + * + * @param paymentInfoHash - Escrow payment identifier. + * @returns The payment record or undefined when not found. + */ + async get(paymentInfoHash: string): Promise { + return this.payments.get(normalizeHash(paymentInfoHash)); + } + + /** + * Lists all stored payment records. + * + * @returns All payment records in storage. + */ + async list(): Promise { + return [...this.payments.values()]; + } + + /** + * Atomically inspects and mutates a payment record while holding a per-key lock. + * + * @param paymentInfoHash - Escrow payment identifier. + * @param update - Mutation callback. Return `undefined` to delete, or `current` to leave unchanged. + * @returns The final stored payment and whether storage updated, stayed unchanged, or deleted. + */ + async update( + paymentInfoHash: string, + update: (current: AuthorizedPayment | undefined) => AuthorizedPayment | undefined, + ): Promise { + const key = normalizeHash(paymentInfoHash); + return this.withLock(key, async () => { + const current = this.payments.get(key); + const next = update(current); + + if (next === current) { + return { payment: current, status: "unchanged" }; + } + + if (!next) { + this.payments.delete(key); + return { payment: undefined, status: current ? "deleted" : "unchanged" }; + } + + this.payments.set(key, next); + return { payment: next, status: "updated" }; + }); + } +} + +/** + * Apply a successful capture to stored capturable/refundable balances. + * + * @param storage - Authorized-payment storage. + * @param paymentInfoHash - Storage key. + * @param amount - Captured atomic amount. + * @param voidRemainder - When true, zero the remaining hold. + * @returns Nothing. + */ +export async function applyCaptureBalances( + storage: AuthorizedPaymentStorage, + paymentInfoHash: string, + amount: string, + voidRemainder: boolean, +): Promise { + await storage.update(paymentInfoHash, current => { + if (!current) return current; + const captured = BigInt(amount); + const capturable = BigInt(current.capturableAmount) - captured; + const refundable = BigInt(current.refundableAmount) + captured; + return { + ...current, + capturableAmount: voidRemainder ? "0" : capturable.toString(), + refundableAmount: refundable.toString(), + }; + }); +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/types.ts b/typescript/packages/mechanisms/evm/src/auth-capture/types.ts index 495019c201..7fb677fc80 100644 --- a/typescript/packages/mechanisms/evm/src/auth-capture/types.ts +++ b/typescript/packages/mechanisms/evm/src/auth-capture/types.ts @@ -15,6 +15,11 @@ import type { PendingSettlementStore } from "@x402/core/facilitator"; import type { TypedData } from "viem"; import type { AssetTransferMethod } from "../types"; +import type { + AuthCaptureDelegatedAuthStorage, + DelegatedSettleContext, + OnDelegatedAuthStorageError, +} from "./facilitator/delegatedAuth"; export type AuthCapturePaymentFlow = "escrow" | "authorization"; export type AuthCaptureCaptureMode = "sync" | "deferred"; @@ -30,6 +35,15 @@ export interface AuthorizerSigner { }): Promise<`0x${string}`>; } +/** + * Who produces `authorizerSignature` for a published `receiverAuthorizer`: + * - `self`: the scheme's `receiverAuthorizerSigner`, which signs locally. + * - `delegated`: a non-zero authorizer the server holds no key for; payloads go out unsigned + * and the facilitator signs after authenticating the request out of band. + * - `collect-only`: the zero address; the facilitator relays no lifecycle. + */ +export type AuthorizerMode = "self" | "delegated" | "collect-only"; + type AuthCaptureLifecycleExtra = | { paymentFlow?: "escrow"; @@ -123,7 +137,47 @@ export type OperatorAllowlistEntry = { export type AuthCaptureFacilitatorConfig = { feeTerms?: AuthCaptureFeeTerms; operators?: OperatorAllowlistEntry[]; - receiverAuthorizer?: `0x${string}`; + /** + * Operator addresses `/supported` advertises one at a time as `extra.captureAuthorizer`. + * Each MUST be an address of one of the facilitator signers, and stay usable (and funded + * when {@link refundFunding} is on) until its payments pass `refundDeadline`. Omitted or + * empty advertises every signer address. Settlement still accepts any signer address, so + * narrowing the pool retires an address from new payments without stranding old ones. + */ + captureAuthorizers?: `0x${string}`[]; + /** + * Picks the pool member for one `/supported` response. An omitted selector, or a result + * outside `candidates`, falls back to a random pick. + */ + selectCaptureAuthorizer?: ( + network: string, + candidates: readonly `0x${string}`[], + ) => `0x${string}` | undefined; + /** + * Enables facilitator-delegated receiver authorization. Advertised as `/supported` + * `extra.receiverAuthorizer`; the facilitator signs `charge` and lifecycle digests with it + * when the server omits `authorizerSignature`. Requires {@link resolveCallerIdentity}. + */ + authorizerSigner?: AuthorizerSigner; + /** + * Authenticates the caller of a delegated settle out of band and returns a stable identity. + * Returning `undefined` or an empty string (or throwing) rejects the settle. The identity + * must be the same across the `authorize` and the later lifecycle settles of one payment. + * Required when {@link authorizerSigner} is set, and only valid with it. + */ + resolveCallerIdentity?: ( + ctx: DelegatedSettleContext, + ) => Promise | string | undefined; + /** + * Caller bindings for delegated payments. Required with {@link authorizerSigner}, + * {@link resolveCallerIdentity}, and {@link onStorageError}; omitted means delegation is off. + */ + delegatedAuthStorage?: AuthCaptureDelegatedAuthStorage; + /** + * Called when reverting or deleting a binding fails. Required with delegation; must not + * replace the settle result. + */ + onStorageError?: OnDelegatedAuthStorageError; /** * Max gas for a custom-operator collect relay (`authorize` or `charge`). * Used as the verify reject threshold and as a hard broadcast ceiling. @@ -199,14 +253,15 @@ type ChargeCompletionV1_0 = { amount: string; feeBps: number; feeReceiver: `0x${string}`; - authorizerSignature: `0x${string}`; + /** Omitted when the authorizer is delegated to the facilitator. */ + authorizerSignature?: `0x${string}`; }; type ChargeCompletionV1_1 = { amount: string; feeAmount: string; feeReceiver: `0x${string}`; - authorizerSignature: `0x${string}`; + authorizerSignature?: `0x${string}`; }; type ChargeCompletion = ChargeCompletionV1_0 | ChargeCompletionV1_1; @@ -259,7 +314,8 @@ export type AuthCaptureCollectPayload = Eip3009Payload | Permit2Payload; type LifecycleBase = { paymentInfo: PaymentInfoStruct; saltNonce: `0x${string}`; - authorizerSignature: `0x${string}`; + /** Omitted when the authorizer is delegated to the facilitator, which signs after authenticating the caller. */ + authorizerSignature?: `0x${string}`; }; export type CapturePayload = LifecycleBase & { @@ -269,11 +325,17 @@ export type CapturePayload = LifecycleBase & { expectedCapturableAmount: string; expectedRefundableAmount: string; voidAuthorizerSignature?: `0x${string}`; + /** + * Delegated authorizer only: asks the facilitator to sign the `Void` leg for the remaining + * hold. Requires `authorizerSignature` and `voidAuthorizerSignature` to be absent. + */ + voidRemainder?: true; } & ({ feeBps: number; feeAmount?: never } | { feeAmount: string; feeBps?: never }); export type VoidPayload = LifecycleBase & { type: "void"; voidAuthorizerSignature?: never; + voidRemainder?: never; }; export type RefundPayload = LifecycleBase & { @@ -282,10 +344,16 @@ export type RefundPayload = LifecycleBase & { expectedCapturableAmount: string; expectedRefundableAmount: string; voidAuthorizerSignature?: never; + voidRemainder?: never; }; export type AuthCaptureLifecyclePayload = CapturePayload | VoidPayload | RefundPayload; +/** A lifecycle payload carrying the authorizer signature, whoever produced it. */ +export type SignedLifecyclePayload = AuthCaptureLifecyclePayload & { + authorizerSignature: `0x${string}`; +}; + export type AuthCapturePayload = AuthCaptureCollectPayload | AuthCaptureLifecyclePayload; /** @@ -320,11 +388,30 @@ export function isCapturePayload(value: unknown): value is CapturePayload { typeof v.feeReceiver === "string" && typeof v.expectedCapturableAmount === "string" && typeof v.expectedRefundableAmount === "string" && - typeof v.authorizerSignature === "string" && - (v.voidAuthorizerSignature === undefined || typeof v.voidAuthorizerSignature === "string") + (v.authorizerSignature === undefined || typeof v.authorizerSignature === "string") && + (v.voidAuthorizerSignature === undefined || typeof v.voidAuthorizerSignature === "string") && + isDelegatedCaptureShape(v) ); } +/** + * Cross-field rules for a capture whose signatures may be delegated: a void signature needs + * the capture signature, and `voidRemainder` is the unsigned stand-in for the void signature. + * + * @param v - Capture payload fields. + * @returns True when the signature fields are mutually consistent. + */ +function isDelegatedCaptureShape(v: Record): boolean { + if (v.voidRemainder !== undefined) { + return ( + v.voidRemainder === true && + v.authorizerSignature === undefined && + v.voidAuthorizerSignature === undefined + ); + } + return v.authorizerSignature !== undefined || v.voidAuthorizerSignature === undefined; +} + /** * Type guard for a void lifecycle payload. * @@ -337,8 +424,9 @@ export function isVoidPayload(value: unknown): value is VoidPayload { return ( isPaymentInfoStruct(v.paymentInfo) && typeof v.saltNonce === "string" && - typeof v.authorizerSignature === "string" && - v.voidAuthorizerSignature === undefined + (v.authorizerSignature === undefined || typeof v.authorizerSignature === "string") && + v.voidAuthorizerSignature === undefined && + v.voidRemainder === undefined ); } @@ -357,8 +445,9 @@ export function isRefundPayload(value: unknown): value is RefundPayload { typeof v.amount === "string" && typeof v.expectedCapturableAmount === "string" && typeof v.expectedRefundableAmount === "string" && - typeof v.authorizerSignature === "string" && - v.voidAuthorizerSignature === undefined + (v.authorizerSignature === undefined || typeof v.authorizerSignature === "string") && + v.voidAuthorizerSignature === undefined && + v.voidRemainder === undefined ); } @@ -398,7 +487,18 @@ function isHexString(value: unknown): value is `0x${string}` { } /** - * Parse the four charge-completion fields as an all-or-none group. + * Whether `value` is absent or a string. + * + * @param value - Candidate wire field. + * @returns True when `value` is undefined or a string. + */ +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === "string"; +} + +/** + * Parse the charge-completion fields as an all-or-none group. The authorizer signature is + * optional because a facilitator-delegated authorizer produces it at settle time. * * @param v - Collect payload fields. * @returns ChargeCompletion when all four are present and well-typed; undefined when all are absent. @@ -416,13 +516,13 @@ function readChargeCompletion(v: Record): ChargeCompletion | un typeof v.feeBps === "number" && v.feeAmount === undefined && typeof v.feeReceiver === "string" && - typeof v.authorizerSignature === "string" + isOptionalString(v.authorizerSignature) ) { return { amount: v.amount, feeBps: v.feeBps, feeReceiver: v.feeReceiver as `0x${string}`, - authorizerSignature: v.authorizerSignature as `0x${string}`, + authorizerSignature: v.authorizerSignature as `0x${string}` | undefined, }; } if ( @@ -430,13 +530,13 @@ function readChargeCompletion(v: Record): ChargeCompletion | un typeof v.feeAmount === "string" && v.feeBps === undefined && typeof v.feeReceiver === "string" && - typeof v.authorizerSignature === "string" + isOptionalString(v.authorizerSignature) ) { return { amount: v.amount, feeAmount: v.feeAmount, feeReceiver: v.feeReceiver as `0x${string}`, - authorizerSignature: v.authorizerSignature as `0x${string}`, + authorizerSignature: v.authorizerSignature as `0x${string}` | undefined, }; } return undefined; diff --git a/typescript/packages/mechanisms/evm/src/shared/extensions/builderCode.ts b/typescript/packages/mechanisms/evm/src/shared/extensions/builderCode.ts index 30f79207ad..711ee8eb07 100644 --- a/typescript/packages/mechanisms/evm/src/shared/extensions/builderCode.ts +++ b/typescript/packages/mechanisms/evm/src/shared/extensions/builderCode.ts @@ -8,9 +8,17 @@ import type { Hex } from "viem"; export const BUILDER_CODE_KEY = "builder-code" as const; +type SettlementMetadataValue = + | number + | bigint + | string + | readonly SettlementMetadataValue[] + | { readonly [key: string]: SettlementMetadataValue }; + export interface DataSuffixContext { paymentPayload: PaymentPayload; paymentRequirements: PaymentRequirements; + metadata?: { readonly [key: string]: SettlementMetadataValue }; } export interface BuilderCodeFacilitatorExtension extends FacilitatorExtension { diff --git a/typescript/packages/mechanisms/evm/src/signer.ts b/typescript/packages/mechanisms/evm/src/signer.ts index 8ce5ca8bd6..b954c9d9fb 100644 --- a/typescript/packages/mechanisms/evm/src/signer.ts +++ b/typescript/packages/mechanisms/evm/src/signer.ts @@ -72,6 +72,7 @@ export type FacilitatorEvmSigner = { abi: readonly unknown[]; functionName: string; args?: readonly unknown[]; + account?: `0x${string}`; }): Promise; verifyTypedData(args: { address: `0x${string}`; @@ -99,6 +100,25 @@ export type FacilitatorEvmSigner = { logs?: readonly Log[]; }>; getCode(args: { address: `0x${string}` }): Promise<`0x${string}` | undefined>; + simulateCalls?(args: { + account: `0x${string}`; + calls: readonly { + to: `0x${string}`; + data?: `0x${string}`; + abi?: readonly unknown[]; + functionName?: string; + args?: readonly unknown[]; + gas?: bigint; + }[]; + }): Promise<{ + results: readonly { + status: "success" | "failure"; + result?: unknown; + logs?: readonly Log[]; + data?: `0x${string}`; + gasUsed?: bigint; + }[]; + }>; }; /** diff --git a/typescript/packages/mechanisms/evm/test/contracts/auth-capture/.gitignore b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/.gitignore new file mode 100644 index 0000000000..a97499d078 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/.gitignore @@ -0,0 +1,2 @@ +out/ +cache/ diff --git a/typescript/packages/mechanisms/evm/test/contracts/auth-capture/ForwardingOperator.sol b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/ForwardingOperator.sol new file mode 100644 index 0000000000..df511da2f6 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/ForwardingOperator.sol @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title ForwardingOperator +/// @notice Integration-test only. Not audited. Do not use in production. +/// @dev Spec-minimum custom operator: permissionless collect wrappers that +/// forward 1:1 to AuthCaptureEscrow. Bytecode artifact: `artifacts/ForwardingOperator.json`. +/// Regenerate: `forge build` in this directory, then copy `out/ForwardingOperator.sol/ForwardingOperator.json` +/// `abi` and `bytecode.object` into the artifact. +interface IAuthCaptureEscrow { + struct PaymentInfo { + address operator; + address payer; + address receiver; + address token; + uint120 maxAmount; + uint48 preApprovalExpiry; + uint48 authorizationExpiry; + uint48 refundExpiry; + uint16 minFeeBps; + uint16 maxFeeBps; + address feeReceiver; + uint256 salt; + } + + function authorize( + PaymentInfo calldata paymentInfo, + uint256 amount, + address tokenCollector, + bytes calldata collectorData + ) external; + + function charge( + PaymentInfo calldata paymentInfo, + uint256 amount, + address tokenCollector, + bytes calldata collectorData, + uint256 feeAmount, + address feeReceiver + ) external; +} + +contract ForwardingOperator { + IAuthCaptureEscrow public immutable escrow; + + constructor(address escrow_) { + escrow = IAuthCaptureEscrow(escrow_); + } + + function authorize( + IAuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint256 amount, + address tokenCollector, + bytes calldata collectorData + ) external { + escrow.authorize(paymentInfo, amount, tokenCollector, collectorData); + } + + function charge( + IAuthCaptureEscrow.PaymentInfo calldata paymentInfo, + uint256 amount, + address tokenCollector, + bytes calldata collectorData, + uint256 feeAmount, + address feeReceiver + ) external { + escrow.charge(paymentInfo, amount, tokenCollector, collectorData, feeAmount, feeReceiver); + } +} diff --git a/typescript/packages/mechanisms/evm/test/contracts/auth-capture/GasWastingOperator.sol b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/GasWastingOperator.sol new file mode 100644 index 0000000000..5c7b780749 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/GasWastingOperator.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title GasWastingOperator +/// @notice Integration-test only. Not audited. Do not use in production. +/// @dev Adversarial custom operator: burns well over the facilitator collect +/// gas cap, then returns without calling escrow. +/// Bytecode artifact: `artifacts/GasWastingOperator.json`. +/// Regenerate: `forge build` in this directory, then copy `out/GasWastingOperator.sol/GasWastingOperator.json` +/// `abi` and `bytecode.object` into the artifact. +contract GasWastingOperator { + struct PaymentInfo { + address operator; + address payer; + address receiver; + address token; + uint120 maxAmount; + uint48 preApprovalExpiry; + uint48 authorizationExpiry; + uint48 refundExpiry; + uint16 minFeeBps; + uint16 maxFeeBps; + address feeReceiver; + uint256 salt; + } + + /// @dev Storage sink so the optimizer cannot drop the burn loop. + uint256 public sink; + + function authorize( + PaymentInfo calldata, + uint256, + address, + bytes calldata + ) external { + _waste(); + } + + function charge( + PaymentInfo calldata, + uint256, + address, + bytes calldata, + uint256, + address + ) external { + _waste(); + } + + function _waste() private { + uint256 n = sink; + // Far above DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT (1_000_000). + unchecked { + for (uint256 i; i < 50_000; ++i) { + n = uint256(keccak256(abi.encode(n, i))); + } + } + sink = n; + } +} diff --git a/typescript/packages/mechanisms/evm/test/contracts/auth-capture/NoopOperator.sol b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/NoopOperator.sol new file mode 100644 index 0000000000..dd64517bb0 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/NoopOperator.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title NoopOperator +/// @notice Integration-test only. Not audited. Do not use in production. +/// @dev Adversarial custom operator: matching collect selectors, empty body. +/// Verify should fail (success with no canonical escrow event). +/// Bytecode artifact: `artifacts/NoopOperator.json`. +/// Regenerate: `forge build` in this directory, then copy `out/NoopOperator.sol/NoopOperator.json` +/// `abi` and `bytecode.object` into the artifact. +contract NoopOperator { + struct PaymentInfo { + address operator; + address payer; + address receiver; + address token; + uint120 maxAmount; + uint48 preApprovalExpiry; + uint48 authorizationExpiry; + uint48 refundExpiry; + uint16 minFeeBps; + uint16 maxFeeBps; + address feeReceiver; + uint256 salt; + } + + function authorize( + PaymentInfo calldata, + uint256, + address, + bytes calldata + ) external {} + + function charge( + PaymentInfo calldata, + uint256, + address, + bytes calldata, + uint256, + address + ) external {} +} diff --git a/typescript/packages/mechanisms/evm/test/contracts/auth-capture/README.md b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/README.md new file mode 100644 index 0000000000..d963bf4c2e --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/README.md @@ -0,0 +1,34 @@ +# Auth-capture test operators + +**Warning: integration-test contracts only. Not audited. Do not use in production.** + +These Solidity files exist so `@x402/evm` custom-operator integration tests can hit real bytecode on Base Sepolia (`eth_simulateV1`, gas cap, escrow events). They are not production operators and not a template for a merchant operator. + +`collectorData` is payer-controlled opaque bytes. A forwarding operator passes it through unchanged; ERC-6492 preparation calldata is interpreted only by the canonical token collector, which executes it through a neutral Multicall3 sender. + +| Contract | Role in tests | +| --- | --- | +| `ForwardingOperator.sol` | Spec-minimum: permissionless `authorize` / `charge` that forward 1:1 to canonical `AuthCaptureEscrow` | +| `NoopOperator.sol` | Adversarial: same selectors, empty body (success, no escrow event) | +| `GasWastingOperator.sol` | Adversarial: burns well over the facilitator collect gas cap, never calls escrow | + +Committed ABI + creation bytecode live in `artifacts/`. Integration tests CREATE2-deploy via Arachnid’s deployer and skip if code is already at the predicted address. + +## Base Sepolia addresses + +CREATE2 factory: `0x4e59b44847b379578588920cA78FbF26c0B4956C`. Salt is `keccak256("x402.auth-capture.test..v1")`. ForwardingOperator is constructed with canonical `AuthCaptureEscrow` `0xf96815976523E00e65Be8f34cA5e64b4f41EB19c`. A bytecode change yields a new address. + +| Contract | Address | +| --- | --- | +| ForwardingOperator | `0x8FE415CdB559fBF5B235B81CC4F7a69684A274bb` | +| NoopOperator | `0x00Cc67f415Fec4ddAFB4F4a36F324AbBfdddFf27` | +| GasWastingOperator | `0x637233E61c1cCC12f2Aa5e83524d976be5E2dE04` | + +## Regenerate artifacts + +```bash +forge build +# copy `out/.sol/.json` `abi` and `bytecode.object` into `artifacts/.json` +``` + +Solc 0.8.28, optimizer 200, `cbor_metadata = false`, `bytecode_hash = none` (see `foundry.toml`). diff --git a/typescript/packages/mechanisms/evm/test/contracts/auth-capture/artifacts/ForwardingOperator.json b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/artifacts/ForwardingOperator.json new file mode 100644 index 0000000000..11bf5cd0f5 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/artifacts/ForwardingOperator.json @@ -0,0 +1,219 @@ +{ + "abi": [ + { + "type": "constructor", + "inputs": [ + { + "name": "escrow_", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "authorize", + "inputs": [ + { + "name": "paymentInfo", + "type": "tuple", + "internalType": "struct IAuthCaptureEscrow.PaymentInfo", + "components": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "payer", + "type": "address", + "internalType": "address" + }, + { + "name": "receiver", + "type": "address", + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "maxAmount", + "type": "uint120", + "internalType": "uint120" + }, + { + "name": "preApprovalExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "authorizationExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "refundExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "minFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "maxFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "feeReceiver", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "tokenCollector", + "type": "address", + "internalType": "address" + }, + { + "name": "collectorData", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "charge", + "inputs": [ + { + "name": "paymentInfo", + "type": "tuple", + "internalType": "struct IAuthCaptureEscrow.PaymentInfo", + "components": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "payer", + "type": "address", + "internalType": "address" + }, + { + "name": "receiver", + "type": "address", + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "maxAmount", + "type": "uint120", + "internalType": "uint120" + }, + { + "name": "preApprovalExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "authorizationExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "refundExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "minFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "maxFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "feeReceiver", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "tokenCollector", + "type": "address", + "internalType": "address" + }, + { + "name": "collectorData", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "feeAmount", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "feeReceiver", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "escrow", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IAuthCaptureEscrow" + } + ], + "stateMutability": "view" + } + ], + "bytecode": "0x60a0604052348015600e575f5ffd5b506040516105e73803806105e7833981016040819052602b91603b565b6001600160a01b03166080526066565b5f60208284031215604a575f5ffd5b81516001600160a01b0381168114605f575f5ffd5b9392505050565b60805161055d61008a5f395f818160700152818160c5015261014d015261055d5ff3fe608060405234801561000f575f5ffd5b506004361061003f575f3560e01c806341d6620214610043578063c449d17c14610058578063e2fdcc171461006b575b5f5ffd5b61005661005136600461023b565b6100ae565b005b6100566100663660046102aa565b610136565b6100927f000000000000000000000000000000000000000000000000000000000000000081565b6040516001600160a01b03909116815260200160405180910390f35b6040516320eb310160e11b81526001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016906341d662029061010290889088908890889088906004016104ba565b5f604051808303815f87803b158015610119575f5ffd5b505af115801561012b573d5f5f3e3d5ffd5b505050505050505050565b604051633112745f60e21b81526001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000169063c449d17c9061018e908a908a908a908a908a908a908a906004016104fe565b5f604051808303815f87803b1580156101a5575f5ffd5b505af11580156101b7573d5f5f3e3d5ffd5b5050505050505050505050565b5f61018082840312156101d5575f5ffd5b50919050565b80356001600160a01b03811681146101f1575f5ffd5b919050565b5f5f83601f840112610206575f5ffd5b50813567ffffffffffffffff81111561021d575f5ffd5b602083019150836020828501011115610234575f5ffd5b9250929050565b5f5f5f5f5f6101e08688031215610250575f5ffd5b61025a87876101c4565b945061018086013593506102716101a087016101db565b92506101c086013567ffffffffffffffff81111561028d575f5ffd5b610299888289016101f6565b969995985093965092949392505050565b5f5f5f5f5f5f5f610220888a0312156102c1575f5ffd5b6102cb89896101c4565b965061018088013595506102e26101a089016101db565b94506101c088013567ffffffffffffffff8111156102fe575f5ffd5b61030a8a828b016101f6565b9095509350506101e0880135915061032561020089016101db565b905092959891949750929550565b80356001600160781b03811681146101f1575f5ffd5b803565ffffffffffff811681146101f1575f5ffd5b803561ffff811681146101f1575f5ffd5b6103898261037c836101db565b6001600160a01b03169052565b610395602082016101db565b6001600160a01b031660208301526103af604082016101db565b6001600160a01b031660408301526103c9606082016101db565b6001600160a01b031660608301526103e360808201610333565b6001600160781b031660808301526103fd60a08201610349565b65ffffffffffff1660a083015261041660c08201610349565b65ffffffffffff1660c083015261042f60e08201610349565b65ffffffffffff1660e0830152610449610100820161035e565b61ffff16610100830152610460610120820161035e565b61ffff1661012083015261047761014082016101db565b6001600160a01b031661014083015261016090810135910152565b81835281816020850137505f828201602090810191909152601f909101601f19169091010190565b6104c4818761036f565b8461018082015260018060a01b0384166101a08201526101e06101c08201525f6104f36101e083018486610492565b979650505050505050565b610508818961036f565b8661018082015260018060a01b0386166101a08201526102206101c08201525f61053761022083018688610492565b6101e0830194909452506001600160a01b0391909116610200909101529594505050505056" +} diff --git a/typescript/packages/mechanisms/evm/test/contracts/auth-capture/artifacts/GasWastingOperator.json b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/artifacts/GasWastingOperator.json new file mode 100644 index 0000000000..4ff1569bba --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/artifacts/GasWastingOperator.json @@ -0,0 +1,208 @@ +{ + "abi": [ + { + "type": "function", + "name": "authorize", + "inputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct GasWastingOperator.PaymentInfo", + "components": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "payer", + "type": "address", + "internalType": "address" + }, + { + "name": "receiver", + "type": "address", + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "maxAmount", + "type": "uint120", + "internalType": "uint120" + }, + { + "name": "preApprovalExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "authorizationExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "refundExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "minFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "maxFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "feeReceiver", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "charge", + "inputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct GasWastingOperator.PaymentInfo", + "components": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "payer", + "type": "address", + "internalType": "address" + }, + { + "name": "receiver", + "type": "address", + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "maxAmount", + "type": "uint120", + "internalType": "uint120" + }, + { + "name": "preApprovalExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "authorizationExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "refundExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "minFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "maxFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "feeReceiver", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "sink", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + } + ], + "bytecode": "0x6080604052348015600e575f5ffd5b5061025e8061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061003f575f3560e01c806341d6620214610043578063c449d17c14610058578063c74e820e1461006b575b5f5ffd5b610056610051366004610166565b610085565b005b6100566100663660046101d5565b610094565b6100735f5481565b60405190815260200160405180910390f35b61008d6100a5565b5050505050565b61009c6100a5565b50505050505050565b5f8054905b61c3508110156100ea57604080516020810184905290810182905260600160408051601f19818403018152919052805160209091012091506001016100aa565b505f55565b5f6101808284031215610100575f5ffd5b50919050565b80356001600160a01b038116811461011c575f5ffd5b919050565b5f5f83601f840112610131575f5ffd5b50813567ffffffffffffffff811115610148575f5ffd5b60208301915083602082850101111561015f575f5ffd5b9250929050565b5f5f5f5f5f6101e0868803121561017b575f5ffd5b61018587876100ef565b9450610180860135935061019c6101a08701610106565b92506101c086013567ffffffffffffffff8111156101b8575f5ffd5b6101c488828901610121565b969995985093965092949392505050565b5f5f5f5f5f5f5f610220888a0312156101ec575f5ffd5b6101f689896100ef565b9650610180880135955061020d6101a08901610106565b94506101c088013567ffffffffffffffff811115610229575f5ffd5b6102358a828b01610121565b9095509350506101e088013591506102506102008901610106565b90509295989194975092955056" +} diff --git a/typescript/packages/mechanisms/evm/test/contracts/auth-capture/artifacts/NoopOperator.json b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/artifacts/NoopOperator.json new file mode 100644 index 0000000000..fda301e197 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/artifacts/NoopOperator.json @@ -0,0 +1,195 @@ +{ + "abi": [ + { + "type": "function", + "name": "authorize", + "inputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct NoopOperator.PaymentInfo", + "components": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "payer", + "type": "address", + "internalType": "address" + }, + { + "name": "receiver", + "type": "address", + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "maxAmount", + "type": "uint120", + "internalType": "uint120" + }, + { + "name": "preApprovalExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "authorizationExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "refundExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "minFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "maxFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "feeReceiver", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "function", + "name": "charge", + "inputs": [ + { + "name": "", + "type": "tuple", + "internalType": "struct NoopOperator.PaymentInfo", + "components": [ + { + "name": "operator", + "type": "address", + "internalType": "address" + }, + { + "name": "payer", + "type": "address", + "internalType": "address" + }, + { + "name": "receiver", + "type": "address", + "internalType": "address" + }, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "maxAmount", + "type": "uint120", + "internalType": "uint120" + }, + { + "name": "preApprovalExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "authorizationExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "refundExpiry", + "type": "uint48", + "internalType": "uint48" + }, + { + "name": "minFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "maxFeeBps", + "type": "uint16", + "internalType": "uint16" + }, + { + "name": "feeReceiver", + "type": "address", + "internalType": "address" + }, + { + "name": "salt", + "type": "uint256", + "internalType": "uint256" + } + ] + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "address", + "internalType": "address" + }, + { + "name": "", + "type": "bytes", + "internalType": "bytes" + }, + { + "name": "", + "type": "uint256", + "internalType": "uint256" + }, + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "outputs": [], + "stateMutability": "nonpayable" + } + ], + "bytecode": "0x6080604052348015600e575f5ffd5b506101d58061001c5f395ff3fe608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806341d6620214610038578063c449d17c1461004f575b5f5ffd5b61004d6100463660046100dd565b5050505050565b005b61004d61005d36600461014c565b50505050505050565b5f6101808284031215610077575f5ffd5b50919050565b80356001600160a01b0381168114610093575f5ffd5b919050565b5f5f83601f8401126100a8575f5ffd5b50813567ffffffffffffffff8111156100bf575f5ffd5b6020830191508360208285010111156100d6575f5ffd5b9250929050565b5f5f5f5f5f6101e086880312156100f2575f5ffd5b6100fc8787610066565b945061018086013593506101136101a0870161007d565b92506101c086013567ffffffffffffffff81111561012f575f5ffd5b61013b88828901610098565b969995985093965092949392505050565b5f5f5f5f5f5f5f610220888a031215610163575f5ffd5b61016d8989610066565b965061018088013595506101846101a0890161007d565b94506101c088013567ffffffffffffffff8111156101a0575f5ffd5b6101ac8a828b01610098565b9095509350506101e088013591506101c7610200890161007d565b90509295989194975092955056" +} diff --git a/typescript/packages/mechanisms/evm/test/contracts/auth-capture/foundry.toml b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/foundry.toml new file mode 100644 index 0000000000..99b83ff7f4 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/contracts/auth-capture/foundry.toml @@ -0,0 +1,12 @@ +# Integration-test operators only. Not audited. Do not use in production. +# Isolated from the audited contracts/evm Foundry project. +[profile.default] +src = "." +out = "out" +libs = [] +solc = "0.8.28" +optimizer = true +optimizer_runs = 200 +via_ir = false +cbor_metadata = false +bytecode_hash = "none" diff --git a/typescript/packages/mechanisms/evm/test/integrations/auth-capture-custom-operator.test.ts b/typescript/packages/mechanisms/evm/test/integrations/auth-capture-custom-operator.test.ts new file mode 100644 index 0000000000..1b19caf456 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/integrations/auth-capture-custom-operator.test.ts @@ -0,0 +1,342 @@ +/** + * Auth-capture custom-operator integration tests against live Base Sepolia. + * + * Deploys (CREATE2, skip-if-present) a spec-minimum forwarding operator and two + * adversarial operators, then checks facilitator verify/settle against real + * AuthCaptureEscrow + eth_simulateV1. + */ + +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { x402Client } from "@x402/core/client"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { x402ResourceServer, FacilitatorClient } from "@x402/core/server"; +import { + Network, + PaymentPayload, + PaymentRequirements, + VerifyResponse, + SettleResponse, + SupportedResponse, +} from "@x402/core/types"; +import { toClientEvmSigner, toFacilitatorEvmSigner } from "../../src"; +import { AuthCaptureEvmScheme as AuthCaptureEvmClient } from "../../src/auth-capture/client/scheme"; +import { AuthCaptureEvmScheme as AuthCaptureEvmServer } from "../../src/auth-capture/server/scheme"; +import { AuthCaptureEvmScheme as AuthCaptureEvmFacilitator } from "../../src/auth-capture/facilitator/scheme"; +import { AUTH_CAPTURE_ESCROW_ADDRESS } from "../../src/auth-capture/constants"; +import { ESCROW_EVENTS_ABI } from "../../src/auth-capture/abi"; +import { ErrSimulationFailed } from "../../src/auth-capture/errors"; +import type { AuthCaptureExtra } from "../../src/auth-capture/types"; +import { privateKeyToAccount } from "viem/accounts"; +import { + createPublicClient, + createWalletClient, + http, + isAddressEqual, + nonceManager, + parseEventLogs, + zeroAddress, +} from "viem"; +import { baseSepolia } from "viem/chains"; +import { + ensureCreate2Operator, + loadOperatorArtifact, + operatorCreate2Salt, +} from "./helpers/authCaptureOperators"; + +const CLIENT_PRIVATE_KEY = process.env.CLIENT_PRIVATE_KEY as `0x${string}` | undefined; +const FACILITATOR_PRIVATE_KEY = process.env.FACILITATOR_PRIVATE_KEY as `0x${string}` | undefined; + +const HAS_KEYS = Boolean(CLIENT_PRIVATE_KEY && FACILITATOR_PRIVATE_KEY); +const describeOnChain = HAS_KEYS ? describe : describe.skip; + +if (!HAS_KEYS) { + console.warn( + "[auth-capture-custom-operator.test.ts] Skipping on-chain tests: CLIENT_PRIVATE_KEY and FACILITATOR_PRIVATE_KEY env vars are required.", + ); +} + +const NETWORK: Network = "eip155:84532"; +const ASSET_USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +const RPC_URL = process.env.EVM_RPC_URL?.trim(); +const transport = RPC_URL ? http(RPC_URL) : http(); + +/** + * Wraps an x402Facilitator instance for use as a FacilitatorClient. + */ +class EvmFacilitatorClient implements FacilitatorClient { + /** + * @param facilitator - The x402 facilitator to wrap. + */ + constructor(private readonly facilitator: x402Facilitator) {} + + /** + * @param paymentPayload - Payment payload to verify. + * @param paymentRequirements - Payment requirements to verify against. + * @returns Verification response from the wrapped facilitator. + */ + verify( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.verify(paymentPayload, paymentRequirements); + } + + /** + * @param paymentPayload - Payment payload to settle. + * @param paymentRequirements - Payment requirements for settlement. + * @returns Settlement response from the wrapped facilitator. + */ + settle( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.settle(paymentPayload, paymentRequirements); + } + + /** + * @returns Supported payment kinds reported by the wrapped facilitator. + */ + getSupported(): Promise { + return Promise.resolve(this.facilitator.getSupported()); + } +} + +/** + * Builds payment requirements for a custom-operator escrow collect on Base Sepolia. + * + * @param payTo - Receiver address. + * @param amount - Amount in smallest token units (USDC has 6 decimals). + * @param captureAuthorizer - Allowlisted custom operator contract. + * @returns Configured {@link PaymentRequirements}. + */ +function buildCustomEscrowRequirements( + payTo: `0x${string}`, + amount: string, + captureAuthorizer: `0x${string}`, +): PaymentRequirements { + const now = Math.floor(Date.now() / 1000); + // captureDeadline must stay >= now + maxTimeoutSeconds through verify+settle. + const extra: AuthCaptureExtra = { + captureAuthorizer, + operatorType: "custom", + captureDeadline: now + 7200, + refundDeadline: now + 14400, + feeRecipient: zeroAddress, + minFeeBps: 0, + maxFeeBps: 0, + name: "USDC", + version: "2", + assetTransferMethod: "eip3009", + paymentFlow: "escrow", + captureMode: "deferred", + receiverAuthorizer: zeroAddress, + }; + return { + scheme: "auth-capture", + network: NETWORK, + asset: ASSET_USDC_BASE_SEPOLIA, + amount, + payTo, + maxTimeoutSeconds: 3600, + extra: extra as unknown as Record, + }; +} + +type Pipeline = { + client: x402Client; + server: x402ResourceServer; + facilitatorClient: EvmFacilitatorClient; + receiverAddress: `0x${string}`; + clientAddress: `0x${string}`; + writeContractCalls: { count: number }; + publicClient: ReturnType; +}; + +/** + * Wires up client + server + facilitator for custom-operator testing. + * + * @param operators - Allowlisted custom operator addresses. + * @returns Pipeline components and a write-counter for asserting no broadcast. + */ +function buildPipeline(operators: readonly `0x${string}`[]): Pipeline { + const clientAccount = privateKeyToAccount(CLIENT_PRIVATE_KEY!); + const facilitatorAccount = privateKeyToAccount(FACILITATOR_PRIVATE_KEY!, { nonceManager }); + + const publicClient = createPublicClient({ chain: baseSepolia, transport }); + const facilitatorWalletClient = createWalletClient({ + account: facilitatorAccount, + chain: baseSepolia, + transport, + }); + + const writeContractCalls = { count: 0 }; + + const facilitatorSigner = toFacilitatorEvmSigner({ + address: facilitatorAccount.address, + readContract: args => publicClient.readContract({ ...args, args: args.args || [] } as never), + verifyTypedData: args => publicClient.verifyTypedData(args as never), + writeContract: args => { + writeContractCalls.count += 1; + return facilitatorWalletClient.writeContract({ + ...args, + args: args.args || [], + } as never); + }, + sendTransaction: args => facilitatorWalletClient.sendTransaction(args), + waitForTransactionReceipt: args => publicClient.waitForTransactionReceipt(args), + getCode: args => publicClient.getCode(args), + simulateCalls: args => + publicClient.simulateCalls(args as Parameters[0]), + }); + + const facilitator = new x402Facilitator().register( + NETWORK, + new AuthCaptureEvmFacilitator(facilitatorSigner, { + operators: operators.map(address => ({ address, operatorType: "custom" as const })), + customOperatorAuthorizeGasLimit: 1_000_000n, + }), + ); + const facilitatorClient = new EvmFacilitatorClient(facilitator); + + const clientSigner = toClientEvmSigner(clientAccount, publicClient); + const client = new x402Client().register(NETWORK, new AuthCaptureEvmClient(clientSigner)); + + const server = new x402ResourceServer(facilitatorClient); + server.register(NETWORK, new AuthCaptureEvmServer({ collectOnlyRoutes: true })); + + return { + client, + server, + facilitatorClient, + receiverAddress: facilitatorAccount.address, + clientAddress: clientAccount.address, + writeContractCalls, + publicClient, + }; +} + +/** + * Signs a custom-operator collect payload against the given operator. + * + * @param pipeline - Wired client/server. + * @param operator - Custom operator used as extra.captureAuthorizer. + * @returns Matching requirements and the client payload. + */ +async function createCustomCollect( + pipeline: Pipeline, + operator: `0x${string}`, +): Promise<{ accepted: PaymentRequirements; payload: PaymentPayload }> { + const accepts = [buildCustomEscrowRequirements(pipeline.receiverAddress, "1000", operator)]; + const resource = { + url: "https://example.com/api", + description: "auth-capture custom-operator test", + mimeType: "application/json", + }; + const paymentRequired = await pipeline.server.createPaymentRequiredResponse(accepts, resource); + const payload = await pipeline.client.createPaymentPayload(paymentRequired); + const accepted = pipeline.server.findMatchingRequirements(accepts, payload); + expect(accepted).toBeDefined(); + return { accepted: accepted!, payload }; +} + +describe("AuthCapture EVM custom operator", () => { + describeOnChain("forwarding vs adversarial operators", () => { + let forwarding: `0x${string}`; + let noop: `0x${string}`; + let gasWaster: `0x${string}`; + let pipeline: Pipeline; + + beforeAll(async () => { + const facilitatorAccount = privateKeyToAccount(FACILITATOR_PRIVATE_KEY!, { nonceManager }); + const publicClient = createPublicClient({ chain: baseSepolia, transport }); + const walletClient = createWalletClient({ + account: facilitatorAccount, + chain: baseSepolia, + transport, + }); + const deployer = { + sendTransaction: (args: { to: `0x${string}`; data: `0x${string}`; gas?: bigint }) => + walletClient.sendTransaction({ + account: facilitatorAccount, + chain: baseSepolia, + ...args, + }), + }; + + forwarding = await ensureCreate2Operator( + publicClient, + deployer, + loadOperatorArtifact("ForwardingOperator"), + operatorCreate2Salt("ForwardingOperator"), + [AUTH_CAPTURE_ESCROW_ADDRESS], + ); + noop = await ensureCreate2Operator( + publicClient, + deployer, + loadOperatorArtifact("NoopOperator"), + operatorCreate2Salt("NoopOperator"), + ); + gasWaster = await ensureCreate2Operator( + publicClient, + deployer, + loadOperatorArtifact("GasWastingOperator"), + operatorCreate2Salt("GasWastingOperator"), + ); + }, 120_000); + + beforeEach(async () => { + pipeline = buildPipeline([forwarding, noop, gasWaster]); + await pipeline.server.initialize(); + }); + + it( + "forwarding operator — verify and settle authorize through canonical escrow", + { timeout: 60_000 }, + async () => { + const { accepted, payload } = await createCustomCollect(pipeline, forwarding); + + // Escrow skips resource-server /verify; call the facilitator so simulateCalls runs. + const verifyResponse = await pipeline.facilitatorClient.verify(payload, accepted); + expect(verifyResponse.isValid, JSON.stringify(verifyResponse)).toBe(true); + expect(verifyResponse.payer?.toLowerCase()).toBe(pipeline.clientAddress.toLowerCase()); + + const settleResponse = await pipeline.server.settlePayment(payload, accepted); + expect(settleResponse.success, JSON.stringify(settleResponse)).toBe(true); + expect(settleResponse.network).toBe(NETWORK); + expect(settleResponse.transaction).toBeDefined(); + expect(pipeline.writeContractCalls.count).toBeGreaterThan(0); + + const receipt = await pipeline.publicClient.getTransactionReceipt({ + hash: settleResponse.transaction as `0x${string}`, + }); + const authorized = parseEventLogs({ + abi: ESCROW_EVENTS_ABI, + eventName: "PaymentAuthorized", + logs: receipt.logs.filter(log => + isAddressEqual(log.address, AUTH_CAPTURE_ESCROW_ADDRESS), + ), + }); + expect(authorized.length).toBeGreaterThan(0); + expect(isAddressEqual(authorized[0]!.args.paymentInfo.operator, forwarding)).toBe(true); + }, + ); + + it("noop operator — verify rejects with no broadcast", { timeout: 60_000 }, async () => { + const { accepted, payload } = await createCustomCollect(pipeline, noop); + + const verifyResponse = await pipeline.facilitatorClient.verify(payload, accepted); + expect(verifyResponse.isValid).toBe(false); + expect(verifyResponse.invalidReason).toBe(ErrSimulationFailed); + expect(pipeline.writeContractCalls.count).toBe(0); + }); + + it("gas-wasting operator — verify rejects with no broadcast", { timeout: 60_000 }, async () => { + const { accepted, payload } = await createCustomCollect(pipeline, gasWaster); + + const verifyResponse = await pipeline.facilitatorClient.verify(payload, accepted); + expect(verifyResponse.isValid).toBe(false); + expect(verifyResponse.invalidReason).toBe(ErrSimulationFailed); + expect(pipeline.writeContractCalls.count).toBe(0); + }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/integrations/auth-capture-evm.test.ts b/typescript/packages/mechanisms/evm/test/integrations/auth-capture-evm.test.ts new file mode 100644 index 0000000000..d5d3a04ce7 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/integrations/auth-capture-evm.test.ts @@ -0,0 +1,322 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { x402Client } from "@x402/core/client"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { x402ResourceServer, FacilitatorClient } from "@x402/core/server"; +import { + Network, + PaymentPayload, + PaymentRequirements, + VerifyResponse, + SettleResponse, + SupportedResponse, +} from "@x402/core/types"; +import { toClientEvmSigner, toFacilitatorEvmSigner } from "../../src"; +import { AuthCaptureEvmScheme as AuthCaptureEvmClient } from "../../src/auth-capture/client/scheme"; +import { AuthCaptureEvmScheme as AuthCaptureEvmServer } from "../../src/auth-capture/server/scheme"; +import { AuthCaptureEvmScheme as AuthCaptureEvmFacilitator } from "../../src/auth-capture/facilitator/scheme"; +import type { AuthCaptureExtra } from "../../src/auth-capture/types"; +import { privateKeyToAccount } from "viem/accounts"; +import { createPublicClient, createWalletClient, http } from "viem"; +import { baseSepolia } from "viem/chains"; + +const CLIENT_PRIVATE_KEY = process.env.CLIENT_PRIVATE_KEY as `0x${string}` | undefined; +const FACILITATOR_PRIVATE_KEY = process.env.FACILITATOR_PRIVATE_KEY as `0x${string}` | undefined; + +const HAS_KEYS = Boolean(CLIENT_PRIVATE_KEY && FACILITATOR_PRIVATE_KEY); +const describeOnChain = HAS_KEYS ? describe : describe.skip; + +if (!HAS_KEYS) { + console.warn( + "[auth-capture-evm.test.ts] Skipping on-chain tests: CLIENT_PRIVATE_KEY and FACILITATOR_PRIVATE_KEY env vars are required.", + ); +} + +const NETWORK: Network = "eip155:84532"; +const ASSET_USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + +/** + * Wraps an x402Facilitator instance for use as a FacilitatorClient. + */ +class EvmFacilitatorClient implements FacilitatorClient { + /** + * @param facilitator - The x402 facilitator to wrap. + */ + constructor(private readonly facilitator: x402Facilitator) {} + + /** + * @param paymentPayload - Payment payload to verify. + * @param paymentRequirements - Payment requirements to verify against. + * @returns Verification response from the wrapped facilitator. + */ + verify( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.verify(paymentPayload, paymentRequirements); + } + + /** + * @param paymentPayload - Payment payload to settle. + * @param paymentRequirements - Payment requirements for settlement. + * @returns Settlement response from the wrapped facilitator. + */ + settle( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.settle(paymentPayload, paymentRequirements); + } + + /** + * @returns Supported payment kinds reported by the wrapped facilitator. + */ + getSupported(): Promise { + return Promise.resolve(this.facilitator.getSupported()); + } +} + +/** + * Builds payment requirements for the auth-capture scheme on Base Sepolia. + * + * @param payTo - Receiver address. + * @param amount - Amount in smallest token units (USDC has 6 decimals). + * @param captureAuthorizer - Address allowed to authorize/capture/void on escrow: facilitator's submitter EOA, or a smart contract that ultimately calls escrow as msg.sender. + * @param overrides - Optional `extra` overrides (e.g., paymentFlow, assetTransferMethod). + * @returns Configured {@link PaymentRequirements}. + */ +function buildAuthCaptureRequirements( + payTo: `0x${string}`, + amount: string, + captureAuthorizer: `0x${string}`, + overrides: Partial = {}, +): PaymentRequirements { + const now = Math.floor(Date.now() / 1000); + // captureDeadline must stay >= now + maxTimeoutSeconds through verify+settle + // (client preApprovalExpiry is now + maxTimeoutSeconds at payload creation). + const extra: AuthCaptureExtra = { + captureAuthorizer, + captureDeadline: now + 7200, + refundDeadline: now + 14400, + feeRecipient: captureAuthorizer, + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + ...overrides, + }; + return { + scheme: "auth-capture", + network: NETWORK, + asset: ASSET_USDC_BASE_SEPOLIA, + amount, + payTo, + maxTimeoutSeconds: 3600, + extra: extra as unknown as Record, + }; +} + +/** + * Wires up client + server + facilitator for live Base Sepolia testing. + * + * @returns Pipeline components and addresses. + */ +function buildPipeline(): { + client: x402Client; + server: x402ResourceServer; + receiverAddress: `0x${string}`; + clientAddress: `0x${string}`; + facilitatorAddress: `0x${string}`; + publicClient: ReturnType; +} { + const clientAccount = privateKeyToAccount(CLIENT_PRIVATE_KEY!); + const facilitatorAccount = privateKeyToAccount(FACILITATOR_PRIVATE_KEY!); + + const publicClient = createPublicClient({ chain: baseSepolia, transport: http() }); + const facilitatorWalletClient = createWalletClient({ + account: facilitatorAccount, + chain: baseSepolia, + transport: http(), + }); + + const facilitatorSigner = toFacilitatorEvmSigner({ + address: facilitatorAccount.address, + readContract: args => publicClient.readContract({ ...args, args: args.args || [] } as never), + verifyTypedData: args => publicClient.verifyTypedData(args as never), + writeContract: args => + facilitatorWalletClient.writeContract({ ...args, args: args.args || [] } as never), + sendTransaction: args => facilitatorWalletClient.sendTransaction(args), + waitForTransactionReceipt: args => publicClient.waitForTransactionReceipt(args), + getCode: args => publicClient.getCode(args), + }); + + const facilitator = new x402Facilitator().register( + NETWORK, + new AuthCaptureEvmFacilitator(facilitatorSigner), + ); + const facilitatorClient = new EvmFacilitatorClient(facilitator); + + const clientSigner = toClientEvmSigner(clientAccount, publicClient); + const client = new x402Client().register(NETWORK, new AuthCaptureEvmClient(clientSigner)); + + const server = new x402ResourceServer(facilitatorClient); + server.register( + NETWORK, + new AuthCaptureEvmServer({ + receiverAuthorizerSigner: { + address: facilitatorAccount.address, + signTypedData: params => facilitatorAccount.signTypedData(params as never), + }, + }), + ); + + return { + client, + server, + receiverAddress: facilitatorAccount.address, + clientAddress: clientAccount.address, + facilitatorAddress: facilitatorAccount.address, + publicClient, + }; +} + +describe("AuthCapture EVM Integration Tests", () => { + describeOnChain("x402Client / x402ResourceServer / x402Facilitator - direct API", () => { + let client: x402Client; + let server: x402ResourceServer; + let receiverAddress: `0x${string}`; + let clientAddress: `0x${string}`; + let facilitatorAddress: `0x${string}`; + + beforeEach(async () => { + const pipeline = buildPipeline(); + client = pipeline.client; + server = pipeline.server; + receiverAddress = pipeline.receiverAddress; + clientAddress = pipeline.clientAddress; + facilitatorAddress = pipeline.facilitatorAddress; + await server.initialize(); + }); + + it( + "EIP-3009 + paymentFlow escrow — verifies and authorizes escrow", + { timeout: 60000 }, + async () => { + const accepts = [ + buildAuthCaptureRequirements(receiverAddress, "1000", facilitatorAddress, { + assetTransferMethod: "eip3009", + paymentFlow: "escrow", + captureMode: "deferred", + }), + ]; + const resource = { + url: "https://example.com/api", + description: "auth-capture test resource", + mimeType: "application/json", + }; + + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const payload = await client.createPaymentPayload(paymentRequired); + + expect(payload.x402Version).toBe(2); + expect(payload.accepted.scheme).toBe("auth-capture"); + + const accepted = server.findMatchingRequirements(accepts, payload); + expect(accepted).toBeDefined(); + + // Escrow flow skips facilitator /verify (settle-before-handler). Authorize is settle. + const settleResponse = await server.settlePayment(payload, accepted!); + expect(settleResponse.success, JSON.stringify(settleResponse)).toBe(true); + expect(settleResponse.network).toBe(NETWORK); + expect(settleResponse.transaction).toBeDefined(); + expect(settleResponse.payer?.toLowerCase()).toBe(clientAddress.toLowerCase()); + }, + ); + + it( + "EIP-3009 + paymentFlow authorization — verifies and charges (single-shot transfer)", + { timeout: 60000 }, + async () => { + const accepts = [ + buildAuthCaptureRequirements(receiverAddress, "1000", facilitatorAddress, { + assetTransferMethod: "eip3009", + paymentFlow: "authorization", + receiverAuthorizer: facilitatorAddress, + }), + ]; + const resource = { + url: "https://example.com/api", + description: "auth-capture charge test", + mimeType: "application/json", + }; + + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const payload = await client.createPaymentPayload(paymentRequired); + const accepted = server.findMatchingRequirements(accepts, payload); + + const verifyResponse = await server.verifyPayment(payload, accepted!); + expect(verifyResponse.isValid, JSON.stringify(verifyResponse)).toBe(true); + + const settleResponse = await server.settlePayment(payload, accepted!); + expect(settleResponse.success, JSON.stringify(settleResponse)).toBe(true); + expect(settleResponse.transaction).toBeDefined(); + }, + ); + + it( + "Permit2 + paymentFlow escrow — verifies and authorizes (requires Permit2 pre-approval)", + { timeout: 60000 }, + async () => { + const accepts = [ + buildAuthCaptureRequirements(receiverAddress, "1000", facilitatorAddress, { + assetTransferMethod: "permit2", + paymentFlow: "escrow", + captureMode: "deferred", + }), + ]; + const resource = { + url: "https://example.com/api", + description: "auth-capture Permit2 test", + mimeType: "application/json", + }; + + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const payload = await client.createPaymentPayload(paymentRequired); + const accepted = server.findMatchingRequirements(accepts, payload); + + const settleResponse = await server.settlePayment(payload, accepted!); + expect(settleResponse.success, JSON.stringify(settleResponse)).toBe(true); + expect(settleResponse.transaction).toBeDefined(); + }, + ); + + it( + "Permit2 + paymentFlow authorization — verifies and charges (requires Permit2 pre-approval)", + { timeout: 60000 }, + async () => { + const accepts = [ + buildAuthCaptureRequirements(receiverAddress, "1000", facilitatorAddress, { + assetTransferMethod: "permit2", + paymentFlow: "authorization", + receiverAuthorizer: facilitatorAddress, + }), + ]; + const resource = { + url: "https://example.com/api", + description: "auth-capture Permit2 charge test", + mimeType: "application/json", + }; + + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const payload = await client.createPaymentPayload(paymentRequired); + const accepted = server.findMatchingRequirements(accepts, payload); + + const verifyResponse = await server.verifyPayment(payload, accepted!); + expect(verifyResponse.isValid, JSON.stringify(verifyResponse)).toBe(true); + + const settleResponse = await server.settlePayment(payload, accepted!); + expect(settleResponse.success, JSON.stringify(settleResponse)).toBe(true); + expect(settleResponse.transaction).toBeDefined(); + }, + ); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/integrations/helpers/authCaptureOperators.ts b/typescript/packages/mechanisms/evm/test/integrations/helpers/authCaptureOperators.ts new file mode 100644 index 0000000000..60f3b8bb20 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/integrations/helpers/authCaptureOperators.ts @@ -0,0 +1,172 @@ +/** + * CREATE2 skip-if-deployed helpers for auth-capture test operators. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + concat, + encodeDeployData, + getAddress, + getContractAddress, + keccak256, + pad, + stringToHex, + type Abi, + type Hex, +} from "viem"; + +/** Arachnid's deterministic CREATE2 deployer (same address on Base Sepolia). */ +export const CREATE2_DEPLOYER = "0x4e59b44847b379578588920cA78FbF26c0B4956C" as const; + +/** Inner CREATE2 needs more gas than a plain call; public RPCs often underestimate. */ +const CREATE2_DEPLOY_GAS = 3_000_000n; + +export type OperatorArtifact = { + abi: Abi; + bytecode: Hex; +}; + +type CodeReader = { + getCode: (args: { address: `0x${string}` }) => Promise; + waitForTransactionReceipt: (args: { hash: Hex }) => Promise<{ + status: string; + to?: `0x${string}` | null; + contractAddress?: `0x${string}` | null; + }>; +}; + +type TxSender = { + sendTransaction: (args: { to: `0x${string}`; data: Hex; gas?: bigint }) => Promise; +}; + +/** + * Loads a committed ABI+bytecode artifact for a test operator. + * + * @param name - Contract file stem (`ForwardingOperator`, `NoopOperator`, `GasWastingOperator`). + * @returns Artifact consumed by CREATE2 deploy. + */ +export function loadOperatorArtifact(name: string): OperatorArtifact { + const path = join(process.cwd(), "test/contracts/auth-capture/artifacts", `${name}.json`); + const parsed = JSON.parse(readFileSync(path, "utf8")) as { + abi: Abi; + bytecode: string; + }; + return { + abi: parsed.abi, + bytecode: parsed.bytecode as Hex, + }; +} + +/** + * CREATE2 salt bound to the operator contract name. Bytecode changes still + * produce a new address via the init-code hash, so a new deploy happens only then. + * + * @param name - Contract file stem. + * @returns 32-byte salt. + */ +export function operatorCreate2Salt(name: string): Hex { + return keccak256(stringToHex(`x402.auth-capture.test.${name}.v1`)); +} + +/** + * Reads contract code at `latest`, retrying so a lagging public RPC does not + * report an empty account right after CREATE2. Historical `blockNumber` is + * avoided: public Base Sepolia RPCs often prune and return "block not found". + * + * @param publicClient - Code reader. + * @param address - Predicted CREATE2 address. + * @returns Bytecode, or undefined when still empty. + */ +async function readDeployedCode( + publicClient: CodeReader, + address: `0x${string}`, +): Promise { + for (let attempt = 0; attempt < 8; attempt++) { + try { + const code = await publicClient.getCode({ address }); + if (code && code !== "0x") { + return code; + } + } catch { + // Public RPCs can throw on a lagging head; retry at latest. + } + await new Promise(resolve => setTimeout(resolve, 250 * (attempt + 1))); + } + return undefined; +} + +/** + * Predicts the CREATE2 address, deploys via Arachnid's deployer if empty, and + * returns the address. Subsequent runs reuse the live contract. + * + * @param publicClient - Reads code at the predicted address. + * @param walletClient - Pays for the one-time deploy. + * @param artifact - Committed ABI and creation bytecode. + * @param salt - CREATE2 salt. + * @param constructorArgs - Encoded after bytecode; empty when the contract has no constructor. + * @returns The operator address. + */ +export async function ensureCreate2Operator( + publicClient: CodeReader, + walletClient: TxSender, + artifact: OperatorArtifact, + salt: Hex, + constructorArgs: readonly unknown[] = [], +): Promise<`0x${string}`> { + const initCode = encodeDeployData({ + abi: artifact.abi, + bytecode: artifact.bytecode, + args: [...constructorArgs], + }); + const saltBytes32 = pad(salt, { size: 32 }); + const address = getAddress( + getContractAddress({ + opcode: "CREATE2", + from: CREATE2_DEPLOYER, + salt: saltBytes32, + bytecode: initCode, + }), + ); + const deployData = concat([saltBytes32, initCode]); + + const deployerCode = await publicClient.getCode({ address: CREATE2_DEPLOYER }); + if (!deployerCode || deployerCode === "0x") { + throw new Error(`CREATE2 deployer ${CREATE2_DEPLOYER} is not deployed on this chain`); + } + + const existing = await publicClient.getCode({ address }); + if (existing && existing !== "0x") { + return address; + } + + const hash = await walletClient.sendTransaction({ + to: CREATE2_DEPLOYER, + data: deployData, + gas: CREATE2_DEPLOY_GAS, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new Error(`CREATE2 deploy of operator at ${address} reverted (tx ${hash})`); + } + + const deployed = await readDeployedCode(publicClient, address); + if (deployed) { + return address; + } + + const fallback = receipt.contractAddress ? getAddress(receipt.contractAddress) : undefined; + if (fallback) { + const fallbackCode = await readDeployedCode(publicClient, fallback); + if (fallbackCode) { + throw new Error( + `CREATE2 predicted ${address} but the tx created ${fallback} instead (tx ${hash}). ` + + `The factory call likely lost its to= field; check sendTransaction args.`, + ); + } + } + + throw new Error( + `No bytecode at predicted CREATE2 address ${address} (tx ${hash}, to=${receipt.to ?? "null"})`, + ); +} diff --git a/typescript/packages/mechanisms/evm/test/unit/assetCache.test.ts b/typescript/packages/mechanisms/evm/test/unit/assetCache.test.ts index 3e6a480ea8..6f1645fd00 100644 --- a/typescript/packages/mechanisms/evm/test/unit/assetCache.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/assetCache.test.ts @@ -7,6 +7,7 @@ import { globalAssetContractCache, resetAssetContractCache, startAssetContractCheck, + validateAssetIsContract, type AssetContractCacheKey, } from "../../src/assetCache"; @@ -96,6 +97,24 @@ describe("asset contract cache", () => { ).toBe(false); }); + it("wraps eth_getCode failures", async () => { + const signer: FacilitatorEvmSigner = { + getAddresses: () => [], + readContract: async () => 0n, + verifyTypedData: async () => false, + writeContract: async () => "0x", + sendTransaction: async () => "0x", + waitForTransactionReceipt: async () => ({ status: "success" }), + getCode: async () => { + throw "rpc down"; + }, + }; + + await expect(validateAssetIsContract(signer, "eip155:84532", cacheTestAsset)).rejects.toThrow( + "failed to check whether asset is a contract: rpc down", + ); + }); + it("expires entries after the TTL", () => { const key: AssetContractCacheKey = { network: "eip155:84532", asset: cacheTestAsset }; const start = Date.now(); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/abi.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/abi.test.ts new file mode 100644 index 0000000000..186f23b292 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/abi.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { + ESCROW_ABI_V1_0, + ESCROW_ABI_V1_1, + escrowAbiForDeployment, + escrowAbiForVersion, +} from "../../../src/auth-capture/abi"; +import { + AUTH_CAPTURE_DEPLOYMENT_V1_0, + AUTH_CAPTURE_DEPLOYMENT_V1_1, +} from "../../../src/auth-capture/constants"; + +describe("auth-capture escrow ABI helpers", () => { + it("selects v1.0 or v1.1 charge/capture ABI by deployment version", () => { + expect(escrowAbiForVersion("v1.0")).toBe(ESCROW_ABI_V1_0); + expect(escrowAbiForVersion("v1.1")).toBe(ESCROW_ABI_V1_1); + }); + + it("selects ABI from a resolved deployment record", () => { + expect(escrowAbiForDeployment(AUTH_CAPTURE_DEPLOYMENT_V1_0)).toBe(ESCROW_ABI_V1_0); + expect(escrowAbiForDeployment(AUTH_CAPTURE_DEPLOYMENT_V1_1)).toBe(ESCROW_ABI_V1_1); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/collect.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/collect.test.ts new file mode 100644 index 0000000000..d858ea5cc0 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/collect.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from "vitest"; +import { zeroAddress } from "viem"; +import { verifyCollect } from "../../../src/auth-capture/facilitator/collect"; +import { EIP3009_TOKEN_COLLECTOR_ADDRESS } from "../../../src/auth-capture/constants"; +import { + computePayerAgnosticPaymentInfoHash, + deriveBoundSalt, +} from "../../../src/auth-capture/nonce"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; +import type { PaymentInfoStruct } from "../../../src/auth-capture/types"; + +const ERC1271_MAGIC_VALUE = "0x1626ba7e" as const; +const PAYER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as `0x${string}`; +const ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; +const PAY_TO = "0xdddddddddddddddddddddddddddddddddddddddd" as `0x${string}`; +const CAPTURE_AUTHORIZER = "0x1234567890123456789012345678901234567890" as `0x${string}`; +const RECEIVER_AUTHORIZER = "0x1111111111111111111111111111111111111111" as `0x${string}`; +const FEE_RECIPIENT = "0x4444444444444444444444444444444444444444" as `0x${string}`; +const SALT_NONCE = + "0x2222222222222222222222222222222222222222222222222222222222222222" as `0x${string}`; +const BOUND_SALT = deriveBoundSalt(RECEIVER_AUTHORIZER, zeroAddress, SALT_NONCE); + +const futureSeconds = Math.floor(Date.now() / 1000) + 3600; +const captureDeadline = futureSeconds + 86400; +const refundDeadline = captureDeadline + 86400; + +function buildPaymentInfo(): PaymentInfoStruct { + return { + operator: CAPTURE_AUTHORIZER, + payer: PAYER, + receiver: PAY_TO, + token: ASSET, + maxAmount: "1000000", + preApprovalExpiry: futureSeconds, + authorizationExpiry: captureDeadline, + refundExpiry: refundDeadline, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: FEE_RECIPIENT, + salt: BOUND_SALT, + }; +} + +function createSigner(): FacilitatorEvmSigner { + return { + getAddresses: () => [CAPTURE_AUTHORIZER], + readContract: vi.fn().mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + return BigInt("1000000000"); + }), + writeContract: vi.fn(), + waitForTransactionReceipt: vi.fn(), + } as FacilitatorEvmSigner; +} + +describe("verifyCollect", () => { + it("requires charge-completion fields at settle time on authorization routes", async () => { + const paymentInfo = buildPaymentInfo(); + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + const extra = { + captureAuthorizer: CAPTURE_AUTHORIZER, + captureDeadline, + refundDeadline, + feeRecipient: FEE_RECIPIENT, + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + paymentFlow: "authorization" as const, + }; + const accepted = { + scheme: "auth-capture", + network: "eip155:84532", + amount: "1000000", + asset: ASSET, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra, + }; + const collect = { + authorization: { + from: PAYER, + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(futureSeconds), + nonce, + }, + signature: "0xabcd" as `0x${string}`, + salt: BOUND_SALT, + saltNonce: SALT_NONCE, + }; + const envelope = { x402Version: 2, accepted, payload: collect }; + const result = await verifyCollect( + [createSigner()], + undefined, + envelope, + accepted, + collect, + undefined, + true, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_payload_format"); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/delegatedAuth.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/delegatedAuth.test.ts new file mode 100644 index 0000000000..4f75818f63 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/delegatedAuth.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + AuthCaptureCallerIdentityConflictError, + InMemoryAuthCaptureDelegatedAuthStorage, + bindThenBroadcast, + getDelegatedAuthorizer, + resolveDelegatedCallerIdentity, + type AuthCaptureDelegatedAuthRecord, + type DelegatedAuthorizer, + type DelegatedSettleContext, +} from "../../../src/auth-capture/facilitator/delegatedAuth"; + +const AUTHORIZER = "0x1111111111111111111111111111111111111111" as `0x${string}`; +const HASH = ("0x" + "ab".repeat(32)) as `0x${string}`; +const NOW_SECONDS = 1_800_000_000; + +function record( + overrides: Partial = {}, +): AuthCaptureDelegatedAuthRecord { + return { + network: "eip155:84532", + paymentInfoHash: HASH, + callerIdentity: "caller-1", + expiresAt: NOW_SECONDS + 3600, + ...overrides, + }; +} + +function delegatedWith(storage: InMemoryAuthCaptureDelegatedAuthStorage): DelegatedAuthorizer { + return { + signer: { address: AUTHORIZER, signTypedData: vi.fn() }, + resolveCallerIdentity: vi.fn(), + storage, + onStorageError: vi.fn(), + }; +} + +describe("InMemoryAuthCaptureDelegatedAuthStorage", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW_SECONDS * 1000); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("stores the first writer and returns a revert token", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + const write = await storage.bind(record()); + expect(write.record.callerIdentity).toBe("caller-1"); + expect(write.revertToken).not.toBe(""); + expect(await storage.get("eip155:84532", HASH)).toEqual(record()); + }); + + it("keeps the first writer and returns an empty token for a different identity", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + await storage.bind(record()); + const write = await storage.bind(record({ callerIdentity: "caller-2", expiresAt: 1 })); + expect(write.record).toEqual(record()); + expect(write.revertToken).toBe(""); + expect(await storage.get("eip155:84532", HASH)).toEqual(record()); + }); + + it("rotates the token on a same-identity bind so the creator's revert no longer matches", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + const creator = await storage.bind(record()); + const retry = await storage.bind(record()); + expect(retry.revertToken).toBe(""); + + await storage.revertBind(creator); + expect(await storage.get("eip155:84532", HASH)).toBeDefined(); + }); + + it("deletes the row on revert only with the matching non-empty token", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + const write = await storage.bind(record()); + await storage.revertBind({ ...write, revertToken: "" }); + expect(await storage.get("eip155:84532", HASH)).toBeDefined(); + await storage.revertBind(write); + expect(await storage.get("eip155:84532", HASH)).toBeUndefined(); + }); + + it("treats an expired row as absent and lets a new identity bind", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + await storage.bind(record({ expiresAt: NOW_SECONDS + 10 })); + vi.setSystemTime((NOW_SECONDS + 10) * 1000); + expect(await storage.get("eip155:84532", HASH)).toBeUndefined(); + + await storage.bind(record({ expiresAt: NOW_SECONDS + 20 })); + vi.setSystemTime((NOW_SECONDS + 20) * 1000); + const write = await storage.bind( + record({ callerIdentity: "caller-2", expiresAt: NOW_SECONDS + 99 }), + ); + expect(write.record.callerIdentity).toBe("caller-2"); + expect(write.revertToken).not.toBe(""); + }); + + it("scopes rows by network and case-insensitive hash", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + await storage.bind(record()); + expect(await storage.get("eip155:8453", HASH)).toBeUndefined(); + expect( + await storage.get("eip155:84532", HASH.toUpperCase().replace("0X", "0x") as `0x${string}`), + ).toBeDefined(); + await storage.delete("eip155:84532", HASH); + expect(await storage.get("eip155:84532", HASH)).toBeUndefined(); + }); +}); + +describe("getDelegatedAuthorizer", () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + const config = { + authorizerSigner: { address: AUTHORIZER, signTypedData: vi.fn() }, + resolveCallerIdentity: () => "caller-1", + delegatedAuthStorage: storage, + onStorageError: vi.fn(), + }; + + it("returns the wiring only when the receiverAuthorizer is the configured signer", () => { + expect(getDelegatedAuthorizer(config, AUTHORIZER)?.storage).toBe(storage); + expect( + getDelegatedAuthorizer(config, AUTHORIZER.toUpperCase().replace("0X", "0x") as `0x${string}`), + ).toBeDefined(); + expect( + getDelegatedAuthorizer(config, "0x2222222222222222222222222222222222222222"), + ).toBeUndefined(); + }); + + it("returns undefined when delegation is not fully configured", () => { + expect(getDelegatedAuthorizer(undefined, AUTHORIZER)).toBeUndefined(); + expect( + getDelegatedAuthorizer({ ...config, delegatedAuthStorage: undefined }, AUTHORIZER), + ).toBeUndefined(); + expect( + getDelegatedAuthorizer({ ...config, onStorageError: undefined }, AUTHORIZER), + ).toBeUndefined(); + }); +}); + +describe("resolveDelegatedCallerIdentity", () => { + const ctx = { step: "capture" } as DelegatedSettleContext; + + async function resolve(result: () => unknown) { + const delegated = delegatedWith(new InMemoryAuthCaptureDelegatedAuthStorage()); + delegated.resolveCallerIdentity = vi.fn().mockImplementation(async () => result()); + return resolveDelegatedCallerIdentity(delegated, ctx); + } + + it("returns a non-empty identity", async () => { + expect(await resolve(() => "caller-1")).toBe("caller-1"); + }); + + it.each([[undefined], [""], [42]])("treats %j as unauthenticated", async value => { + expect(await resolve(() => value)).toBeUndefined(); + }); + + it("treats a thrown error as unauthenticated", async () => { + expect( + await resolve(() => { + throw new Error("boom"); + }), + ).toBeUndefined(); + }); +}); + +describe("bindThenBroadcast", () => { + it("broadcasts after a successful bind and keeps the row", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + const broadcast = vi.fn().mockResolvedValue({ value: "ok", disposition: "keep" }); + const result = await bindThenBroadcast({ + delegated: delegatedWith(storage), + record: record(), + broadcast, + }); + expect(result).toEqual({ ok: true, value: "ok" }); + expect(await storage.get("eip155:84532", HASH)).toBeDefined(); + }); + + it("reverts the created row when the broadcast asks for it", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + const result = await bindThenBroadcast({ + delegated: delegatedWith(storage), + record: record(), + broadcast: async () => ({ value: "failed", disposition: "revert" }), + }); + expect(result).toEqual({ ok: true, value: "failed" }); + expect(await storage.get("eip155:84532", HASH)).toBeUndefined(); + }); + + it("reports a failing revert without replacing the broadcast result", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + vi.spyOn(storage, "revertBind").mockRejectedValue(new Error("db down")); + const delegated = delegatedWith(storage); + const result = await bindThenBroadcast({ + delegated, + record: record(), + broadcast: async () => ({ value: "failed", disposition: "revert" }), + }); + expect(result).toEqual({ ok: true, value: "failed" }); + expect(delegated.onStorageError).toHaveBeenCalledWith(expect.any(Error), "eip155:84532", HASH); + }); + + it("does not revert when the broadcast throws, because the outcome is unknown", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + await expect( + bindThenBroadcast({ + delegated: delegatedWith(storage), + record: record(), + broadcast: async () => { + throw new Error("rpc dropped"); + }, + }), + ).rejects.toThrow("rpc dropped"); + expect(await storage.get("eip155:84532", HASH)).toBeDefined(); + }); + + it("fails closed without broadcasting on an identity conflict", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + await storage.bind(record({ callerIdentity: "someone-else" })); + const broadcast = vi.fn(); + const result = await bindThenBroadcast({ + delegated: delegatedWith(storage), + record: record(), + broadcast, + }); + expect(result).toMatchObject({ ok: false, reason: "conflict" }); + expect(!result.ok && result.error).toBeInstanceOf(AuthCaptureCallerIdentityConflictError); + expect(broadcast).not.toHaveBeenCalled(); + }); + + it("fails closed without broadcasting when the store is unavailable", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + vi.spyOn(storage, "bind").mockRejectedValue(new Error("db down")); + const broadcast = vi.fn(); + const result = await bindThenBroadcast({ + delegated: delegatedWith(storage), + record: record(), + broadcast, + }); + expect(result).toMatchObject({ ok: false, reason: "unavailable" }); + expect(broadcast).not.toHaveBeenCalled(); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/extra.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/extra.test.ts new file mode 100644 index 0000000000..450642ac99 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/extra.test.ts @@ -0,0 +1,378 @@ +import { describe, it, expect } from "vitest"; +import { zeroAddress } from "viem"; +import { + AUTH_CAPTURE_DEPLOYMENT_V1_0, + AUTH_CAPTURE_DEPLOYMENT_V1_1, + AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, +} from "../../../src/auth-capture/constants"; +import * as Errors from "../../../src/auth-capture/errors"; +import { + captureEscrowArgs, + captureFeeFromPayload, + chargeEscrowArgs, + chargeFeeFromCollectPayload, + defaultSubmittedFee, + feeFieldMatchesDeployment, + parseAuthCaptureExtra, + submittedFeeAmount, + validateOperator, + validateSubmittedFee, + verifyCommon, +} from "../../../src/auth-capture/extra"; +import { paymentInfoToContractTuple } from "../../../src/auth-capture/utils"; +import type { NormalizedAuthCaptureExtra } from "../../../src/auth-capture/extra"; + +const FUTURE = Math.floor(Date.now() / 1000) + 86400; + +function baseExtra(overrides: Record = {}) { + return { + captureAuthorizer: "0xcccccccccccccccccccccccccccccccccccccccc", + captureDeadline: FUTURE, + refundDeadline: FUTURE + 86400, + feeRecipient: "0x4444444444444444444444444444444444444444", + minFeeBps: 10, + maxFeeBps: 100, + name: "USDC", + version: "2", + ...overrides, + }; +} + +function parseOk(overrides: Record = {}): NormalizedAuthCaptureExtra { + const parsed = parseAuthCaptureExtra(baseExtra(overrides)); + if ("error" in parsed) { + throw new Error(`expected parse ok, got ${parsed.error}`); + } + return parsed.extra; +} + +describe("parseAuthCaptureExtra", () => { + it("rejects removed autoCapture flag", () => { + const result = parseAuthCaptureExtra({ ...baseExtra(), autoCapture: true }); + expect(result).toEqual({ error: Errors.ErrUnsupportedPaymentFlow }); + }); + + it("rejects unknown escrow pins", () => { + const result = parseAuthCaptureExtra({ + ...baseExtra(), + authCaptureEscrow: "0x0000000000000000000000000000000000000001", + }); + expect(result).toEqual({ error: Errors.ErrInvalidAuthCaptureExtra }); + }); + + it("rejects unsupported assetTransferMethod values", () => { + expect(parseAuthCaptureExtra({ ...baseExtra(), assetTransferMethod: "transfer" })).toEqual({ + error: Errors.ErrUnsupportedAssetTransferMethod, + }); + }); + + it("rejects unsupported paymentFlow and captureMode values", () => { + expect(parseAuthCaptureExtra({ ...baseExtra(), paymentFlow: "hold" })).toEqual({ + error: Errors.ErrUnsupportedPaymentFlow, + }); + expect(parseAuthCaptureExtra({ ...baseExtra(), captureMode: "later" })).toEqual({ + error: Errors.ErrInvalidAuthCaptureExtra, + }); + }); + + it("rejects policy operator type and unknown operator types", () => { + expect(parseAuthCaptureExtra({ ...baseExtra(), operatorType: "policy" })).toEqual({ + error: Errors.ErrUnsupportedOperatorType, + }); + expect(parseAuthCaptureExtra({ ...baseExtra(), operatorType: "relay" })).toEqual({ + error: Errors.ErrUnsupportedOperatorType, + }); + }); + + it("rejects inverted fee bounds and non-integer bps", () => { + expect(parseAuthCaptureExtra({ ...baseExtra(), minFeeBps: 200, maxFeeBps: 100 })).toEqual({ + error: Errors.ErrInvalidAuthCaptureExtra, + }); + expect(parseAuthCaptureExtra({ ...baseExtra(), minFeeBps: 1.5 })).toEqual({ + error: Errors.ErrInvalidAuthCaptureExtra, + }); + }); + + it("rejects non-zero fees when feeRecipient is the zero address", () => { + const result = parseAuthCaptureExtra({ + ...baseExtra(), + feeRecipient: zeroAddress, + minFeeBps: 1, + maxFeeBps: 10, + }); + expect(result).toEqual({ error: Errors.ErrInvalidAuthCaptureExtra }); + }); + + it("requires receiverAuthorizer on authorization flow", () => { + const result = parseAuthCaptureExtra({ + ...baseExtra(), + paymentFlow: "authorization", + receiverAuthorizer: zeroAddress, + }); + expect(result).toEqual({ error: Errors.ErrMissingReceiverAuthorizer }); + }); + + it("normalizes v1.0 escrow pin into deployment metadata", () => { + const { extra } = parseAuthCaptureExtra({ + ...baseExtra(), + authCaptureEscrow: AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + }) as { extra: NormalizedAuthCaptureExtra }; + expect(extra.deployment).toEqual(AUTH_CAPTURE_DEPLOYMENT_V1_0); + expect(extra.authCaptureEscrow).toBe(AUTH_CAPTURE_ESCROW_V1_0_ADDRESS); + }); + + it("defaults to v1.1 deployment when escrow is omitted", () => { + const extra = parseOk(); + expect(extra.deployment).toEqual(AUTH_CAPTURE_DEPLOYMENT_V1_1); + }); +}); + +describe("validateSubmittedFee", () => { + it("rejects v1.0 fee bps outside published bounds", () => { + const extra = parseOk({ minFeeBps: 50, maxFeeBps: 100 }); + expect( + validateSubmittedFee(extra, "1000000", { + version: "v1.0", + feeBps: 10, + feeReceiver: extra.feeRecipient, + }), + ).toBe(Errors.ErrFeeBpsOutOfRange); + }); + + it("rejects v1.1 absolute fee above the max bps-derived amount", () => { + const extra = parseOk({ minFeeBps: 0, maxFeeBps: 100 }); + expect( + validateSubmittedFee(extra, "1000000", { + version: "v1.1", + feeAmount: "20000", + feeReceiver: extra.feeRecipient, + }), + ).toBe(Errors.ErrFeeBpsOutOfRange); + }); + + it("rejects a mismatched fee receiver when extra pins a non-zero recipient", () => { + const extra = parseOk(); + expect( + validateSubmittedFee(extra, "1000000", { + version: "v1.1", + feeAmount: "5000", + feeReceiver: zeroAddress, + }), + ).toBe(Errors.ErrInvalidFeeReceiver); + }); + + it("accepts in-range v1.1 absolute fee", () => { + const extra = parseOk({ minFeeBps: 0, maxFeeBps: 100 }); + expect( + validateSubmittedFee(extra, "1000000", { + version: "v1.1", + feeAmount: "5000", + feeReceiver: extra.feeRecipient, + }), + ).toBeUndefined(); + }); + + it("rejects non-zero v1.1 fee when the fee receiver is the zero address", () => { + const extra = { ...parseOk(), feeRecipient: zeroAddress }; + expect( + validateSubmittedFee(extra, "1000000", { + version: "v1.1", + feeAmount: "5000", + feeReceiver: zeroAddress, + }), + ).toBe(Errors.ErrZeroFeeReceiver); + }); +}); + +describe("validateOperator", () => { + const submitters = ["0xcccccccccccccccccccccccccccccccccccccccc"] as const; + + it("rejects custom operators that are not on the facilitator allowlist", () => { + const extra = parseOk({ + operatorType: "custom", + captureAuthorizer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + expect(validateOperator(extra, [], { operators: [] }, false)).toBe( + Errors.ErrOperatorNotAdmitted, + ); + }); + + it("rejects delegated operators that are not facilitator submitters", () => { + const extra = parseOk(); + expect( + validateOperator(extra, ["0x0000000000000000000000000000000000000001"], undefined, false), + ).toBe(Errors.ErrOperatorNotAdmitted); + }); + + it("blocks lifecycle relay for custom operators", () => { + const extra = parseOk({ + operatorType: "custom", + captureAuthorizer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + expect( + validateOperator( + extra, + submitters, + { operators: [{ operatorType: "custom", address: "*" }] }, + true, + ), + ).toBe(Errors.ErrLifecycleNotRelayed); + }); + + it("blocks delegated lifecycle relay when no receiver authorizer is configured", () => { + const extra = parseOk({ receiverAuthorizer: zeroAddress }); + expect(validateOperator(extra, submitters, undefined, true)).toBe( + Errors.ErrLifecycleNotRelayed, + ); + }); + + it("rejects non-zero policy addresses", () => { + const extra = parseOk({ policy: "0x2222222222222222222222222222222222222222" }); + expect(validateOperator(extra, submitters, undefined, false)).toBe(Errors.ErrInvalidPolicy); + }); +}); + +describe("fee encoding helpers", () => { + it("matches deployment version to wire fee fields", () => { + expect(feeFieldMatchesDeployment(AUTH_CAPTURE_DEPLOYMENT_V1_0, { feeBps: 0 })).toBe(true); + expect(feeFieldMatchesDeployment(AUTH_CAPTURE_DEPLOYMENT_V1_0, { feeAmount: "1" })).toBe(false); + expect(feeFieldMatchesDeployment(AUTH_CAPTURE_DEPLOYMENT_V1_1, { feeAmount: "1" })).toBe(true); + }); + + it("derives default submitted fee per deployment version", () => { + const v10 = parseOk({ authCaptureEscrow: AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, minFeeBps: 25 }); + expect(defaultSubmittedFee(v10, "1000000")).toEqual({ + version: "v1.0", + feeBps: 25, + feeReceiver: v10.feeRecipient, + }); + const v11 = parseOk({ minFeeBps: 50 }); + expect(defaultSubmittedFee(v11, "1000000")).toEqual({ + version: "v1.1", + feeAmount: "5000", + feeReceiver: v11.feeRecipient, + }); + }); + + it("converts submitted fee to atomic amount for balance checks", () => { + expect( + submittedFeeAmount({ version: "v1.0", feeBps: 100, feeReceiver: zeroAddress }, 1_000_000n), + ).toBe(10_000n); + expect( + submittedFeeAmount( + { version: "v1.1", feeAmount: "123", feeReceiver: zeroAddress }, + 1_000_000n, + ), + ).toBe(123n); + }); + + it("builds escrow capture and charge args for each deployment", () => { + const tuple = paymentInfoToContractTuple({ + operator: "0xcccccccccccccccccccccccccccccccccccccccc", + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + receiver: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + maxAmount: "1000000", + preApprovalExpiry: FUTURE, + authorizationExpiry: FUTURE + 100, + refundExpiry: FUTURE + 200, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: "0x4444444444444444444444444444444444444444", + salt: "0x" + "11".repeat(32), + }); + const feeRecipient = "0x4444444444444444444444444444444444444444" as `0x${string}`; + expect( + captureEscrowArgs(tuple, 500_000n, { + version: "v1.0", + feeBps: 10, + feeReceiver: feeRecipient, + }), + ).toEqual([tuple, 500_000n, 10, feeRecipient]); + expect( + captureEscrowArgs(tuple, 500_000n, { + version: "v1.1", + feeAmount: "99", + feeReceiver: feeRecipient, + }), + ).toEqual([tuple, 500_000n, 99n, feeRecipient]); + const collector = "0xdddddddddddddddddddddddddddddddddddddddd" as `0x${string}`; + const data = "0x1234" as `0x${string}`; + expect( + chargeEscrowArgs(tuple, 1n, collector, data, { + version: "v1.0", + feeBps: 0, + feeReceiver: feeRecipient, + }), + ).toEqual([tuple, 1n, collector, data, 0, feeRecipient]); + }); + + it("reads capture and charge fees only when wire fields match deployment", () => { + const v11 = parseOk(); + expect( + captureFeeFromPayload(v11, { + feeBps: 1, + feeReceiver: v11.feeRecipient, + }), + ).toBeUndefined(); + expect( + captureFeeFromPayload(v11, { + feeAmount: "10", + feeReceiver: v11.feeRecipient, + }), + ).toEqual({ version: "v1.1", feeAmount: "10", feeReceiver: v11.feeRecipient }); + expect( + chargeFeeFromCollectPayload(v11, { feeAmount: "10", feeReceiver: v11.feeRecipient }), + ).toEqual({ version: "v1.1", feeAmount: "10", feeReceiver: v11.feeRecipient }); + }); +}); + +describe("verifyCommon", () => { + const requirements = { + scheme: "auth-capture", + network: "eip155:84532", + amount: "1", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 60, + extra: baseExtra(), + }; + + it("rejects scheme and network mismatches before parsing extra", () => { + expect( + verifyCommon("exact", "eip155:84532", requirements, "auth-capture", [], undefined, false), + ).toEqual({ error: Errors.ErrUnsupportedScheme }); + expect( + verifyCommon("auth-capture", "eip155:1", requirements, "auth-capture", [], undefined, false), + ).toEqual({ error: Errors.ErrNetworkMismatch }); + }); + + it("rejects invalid CAIP-2 network strings", () => { + const badNetworkRequirements = { ...requirements, network: "not-a-network" }; + expect( + verifyCommon( + "auth-capture", + "not-a-network", + badNetworkRequirements, + "auth-capture", + [], + undefined, + false, + ), + ).toEqual({ error: Errors.ErrInvalidNetwork }); + }); + + it("returns normalized extra when checks pass", () => { + const submitters = [baseExtra().captureAuthorizer as `0x${string}`]; + const result = verifyCommon( + "auth-capture", + "eip155:84532", + requirements, + "auth-capture", + submitters, + undefined, + false, + ); + expect("extra" in result).toBe(true); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/facilitator-utils.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/facilitator-utils.test.ts new file mode 100644 index 0000000000..d7f2a13c10 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/facilitator-utils.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; +import { BaseError, ContractFunctionRevertedError } from "viem"; +import { + collectPayer, + decodeRevertReason, + facilitatorAddresses, + resolveSubmitter, + selectSubmitter, +} from "../../../src/auth-capture/facilitator/utils"; +import { + ESCROW_ERROR_TO_INVALID_REASON, + ErrSimulationFailed, +} from "../../../src/auth-capture/errors"; +import { EIP3009_TOKEN_COLLECTOR_ADDRESS } from "../../../src/auth-capture/constants"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; + +const A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as `0x${string}`; +const B = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as `0x${string}`; +const C = "0xcccccccccccccccccccccccccccccccccccccccc" as `0x${string}`; + +function signer(addresses: readonly `0x${string}`[]): FacilitatorEvmSigner { + return { getAddresses: () => [...addresses] } as FacilitatorEvmSigner; +} + +describe("auth-capture facilitator utils", () => { + it("deduplicates facilitator submitter addresses across signers", () => { + expect(facilitatorAddresses([signer([A]), signer([A, B])])).toEqual([A, B]); + }); + + it("selects the signer that owns a delegated captureAuthorizer", () => { + const signers = [signer([A]), signer([B])]; + expect(selectSubmitter(signers, B)?.getAddresses()).toEqual([B]); + expect(selectSubmitter(signers, C)).toBeUndefined(); + }); + + it("uses the first signer for custom operators", () => { + const signers = [signer([A]), signer([B])]; + expect(resolveSubmitter(signers, { operatorType: "custom", captureAuthorizer: C })).toBe( + signers[0], + ); + expect(resolveSubmitter(signers, { operatorType: "delegated", captureAuthorizer: B })).toBe( + signers[1], + ); + }); + + it("reads the payer from EIP-3009 and Permit2 collect envelopes", () => { + expect( + collectPayer({ + authorization: { + from: A, + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1", + validAfter: "0", + validBefore: "9999999999", + nonce: "0x" + "11".repeat(32), + }, + signature: "0xab", + salt: "0x" + "22".repeat(32), + }), + ).toBe(A); + expect( + collectPayer({ + permit2Authorization: { + from: B, + permitted: { token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", amount: "1" }, + spender: EIP3009_TOKEN_COLLECTOR_ADDRESS, + nonce: "1", + deadline: "9999999999", + }, + signature: "0xab", + salt: "0x" + "22".repeat(32), + }), + ).toBe(B); + }); + + it("maps escrow custom errors to stable wire reasons", () => { + for (const [errorName, reason] of Object.entries(ESCROW_ERROR_TO_INVALID_REASON)) { + const revert = new ContractFunctionRevertedError({ + abi: [], + data: "0x", + functionName: "authorize", + sender: A, + }); + Object.defineProperty(revert, "data", { value: { errorName } }); + const wrapped = new BaseError("reverted", { cause: revert }); + expect(decodeRevertReason(wrapped)).toBe(reason); + } + expect(decodeRevertReason(new Error("boom"))).toBe(ErrSimulationFailed); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts new file mode 100644 index 0000000000..3b65d8fe5e --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts @@ -0,0 +1,3171 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + ContractFunctionExecutionError, + ContractFunctionRevertedError, + decodeFunctionData, + encodeAbiParameters, + encodeEventTopics, + encodeErrorResult, + getAddress, + hexToBigInt, + serializeErc6492Signature, + type Log, + zeroAddress, +} from "viem"; +import { + InMemoryPendingSettlementStore, + type PendingSettlementStore, +} from "@x402/core/facilitator"; +import { AuthCaptureEvmScheme } from "../../../src/auth-capture/facilitator/scheme"; +import { + InMemoryAuthCaptureDelegatedAuthStorage, + type AuthCaptureDelegatedAuthStorage, +} from "../../../src/auth-capture/facilitator/delegatedAuth"; +import { + facilitatorAddresses, + normalizePaymentState, + PAYMENT_STATE_MAX_ATTEMPTS, + PAYMENT_STATE_RETRY_DELAYS_MS, + readPaymentStateForBalances, + readPaymentStateOnce, + selectSubmitter, +} from "../../../src/auth-capture/facilitator/utils"; +import { + ESCROW_ABI_WITH_ERRORS, + ESCROW_EVENTS_ABI_V1_1, + PAYMENT_INFO_COMPONENTS, +} from "../../../src/auth-capture/abi"; +import { + AUTH_CAPTURE_ESCROW_ADDRESS, + AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT, + EIP3009_TOKEN_COLLECTOR_ADDRESS, + EIP3009_TOKEN_COLLECTOR_V1_0_ADDRESS, + OPERATOR_REFUND_COLLECTOR_ADDRESS, + PERMIT2_TOKEN_COLLECTOR_ADDRESS, +} from "../../../src/auth-capture/constants"; +import { + computePayerAgnosticPaymentInfoHash, + computePaymentInfoHash, + deriveBoundSalt, +} from "../../../src/auth-capture/nonce"; +import { paymentInfoToContractTuple } from "../../../src/auth-capture/utils"; +import { BUILDER_CODE_KEY } from "../../../src/shared/extensions"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; +import * as Errors from "../../../src/auth-capture/errors"; +import type { PaymentInfoStruct } from "../../../src/auth-capture/types"; + +const DEPLOYED_BYTECODE = "0x6080604052" as const; +const ERC1271_MAGIC_VALUE = "0x1626ba7e" as const; +const MOCK_TX_HASH = ("0x" + "ab".repeat(32)) as `0x${string}`; +const TOKEN_STORE = "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" as `0x${string}`; +const INITIAL_BALANCE = BigInt("1000000000"); + +function makeEscrowEventLog( + eventName: "PaymentAuthorized" | "PaymentCharged", + paymentInfo: PaymentInfoStruct, + amount: bigint, + tokenCollector: `0x${string}`, + chainId: number, + fee?: { feeAmount: string | bigint; feeReceiver: `0x${string}` }, +): Log { + const paymentInfoHash = computePaymentInfoHash(chainId, paymentInfo); + const tuple = paymentInfoToContractTuple(paymentInfo); + if (eventName === "PaymentAuthorized") { + const topics = encodeEventTopics({ + abi: ESCROW_EVENTS_ABI_V1_1, + eventName: "PaymentAuthorized", + args: { paymentInfoHash }, + }); + const data = encodeAbiParameters( + [ + { type: "tuple", name: "paymentInfo", components: [...PAYMENT_INFO_COMPONENTS] }, + { type: "uint256", name: "amount" }, + { type: "address", name: "tokenCollector" }, + ], + [tuple, amount, tokenCollector], + ); + return { + address: AUTH_CAPTURE_ESCROW_ADDRESS, + topics, + data, + blockHash: "0x1111111111111111111111111111111111111111111111111111111111111111", + blockNumber: 1n, + transactionHash: "0x2222222222222222222222222222222222222222222222222222222222222222", + transactionIndex: 0, + logIndex: 0, + removed: false, + }; + } + + const topics = encodeEventTopics({ + abi: ESCROW_EVENTS_ABI_V1_1, + eventName: "PaymentCharged", + args: { paymentInfoHash }, + }); + const data = encodeAbiParameters( + [ + { type: "tuple", name: "paymentInfo", components: [...PAYMENT_INFO_COMPONENTS] }, + { type: "uint256", name: "amount" }, + { type: "address", name: "tokenCollector" }, + { type: "uint256", name: "feeAmount" }, + { type: "address", name: "feeReceiver" }, + ], + [tuple, amount, tokenCollector, BigInt(fee!.feeAmount), fee!.feeReceiver], + ); + return { + address: AUTH_CAPTURE_ESCROW_ADDRESS, + topics, + data, + blockHash: "0x1111111111111111111111111111111111111111111111111111111111111111", + blockNumber: 1n, + transactionHash: "0x2222222222222222222222222222222222222222222222222222222222222222", + transactionIndex: 0, + logIndex: 0, + removed: false, + }; +} + +describe("AuthCaptureEvmScheme", () => { + /** + * Signature verification mirrors on-chain SignatureChecker: it reads the + * payer's bytecode and, for addresses with code, calls ERC-1271 + * `isValidSignature`. The payer here is a deployed smart account so the + * fixtures' placeholder signatures verify without real ECDSA material, while + * every other address (notably `captureAuthorizer`) reads as an EOA so + * `resolveSettleTarget` targets the canonical escrow. + */ + const createMockSigner = ( + addresses: readonly `0x${string}`[] = ["0x1234567890123456789012345678901234567890"], + ) => ({ + getAddresses: () => addresses, + readContract: vi.fn().mockImplementation(async (args: { functionName: string }) => { + if (args?.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args?.functionName === "getTokenStore") return TOKEN_STORE; + if (args?.functionName === "paymentState") { + return { + hasCollectedPayment: true, + capturableAmount: BigInt("1000000"), + refundableAmount: 0n, + }; + } + return INITIAL_BALANCE; + }), + writeContract: vi.fn().mockResolvedValue(MOCK_TX_HASH), + verifyTypedData: vi.fn().mockResolvedValue(true), + sendTransaction: vi.fn(), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), + getCode: vi.fn().mockImplementation(async ({ address }: { address: `0x${string}` }) => { + const lower = address.toLowerCase(); + if (lower === PAYER.toLowerCase() || lower === RECEIVER_AUTHORIZER.toLowerCase()) { + return DEPLOYED_BYTECODE; + } + return "0x"; + }), + }); + + let mockSigner: ReturnType; + + function installCustomOperatorSimulation(opts: { + paymentInfo: PaymentInfoStruct; + amount?: bigint; + tokenCollector?: `0x${string}`; + functionName?: "authorize" | "charge"; + gasUsed?: bigint; + logs?: Log[]; + forwardedStatus?: "success" | "failure"; + postState?: { + hasCollectedPayment: boolean; + capturableAmount: bigint; + refundableAmount: bigint; + }; + payerDelta?: bigint; + tokenStoreDelta?: bigint; + receiverDelta?: bigint; + feeReceiverDelta?: bigint; + facilitatorDelta?: bigint; + settledPayerDelta?: bigint; + settledTokenStoreDelta?: bigint; + settledReceiverDelta?: bigint; + settledFeeReceiverDelta?: bigint; + settledFacilitatorDelta?: bigint; + facilitatorAddress?: `0x${string}`; + feeAmount?: string | bigint; + feeReceiver?: `0x${string}`; + receiptLogs?: Log[]; + rejectDataSuffix?: `0x${string}`; + }) { + const amount = opts.amount ?? BigInt("1000000"); + const tokenCollector = opts.tokenCollector ?? EIP3009_TOKEN_COLLECTOR_ADDRESS; + const functionName = opts.functionName ?? "authorize"; + const feeAmount = opts.feeAmount ?? 0; + const feeReceiver = opts.feeReceiver ?? FEE_RECIPIENT; + const logs = + opts.logs ?? + (functionName === "charge" + ? [ + makeEscrowEventLog("PaymentCharged", opts.paymentInfo, amount, tokenCollector, 84532, { + feeAmount, + feeReceiver, + }), + ] + : [ + makeEscrowEventLog( + "PaymentAuthorized", + opts.paymentInfo, + amount, + tokenCollector, + 84532, + ), + ]); + + const freshState = { + hasCollectedPayment: false, + capturableAmount: 0n, + refundableAmount: 0n, + }; + const defaultPostState = + functionName === "authorize" + ? { hasCollectedPayment: true, capturableAmount: amount, refundableAmount: 0n } + : { hasCollectedPayment: true, capturableAmount: 0n, refundableAmount: amount }; + const postState = opts.postState ?? defaultPostState; + const payerDelta = opts.payerDelta ?? -amount; + const tokenStoreDelta = opts.tokenStoreDelta ?? (functionName === "authorize" ? amount : 0n); + const fee = BigInt(feeAmount); + const receiverDelta = opts.receiverDelta ?? (functionName === "charge" ? amount - fee : 0n); + const feeReceiverDelta = opts.feeReceiverDelta ?? fee; + const facilitatorDelta = opts.facilitatorDelta ?? 0n; + const settledPayerDelta = opts.settledPayerDelta ?? payerDelta; + const settledTokenStoreDelta = opts.settledTokenStoreDelta ?? tokenStoreDelta; + const settledReceiverDelta = opts.settledReceiverDelta ?? receiverDelta; + const settledFeeReceiverDelta = opts.settledFeeReceiverDelta ?? feeReceiverDelta; + const settledFacilitatorDelta = opts.settledFacilitatorDelta ?? facilitatorDelta; + const facilitatorAddress = opts.facilitatorAddress ?? FACILITATOR_EOA; + let settled = false; + + mockSigner.simulateCalls = vi.fn().mockImplementation(async ({ calls }) => { + const forwardedIndex = calls.findIndex( + (call: { data?: string; functionName?: string }) => call.data && !call.functionName, + ); + const results = calls.map( + ( + call: { functionName?: string; args?: readonly unknown[]; data?: string }, + index: number, + ) => { + if (call.data && !call.functionName) { + if (opts.forwardedStatus === "failure") { + return { status: "failure" as const }; + } + if (opts.rejectDataSuffix && call.data.endsWith(opts.rejectDataSuffix.slice(2))) { + return { + status: "success" as const, + logs: [], + gasUsed: opts.gasUsed ?? 100_000n, + }; + } + return { + status: "success" as const, + logs, + gasUsed: opts.gasUsed ?? 100_000n, + }; + } + if (call.functionName === "paymentState") { + return { + status: "success" as const, + result: index < forwardedIndex ? freshState : postState, + }; + } + if (call.functionName === "balanceOf") { + const account = (call.args?.[0] as string).toLowerCase(); + const isPre = index < forwardedIndex; + let base = INITIAL_BALANCE; + if (account === TOKEN_STORE.toLowerCase()) base = 0n; + if (account === PAY_TO.toLowerCase()) base = 0n; + if (account === FEE_RECIPIENT.toLowerCase()) base = 0n; + if (!isPre) { + if (account === TOKEN_STORE.toLowerCase()) { + return { status: "success" as const, result: base + tokenStoreDelta }; + } + if (account === PAYER.toLowerCase()) { + return { status: "success" as const, result: base + payerDelta }; + } + if (account === facilitatorAddress.toLowerCase()) { + return { status: "success" as const, result: base + facilitatorDelta }; + } + if (account === PAY_TO.toLowerCase() && functionName === "charge") { + return { status: "success" as const, result: base + receiverDelta }; + } + if (account === FEE_RECIPIENT.toLowerCase() && functionName === "charge") { + return { status: "success" as const, result: base + feeReceiverDelta }; + } + } + return { status: "success" as const, result: base }; + } + return { status: "success" as const, result: 0n }; + }, + ); + return { results }; + }); + + mockSigner.writeContract.mockImplementation(async () => { + settled = true; + return MOCK_TX_HASH; + }); + mockSigner.readContract.mockImplementation( + async (args: { functionName: string; address?: string; args?: readonly unknown[] }) => { + if (args.functionName === "getTokenStore") return TOKEN_STORE; + if (args.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args.functionName === "paymentState") { + return { + hasCollectedPayment: true, + capturableAmount: functionName === "authorize" ? amount : 0n, + refundableAmount: functionName === "charge" ? amount : 0n, + }; + } + if (args.functionName === "balanceOf") { + const account = (args.args?.[0] as string).toLowerCase(); + let base = INITIAL_BALANCE; + if (account === TOKEN_STORE.toLowerCase()) base = 0n; + if (account === PAY_TO.toLowerCase()) base = 0n; + if (account === FEE_RECIPIENT.toLowerCase()) base = 0n; + if (!settled) return base; + if (account === TOKEN_STORE.toLowerCase()) return base + settledTokenStoreDelta; + if (account === PAYER.toLowerCase()) return base + settledPayerDelta; + if (account === facilitatorAddress.toLowerCase()) { + return base + settledFacilitatorDelta; + } + if (account === PAY_TO.toLowerCase() && functionName === "charge") { + return base + settledReceiverDelta; + } + if (account === FEE_RECIPIENT.toLowerCase() && functionName === "charge") { + return base + settledFeeReceiverDelta; + } + return base; + } + return INITIAL_BALANCE; + }, + ); + + mockSigner.waitForTransactionReceipt.mockResolvedValue({ + status: "success", + logs: opts.receiptLogs ?? logs, + }); + } + + function customOperatorConfig() { + return { operators: [{ address: CUSTOM_OPERATOR, operatorType: "custom" as const }] }; + } + + function customOperatorRequirements() { + return { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + captureAuthorizer: CUSTOM_OPERATOR, + operatorType: "custom" as const, + }, + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + mockSigner = createMockSigner(); + }); + + /** + * Make the escrow simulation revert while leaving ERC-1271 verification and + * the `balanceOf` fallback intact, so the test asserts revert decoding rather + * than tripping over an earlier step. + * + * @param error - Error the simulated `authorize`/`charge` call throws. + * @param balance - Payer balance returned by the `balanceOf` fallback. + */ + function mockSimulationRevert(error: unknown, balance = BigInt("1000000000")) { + mockSigner.readContract.mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args.functionName === "balanceOf") return balance; + throw error; + }); + } + + const futureSeconds = Math.floor(Date.now() / 1000) + 3600; + const captureDeadline = futureSeconds + 86400; + const refundDeadline = captureDeadline + 86400; + + const FACILITATOR_EOA = "0x1234567890123456789012345678901234567890" as `0x${string}`; + const SUBMITTER_B = "0x2222222222222222222222222222222222222222" as `0x${string}`; + const SUBMITTER_C = "0x5555555555555555555555555555555555555555" as `0x${string}`; + const MULTI_SUBMITTERS = [FACILITATOR_EOA, SUBMITTER_B, SUBMITTER_C] as const; + const PAYER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as `0x${string}`; + const ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; + const PAY_TO = "0xdddddddddddddddddddddddddddddddddddddddd" as `0x${string}`; + const CAPTURE_AUTHORIZER = FACILITATOR_EOA; + const RECEIVER_AUTHORIZER = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const CUSTOM_OPERATOR = "0xcccccccccccccccccccccccccccccccccccccccc" as `0x${string}`; + const FEE_RECIPIENT = "0x4444444444444444444444444444444444444444" as `0x${string}`; + const SALT = + "0x0000000000000000000000000000000000000000000000000000000000000abc" as `0x${string}`; + const SALT_NONCE = + "0x2222222222222222222222222222222222222222222222222222222222222222" as `0x${string}`; + const BOUND_SALT = deriveBoundSalt(RECEIVER_AUTHORIZER, zeroAddress, SALT_NONCE); + + const mockRequirements = { + scheme: "auth-capture", + network: "eip155:84532", + amount: "1000000", + asset: ASSET, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { + captureAuthorizer: CAPTURE_AUTHORIZER, + captureDeadline, + refundDeadline, + feeRecipient: FEE_RECIPIENT, + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + }, + }; + + // Build a PaymentInfoStruct that matches what the facilitator will reconstruct. + function buildPaymentInfo(operator: `0x${string}` = CAPTURE_AUTHORIZER): PaymentInfoStruct { + return { + operator, + payer: PAYER, + receiver: PAY_TO, + token: ASSET, + maxAmount: "1000000", + preApprovalExpiry: futureSeconds, + authorizationExpiry: captureDeadline, + refundExpiry: refundDeadline, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: FEE_RECIPIENT, + salt: SALT, + }; + } + + function buildEip3009Payload(operator: `0x${string}` = CAPTURE_AUTHORIZER) { + const paymentInfo = buildPaymentInfo(operator); + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + const extra = { ...mockRequirements.extra, captureAuthorizer: operator }; + const accepted = { ...mockRequirements, extra }; + return { + x402Version: 2, + scheme: "auth-capture", + resource: { url: "https://example.com/weather", method: "GET" }, + accepted, + payload: { + authorization: { + from: PAYER, + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(futureSeconds), + nonce, + }, + signature: "0xabcd" as `0x${string}`, + salt: SALT, + }, + }; + } + + function buildPermit2Payload(operator: `0x${string}` = CAPTURE_AUTHORIZER) { + const paymentInfo = buildPaymentInfo(operator); + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + const extra = { + ...mockRequirements.extra, + captureAuthorizer: operator, + assetTransferMethod: "permit2" as const, + }; + const accepted = { ...mockRequirements, extra }; + return { + x402Version: 2, + scheme: "auth-capture", + resource: { url: "https://example.com/weather", method: "GET" }, + accepted, + payload: { + permit2Authorization: { + from: PAYER, + permitted: { token: ASSET, amount: "1000000" }, + spender: PERMIT2_TOKEN_COLLECTOR_ADDRESS, + nonce: hexToBigInt(nonce).toString(), + deadline: String(futureSeconds), + }, + signature: "0xabcd" as `0x${string}`, + salt: SALT, + }, + }; + } + + function boundPaymentInfo(operator: `0x${string}` = CAPTURE_AUTHORIZER): PaymentInfoStruct { + return { ...buildPaymentInfo(operator), salt: BOUND_SALT }; + } + + function boundExtra(overrides: Record = {}) { + return { + ...mockRequirements.extra, + receiverAuthorizer: RECEIVER_AUTHORIZER, + ...overrides, + }; + } + + function buildBoundEip3009Payload( + operator: `0x${string}` = CAPTURE_AUTHORIZER, + extraOverrides: Record = {}, + ) { + const paymentInfo = boundPaymentInfo(operator); + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + const extra = boundExtra({ captureAuthorizer: operator, ...extraOverrides }); + const accepted = { ...mockRequirements, extra }; + return { + x402Version: 2, + scheme: "auth-capture", + resource: { url: "https://example.com/weather", method: "GET" }, + accepted, + payload: { + authorization: { + from: PAYER, + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(futureSeconds), + nonce, + }, + signature: "0xabcd" as `0x${string}`, + salt: BOUND_SALT, + saltNonce: SALT_NONCE, + }, + }; + } + + function buildChargeEip3009Payload( + operator: `0x${string}` = CAPTURE_AUTHORIZER, + extraOverrides: Record = {}, + ) { + const envelope = buildBoundEip3009Payload(operator, { + paymentFlow: "authorization", + ...extraOverrides, + }); + return { + ...envelope, + payload: { + ...envelope.payload, + amount: "1000000", + feeAmount: "0", + feeReceiver: FEE_RECIPIENT, + authorizerSignature: "0xabcd" as `0x${string}`, + }, + }; + } + + function buildChargePermit2Payload( + operator: `0x${string}` = CAPTURE_AUTHORIZER, + extraOverrides: Record = {}, + ) { + const paymentInfo = boundPaymentInfo(operator); + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + const extra = boundExtra({ + captureAuthorizer: operator, + assetTransferMethod: "permit2", + paymentFlow: "authorization", + ...extraOverrides, + }); + const accepted = { ...mockRequirements, extra }; + return { + x402Version: 2, + scheme: "auth-capture", + resource: { url: "https://example.com/weather", method: "GET" }, + accepted, + payload: { + permit2Authorization: { + from: PAYER, + permitted: { token: ASSET, amount: "1000000" }, + spender: PERMIT2_TOKEN_COLLECTOR_ADDRESS, + nonce: hexToBigInt(nonce).toString(), + deadline: String(futureSeconds), + }, + signature: "0xabcd" as `0x${string}`, + salt: BOUND_SALT, + saltNonce: SALT_NONCE, + amount: "1000000", + feeAmount: "0", + feeReceiver: FEE_RECIPIENT, + authorizerSignature: "0xabcd" as `0x${string}`, + }, + }; + } + + function buildCapturePayload(overrides: Record = {}) { + const extra = boundExtra(); + const accepted = { ...mockRequirements, extra }; + return { + x402Version: 2, + accepted, + payload: { + type: "capture", + paymentInfo: boundPaymentInfo(), + saltNonce: SALT_NONCE, + amount: "500000", + feeAmount: "0", + feeReceiver: FEE_RECIPIENT, + expectedCapturableAmount: "1000000", + expectedRefundableAmount: "0", + authorizerSignature: "0xabcd", + ...overrides, + }, + }; + } + + function delegatedSigner() { + return { + address: RECEIVER_AUTHORIZER, + signTypedData: vi.fn().mockResolvedValue("0xabcd" as `0x${string}`), + }; + } + + describe("settle — builder-code dataSuffix", () => { + const BUILDER_SUFFIX = "0xdeadbeef" as const; + + function mockBuilderCodeContext() { + return { + getExtension: vi.fn().mockImplementation((key: string) => { + if (key === BUILDER_CODE_KEY) { + return { + key: BUILDER_CODE_KEY, + buildDataSuffix: vi.fn().mockReturnValue(BUILDER_SUFFIX), + }; + } + return undefined; + }), + }; + } + + it("should append builder-code dataSuffix on collect authorize", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements, mockBuilderCodeContext()); + + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "authorize", + dataSuffix: BUILDER_SUFFIX, + }), + ); + }); + + it("should include builder-code dataSuffix in custom-operator simulation", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ paymentInfo }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const envelope = buildEip3009Payload(CUSTOM_OPERATOR); + const requirements = customOperatorRequirements(); + const context = mockBuilderCodeContext(); + const verification = await scheme.verify(envelope, requirements, context); + const result = await scheme.settle(envelope, requirements, context); + + expect(verification.isValid).toBe(true); + expect(result.success).toBe(true); + for (const call of mockSigner.simulateCalls.mock.calls) { + const simulation = call[0] as { + calls: readonly { data?: `0x${string}`; functionName?: string }[]; + }; + const forwarded = simulation.calls.find(item => item.data && !item.functionName); + expect(forwarded?.data?.endsWith(BUILDER_SUFFIX.slice(2))).toBe(true); + } + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ dataSuffix: BUILDER_SUFFIX }), + ); + }); + + it("should reject a custom operator that changes behavior for suffixed calldata", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + rejectDataSuffix: BUILDER_SUFFIX, + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.settle( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + mockBuilderCodeContext(), + ); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_auth_capture_evm_simulation_failed"); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + }); + + it("should append builder-code dataSuffix on lifecycle capture and remainder void", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildCapturePayload({ voidAuthorizerSignature: "0xabcd" }); + await scheme.settle(envelope, envelope.accepted, mockBuilderCodeContext()); + + expect(mockSigner.writeContract).toHaveBeenCalledTimes(2); + for (const call of mockSigner.writeContract.mock.calls) { + expect(call[0]).toEqual( + expect.objectContaining({ + dataSuffix: BUILDER_SUFFIX, + }), + ); + } + const names = mockSigner.writeContract.mock.calls.map( + (call: [{ functionName: string }]) => call[0].functionName, + ); + expect(names).toEqual(["capture", "void"]); + }); + }); + + describe("settle — paymentFlow routing", () => { + it("should default to authorize when paymentFlow is absent (escrow)", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "authorize" }), + ); + }); + + it("should call charge when paymentFlow is authorization", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildChargeEip3009Payload(); + await scheme.settle(envelope, envelope.accepted); + + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "charge" }), + ); + }); + + it("should verify authorization charge payloads that include charge-completion fields", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const eip3009 = buildChargeEip3009Payload(); + expect(await scheme.verify(eip3009, eip3009.accepted).then(r => r.isValid)).toBe(true); + const permit2 = buildChargePermit2Payload(CAPTURE_AUTHORIZER, { + assetTransferMethod: "permit2", + }); + expect(await scheme.verify(permit2, permit2.accepted).then(r => r.isValid)).toBe(true); + }); + + it("should call authorize when paymentFlow is escrow", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, paymentFlow: "escrow" as const }, + }; + await scheme.settle(buildEip3009Payload(), reqs); + + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "authorize" }), + ); + }); + + it("should reject autoCapture: true", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, autoCapture: true }, + }; + const result = await scheme.verify(buildEip3009Payload(), reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_unsupported_payment_flow"); + }); + }); + + describe("settle — target address", () => { + it("should target the canonical AuthCaptureEscrow address when captureAuthorizer is an EOA", async () => { + // Default mock: only the payer has code, so captureAuthorizer is an EOA. + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ address: AUTH_CAPTURE_ESCROW_ADDRESS }), + ); + }); + + it("should route authorize × eip3009 × custom operator via the captureAuthorizer with the literal escrow ABI and 4 args", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ paymentInfo }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const reqs = customOperatorRequirements(); + await scheme.settle(buildEip3009Payload(CUSTOM_OPERATOR), reqs); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.address).toBe(getAddress(CUSTOM_OPERATOR)); + expect(call.functionName).toBe("authorize"); + expect(call.args).toHaveLength(4); + expect(call.args[2]).toBe(EIP3009_TOKEN_COLLECTOR_ADDRESS); + expect(call.gas).toBe(DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT); + }); + + it("should route charge × eip3009 × custom operator via the captureAuthorizer with the 6-arg ABI", async () => { + const paymentInfo = boundPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ paymentInfo, functionName: "charge" }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const envelope = buildChargeEip3009Payload(CUSTOM_OPERATOR, { + operatorType: "custom", + }); + await scheme.settle(envelope, envelope.accepted); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.address).toBe(getAddress(CUSTOM_OPERATOR)); + expect(call.functionName).toBe("charge"); + expect(call.args).toHaveLength(6); + expect(call.args[2]).toBe(EIP3009_TOKEN_COLLECTOR_ADDRESS); + expect(call.args[4]).toBe(0n); + expect(call.args[5]).toBe(FEE_RECIPIENT); + expect(call.gas).toBe(DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT); + }); + + it("should route authorize × permit2 × custom operator via the captureAuthorizer with the permit2 collector", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + tokenCollector: PERMIT2_TOKEN_COLLECTOR_ADDRESS, + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const reqs = { + ...customOperatorRequirements(), + extra: { + ...customOperatorRequirements().extra, + assetTransferMethod: "permit2" as const, + }, + }; + await scheme.settle(buildPermit2Payload(CUSTOM_OPERATOR), reqs); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.address).toBe(getAddress(CUSTOM_OPERATOR)); + expect(call.functionName).toBe("authorize"); + expect(call.args).toHaveLength(4); + expect(call.args[2]).toBe(PERMIT2_TOKEN_COLLECTOR_ADDRESS); + }); + + it("should forward raw ERC-6492 collectorData while verifying its inner signature", async () => { + const wrappedSignature = serializeErc6492Signature({ + address: "0xca11bde05977b3631167028862be2a173976ca11", + data: "0xdeadbeef", + signature: "0xabcd", + }); + const envelope = buildEip3009Payload(CUSTOM_OPERATOR); + envelope.payload.signature = wrappedSignature; + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ paymentInfo }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + + const result = await scheme.settle(envelope, customOperatorRequirements()); + + expect(result.success).toBe(true); + const call = mockSigner.writeContract.mock.calls[0]![0]; + expect(call.args[3]).toBe(wrappedSignature); + const simulation = mockSigner.simulateCalls.mock.calls[0]![0] as { + calls: readonly { data?: `0x${string}`; functionName?: string }[]; + }; + const forwarded = simulation.calls.find(item => item.data && !item.functionName); + expect(forwarded?.data).toBeDefined(); + const decoded = decodeFunctionData({ + abi: ESCROW_ABI_WITH_ERRORS, + data: forwarded!.data!, + }); + expect(decoded.args?.[3]).toBe(wrappedSignature); + }); + + it("should route charge × permit2 × custom operator via the captureAuthorizer with 6 args + permit2 collector", async () => { + const paymentInfo = boundPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + functionName: "charge", + tokenCollector: PERMIT2_TOKEN_COLLECTOR_ADDRESS, + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const envelope = buildChargePermit2Payload(CUSTOM_OPERATOR, { + operatorType: "custom", + }); + await scheme.settle(envelope, envelope.accepted); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.address).toBe(getAddress(CUSTOM_OPERATOR)); + expect(call.functionName).toBe("charge"); + expect(call.args).toHaveLength(6); + expect(call.args[2]).toBe(PERMIT2_TOKEN_COLLECTOR_ADDRESS); + }); + + it("should preflight custom collect via simulateCalls with a gas cap", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ paymentInfo }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + await scheme.verify(buildEip3009Payload(CUSTOM_OPERATOR), customOperatorRequirements()); + + expect(mockSigner.simulateCalls).toHaveBeenCalledWith( + expect.objectContaining({ + account: FACILITATOR_EOA, + calls: expect.arrayContaining([ + expect.objectContaining({ + to: getAddress(CUSTOM_OPERATOR), + gas: DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT, + }), + ]), + }), + ); + }); + + it("should pass EIP3009_TOKEN_COLLECTOR as the tokenCollector arg for eip3009", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.args[2]).toBe(EIP3009_TOKEN_COLLECTOR_ADDRESS); + }); + + it("should pass PERMIT2_TOKEN_COLLECTOR as the tokenCollector arg for permit2", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + await scheme.settle(buildPermit2Payload(), reqs); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.args[2]).toBe(PERMIT2_TOKEN_COLLECTOR_ADDRESS); + }); + }); + + describe("verify/settle — wire payload shape", () => { + it("should reject lifecycle-shaped objects with an unknown type on verify and settle", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = { + x402Version: 2, + accepted: mockRequirements, + payload: { type: "not-a-real-lifecycle" }, + }; + + const verifyResult = await scheme.verify(envelope, mockRequirements); + expect(verifyResult.isValid).toBe(false); + expect(verifyResult.invalidReason).toBe(Errors.ErrInvalidPayloadType); + + const settleResult = await scheme.settle(envelope, mockRequirements); + expect(settleResult.success).toBe(false); + expect(settleResult.errorReason).toBe(Errors.ErrInvalidPayloadType); + expect(settleResult.transaction).toBe(""); + }); + + it("should reject non-object wire payloads on verify and settle", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = { + x402Version: 2, + accepted: mockRequirements, + payload: "not-json", + }; + + const verifyResult = await scheme.verify(envelope, mockRequirements); + expect(verifyResult.isValid).toBe(false); + expect(verifyResult.invalidReason).toBe(Errors.ErrInvalidPayloadFormat); + + const settleResult = await scheme.settle(envelope, mockRequirements); + expect(settleResult.success).toBe(false); + expect(settleResult.errorReason).toBe(Errors.ErrInvalidPayloadFormat); + }); + }); + + describe("verify — invariants", () => { + it("should reject when extra is missing required fields", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const bad = { + ...mockRequirements, + extra: { name: "USDC", version: "2" } as unknown as typeof mockRequirements.extra, + }; + const result = await scheme.verify(buildEip3009Payload(), bad); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_extra"); + }); + + it("should reject charge when receiverAuthorizer is absent", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const requirements = { + ...mockRequirements, + extra: { ...mockRequirements.extra, paymentFlow: "authorization" as const }, + }; + const result = await scheme.verify(buildEip3009Payload(), requirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_missing_receiver_authorizer"); + }); + + it("should verify raw charge intent but reject settle without authorizer-signed fields", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildBoundEip3009Payload(CAPTURE_AUTHORIZER, { + paymentFlow: "authorization", + }); + const verification = await scheme.verify(envelope, envelope.accepted); + expect(verification.isValid).toBe(true); + + const settlement = await scheme.settle(envelope, envelope.accepted); + expect(settlement.success).toBe(false); + expect(settlement.errorReason).toBe("invalid_auth_capture_evm_payload_format"); + }); + + it("should reject when refundDeadline is not after captureDeadline", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const bad = { + ...mockRequirements, + extra: { ...mockRequirements.extra, refundDeadline: captureDeadline - 1 }, + }; + const result = await scheme.verify(buildEip3009Payload(), bad); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_deadline_ordering"); + }); + + it("should reject when payload method does not match assetTransferMethod", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + const result = await scheme.verify(buildEip3009Payload(), reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_payload_method_mismatch"); + }); + + it("should reject when EIP-3009 payload.to is not the canonical collector", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + payload.payload.authorization.to = + "0x9999999999999999999999999999999999999999" as `0x${string}`; + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_token_collector_mismatch"); + }); + + it("should reject when Permit2 payload.spender is not the canonical collector", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + const payload = buildPermit2Payload(); + payload.payload.permit2Authorization.spender = + "0x9999999999999999999999999999999999999999" as `0x${string}`; + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_token_collector_mismatch"); + }); + + it("should reject when Permit2 token does not match requirements.asset", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + const payload = buildPermit2Payload(); + payload.payload.permit2Authorization.permitted.token = + "0x9999999999999999999999999999999999999999" as `0x${string}`; + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_token_mismatch"); + }); + + it("should reject when authorization.value does not match requirements.amount", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + payload.payload.authorization.value = "999999"; + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_amount_mismatch"); + }); + + it("should reject when EIP-3009 validBefore is in the past", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + payload.payload.authorization.validBefore = String(Math.floor(Date.now() / 1000) - 60); + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_authorization_expired"); + }); + + it("should reject when EIP-3009 validAfter is in the future", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + payload.payload.authorization.validAfter = String(Math.floor(Date.now() / 1000) + 3600); + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_authorization_not_yet_valid"); + }); + + it("should reject unsupported assetTransferMethod", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + assetTransferMethod: "allowance" as unknown as "eip3009", + }, + }; + const result = await scheme.verify(buildEip3009Payload(), reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe( + "invalid_auth_capture_evm_unsupported_asset_transfer_method", + ); + }); + + it("should reject when payload.accepted.network differs from requirements.network", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + payload.accepted = { ...payload.accepted, network: "eip155:8453" }; + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_network_mismatch"); + }); + + it("should reject invalid signature", async () => { + // Payer has code, so strict verification takes the ERC-1271 path; a + // non-magic return means the smart account rejected the signature. + mockSigner.readContract.mockImplementation(async (args: { functionName: string }) => + args.functionName === "isValidSignature" ? "0x00000000" : BigInt("1000000000"), + ); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_signature"); + }); + + it("should report an undeployed smart wallet whose signature cannot be checked", async () => { + // No code at the payer and a non-ECDSA signature without an ERC-6492 envelope. + mockSigner.getCode.mockImplementation(async () => "0x"); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrUndeployedSmartWallet); + }); + + it("should run signature verification through the strict primitive (getCode + isValidSignature)", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.verify(buildEip3009Payload(), mockRequirements); + + expect(mockSigner.getCode).toHaveBeenCalledWith( + expect.objectContaining({ address: getAddress(PAYER) }), + ); + expect(mockSigner.readContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "isValidSignature" }), + ); + // The weaker signer.verifyTypedData path (ECDSA fallback on EIP-1271 + // failure) must not be used — it diverges from on-chain SignatureChecker. + expect(mockSigner.verifyTypedData).not.toHaveBeenCalled(); + }); + + it("should reject when simulation reverts and balance is sufficient", async () => { + mockSimulationRevert(new Error("execution reverted")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + + it("should surface insufficient_balance when simulation fails and balance is short", async () => { + mockSimulationRevert(new Error("execution reverted"), BigInt("1")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_insufficient_balance"); + }); + + it("should reject when preApprovalExpiry exceeds captureDeadline", async () => { + // maxTimeoutSeconds = 60s, but captureDeadline only 5s in the future → + // preApprovalExpiry (now + 60) > captureDeadline. Mirrors the on-chain + // _validatePayment ordering check. + const scheme = new AuthCaptureEvmScheme(mockSigner); + const tightCaptureDeadline = Math.floor(Date.now() / 1000) + 30; + const reqs = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + captureDeadline: tightCaptureDeadline, + refundDeadline: tightCaptureDeadline + 86400, + }, + }; + // Build payload with a fresh preApprovalExpiry that exceeds captureDeadline + const futureSecondsLocal = Math.floor(Date.now() / 1000) + 3600; + const paymentInfo: PaymentInfoStruct = { + operator: CAPTURE_AUTHORIZER, + payer: PAYER, + receiver: PAY_TO, + token: ASSET, + maxAmount: "1000000", + preApprovalExpiry: futureSecondsLocal, + authorizationExpiry: tightCaptureDeadline, + refundExpiry: tightCaptureDeadline + 86400, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: FEE_RECIPIENT, + salt: SALT, + }; + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + const payload = { + x402Version: 2, + scheme: "auth-capture", + resource: { url: "https://example.com", method: "GET" }, + accepted: { ...reqs }, + payload: { + authorization: { + from: PAYER, + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(futureSecondsLocal), + nonce, + }, + signature: "0xabcd" as `0x${string}`, + salt: SALT, + }, + }; + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_deadline_ordering"); + }); + }); + + describe("verify — nonce binding (regression for payer-agnostic-hash design)", () => { + it("should reject when salt is mutated after signing", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + // Tamper with salt — wire nonce was computed against SALT, not this new value + payload.payload.salt = + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" as `0x${string}`; + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_nonce_mismatch"); + }); + + it("should reject when extra.captureAuthorizer is mutated after signing", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const tampered = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + captureAuthorizer: "0x9999999999999999999999999999999999999999" as `0x${string}`, + }, + }; + const result = await scheme.verify(buildEip3009Payload(), tampered); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_operator_not_admitted"); + }); + + it("should reject when requirements.amount is mutated after signing (Permit2)", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildPermit2Payload(); + payload.payload.permit2Authorization.permitted.amount = "999999"; + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_amount_mismatch"); + }); + }); + + describe("verify — ERC-6492 counterfactual payers", () => { + const FACTORY = "0x7777777777777777777777777777777777777777" as `0x${string}`; + + /** + * A counterfactual payer's signature cannot be checked locally, so these tests make the + * payer read as undeployed and rely on the escrow simulation as the only signature check. + * + * @returns An EIP-3009 envelope whose signature is an ERC-6492 envelope naming `FACTORY`. + */ + function buildCounterfactualPayload() { + const envelope = buildEip3009Payload(); + envelope.payload.signature = serializeErc6492Signature({ + address: FACTORY, + data: "0xdeadbeef", + signature: "0xabcd", + }); + return envelope; + } + + beforeEach(() => { + mockSigner.getCode.mockImplementation(async () => "0x"); + }); + + it("should accept a counterfactual payer whose factory is allowlisted", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner, { + eip6492AllowedFactories: [FACTORY], + }); + + const result = await scheme.verify(buildCounterfactualPayload(), mockRequirements); + + expect(result.isValid).toBe(true); + expect(result.payer).toBe(PAYER); + // Deferred to the escrow simulation, never to a local ERC-1271 call. + const readFunctions = mockSigner.readContract.mock.calls.map( + (call: [{ functionName: string }]) => call[0].functionName, + ); + expect(readFunctions).toContain("authorize"); + expect(readFunctions).not.toContain("isValidSignature"); + }); + + it("should match allowlist entries case-insensitively and ignore surrounding space", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner, { + eip6492AllowedFactories: [` ${FACTORY.toUpperCase()} `], + }); + + const result = await scheme.verify(buildCounterfactualPayload(), mockRequirements); + + expect(result.isValid).toBe(true); + }); + + it("should reject a counterfactual payer whose factory is not allowlisted", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner, { + eip6492AllowedFactories: ["0x8888888888888888888888888888888888888888"], + }); + + const result = await scheme.verify(buildCounterfactualPayload(), mockRequirements); + + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_erc6492_factory_not_allowed"); + }); + + it("should reject a counterfactual payer when no allowlist is configured", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + + const result = await scheme.verify(buildCounterfactualPayload(), mockRequirements); + + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_erc6492_factory_not_allowed"); + }); + + it("should reject an allowlisted counterfactual payer when the simulation reverts", async () => { + mockSimulationRevert(new Error("execution reverted")); + const scheme = new AuthCaptureEvmScheme(mockSigner, { + eip6492AllowedFactories: [FACTORY], + }); + + const result = await scheme.verify(buildCounterfactualPayload(), mockRequirements); + + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + + it("should settle an allowlisted counterfactual payer forwarding the wrapper intact", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner, { + eip6492AllowedFactories: [FACTORY], + }); + const envelope = buildCounterfactualPayload(); + + const result = await scheme.settle(envelope, mockRequirements); + + expect(result.success).toBe(true); + const call = mockSigner.writeContract.mock.calls[0]![0]; + expect(call.functionName).toBe("authorize"); + expect(call.args[3]).toBe(envelope.payload.signature); + // No separate deployment transaction: the token collector deploys via Multicall3. + expect(mockSigner.sendTransaction).not.toHaveBeenCalled(); + }); + + it("should verify a deployed wallet locally even when its signature stays wrapped", async () => { + mockSigner.getCode.mockImplementation(async ({ address }: { address: `0x${string}` }) => + address.toLowerCase() === PAYER.toLowerCase() ? DEPLOYED_BYTECODE : "0x", + ); + const scheme = new AuthCaptureEvmScheme(mockSigner); + + const result = await scheme.verify(buildCounterfactualPayload(), mockRequirements); + + expect(result.isValid).toBe(true); + const isValidSignatureCall = mockSigner.readContract.mock.calls.find( + (call: [{ functionName: string }]) => call[0].functionName === "isValidSignature", + ); + // The inner signature is what ERC-1271 sees; the wrapper is stripped onchain. + expect(isValidSignatureCall?.[0].args?.[1]).toBe("0xabcd"); + }); + }); + + describe("verify — typed simulation revert decoding", () => { + /** + * Build a viem ContractFunctionExecutionError that wraps a real + * ContractFunctionRevertedError encoded from the named custom error. + * Mirrors what viem produces when the chain reverts with a known error + * declared in the call's ABI. + */ + function buildRevertError(errorName: string): Error { + const errorAbi = [{ type: "error" as const, name: errorName, inputs: [] }]; + const data = encodeErrorResult({ abi: errorAbi, errorName }); + const inner = new ContractFunctionRevertedError({ + abi: errorAbi, + data, + functionName: "authorize", + }); + return new ContractFunctionExecutionError(inner, { + abi: errorAbi, + functionName: "authorize", + args: [], + }); + } + + it("should decode AfterPreApprovalExpiry → authorization_expired", async () => { + mockSimulationRevert(buildRevertError("AfterPreApprovalExpiry")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_authorization_expired"); + }); + + it("should decode PaymentAlreadyCollected → payment_already_collected", async () => { + mockSimulationRevert(buildRevertError("PaymentAlreadyCollected")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_payment_already_collected"); + }); + + it("should decode FeeBpsOutOfRange → fee_bps_out_of_range", async () => { + mockSimulationRevert(buildRevertError("FeeBpsOutOfRange")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_fee_bps_out_of_range"); + }); + + it("should decode InvalidFeeReceiver → invalid_fee_receiver", async () => { + mockSimulationRevert(buildRevertError("InvalidFeeReceiver")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_fee_receiver"); + }); + + it("should decode TokenCollectionFailed → token_collection_failed", async () => { + mockSimulationRevert(buildRevertError("TokenCollectionFailed")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_token_collection_failed"); + }); + + it("should fall through unknown reverts to generic simulation_failed", async () => { + mockSimulationRevert(buildRevertError("SomeUnmappedError")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + + it("should fall through plain Error (not BaseError) to simulation_failed", async () => { + mockSimulationRevert(new Error("RPC went sideways")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + }); + + describe("settle — charge fee args (ABI 6-arg correctness)", () => { + it("should pass feeAmount and feeReceiver as args[4] and args[5] for charge", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildChargeEip3009Payload(); + await scheme.settle(envelope, envelope.accepted); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.functionName).toBe("charge"); + expect(call.args.length).toBe(6); + expect(call.args[4]).toBe(0n); + expect(call.args[5]).toBe(FEE_RECIPIENT); + }); + + it("should pass 4 args for authorize (no feeAmount/feeReceiver)", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.functionName).toBe("authorize"); + expect(call.args.length).toBe(4); + }); + + it("should use v1.0 feeBps wire and collectors when extra.authCaptureEscrow pins v1.0", async () => { + const paymentInfo = boundPaymentInfo(); + const nonce = computePayerAgnosticPaymentInfoHash( + 84532, + paymentInfo, + AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + ); + const extra = boundExtra({ + authCaptureEscrow: AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + paymentFlow: "authorization", + }); + const accepted = { ...mockRequirements, extra }; + const envelope = { + x402Version: 2, + scheme: "auth-capture", + resource: { url: "https://example.com/weather", method: "GET" }, + accepted, + payload: { + authorization: { + from: PAYER, + to: EIP3009_TOKEN_COLLECTOR_V1_0_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(futureSeconds), + nonce, + }, + signature: "0xabcd" as `0x${string}`, + salt: BOUND_SALT, + saltNonce: SALT_NONCE, + amount: "1000000", + feeBps: 0, + feeReceiver: FEE_RECIPIENT, + authorizerSignature: "0xabcd" as `0x${string}`, + }, + }; + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(envelope, accepted); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.address).toBe(AUTH_CAPTURE_ESCROW_V1_0_ADDRESS); + expect(call.functionName).toBe("charge"); + expect(call.args[2]).toBe(EIP3009_TOKEN_COLLECTOR_V1_0_ADDRESS); + expect(call.args[4]).toBe(0); + }); + }); + + describe("getExtra", () => { + it("should advertise a captureAuthorizer from getAddresses when unconfigured", () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const extra = scheme.getExtra("eip155:8453"); + expect(extra).toBeDefined(); + expect(mockSigner.getAddresses()).toContain(extra?.captureAuthorizer); + }); + + it("should return undefined for an empty signer set", () => { + const scheme = new AuthCaptureEvmScheme([]); + expect(scheme.getExtra("eip155:8453")).toBeUndefined(); + }); + + it("should advertise grouped fee terms and operators when simulateCalls is wired", () => { + const signerWithSim = { ...mockSigner, simulateCalls: vi.fn() }; + const scheme = new AuthCaptureEvmScheme(signerWithSim, { + feeTerms: { feeRecipient: FEE_RECIPIENT, minFeeBps: 100, maxFeeBps: 100 }, + operators: [{ address: "*", operatorType: "custom" }], + authorizerSigner: delegatedSigner(), + resolveCallerIdentity: () => "caller-1", + delegatedAuthStorage: new InMemoryAuthCaptureDelegatedAuthStorage(), + onStorageError: vi.fn(), + }); + const extra = scheme.getExtra("eip155:8453"); + expect(extra).toMatchObject({ + receiverAuthorizer: getAddress(RECEIVER_AUTHORIZER), + feeRecipient: FEE_RECIPIENT, + minFeeBps: 100, + maxFeeBps: 100, + operators: [{ address: "*", operatorType: "custom" }], + }); + expect(signerWithSim.getAddresses()).toContain(extra?.captureAuthorizer); + }); + + describe("captureAuthorizers pool", () => { + const ADDR_A = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const ADDR_B = "0x2222222222222222222222222222222222222222" as `0x${string}`; + const ADDR_C = "0x3333333333333333333333333333333333333333" as `0x${string}`; + const signerAt = (address: `0x${string}`) => ({ + ...mockSigner, + getAddresses: () => [address] as readonly `0x${string}`[], + }); + const signers = [signerAt(ADDR_A), signerAt(ADDR_B), signerAt(ADDR_C)]; + + it("advertises only configured pool members", () => { + const scheme = new AuthCaptureEvmScheme(signers, { captureAuthorizers: [ADDR_B] }); + for (let i = 0; i < 10; i++) { + expect(scheme.getExtra("eip155:8453")?.captureAuthorizer).toBe(getAddress(ADDR_B)); + } + }); + + it("still submits from signer addresses outside the pool", () => { + const scheme = new AuthCaptureEvmScheme(signers, { captureAuthorizers: [ADDR_B] }); + expect(scheme.getSigners("eip155:8453")).toEqual([ADDR_A, ADDR_B, ADDR_C]); + }); + + it("uses selectCaptureAuthorizer and falls back to a pool member for a stranger", () => { + const select = vi.fn().mockReturnValueOnce(ADDR_C).mockReturnValue(ADDR_A.toLowerCase()); + const scheme = new AuthCaptureEvmScheme(signers, { + captureAuthorizers: [ADDR_B, ADDR_C], + selectCaptureAuthorizer: select, + }); + expect(scheme.getExtra("eip155:8453")?.captureAuthorizer).toBe(getAddress(ADDR_C)); + expect(select).toHaveBeenCalledWith("eip155:8453", [ + getAddress(ADDR_B), + getAddress(ADDR_C), + ]); + expect([getAddress(ADDR_B), getAddress(ADDR_C)]).toContain( + scheme.getExtra("eip155:8453")?.captureAuthorizer, + ); + }); + + it("deduplicates the pool", () => { + const select = vi.fn(); + const scheme = new AuthCaptureEvmScheme(signers, { + captureAuthorizers: [ADDR_A, ADDR_A.toLowerCase() as `0x${string}`], + selectCaptureAuthorizer: select, + }); + expect(scheme.getExtra("eip155:8453")?.captureAuthorizer).toBe(getAddress(ADDR_A)); + expect(select).not.toHaveBeenCalled(); + }); + + it("rejects a pool address no signer holds", () => { + expect( + () => + new AuthCaptureEvmScheme(signers, { + captureAuthorizers: [ADDR_A, "0x4444444444444444444444444444444444444444"], + }), + ).toThrow("is not one of the signers' addresses"); + }); + + it.each([zeroAddress, "not-an-address"])("rejects the invalid pool address %s", bad => { + expect( + () => new AuthCaptureEvmScheme(signers, { captureAuthorizers: [bad as `0x${string}`] }), + ).toThrow("empty or zero address"); + }); + }); + + it("should omit operators from getExtra when simulateCalls is unavailable", () => { + const scheme = new AuthCaptureEvmScheme(mockSigner, { + operators: [{ address: "*", operatorType: "custom" }], + }); + const extra = scheme.getExtra("eip155:8453"); + expect(extra).not.toHaveProperty("operators"); + expect(mockSigner.getAddresses()).toContain(extra?.captureAuthorizer); + }); + }); + + describe("delegated receiver authorizer", () => { + const CALLER = "caller-1"; + const paymentInfoHash = () => + computePaymentInfoHash(84532, boundPaymentInfo(), AUTH_CAPTURE_ESCROW_ADDRESS); + + function unsigned }>(envelope: T): T { + const payload = { ...envelope.payload }; + delete payload.authorizerSignature; + return { ...envelope, payload }; + } + + function makeDelegated( + overrides: { + identity?: string | undefined; + storage?: AuthCaptureDelegatedAuthStorage; + refundFunding?: boolean; + pendingSettlementStore?: PendingSettlementStore; + } = {}, + ) { + const storage = overrides.storage ?? new InMemoryAuthCaptureDelegatedAuthStorage(); + const signer = delegatedSigner(); + const resolveCallerIdentity = vi + .fn() + .mockResolvedValue("identity" in overrides ? overrides.identity : CALLER); + const onStorageError = vi.fn(); + const scheme = new AuthCaptureEvmScheme(mockSigner, { + authorizerSigner: signer, + resolveCallerIdentity, + delegatedAuthStorage: storage, + onStorageError, + refundFunding: overrides.refundFunding ?? true, + pendingSettlementStore: overrides.pendingSettlementStore, + }); + return { scheme, storage, signer, resolveCallerIdentity, onStorageError }; + } + + function bindRow( + storage: AuthCaptureDelegatedAuthStorage, + callerIdentity = CALLER, + expiresAt = refundDeadline, + ) { + return storage.bind({ + network: "eip155:84532", + paymentInfoHash: paymentInfoHash(), + callerIdentity, + expiresAt, + }); + } + + /** Report `pre` from `paymentState` until a transaction is written, then `post`. */ + function mockPaymentStates( + pre: { capturableAmount: bigint; refundableAmount: bigint }, + post: { capturableAmount: bigint; refundableAmount: bigint }, + ) { + let written = false; + mockSigner.writeContract.mockImplementation(async () => { + written = true; + return MOCK_TX_HASH; + }); + mockSigner.readContract.mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args.functionName === "getTokenStore") return TOKEN_STORE; + if (args.functionName === "paymentState") { + return { hasCollectedPayment: true, ...(written ? post : pre) }; + } + return INITIAL_BALANCE; + }); + } + + const CHARGED = { capturableAmount: 0n, refundableAmount: BigInt("1000000") }; + + function lifecycleEnvelope(payload: Record) { + return buildCapturePayload({ authorizerSignature: undefined, ...payload }); + } + + describe("configuration", () => { + const fullDelegation = { + authorizerSigner: delegatedSigner(), + resolveCallerIdentity: () => CALLER, + delegatedAuthStorage: new InMemoryAuthCaptureDelegatedAuthStorage(), + onStorageError: vi.fn(), + }; + + it("requires the full delegation quartet together", () => { + expect( + () => new AuthCaptureEvmScheme(mockSigner, { authorizerSigner: delegatedSigner() }), + ).toThrow("authorizerSigner requires resolveCallerIdentity"); + expect( + () => new AuthCaptureEvmScheme(mockSigner, { resolveCallerIdentity: () => CALLER }), + ).toThrow("facilitator-delegated receiver authorization requires authorizerSigner"); + expect( + () => + new AuthCaptureEvmScheme(mockSigner, { + authorizerSigner: delegatedSigner(), + resolveCallerIdentity: () => CALLER, + }), + ).toThrow("authorizerSigner requires delegatedAuthStorage"); + expect( + () => + new AuthCaptureEvmScheme(mockSigner, { + ...fullDelegation, + onStorageError: undefined, + }), + ).toThrow("authorizerSigner requires onStorageError"); + expect( + () => + new AuthCaptureEvmScheme(mockSigner, { + delegatedAuthStorage: new InMemoryAuthCaptureDelegatedAuthStorage(), + }), + ).toThrow("facilitator-delegated receiver authorization requires authorizerSigner"); + }); + + it("rejects a delegatedAuthStorage that does not implement the interface", () => { + expect( + () => + new AuthCaptureEvmScheme(mockSigner, { + ...fullDelegation, + delegatedAuthStorage: {} as AuthCaptureDelegatedAuthStorage, + }), + ).toThrow("delegatedAuthStorage must implement"); + }); + + it("does not advertise receiverAuthorizer when delegation is not configured", () => { + const extra = new AuthCaptureEvmScheme(mockSigner).getExtra("eip155:8453"); + expect(extra).not.toHaveProperty("receiverAuthorizer"); + }); + }); + + describe("charge", () => { + it("verifies an unsigned charge without resolving identity or binding", async () => { + const { scheme, storage, resolveCallerIdentity } = makeDelegated(); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.verify(envelope, envelope.accepted); + expect(result.isValid).toBe(true); + expect(resolveCallerIdentity).not.toHaveBeenCalled(); + expect(await storage.get("eip155:84532", paymentInfoHash())).toBeUndefined(); + }); + + it("rejects an unsigned charge when no delegated authorizer is configured", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.verify(envelope, envelope.accepted); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrAuthorizerSignature); + }); + + it("signs the Charge digest, binds the caller until refundDeadline, then broadcasts", async () => { + const { scheme, storage, signer, resolveCallerIdentity } = makeDelegated(); + mockPaymentStates({ capturableAmount: 0n, refundableAmount: 0n }, CHARGED); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(true); + expect(signer.signTypedData).toHaveBeenCalledWith( + expect.objectContaining({ primaryType: "Charge" }), + ); + expect(resolveCallerIdentity).toHaveBeenCalledWith( + expect.objectContaining({ step: "charge", paymentInfoHash: paymentInfoHash() }), + ); + expect(await storage.get("eip155:84532", paymentInfoHash())).toMatchObject({ + callerIdentity: CALLER, + expiresAt: refundDeadline, + }); + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "charge" }), + ); + }); + + it("does not bind a charge that cannot be refunded through this facilitator", async () => { + const { scheme, storage } = makeDelegated({ refundFunding: false }); + mockPaymentStates({ capturableAmount: 0n, refundableAmount: 0n }, CHARGED); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(true); + expect(await storage.get("eip155:84532", paymentInfoHash())).toBeUndefined(); + }); + + it.each([ + ["unauthenticated", undefined], + ["empty identity", ""], + ])("refuses to sign or broadcast for an %s caller", async (_name, identity) => { + const { scheme, signer } = makeDelegated({ identity }); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrUnauthenticatedAuthorizerRequest); + expect(signer.signTypedData).not.toHaveBeenCalled(); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + }); + + it("treats a throwing identity resolver as unauthenticated", async () => { + const { scheme, resolveCallerIdentity } = makeDelegated(); + resolveCallerIdentity.mockRejectedValue(new Error("auth backend down")); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.errorReason).toBe(Errors.ErrUnauthenticatedAuthorizerRequest); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + }); + + it("fails closed with no broadcast when another identity already holds the binding", async () => { + const { scheme, storage } = makeDelegated(); + await bindRow(storage, "someone-else"); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.errorReason).toBe(Errors.ErrUnauthenticatedAuthorizerRequest); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + expect((await storage.get("eip155:84532", paymentInfoHash()))?.callerIdentity).toBe( + "someone-else", + ); + }); + + it("fails closed with no broadcast when the binding store is unavailable", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + vi.spyOn(storage, "bind").mockRejectedValue(new Error("db down")); + const { scheme } = makeDelegated({ storage }); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrDelegatedAuthUnavailable); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + }); + + it("reverts the binding it created when the broadcast is rejected", async () => { + const { scheme, storage } = makeDelegated(); + mockSigner.writeContract.mockRejectedValue(new Error("insufficient funds for gas")); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(false); + expect(await storage.get("eip155:84532", paymentInfoHash())).toBeUndefined(); + }); + + it("reverts the binding it created when the transaction reverts onchain", async () => { + const { scheme, storage } = makeDelegated(); + mockSigner.waitForTransactionReceipt.mockResolvedValue({ status: "reverted" }); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrTransactionReverted); + expect(await storage.get("eip155:84532", paymentInfoHash())).toBeUndefined(); + }); + + it("keeps the binding when the receipt wait times out", async () => { + const { scheme, storage } = makeDelegated(); + mockSigner.waitForTransactionReceipt.mockRejectedValue(new Error("receipt timeout")); + const envelope = unsigned(buildChargeEip3009Payload()); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(false); + expect(await storage.get("eip155:84532", paymentInfoHash())).toMatchObject({ + callerIdentity: CALLER, + }); + }); + + it("does not delete a pre-existing binding when a same-identity retry reverts", async () => { + const { scheme, storage } = makeDelegated(); + await bindRow(storage); + mockSigner.waitForTransactionReceipt.mockResolvedValue({ status: "reverted" }); + const envelope = unsigned(buildChargeEip3009Payload()); + await scheme.settle(envelope, envelope.accepted); + expect(await storage.get("eip155:84532", paymentInfoHash())).toBeDefined(); + }); + + it("binds an authorize settle for a delegated operator", async () => { + const { scheme, storage } = makeDelegated(); + const envelope = buildBoundEip3009Payload(); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(true); + expect(await storage.get("eip155:84532", paymentInfoHash())).toMatchObject({ + callerIdentity: CALLER, + }); + }); + }); + + describe("lifecycle", () => { + it("signs and settles an unsigned capture for the bound caller", async () => { + const { scheme, storage, signer, resolveCallerIdentity } = makeDelegated(); + await bindRow(storage); + const envelope = lifecycleEnvelope({}); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(true); + expect(resolveCallerIdentity).toHaveBeenCalledWith( + expect.objectContaining({ step: "capture" }), + ); + expect(signer.signTypedData).toHaveBeenCalledWith( + expect.objectContaining({ primaryType: "Capture" }), + ); + const names = mockSigner.writeContract.mock.calls.map( + (call: [{ functionName: string }]) => call[0].functionName, + ); + expect(names).toEqual(["capture"]); + }); + + it("reconciles a retried delegated capture even when the facilitator signs differently", async () => { + const pendingSettlementStore = new InMemoryPendingSettlementStore(); + const { scheme, storage, signer } = makeDelegated({ pendingSettlementStore }); + await bindRow(storage); + const envelope = lifecycleEnvelope({}); + + mockSigner.waitForTransactionReceipt.mockRejectedValueOnce(new Error("rpc timeout")); + const first = await scheme.settle(envelope, envelope.accepted); + expect(first.errorReason).toBe(Errors.ErrSettlementPending); + expect(first.transaction).toBe(MOCK_TX_HASH); + + signer.signTypedData.mockResolvedValue("0xbeef" as `0x${string}`); + const retry = await scheme.settle(envelope, envelope.accepted); + expect(retry.success).toBe(true); + expect(retry.transaction).toBe(MOCK_TX_HASH); + expect(mockSigner.writeContract).toHaveBeenCalledTimes(1); + }); + + it("verifies an unsigned capture for the bound caller and rejects everyone else", async () => { + const { scheme, storage } = makeDelegated(); + const envelope = lifecycleEnvelope({}); + + const unbound = await scheme.verify(envelope, envelope.accepted); + expect(unbound.invalidReason).toBe(Errors.ErrUnauthenticatedAuthorizerRequest); + + await bindRow(storage, "someone-else"); + const mismatch = await scheme.verify(envelope, envelope.accepted); + expect(mismatch.invalidReason).toBe(Errors.ErrUnauthenticatedAuthorizerRequest); + + await storage.delete("eip155:84532", paymentInfoHash()); + await bindRow(storage); + const match = await scheme.verify(envelope, envelope.accepted); + expect(match.isValid).toBe(true); + }); + + it("rejects an unauthenticated caller before touching the binding store", async () => { + const { scheme, storage } = makeDelegated({ identity: undefined }); + await bindRow(storage); + const envelope = lifecycleEnvelope({}); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.errorReason).toBe(Errors.ErrUnauthenticatedAuthorizerRequest); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + }); + + it("treats an expired binding as absent", async () => { + const { scheme, storage } = makeDelegated(); + await bindRow(storage, CALLER, Math.floor(Date.now() / 1000) - 1); + const envelope = lifecycleEnvelope({}); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.errorReason).toBe(Errors.ErrUnauthenticatedAuthorizerRequest); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + }); + + it("reports the binding store being unavailable instead of unauthenticated", async () => { + const storage = new InMemoryAuthCaptureDelegatedAuthStorage(); + vi.spyOn(storage, "get").mockRejectedValue(new Error("db down")); + const { scheme } = makeDelegated({ storage }); + const envelope = lifecycleEnvelope({}); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.errorReason).toBe(Errors.ErrDelegatedAuthUnavailable); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + }); + + it("signs both legs of a capture that voids the remainder", async () => { + const { scheme, storage, signer } = makeDelegated(); + await bindRow(storage); + const envelope = lifecycleEnvelope({ voidRemainder: true }); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(true); + expect(signer.signTypedData.mock.calls.map(call => call[0].primaryType)).toEqual([ + "Capture", + "Void", + ]); + const names = mockSigner.writeContract.mock.calls.map( + (call: [{ functionName: string }]) => call[0].functionName, + ); + expect(names).toEqual(["capture", "void"]); + }); + + it("signs an unsigned void and releases the binding once nothing is left", async () => { + const { scheme, storage, signer } = makeDelegated(); + await bindRow(storage); + mockPaymentStates( + { capturableAmount: BigInt("1000000"), refundableAmount: 0n }, + { capturableAmount: 0n, refundableAmount: 0n }, + ); + const envelope = { + ...buildCapturePayload(), + payload: { type: "void", paymentInfo: boundPaymentInfo(), saltNonce: SALT_NONCE }, + }; + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(true); + expect(signer.signTypedData).toHaveBeenCalledWith( + expect.objectContaining({ primaryType: "Void" }), + ); + expect(await storage.get("eip155:84532", paymentInfoHash())).toBeUndefined(); + }); + + it("keeps the binding after a capture that leaves a refundable balance", async () => { + const { scheme, storage } = makeDelegated(); + await bindRow(storage); + const envelope = lifecycleEnvelope({}); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(true); + expect(await storage.get("eip155:84532", paymentInfoHash())).toBeDefined(); + }); + + it("does not release the binding when the lifecycle settle fails", async () => { + const { scheme, storage } = makeDelegated(); + await bindRow(storage); + mockSigner.writeContract.mockRejectedValue(new Error("insufficient funds for gas")); + const envelope = lifecycleEnvelope({ + amount: "1000000", + expectedCapturableAmount: "1000000", + }); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(false); + expect(await storage.get("eip155:84532", paymentInfoHash())).toBeDefined(); + }); + + it("reports a failing early delete to onStorageError without failing the settle", async () => { + const { scheme, storage, onStorageError } = makeDelegated(); + await bindRow(storage); + vi.spyOn(storage, "delete").mockRejectedValue(new Error("db down")); + mockPaymentStates( + { capturableAmount: BigInt("1000000"), refundableAmount: 0n }, + { capturableAmount: 0n, refundableAmount: 0n }, + ); + const envelope = lifecycleEnvelope({ voidRemainder: true }); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(true); + expect(onStorageError).toHaveBeenCalled(); + }); + }); + }); + + describe("constructor and getSigners", () => { + it("should accept a single signer and return its addresses", () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + expect(scheme.getSigners("eip155:84532")).toEqual([FACILITATOR_EOA]); + }); + + it("should accept an array of signers and return the union", () => { + const signerB = createMockSigner([SUBMITTER_B]); + const scheme = new AuthCaptureEvmScheme([mockSigner, signerB]); + expect(scheme.getSigners("eip155:84532")).toEqual([FACILITATOR_EOA, SUBMITTER_B]); + }); + }); + + describe("multi-signer submitter selection", () => { + function createThreeSigners() { + return MULTI_SUBMITTERS.map(address => createMockSigner([address])); + } + + it("should verify and settle from addresses[2], not addresses[0]", async () => { + const signers = createThreeSigners(); + const scheme = new AuthCaptureEvmScheme(signers); + const payload = buildEip3009Payload(SUBMITTER_C); + const requirements = { ...payload.accepted }; + + const verified = await scheme.verify(payload, requirements); + expect(verified.isValid).toBe(true); + + const settled = await scheme.settle(payload, requirements); + expect(settled.success).toBe(true); + expect(signers[2]!.writeContract).toHaveBeenCalled(); + expect(signers[0]!.writeContract).not.toHaveBeenCalled(); + + const simulateAccount = signers[2]!.readContract.mock.calls.find( + (call: [{ functionName: string; account?: string }]) => + call[0].functionName === "authorize", + )?.[0].account; + expect(simulateAccount).toBe(SUBMITTER_C); + }); + + it("should reject a captureAuthorizer outside the signer set", async () => { + const scheme = new AuthCaptureEvmScheme(createThreeSigners()); + const outsider = "0x9999999999999999999999999999999999999999" as `0x${string}`; + const result = await scheme.verify(buildEip3009Payload(outsider), { + ...mockRequirements, + extra: { ...mockRequirements.extra, captureAuthorizer: outsider }, + }); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_operator_not_admitted"); + }); + + it("should match a lowercase advertised address to a checksummed signer", async () => { + const scheme = new AuthCaptureEvmScheme(createThreeSigners()); + const lowercase = SUBMITTER_C.toLowerCase() as `0x${string}`; + const payload = buildEip3009Payload(lowercase); + const result = await scheme.verify(payload, payload.accepted); + expect(result.isValid).toBe(true); + }); + + it("should settle a lifecycle capture from the paymentInfo.operator signer", async () => { + const signers = createThreeSigners(); + const scheme = new AuthCaptureEvmScheme(signers); + const envelope = buildCapturePayload({ + paymentInfo: boundPaymentInfo(SUBMITTER_C), + }); + envelope.accepted = { + ...envelope.accepted, + extra: boundExtra({ captureAuthorizer: SUBMITTER_C }), + }; + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(true); + expect(signers[2]!.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "capture" }), + ); + expect(signers[0]!.writeContract).not.toHaveBeenCalled(); + }); + + it("should snapshot the custom-path submitter and fail when that balance drops", async () => { + const signers = createThreeSigners(); + mockSigner = signers[0]!; + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + facilitatorAddress: FACILITATOR_EOA, + facilitatorDelta: -1n, + }); + const scheme = new AuthCaptureEvmScheme(signers, customOperatorConfig()); + const result = await scheme.verify( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + expect(signers[0]!.simulateCalls).toHaveBeenCalledWith( + expect.objectContaining({ account: FACILITATOR_EOA }), + ); + }); + + it("should settle custom collect from the first signer", async () => { + const signers = createThreeSigners(); + mockSigner = signers[0]!; + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + facilitatorAddress: FACILITATOR_EOA, + }); + const scheme = new AuthCaptureEvmScheme(signers, customOperatorConfig()); + const result = await scheme.settle( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + expect(result.success).toBe(true); + expect(signers[0]!.writeContract).toHaveBeenCalled(); + expect(signers[2]!.writeContract).not.toHaveBeenCalled(); + expect(signers[0]!.simulateCalls).toHaveBeenCalledWith( + expect.objectContaining({ account: FACILITATOR_EOA }), + ); + }); + }); + + describe("custom operator — simulateCalls outcome checks", () => { + it("should verify happy authorize with event, state, token deltas, and gas under the limit", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ paymentInfo }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.verify( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + expect(result.isValid).toBe(true); + }); + + it("should reject when simulated gasUsed exceeds the custom gas limit", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + gasUsed: DEFAULT_CUSTOM_OPERATOR_AUTHORIZE_GAS_LIMIT + 1n, + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.verify( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + expect(mockSigner.writeContract).not.toHaveBeenCalled(); + }); + + it("should reject when the forwarded call succeeds but emits no escrow-address log", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ paymentInfo, logs: [] }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.verify( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + + it("should reject authorize-then-void style final paymentState", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + postState: { + hasCollectedPayment: false, + capturableAmount: 0n, + refundableAmount: 0n, + }, + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.verify( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + + it("should reject token delta mismatch", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + tokenStoreDelta: 0n, + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.verify( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + + it("should reject when facilitator balance decreases", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + facilitatorDelta: -1n, + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.verify( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + + it("should reject custom collect when simulateCalls is missing", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.verify( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + + it("should fail settle when the mined receipt lacks the escrow event", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ paymentInfo, receiptLogs: [] }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.settle( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + + it("should fail settle when mined token deltas differ from simulation", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + settledTokenStoreDelta: BigInt("999999"), + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.settle( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_auth_capture_evm_simulation_failed"); + expect(result.transaction).toBe(MOCK_TX_HASH); + }); + + it("should fail settle when the mined call spends facilitator payment tokens", async () => { + const paymentInfo = buildPaymentInfo(CUSTOM_OPERATOR); + installCustomOperatorSimulation({ + paymentInfo, + settledFacilitatorDelta: -1n, + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, customOperatorConfig()); + const result = await scheme.settle( + buildEip3009Payload(CUSTOM_OPERATOR), + customOperatorRequirements(), + ); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_auth_capture_evm_simulation_failed"); + }); + }); + + describe("verify — operator types and salt binding", () => { + it("should reject operatorType policy", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, operatorType: "policy" }, + }; + const result = await scheme.verify(buildEip3009Payload(), reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_unsupported_operator_type"); + }); + + it("should reject custom operators that are not allowlisted", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + captureAuthorizer: CUSTOM_OPERATOR, + operatorType: "custom" as const, + }, + }; + const result = await scheme.verify(buildEip3009Payload(CUSTOM_OPERATOR), reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_operator_not_admitted"); + }); + + it("should reject a bound extra without saltNonce", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + receiverAuthorizer: "0x1111111111111111111111111111111111111111" as `0x${string}`, + }, + }; + const result = await scheme.verify(buildEip3009Payload(), reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_payload_format"); + }); + + it("should reject a lifecycle payload for delegated without a receiver authorizer", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = { + x402Version: 2, + accepted: mockRequirements, + payload: { + type: "void", + paymentInfo: buildPaymentInfo(), + saltNonce: SALT, + authorizerSignature: "0xabcd" as `0x${string}`, + }, + }; + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_lifecycle_not_relayed"); + }); + + it("should accept a bound collect payload with matching saltNonce", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildBoundEip3009Payload(); + const result = await scheme.verify(payload, payload.accepted); + expect(result.isValid).toBe(true); + }); + + it("should reject a bound collect payload whose salt does not match saltNonce", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildBoundEip3009Payload(); + payload.payload.salt = SALT; + const result = await scheme.verify(payload, payload.accepted); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_salt_binding_mismatch"); + }); + }); + + describe("verify — collect envelope rules", () => { + it("should reject collect verification after captureDeadline passes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date((captureDeadline + 10) * 1000)); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + vi.useRealTimers(); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_capture_deadline_expired"); + }); + + it("should reject bound collect payloads that omit saltNonce", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildBoundEip3009Payload(); + const collect = { ...payload.payload }; + delete collect.saltNonce; + const result = await scheme.verify({ ...payload, payload: collect }, payload.accepted); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_payload_format"); + }); + + it("should reject escrow collect payloads that carry charge-completion signatures", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + const result = await scheme.verify( + { + ...payload, + payload: { ...payload.payload, authorizerSignature: "0xabcd" as `0x${string}` }, + }, + payload.accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_payload_format"); + }); + + it("should reject unbound collect payloads that include saltNonce", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + const result = await scheme.verify( + { + ...payload, + payload: { ...payload.payload, saltNonce: SALT_NONCE }, + }, + payload.accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_payload_format"); + }); + + it("should reject EIP-3009 collect routed to the wrong token collector", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + const wrongCollector = "0x0000000000000000000000000000000000000001" as `0x${string}`; + const result = await scheme.verify( + { + ...payload, + payload: { + ...payload.payload, + authorization: { ...payload.payload.authorization, to: wrongCollector }, + }, + }, + payload.accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_token_collector_mismatch"); + }); + + it("should reject collect when maxTimeoutSeconds exceeds the captureDeadline window", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const accepted = { + ...mockRequirements, + maxTimeoutSeconds: 999_999, + extra: { + ...mockRequirements.extra, + captureDeadline: futureSeconds + 100, + refundDeadline: futureSeconds + 200, + }, + }; + const result = await scheme.verify(buildEip3009Payload(), accepted); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_deadline_ordering"); + }); + + it("should reject collect when EIP-3009 validBefore is inside the safety margin", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + const soon = String(Math.floor(Date.now() / 1000) + 3); + const result = await scheme.verify( + { + ...payload, + payload: { + ...payload.payload, + authorization: { ...payload.payload.authorization, validBefore: soon }, + }, + }, + payload.accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_authorization_expired"); + }); + + it("should reject collect when refundDeadline precedes captureDeadline", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const extra = { + ...mockRequirements.extra, + captureDeadline: futureSeconds + 10_000, + refundDeadline: futureSeconds + 5000, + }; + const accepted = { ...mockRequirements, extra }; + const result = await scheme.verify(buildEip3009Payload(), accepted); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_deadline_ordering"); + }); + + it("should reject permit2 requirements paired with an EIP-3009 collect payload", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const accepted = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + const result = await scheme.verify(buildEip3009Payload(), accepted); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_payload_method_mismatch"); + }); + + it("should reject collect when the signed authorization value does not match requirements.amount", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + const result = await scheme.verify( + { + ...payload, + payload: { + ...payload.payload, + authorization: { ...payload.payload.authorization, value: "500000" }, + }, + }, + payload.accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_amount_mismatch"); + }); + + it("should reject authorization charge with both feeBps and feeAmount on the wire", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildChargeEip3009Payload(); + const result = await scheme.verify( + { + ...envelope, + payload: { ...envelope.payload, feeBps: 0, feeAmount: "0" }, + }, + envelope.accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_payload_format"); + }); + + it("should reject authorization charge when submitted fee is out of range", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildChargeEip3009Payload(); + const result = await scheme.verify( + { + ...envelope, + payload: { ...envelope.payload, feeAmount: "999999" }, + }, + envelope.accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_fee_bps_out_of_range"); + }); + + it("should reject authorization charge when the charge amount exceeds the signed permit", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildChargeEip3009Payload(); + const result = await scheme.verify( + { + ...envelope, + payload: { ...envelope.payload, amount: "2000000" }, + }, + envelope.accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_amount_mismatch"); + }); + + it("should reject EIP-3009 collect when validAfter is still in the future", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + const futureValidAfter = String(Math.floor(Date.now() / 1000) + 3600); + const result = await scheme.verify( + { + ...payload, + payload: { + ...payload.payload, + authorization: { ...payload.payload.authorization, validAfter: futureValidAfter }, + }, + }, + payload.accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_authorization_not_yet_valid"); + }); + }); + + describe("verify/settle — lifecycle payloads", () => { + it("should reject capture when paymentState balances do not match the signed expectations", async () => { + mockSigner.readContract.mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args.functionName === "paymentState") { + return { + hasCollectedPayment: true, + capturableAmount: BigInt("400000"), + refundableAmount: 0n, + }; + } + return BigInt("1000000000"); + }); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildCapturePayload(); + const result = await scheme.verify(envelope, envelope.accepted); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_unexpected_payment_state"); + }); + + it("should retry stale paymentState reads before capture verify", async () => { + let paymentStateCalls = 0; + mockSigner.readContract.mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args.functionName === "paymentState") { + paymentStateCalls += 1; + if (paymentStateCalls === 1) { + return { + hasCollectedPayment: false, + capturableAmount: 0n, + refundableAmount: 0n, + }; + } + return { + hasCollectedPayment: true, + capturableAmount: BigInt("1000000"), + refundableAmount: 0n, + }; + } + return BigInt("1000000000"); + }); + + vi.useFakeTimers(); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildCapturePayload(); + const verifyPromise = scheme.verify(envelope, envelope.accepted); + await vi.advanceTimersByTimeAsync(200); + const result = await verifyPromise; + vi.useRealTimers(); + + expect(result.isValid).toBe(true); + expect(paymentStateCalls).toBe(2); + }); + + it("should reject voidAuthorizerSignature on a full capture", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildCapturePayload({ + amount: "1000000", + voidAuthorizerSignature: "0xabcd", + }); + const result = await scheme.verify(envelope, envelope.accepted); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_void_remainder_full_capture"); + }); + + it("should reject voidAuthorizerSignature on a void payload", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const extra = boundExtra(); + const accepted = { ...mockRequirements, extra }; + const result = await scheme.verify( + { + x402Version: 2, + accepted, + payload: { + type: "void", + paymentInfo: boundPaymentInfo(), + saltNonce: SALT_NONCE, + authorizerSignature: "0xabcd", + voidAuthorizerSignature: "0xabcd", + }, + }, + accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_void_authorizer_signature"); + }); + + it("should settle capture then void when voidAuthorizerSignature is present", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildCapturePayload({ voidAuthorizerSignature: "0xabcd" }); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(true); + expect(result.amount).toBe("500000"); + const names = mockSigner.writeContract.mock.calls.map( + (call: [{ functionName: string }]) => call[0].functionName, + ); + expect(names).toEqual(["capture", "void"]); + }); + + it("should keep the capture transaction when the trailing void fails on RPC", async () => { + const captureTx = "0xcafe000000000000000000000000000000000000000000000000000000000001"; + mockSigner.writeContract.mockImplementation( + async ({ functionName }: { functionName: string }) => { + if (functionName === "void") throw new Error("Transaction receipt timeout after 60s"); + return captureTx as `0x${string}`; + }, + ); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildCapturePayload({ voidAuthorizerSignature: "0xabcd" }); + + const result = await scheme.settle(envelope, envelope.accepted); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(captureTx); + expect(result.amount).toBe("500000"); + }); + + it("should keep the capture transaction when the trailing void reverts", async () => { + const captureTx = "0xcafe000000000000000000000000000000000000000000000000000000000002"; + mockSigner.writeContract.mockResolvedValue(captureTx as `0x${string}`); + mockSigner.waitForTransactionReceipt.mockImplementation(async () => + mockSigner.writeContract.mock.calls.length > 1 + ? { status: "reverted" } + : { status: "success" }, + ); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildCapturePayload({ voidAuthorizerSignature: "0xabcd" }); + + const result = await scheme.settle(envelope, envelope.accepted); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(captureTx); + }); + + it("should reject delegated refunds when refundFunding is not configured", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const extra = boundExtra(); + const accepted = { ...mockRequirements, extra }; + const result = await scheme.verify( + { + x402Version: 2, + accepted, + payload: { + type: "refund", + paymentInfo: boundPaymentInfo(), + saltNonce: SALT_NONCE, + amount: "100000", + expectedCapturableAmount: "0", + expectedRefundableAmount: "1000000", + authorizerSignature: "0xabcd", + }, + }, + accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_refund_funding_unavailable"); + }); + + it("should verify lifecycle capture against v1.0 feeBps wire fields", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const extra = boundExtra({ authCaptureEscrow: AUTH_CAPTURE_ESCROW_V1_0_ADDRESS }); + const accepted = { ...mockRequirements, extra }; + const result = await scheme.verify( + { + x402Version: 2, + accepted, + payload: { + type: "capture", + paymentInfo: boundPaymentInfo(), + saltNonce: SALT_NONCE, + amount: "500000", + feeBps: 0, + feeReceiver: FEE_RECIPIENT, + expectedCapturableAmount: "1000000", + expectedRefundableAmount: "0", + authorizerSignature: "0xabcd", + }, + }, + accepted, + ); + expect(result.isValid).toBe(true); + }); + + it("should reject lifecycle refund after refundDeadline expires", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner, { refundFunding: true }); + const expired = Math.floor(Date.now() / 1000) - 60; + const extra = boundExtra({ refundDeadline: expired }); + const accepted = { ...mockRequirements, extra }; + const paymentInfo = { ...boundPaymentInfo(), refundExpiry: expired }; + const result = await scheme.verify( + { + x402Version: 2, + accepted, + payload: { + type: "refund", + paymentInfo, + saltNonce: SALT_NONCE, + amount: "100000", + expectedCapturableAmount: "0", + expectedRefundableAmount: "1000000", + authorizerSignature: "0xabcd", + }, + }, + accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_refund_deadline_expired"); + }); + + it("should reject lifecycle capture amounts above onchain capturable balance", async () => { + mockSigner.readContract.mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args.functionName === "paymentState") { + return { + hasCollectedPayment: true, + capturableAmount: BigInt("100000"), + refundableAmount: 0n, + }; + } + return BigInt("1000000000"); + }); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildCapturePayload({ + amount: "500000", + expectedCapturableAmount: "100000", + }); + const result = await scheme.verify(envelope, envelope.accepted); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_amount_mismatch"); + }); + + it("should reject lifecycle refunds above onchain refundable balance", async () => { + mockSigner.readContract.mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args.functionName === "paymentState") { + return { + hasCollectedPayment: true, + capturableAmount: 0n, + refundableAmount: BigInt("100000"), + }; + } + return BigInt("1000000000"); + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, { refundFunding: true }); + const extra = boundExtra(); + const accepted = { ...mockRequirements, extra }; + const result = await scheme.verify( + { + x402Version: 2, + accepted, + payload: { + type: "refund", + paymentInfo: boundPaymentInfo(), + saltNonce: SALT_NONCE, + amount: "500000", + expectedCapturableAmount: "0", + expectedRefundableAmount: "100000", + authorizerSignature: "0xabcd", + }, + }, + accepted, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_refund_exceeds_capture"); + }); + + it("should verify a void lifecycle payload when capturable balance remains", async () => { + mockSigner.readContract.mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args.functionName === "paymentState") { + return { + hasCollectedPayment: true, + capturableAmount: BigInt("500000"), + refundableAmount: 0n, + }; + } + return BigInt("1000000000"); + }); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const extra = boundExtra(); + const accepted = { ...mockRequirements, extra }; + const result = await scheme.verify( + { + x402Version: 2, + accepted, + payload: { + type: "void", + paymentInfo: boundPaymentInfo(), + saltNonce: SALT_NONCE, + authorizerSignature: "0xabcd", + }, + }, + accepted, + ); + expect(result.isValid).toBe(true); + }); + + it("should reject lifecycle capture when submitted absolute fee is out of range", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildCapturePayload({ feeAmount: "999999" }); + const result = await scheme.verify(envelope, envelope.accepted); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_evm_fee_bps_out_of_range"); + }); + + it("should fail lifecycle capture settle when fee wire does not match v1.1 deployment", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const envelope = buildCapturePayload({ feeAmount: undefined, feeBps: 0 }); + const result = await scheme.settle(envelope, envelope.accepted); + expect(result.success).toBe(false); + expect(result.errorReason).toBe("invalid_auth_capture_evm_payload_format"); + }); + + it("should settle a refund through the operator refund collector when funding is configured", async () => { + mockSigner.readContract.mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args.functionName === "paymentState") { + return { + hasCollectedPayment: true, + capturableAmount: 0n, + refundableAmount: BigInt("1000000"), + }; + } + return BigInt("1000000000"); + }); + const scheme = new AuthCaptureEvmScheme(mockSigner, { refundFunding: true }); + const extra = boundExtra(); + const accepted = { ...mockRequirements, extra }; + const envelope = { + x402Version: 2, + accepted, + payload: { + type: "refund" as const, + paymentInfo: boundPaymentInfo(), + saltNonce: SALT_NONCE, + amount: "250000", + expectedCapturableAmount: "0", + expectedRefundableAmount: "1000000", + authorizerSignature: "0xabcd" as `0x${string}`, + }, + }; + const result = await scheme.settle(envelope, accepted); + expect(result.success).toBe(true); + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.functionName).toBe("refund"); + expect(call.args[2]).toBe(OPERATOR_REFUND_COLLECTOR_ADDRESS); + }); + }); +}); + +describe("auth-capture paymentState reads", () => { + describe("normalizePaymentState", () => { + it("should parse flat object shapes from viem", () => { + expect( + normalizePaymentState({ + hasCollectedPayment: true, + capturableAmount: 10000n, + refundableAmount: 0n, + }), + ).toEqual({ + hasCollectedPayment: true, + capturableAmount: 10000n, + refundableAmount: 0n, + }); + }); + + it("should parse tuple arrays", () => { + expect(normalizePaymentState([true, 10000n, 0n])).toEqual({ + hasCollectedPayment: true, + capturableAmount: 10000n, + refundableAmount: 0n, + }); + }); + + it("should unwrap nested state objects", () => { + expect( + normalizePaymentState({ + state: { hasCollectedPayment: false, capturableAmount: 1n, refundableAmount: 2n }, + }), + ).toEqual({ + hasCollectedPayment: false, + capturableAmount: 1n, + refundableAmount: 2n, + }); + }); + + it("should return undefined for unrecognized values", () => { + expect(normalizePaymentState(null)).toBeUndefined(); + expect(normalizePaymentState({})).toBeUndefined(); + expect(normalizePaymentState([true])).toBeUndefined(); + }); + }); + + describe("readPaymentStateForBalances", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function createSigner(readImpl: () => Promise): FacilitatorEvmSigner { + return { + readContract: vi.fn().mockImplementation(readImpl), + } as unknown as FacilitatorEvmSigner; + } + + it("should return immediately when balances match", async () => { + const signer = createSigner(async () => ({ + hasCollectedPayment: true, + capturableAmount: 10000n, + refundableAmount: 0n, + })); + + const result = await readPaymentStateForBalances( + signer, + "0x1234567890123456789012345678901234567890123456789012345678901234", + 10000n, + 0n, + ); + + expect(result).toEqual({ + state: { + hasCollectedPayment: true, + capturableAmount: 10000n, + refundableAmount: 0n, + }, + readFailed: false, + attempts: 1, + }); + expect(signer.readContract).toHaveBeenCalledTimes(1); + }); + + it("should retry stale zero balances until RPC catches up", async () => { + let calls = 0; + const signer = createSigner(async () => { + calls += 1; + if (calls === 1) { + return { hasCollectedPayment: false, capturableAmount: 0n, refundableAmount: 0n }; + } + return { hasCollectedPayment: true, capturableAmount: 10000n, refundableAmount: 0n }; + }); + + const resultPromise = readPaymentStateForBalances( + signer, + "0x1234567890123456789012345678901234567890123456789012345678901234", + 10000n, + 0n, + ); + await vi.advanceTimersByTimeAsync(PAYMENT_STATE_RETRY_DELAYS_MS[0]!); + const result = await resultPromise; + + expect(result.state?.capturableAmount).toBe(10000n); + expect(result.readFailed).toBe(false); + expect(result.attempts).toBe(2); + expect(signer.readContract).toHaveBeenCalledTimes(2); + }); + + it("should retry stale zero balances after charge until RPC catches up", async () => { + let calls = 0; + const signer = createSigner(async () => { + calls += 1; + if (calls === 1) { + return { hasCollectedPayment: false, capturableAmount: 0n, refundableAmount: 0n }; + } + return { hasCollectedPayment: true, capturableAmount: 0n, refundableAmount: 10000n }; + }); + + const resultPromise = readPaymentStateForBalances( + signer, + "0x1234567890123456789012345678901234567890123456789012345678901234", + 0n, + 10000n, + ); + await vi.advanceTimersByTimeAsync(PAYMENT_STATE_RETRY_DELAYS_MS[0]!); + const result = await resultPromise; + + expect(result.state?.refundableAmount).toBe(10000n); + expect(result.readFailed).toBe(false); + expect(result.attempts).toBe(2); + expect(signer.readContract).toHaveBeenCalledTimes(2); + }); + + it("should stop retrying when balances genuinely mismatch", async () => { + const signer = createSigner(async () => ({ + hasCollectedPayment: true, + capturableAmount: 5000n, + refundableAmount: 0n, + })); + + const result = await readPaymentStateForBalances( + signer, + "0x1234567890123456789012345678901234567890123456789012345678901234", + 10000n, + 0n, + ); + + expect(result.state?.capturableAmount).toBe(5000n); + expect(result.readFailed).toBe(false); + expect(result.attempts).toBe(1); + expect(signer.readContract).toHaveBeenCalledTimes(1); + }); + + it("should exhaust retries when reads keep failing", async () => { + const signer = createSigner(async () => { + throw new Error("rpc down"); + }); + + const resultPromise = readPaymentStateForBalances( + signer, + "0x1234567890123456789012345678901234567890123456789012345678901234", + 10000n, + 0n, + ); + await vi.advanceTimersByTimeAsync( + PAYMENT_STATE_RETRY_DELAYS_MS.reduce((sum, delay) => sum + delay, 0), + ); + const result = await resultPromise; + + expect(result.readFailed).toBe(true); + expect(result.state).toBeUndefined(); + expect(result.attempts).toBe(PAYMENT_STATE_MAX_ATTEMPTS); + expect(signer.readContract).toHaveBeenCalledTimes(PAYMENT_STATE_MAX_ATTEMPTS); + }); + }); + + describe("readPaymentStateOnce", () => { + it("should return undefined when readContract throws", async () => { + const signer = { + readContract: vi.fn().mockRejectedValue(new Error("rpc down")), + } as unknown as FacilitatorEvmSigner; + + await expect( + readPaymentStateOnce( + signer, + "0x1234567890123456789012345678901234567890123456789012345678901234", + ), + ).resolves.toBeUndefined(); + }); + }); +}); + +describe("facilitatorAddresses and selectSubmitter", () => { + const addrA = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as `0x${string}`; + const addrB = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as `0x${string}`; + const addrC = "0xcccccccccccccccccccccccccccccccccccccccc" as `0x${string}`; + + function stubSigner(addresses: readonly `0x${string}`[]): FacilitatorEvmSigner { + return { getAddresses: () => addresses } as FacilitatorEvmSigner; + } + + it("should flatten and dedupe addresses across members", () => { + const signers = [stubSigner([addrA, addrB]), stubSigner([addrB, addrC])]; + expect(facilitatorAddresses(signers)).toEqual([addrA, addrB, addrC]); + }); + + it("should return the owning signer", () => { + const owner = stubSigner([addrB]); + const other = stubSigner([addrA]); + expect(selectSubmitter([other, owner], addrB)).toBe(owner); + }); + + it("should match across checksum and lowercase", () => { + const owner = stubSigner([getAddress(addrB)]); + expect(selectSubmitter([owner], addrB.toLowerCase() as `0x${string}`)).toBe(owner); + }); + + it("should return undefined for an unknown address", () => { + expect(selectSubmitter([stubSigner([addrA])], addrB)).toBeUndefined(); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/lifecycleFacilitator.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/lifecycleFacilitator.test.ts new file mode 100644 index 0000000000..5e9dde7248 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/lifecycleFacilitator.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { zeroAddress } from "viem"; +import { verifyLifecycle } from "../../../src/auth-capture/facilitator/lifecycle"; +import * as Errors from "../../../src/auth-capture/errors"; +import { deriveBoundSalt } from "../../../src/auth-capture/nonce"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; +import type { PaymentInfoStruct } from "../../../src/auth-capture/types"; + +const PAYER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as `0x${string}`; +const ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; +const PAY_TO = "0xdddddddddddddddddddddddddddddddddddddddd" as `0x${string}`; +const CAPTURE_AUTHORIZER = "0x1234567890123456789012345678901234567890" as `0x${string}`; +const RECEIVER_AUTHORIZER = "0x1111111111111111111111111111111111111111" as `0x${string}`; +const FEE_RECIPIENT = "0x4444444444444444444444444444444444444444" as `0x${string}`; +const SALT_NONCE = + "0x2222222222222222222222222222222222222222222222222222222222222222" as `0x${string}`; +const BOUND_SALT = deriveBoundSalt(RECEIVER_AUTHORIZER, zeroAddress, SALT_NONCE); + +const futureSeconds = Math.floor(Date.now() / 1000) + 3600; +const captureDeadline = futureSeconds + 86400; +const refundDeadline = captureDeadline + 86400; + +const mockRequirements = { + scheme: "auth-capture", + network: "eip155:84532", + amount: "1000000", + asset: ASSET, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { + captureAuthorizer: CAPTURE_AUTHORIZER, + captureDeadline, + refundDeadline, + feeRecipient: FEE_RECIPIENT, + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + }, +}; + +function paymentInfo(): PaymentInfoStruct { + return { + operator: CAPTURE_AUTHORIZER, + payer: PAYER, + receiver: PAY_TO, + token: ASSET, + maxAmount: "1000000", + preApprovalExpiry: futureSeconds, + authorizationExpiry: captureDeadline, + refundExpiry: refundDeadline, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: FEE_RECIPIENT, + salt: BOUND_SALT, + }; +} + +function createMockSigner(): FacilitatorEvmSigner { + return { + getAddresses: () => [CAPTURE_AUTHORIZER], + readContract: vi.fn().mockResolvedValue("0x1626ba7e"), + writeContract: vi.fn(), + waitForTransactionReceipt: vi.fn(), + } as FacilitatorEvmSigner; +} + +describe("verifyLifecycle", () => { + let signer: FacilitatorEvmSigner; + + beforeEach(() => { + signer = createMockSigner(); + }); + + it("rejects capture on authorization flow", async () => { + const accepted = { + ...mockRequirements, + extra: { ...mockRequirements.extra, paymentFlow: "authorization" }, + }; + const result = await verifyLifecycle( + [signer], + undefined, + { x402Version: 2, accepted, payload: {} }, + accepted, + { + type: "capture", + paymentInfo: paymentInfo(), + saltNonce: SALT_NONCE, + amount: "500000", + feeAmount: "0", + feeReceiver: FEE_RECIPIENT, + expectedCapturableAmount: "1000000", + expectedRefundableAmount: "0", + authorizerSignature: "0xabcd", + }, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrInvalidPayloadType); + }); + + it("rejects refund when delegated facilitator has no refund funding", async () => { + const accepted = mockRequirements; + const result = await verifyLifecycle( + [signer], + undefined, + { x402Version: 2, accepted, payload: {} }, + accepted, + { + type: "refund", + paymentInfo: paymentInfo(), + saltNonce: SALT_NONCE, + amount: "100000", + expectedCapturableAmount: "0", + expectedRefundableAmount: "100000", + authorizerSignature: "0xabcd", + }, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrRefundFundingUnavailable); + }); + + it("rejects a missing signature when no delegated authorizer is configured", async () => { + const accepted = mockRequirements; + const info = paymentInfo(); + const result = await verifyLifecycle( + [signer], + undefined, + { x402Version: 2, accepted, payload: {} }, + accepted, + { + type: "void", + paymentInfo: info, + saltNonce: SALT_NONCE, + authorizerSignature: "", + }, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrAuthorizerSignature); + }); + + it("rejects malformed void payloads that carry voidAuthorizerSignature", async () => { + const accepted = mockRequirements; + const result = await verifyLifecycle( + [signer], + undefined, + { x402Version: 2, accepted, payload: {} }, + accepted, + { + type: "void", + paymentInfo: paymentInfo(), + saltNonce: SALT_NONCE, + authorizerSignature: "0xabcd", + voidAuthorizerSignature: "0xdead", + }, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrVoidAuthorizerSignature); + }); + + it("rejects lifecycle payloads whose paymentInfo hash binding does not match saltNonce", async () => { + const accepted = mockRequirements; + const info = paymentInfo(); + const wrongSalt = "0x" + "99".repeat(32); + const result = await verifyLifecycle( + [signer], + undefined, + { x402Version: 2, accepted, payload: {} }, + accepted, + { + type: "void", + paymentInfo: { ...info, salt: wrongSalt }, + saltNonce: SALT_NONCE, + authorizerSignature: "0xabcd", + }, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrSaltBindingMismatch); + }); + + it("rejects capture payloads that include both feeBps and feeAmount", async () => { + const accepted = mockRequirements; + const result = await verifyLifecycle( + [signer], + undefined, + { x402Version: 2, accepted, payload: {} }, + accepted, + { + type: "capture", + paymentInfo: paymentInfo(), + saltNonce: SALT_NONCE, + amount: "500000", + feeBps: 0, + feeAmount: "0", + feeReceiver: FEE_RECIPIENT, + expectedCapturableAmount: "1000000", + expectedRefundableAmount: "0", + authorizerSignature: "0xabcd", + }, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrInvalidPayloadFormat); + }); + + it("rejects capture payloads whose operator does not match extra.captureAuthorizer", async () => { + const accepted = mockRequirements; + const info = paymentInfo(); + const result = await verifyLifecycle( + [signer], + undefined, + { x402Version: 2, accepted, payload: {} }, + accepted, + { + type: "capture", + paymentInfo: { + ...info, + operator: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + saltNonce: SALT_NONCE, + amount: "500000", + feeAmount: "0", + feeReceiver: FEE_RECIPIENT, + expectedCapturableAmount: "1000000", + expectedRefundableAmount: "0", + authorizerSignature: "0xabcd", + }, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrOperatorMismatch); + }); + + it("rejects capture payloads whose paymentInfo differs from the requirements", async () => { + const accepted = mockRequirements; + const result = await verifyLifecycle( + [signer], + undefined, + { x402Version: 2, accepted, payload: {} }, + accepted, + { + type: "capture", + paymentInfo: { + ...paymentInfo(), + receiver: "0xcccccccccccccccccccccccccccccccccccccccc", + }, + saltNonce: SALT_NONCE, + amount: "500000", + feeAmount: "0", + feeReceiver: FEE_RECIPIENT, + expectedCapturableAmount: "1000000", + expectedRefundableAmount: "0", + authorizerSignature: "0xabcd", + }, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrPaymentInfoMismatch); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/lifecyclePayload.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/lifecyclePayload.test.ts new file mode 100644 index 0000000000..3c76ef59d9 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/lifecyclePayload.test.ts @@ -0,0 +1,374 @@ +import { describe, it, expect, vi } from "vitest"; +import { + AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + CAPTURE_TYPES_V1_0, + EIP3009_TOKEN_COLLECTOR_ADDRESS, +} from "../../../src/auth-capture/constants"; +import { parseAuthCaptureExtra } from "../../../src/auth-capture/extra"; +import { + buildCaptureEnrichment, + buildCapturePayload, + buildChargeCompletionEnrichment, + buildRefundPayload, + buildVoidEnrichment, + buildVoidPayload, + buildCaptureFields, +} from "../../../src/auth-capture/lifecyclePayload"; +import type { NormalizedAuthCaptureExtra } from "../../../src/auth-capture/extra"; +import type { PaymentInfoStruct } from "../../../src/auth-capture/types"; + +const FUTURE = Math.floor(Date.now() / 1000) + 86400; +const CHAIN_ID = 84532; +const BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + +function normalizedExtra(overrides: Record = {}): NormalizedAuthCaptureExtra { + const parsed = parseAuthCaptureExtra({ + captureAuthorizer: "0x1234567890123456789012345678901234567890", + captureDeadline: FUTURE, + refundDeadline: FUTURE + 86400, + feeRecipient: "0x4444444444444444444444444444444444444444", + minFeeBps: 100, + maxFeeBps: 200, + name: "USDC", + version: "2", + receiverAuthorizer: "0x1111111111111111111111111111111111111111", + ...overrides, + }); + if ("error" in parsed) { + throw new Error(parsed.error); + } + return parsed.extra; +} + +function mockSigner() { + return { + address: "0x1111111111111111111111111111111111111111" as `0x${string}`, + signTypedData: vi.fn().mockResolvedValue("0xsig" as `0x${string}`), + }; +} + +const paymentInfo: PaymentInfoStruct = { + operator: "0x1234567890123456789012345678901234567890", + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + receiver: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + token: BASE_SEPOLIA_USDC, + maxAmount: "1000000", + preApprovalExpiry: FUTURE - 100, + authorizationExpiry: FUTURE, + refundExpiry: FUTURE + 86400, + minFeeBps: 100, + maxFeeBps: 200, + feeReceiver: "0x4444444444444444444444444444444444444444", + salt: "0x" + "44".repeat(32), +}; + +describe("buildCaptureFields", () => { + it("rejects implicit void on full capture but signs void when capture is partial", async () => { + const signer = mockSigner(); + const extra = normalizedExtra(); + const hash = "0x" + "aa".repeat(32); + + const full = await buildCaptureFields({ + signer, + chainId: CHAIN_ID, + extra, + paymentInfoHash: hash, + amount: "1000000", + capturable: "1000000", + refundable: "0", + voidOnPartialCapture: true, + }); + expect(full.voidAuthorizerSignature).toBeUndefined(); + + const partial = await buildCaptureFields({ + signer, + chainId: CHAIN_ID, + extra, + paymentInfoHash: hash, + amount: "400000", + capturable: "1000000", + refundable: "0", + voidOnPartialCapture: true, + }); + expect(partial.voidAuthorizerSignature).toBe("0xsig"); + expect(full.voidAuthorizerSignature).toBeUndefined(); + }); + + it("uses v1.0 feeBps wire when deployment is pinned to v1.0", async () => { + const signer = mockSigner(); + const extra = normalizedExtra({ authCaptureEscrow: AUTH_CAPTURE_ESCROW_V1_0_ADDRESS }); + const fields = await buildCaptureFields({ + signer, + chainId: CHAIN_ID, + extra, + paymentInfoHash: "0x" + "bb".repeat(32), + amount: "1000000", + capturable: "1000000", + refundable: "0", + feeBps: 50, + feeReceiver: extra.feeRecipient, + }); + expect(fields.feeBps).toBe(50); + expect(fields.feeAmount).toBeUndefined(); + expect(signer.signTypedData).toHaveBeenCalledWith( + expect.objectContaining({ types: CAPTURE_TYPES_V1_0, primaryType: "Capture" }), + ); + }); + + it("derives v1.1 feeAmount from feeBps when feeAmount is omitted", async () => { + const signer = mockSigner(); + const extra = normalizedExtra(); + const fields = await buildCaptureFields({ + signer, + chainId: CHAIN_ID, + extra, + paymentInfoHash: "0x" + "ff".repeat(32), + amount: "1000000", + capturable: "1000000", + refundable: "0", + feeBps: 100, + feeReceiver: extra.feeRecipient, + }); + expect(fields.feeAmount).toBe("10000"); + expect(fields.feeBps).toBeUndefined(); + }); + + it("honors explicit v1.1 feeAmount override", async () => { + const signer = mockSigner(); + const extra = normalizedExtra(); + const fields = await buildCaptureFields({ + signer, + chainId: CHAIN_ID, + extra, + paymentInfoHash: "0x" + "cc".repeat(32), + amount: "1000000", + capturable: "1000000", + refundable: "0", + feeAmount: "12345", + feeReceiver: extra.feeRecipient, + }); + expect(fields.feeAmount).toBe("12345"); + expect(fields.feeBps).toBeUndefined(); + }); +}); + +describe("lifecycle payload builders", () => { + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra: { + captureAuthorizer: "0x1234567890123456789012345678901234567890", + captureDeadline: FUTURE, + refundDeadline: FUTURE + 86400, + feeRecipient: "0x4444444444444444444444444444444444444444", + minFeeBps: 100, + maxFeeBps: 200, + name: "USDC", + version: "2", + receiverAuthorizer: "0x1111111111111111111111111111111111111111", + }, + }; + + const collect = { + authorization: { + from: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(FUTURE), + nonce: "0x" + "33".repeat(32), + }, + signature: "0xabcd", + salt: paymentInfo.salt, + saltNonce: "0x" + "22".repeat(32), + }; + + it("builds void enrichment for cancel-phase settle", async () => { + const signer = mockSigner(); + const extra = normalizedExtra(); + const enrichment = await buildVoidEnrichment({ + paymentInfo, + extra, + signer, + chainId: CHAIN_ID, + paymentInfoHash: "0x" + "dd".repeat(32), + }); + expect(enrichment).toEqual({ + type: "void", + paymentInfo, + authorizerSignature: "0xsig", + }); + }); + + it("includes saltNonce on deferred void and refund payloads", async () => { + const signer = mockSigner(); + const extra = normalizedExtra(); + const record = { + paymentInfo, + paymentInfoHash: "0x" + "ee".repeat(32), + capturableAmount: "0", + refundableAmount: "500000", + saltNonce: "0x" + "22".repeat(32), + }; + const voidPayload = await buildVoidPayload({ record, extra, signer, chainId: CHAIN_ID }); + expect(voidPayload.saltNonce).toBe(record.saltNonce); + const refundPayload = await buildRefundPayload({ + record, + extra, + signer, + chainId: CHAIN_ID, + amount: "100000", + }); + expect(refundPayload.type).toBe("refund"); + expect(refundPayload.amount).toBe("100000"); + }); + + it("buildCapturePayload carries voidRemainder signatures through", async () => { + const signer = mockSigner(); + const extra = normalizedExtra(); + const payload = await buildCapturePayload({ + record: { + paymentInfo, + paymentInfoHash: "0x" + "ff".repeat(32), + capturableAmount: "1000000", + refundableAmount: "0", + saltNonce: "0x" + "22".repeat(32), + }, + extra, + signer, + chainId: CHAIN_ID, + amount: "250000", + voidRemainder: true, + }); + expect(payload.voidAuthorizerSignature).toBe("0xsig"); + expect(payload.amount).toBe("250000"); + }); + + describe("without a local signer (authorizer delegated to the facilitator)", () => { + const extra = normalizedExtra(); + const record = { + paymentInfo, + paymentInfoHash: ("0x" + "ee".repeat(32)) as `0x${string}`, + capturableAmount: "1000000", + refundableAmount: "0", + saltNonce: ("0x" + "22".repeat(32)) as `0x${string}`, + }; + + it("omits every signature and flags the void leg with voidRemainder", async () => { + const payload = await buildCapturePayload({ + record, + extra, + chainId: CHAIN_ID, + amount: "250000", + voidRemainder: true, + }); + expect(payload).toMatchObject({ type: "capture", amount: "250000", voidRemainder: true }); + expect(payload).not.toHaveProperty("authorizerSignature"); + expect(payload).not.toHaveProperty("voidAuthorizerSignature"); + }); + + it("builds unsigned void, refund, void enrichment, and charge completion", async () => { + const voidPayload = await buildVoidPayload({ record, extra, chainId: CHAIN_ID }); + expect(voidPayload).not.toHaveProperty("authorizerSignature"); + expect(voidPayload.saltNonce).toBe(record.saltNonce); + + const refundPayload = await buildRefundPayload({ + record, + extra, + chainId: CHAIN_ID, + amount: "1", + }); + expect(refundPayload).not.toHaveProperty("authorizerSignature"); + expect(refundPayload.expectedCapturableAmount).toBe("1000000"); + + const voidEnrichment = await buildVoidEnrichment({ + paymentInfo, + extra, + chainId: CHAIN_ID, + paymentInfoHash: record.paymentInfoHash, + }); + expect(voidEnrichment).toEqual({ type: "void", paymentInfo }); + + const completion = await buildChargeCompletionEnrichment({ + collect, + requirements, + extra, + chainId: CHAIN_ID, + amount: "1000000", + }); + expect(completion).toEqual({ + amount: "1000000", + feeAmount: "10000", + feeReceiver: extra.feeRecipient, + }); + }); + }); + + it("buildCaptureEnrichment uses stored balances when present", async () => { + const signer = mockSigner(); + const extra = normalizedExtra(); + const enrichment = await buildCaptureEnrichment({ + collect, + requirements: { ...requirements, amount: "500000" }, + extra, + signer, + chainId: CHAIN_ID, + capturable: "750000", + refundable: "100000", + amount: "500000", + paymentInfo, + }); + expect(enrichment).toMatchObject({ + type: "capture", + amount: "500000", + expectedCapturableAmount: "750000", + expectedRefundableAmount: "100000", + }); + expect(enrichment).not.toHaveProperty("saltNonce"); + }); + + it("buildChargeCompletionEnrichment uses feeAmount on v1.1", async () => { + const signer = mockSigner(); + const extra = normalizedExtra(); + const enrichment = await buildChargeCompletionEnrichment({ + collect, + requirements, + extra, + signer, + chainId: CHAIN_ID, + amount: "1000000", + }); + expect(enrichment).toMatchObject({ + amount: "1000000", + feeAmount: "10000", + authorizerSignature: "0xsig", + }); + expect(enrichment).not.toHaveProperty("feeBps"); + }); + + it("buildChargeCompletionEnrichment uses feeBps on v1.0 deployment", async () => { + const signer = mockSigner(); + const extra = normalizedExtra({ authCaptureEscrow: AUTH_CAPTURE_ESCROW_V1_0_ADDRESS }); + const enrichment = await buildChargeCompletionEnrichment({ + collect, + requirements: { + ...requirements, + extra: { ...requirements.extra, authCaptureEscrow: AUTH_CAPTURE_ESCROW_V1_0_ADDRESS }, + }, + extra, + signer, + chainId: CHAIN_ID, + amount: "1000000", + }); + expect(enrichment).toMatchObject({ + amount: "1000000", + feeBps: 100, + authorizerSignature: "0xsig", + }); + expect(enrichment).not.toHaveProperty("feeAmount"); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/pendingSettlement.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/pendingSettlement.test.ts new file mode 100644 index 0000000000..4352f6a771 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/pendingSettlement.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { hexToBigInt, zeroAddress } from "viem"; +import { AuthCaptureEvmScheme } from "../../../src/auth-capture/facilitator/scheme"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; +import { + InMemoryPendingSettlementStore, + type PendingSettlementStore, +} from "@x402/core/facilitator"; +import { + EIP3009_TOKEN_COLLECTOR_ADDRESS, + PERMIT2_TOKEN_COLLECTOR_ADDRESS, +} from "../../../src/auth-capture/constants"; +import { + computePayerAgnosticPaymentInfoHash, + deriveBoundSalt, +} from "../../../src/auth-capture/nonce"; +import * as Errors from "../../../src/auth-capture/errors"; + +const MOCK_TX_HASH = ("0x" + "a1".repeat(32)) as `0x${string}`; +const ERC1271_MAGIC_VALUE = "0x1626ba7e" as const; +const PAYER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as `0x${string}`; +const ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; +const PAY_TO = "0xdddddddddddddddddddddddddddddddddddddddd" as `0x${string}`; +const CAPTURE_AUTHORIZER = "0x1234567890123456789012345678901234567890" as `0x${string}`; +const RECEIVER_AUTHORIZER = "0x1111111111111111111111111111111111111111" as `0x${string}`; +const FEE_RECIPIENT = "0x4444444444444444444444444444444444444444" as `0x${string}`; +const SALT = "0x0000000000000000000000000000000000000000000000000000000000000abc" as `0x${string}`; +const SALT_NONCE = + "0x2222222222222222222222222222222222222222222222222222222222222222" as `0x${string}`; +const BOUND_SALT = deriveBoundSalt(RECEIVER_AUTHORIZER, zeroAddress, SALT_NONCE); + +const futureSeconds = Math.floor(Date.now() / 1000) + 3600; +const captureDeadline = futureSeconds + 86400; +const refundDeadline = captureDeadline + 86400; + +const mockRequirements = { + scheme: "auth-capture", + network: "eip155:84532", + amount: "1000000", + asset: ASSET, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { + captureAuthorizer: CAPTURE_AUTHORIZER, + captureDeadline, + refundDeadline, + feeRecipient: FEE_RECIPIENT, + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + }, +}; + +function buildEip3009Payload(signature = "0xabcd" as `0x${string}`) { + const paymentInfo = { + operator: CAPTURE_AUTHORIZER, + payer: PAYER, + receiver: PAY_TO, + token: ASSET, + maxAmount: "1000000", + preApprovalExpiry: futureSeconds, + authorizationExpiry: captureDeadline, + refundExpiry: refundDeadline, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: FEE_RECIPIENT, + salt: SALT, + }; + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + return { + x402Version: 2, + accepted: mockRequirements, + resource: { url: "", description: "", mimeType: "" }, + payload: { + authorization: { + from: PAYER, + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(futureSeconds), + nonce, + }, + signature, + salt: SALT, + }, + }; +} + +function buildPermit2Payload(signature = "0xpermit2sig" as `0x${string}`) { + const paymentInfo = { + operator: CAPTURE_AUTHORIZER, + payer: PAYER, + receiver: PAY_TO, + token: ASSET, + maxAmount: "1000000", + preApprovalExpiry: futureSeconds, + authorizationExpiry: captureDeadline, + refundExpiry: refundDeadline, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: FEE_RECIPIENT, + salt: SALT, + }; + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + const accepted = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + return { + x402Version: 2, + accepted, + resource: { url: "", description: "", mimeType: "" }, + payload: { + permit2Authorization: { + from: PAYER, + permitted: { token: ASSET, amount: "1000000" }, + spender: PERMIT2_TOKEN_COLLECTOR_ADDRESS, + nonce: hexToBigInt(nonce).toString(), + deadline: String(futureSeconds), + }, + signature, + salt: SALT, + }, + }; +} + +function buildCapturePayload(authorizerSignature = "0xabcd" as `0x${string}`) { + const accepted = { + ...mockRequirements, + extra: { ...mockRequirements.extra, receiverAuthorizer: RECEIVER_AUTHORIZER }, + }; + return { + x402Version: 2, + accepted, + resource: { url: "", description: "", mimeType: "" }, + payload: { + type: "capture" as const, + paymentInfo: { + operator: CAPTURE_AUTHORIZER, + payer: PAYER, + receiver: PAY_TO, + token: ASSET, + maxAmount: "1000000", + preApprovalExpiry: futureSeconds, + authorizationExpiry: captureDeadline, + refundExpiry: refundDeadline, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: FEE_RECIPIENT, + salt: BOUND_SALT, + }, + saltNonce: SALT_NONCE, + amount: "500000", + feeAmount: "0", + feeReceiver: FEE_RECIPIENT, + expectedCapturableAmount: "1000000", + expectedRefundableAmount: "0", + authorizerSignature, + }, + }; +} + +function buildVoidPayload(authorizerSignature = "0xvoidsig" as `0x${string}`) { + const accepted = { + ...mockRequirements, + extra: { ...mockRequirements.extra, receiverAuthorizer: RECEIVER_AUTHORIZER }, + }; + return { + x402Version: 2, + accepted, + resource: { url: "", description: "", mimeType: "" }, + payload: { + type: "void" as const, + paymentInfo: { + operator: CAPTURE_AUTHORIZER, + payer: PAYER, + receiver: PAY_TO, + token: ASSET, + maxAmount: "1000000", + preApprovalExpiry: futureSeconds, + authorizationExpiry: captureDeadline, + refundExpiry: refundDeadline, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: FEE_RECIPIENT, + salt: BOUND_SALT, + }, + saltNonce: SALT_NONCE, + authorizerSignature, + }, + }; +} + +describe("AuthCaptureEvmScheme pending-settlement store integration", () => { + let mockFacilitatorSigner: FacilitatorEvmSigner; + let store: PendingSettlementStore; + + beforeEach(() => { + mockFacilitatorSigner = { + getAddresses: vi.fn().mockReturnValue([CAPTURE_AUTHORIZER]), + readContract: vi.fn().mockImplementation(async (args: { functionName: string }) => { + if (args?.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args?.functionName === "paymentState") { + return { + hasCollectedPayment: true, + capturableAmount: BigInt("1000000"), + refundableAmount: 0n, + }; + } + return BigInt("1000000000"); + }), + verifyTypedData: vi.fn().mockResolvedValue(true), + writeContract: vi.fn().mockResolvedValue(MOCK_TX_HASH), + sendTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success", logs: null }), + getCode: vi.fn().mockImplementation(async ({ address }: { address: `0x${string}` }) => { + const lower = address.toLowerCase(); + if (lower === PAYER.toLowerCase() || lower === RECEIVER_AUTHORIZER.toLowerCase()) { + return "0x6080604052"; + } + return "0x"; + }), + }; + store = new InMemoryPendingSettlementStore(); + }); + + describe("collect — EIP-3009", () => { + it("cache-miss success leaves no pending entry", async () => { + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildEip3009Payload(); + const signature = payload.payload.signature; + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(true); + expect(await store.get(signature)).toBeUndefined(); + }); + + it("cache-miss + receipt wait fails returns settlement_pending and populates the store", async () => { + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockRejectedValue(new Error("rpc: timeout waiting for receipt")); + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildEip3009Payload(); + const signature = payload.payload.signature; + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(await store.get(signature)).toBe(MOCK_TX_HASH); + }); + + it("cache-hit skips verify/broadcast and reconciles against the cached tx", async () => { + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildEip3009Payload(); + const signature = payload.payload.signature; + await store.set(signature, MOCK_TX_HASH); + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(mockFacilitatorSigner.waitForTransactionReceipt).toHaveBeenCalledWith( + expect.objectContaining({ hash: MOCK_TX_HASH }), + ); + expect(await store.get(signature)).toBeUndefined(); + }); + + it("cache-hit + extra re-parse fails keeps the hash and returns settlement_pending", async () => { + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildEip3009Payload(); + const signature = payload.payload.signature; + await store.set(signature, MOCK_TX_HASH); + const brokenRequirements = { + ...payload.accepted, + extra: { ...payload.accepted.extra, captureAuthorizer: "not-an-address" }, + }; + + const result = await facilitator.settle(payload, brokenRequirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get(signature)).toBe(MOCK_TX_HASH); + }); + + it("cache-hit + submitter unresolved keeps the hash and returns settlement_pending", async () => { + mockFacilitatorSigner.getAddresses = vi + .fn() + .mockReturnValue(["0x9999999999999999999999999999999999999999"]); + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildEip3009Payload(); + const signature = payload.payload.signature; + await store.set(signature, MOCK_TX_HASH); + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(mockFacilitatorSigner.waitForTransactionReceipt).not.toHaveBeenCalled(); + expect(await store.get(signature)).toBe(MOCK_TX_HASH); + }); + + it("cache-hit still unconfirmed returns settlement_pending again without re-broadcasting", async () => { + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockRejectedValue(new Error("still pending")); + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildEip3009Payload(); + const signature = payload.payload.signature; + await store.set(signature, MOCK_TX_HASH); + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get(signature)).toBe(MOCK_TX_HASH); + }); + + it("verify-only failure is terminal and never touches the store", async () => { + mockFacilitatorSigner.readContract = vi + .fn() + .mockImplementation(async (args: { functionName: string }) => { + if (args?.functionName === "isValidSignature") return "0x00000000"; + if (args?.functionName === "paymentState") { + return { + hasCollectedPayment: true, + capturableAmount: BigInt("1000000"), + refundableAmount: 0n, + }; + } + return BigInt("1000000000"); + }); + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildEip3009Payload(); + const signature = payload.payload.signature; + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrInvalidAuthCaptureSignature); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get(signature)).toBeUndefined(); + }); + + it("invalid broadcast hash is terminal and never populates the store", async () => { + mockFacilitatorSigner.writeContract = vi.fn().mockResolvedValue("0xnothash"); + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildEip3009Payload(); + const signature = payload.payload.signature; + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrTransactionReverted); + expect(await store.get(signature)).toBeUndefined(); + }); + }); + + describe("collect — Permit2", () => { + it("cache-miss + receipt wait fails returns settlement_pending keyed by signature", async () => { + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockRejectedValue(new Error("rpc timeout")); + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildPermit2Payload(); + const signature = payload.payload.signature; + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(await store.get(signature)).toBe(MOCK_TX_HASH); + }); + + it("cache-hit reconciles without re-broadcasting", async () => { + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildPermit2Payload(); + const signature = payload.payload.signature; + await store.set(signature, MOCK_TX_HASH); + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(true); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get(signature)).toBeUndefined(); + }); + }); + + describe("lifecycle", () => { + it("capture cache-miss + receipt wait fails returns settlement_pending keyed by authorizerSignature", async () => { + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockRejectedValue(new Error("rpc timeout")); + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildCapturePayload(); + const key = payload.payload.authorizerSignature; + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(await store.get(key)).toBe(MOCK_TX_HASH); + }); + + it("capture cache-hit reconciles without re-broadcasting", async () => { + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildCapturePayload(); + const key = payload.payload.authorizerSignature; + await store.set(key, MOCK_TX_HASH); + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(true); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get(key)).toBeUndefined(); + }); + + it("capture cache-hit + extra re-parse fails keeps the hash and returns settlement_pending", async () => { + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildCapturePayload(); + const key = payload.payload.authorizerSignature; + await store.set(key, MOCK_TX_HASH); + const brokenRequirements = { + ...payload.accepted, + extra: { ...payload.accepted.extra, captureAuthorizer: "not-an-address" }, + }; + + const result = await facilitator.settle(payload, brokenRequirements); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(result.transaction).toBe(MOCK_TX_HASH); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get(key)).toBe(MOCK_TX_HASH); + }); + + it("void cache-hit still unconfirmed returns settlement_pending again", async () => { + mockFacilitatorSigner.readContract = vi + .fn() + .mockImplementation(async (args: { functionName: string }) => { + if (args?.functionName === "isValidSignature") return ERC1271_MAGIC_VALUE; + if (args?.functionName === "paymentState") { + return { + hasCollectedPayment: true, + capturableAmount: BigInt("500000"), + refundableAmount: 0n, + }; + } + return BigInt("1000000000"); + }); + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockRejectedValue(new Error("still pending")); + const facilitator = new AuthCaptureEvmScheme(mockFacilitatorSigner, { + pendingSettlementStore: store, + }); + const payload = buildVoidPayload(); + const key = payload.payload.authorizerSignature; + await store.set(key, MOCK_TX_HASH); + + const result = await facilitator.settle(payload, payload.accepted); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettlementPending); + expect(mockFacilitatorSigner.writeContract).not.toHaveBeenCalled(); + expect(await store.get(key)).toBe(MOCK_TX_HASH); + }); + }); + + describe("ErrSettlementPending wire contract", () => { + it("equals the settlement_pending wire literal", () => { + expect(Errors.ErrSettlementPending).toBe("settlement_pending"); + }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/server.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/server.test.ts new file mode 100644 index 0000000000..bb07e08b19 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/server.test.ts @@ -0,0 +1,1717 @@ +import { describe, it, expect, vi } from "vitest"; +import { + AuthCaptureEvmScheme, + InMemoryAuthorizedPaymentStorage, +} from "../../../src/auth-capture/server/index"; +import { + AUTH_CAPTURE_ESCROW_ADDRESS, + AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + CAPTURE_TYPES_V1_0, + EIP3009_TOKEN_COLLECTOR_ADDRESS, +} from "../../../src/auth-capture/constants"; +import * as Errors from "../../../src/auth-capture/errors"; +import type { FacilitatorClient } from "@x402/core/server"; +import { zeroAddress } from "viem"; +import type { AuthorizedPayment } from "../../../src/auth-capture/server/storage"; + +const BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + +describe("AuthCaptureEvmScheme", () => { + describe("parsePrice", () => { + it("should parse dollar amounts with default decimals (6 for USDC)", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$1.00", "eip155:84532"); + + expect(result.amount).toBe("1000000"); + expect(result.asset).toBe(BASE_SEPOLIA_USDC); + expect(result.extra).toEqual({ name: "USDC", version: "2" }); + }); + + it("should parse amounts without dollar sign", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("0.50", "eip155:84532"); + + expect(result.amount).toBe("500000"); + expect(result.extra).toEqual({ name: "USDC", version: "2" }); + }); + + it("should parse small amounts correctly", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$0.01", "eip155:84532"); + + expect(result.amount).toBe("10000"); + }); + + it("should parse large amounts correctly", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$1000.00", "eip155:84532"); + + expect(result.amount).toBe("1000000000"); + }); + + it("should reject thousands separators, matching core parseMoney", async () => { + const scheme = new AuthCaptureEvmScheme(); + + await expect(scheme.parsePrice("$1,000.50", "eip155:84532")).rejects.toThrow( + "Invalid money format", + ); + }); + + it("should resolve a ticker-suffixed price against the network's default assets", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("1.00 USDC", "eip155:84532"); + + expect(result.amount).toBe("1000000"); + expect(result.asset).toBe(BASE_SEPOLIA_USDC); + }); + + it("should throw for a ticker with no default asset on the network", async () => { + const scheme = new AuthCaptureEvmScheme(); + + await expect(scheme.parsePrice("1.00 NOPE", "eip155:84532")).rejects.toThrow( + "No NOPE default asset configured for network eip155:84532", + ); + }); + it("should handle zero amounts", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$0.00", "eip155:84532"); + + expect(result.amount).toBe("0"); + }); + + it("should accept numeric price", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice(0.01, "eip155:84532"); + + expect(result.amount).toBe("10000"); + expect(result.asset).toBe(BASE_SEPOLIA_USDC); + expect(result.extra).toEqual({ name: "USDC", version: "2" }); + }); + + it("should return extra with name and version for Base mainnet", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$1.00", "eip155:8453"); + + expect(result.extra).toEqual({ name: "USD Coin", version: "2" }); + }); + + it("should pass through AssetAmount objects with extra", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice( + { asset: "0xCustomToken", amount: "42000", extra: { name: "Custom", version: "1" } }, + "eip155:84532", + ); + + expect(result.amount).toBe("42000"); + expect(result.asset).toBe("0xCustomToken"); + expect(result.extra).toEqual({ name: "Custom", version: "1" }); + }); + + it("should pass through AssetAmount objects without extra", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice( + { asset: "0xCustomToken", amount: "42000" }, + "eip155:84532", + ); + + expect(result.amount).toBe("42000"); + expect(result.asset).toBe("0xCustomToken"); + expect(result.extra).toEqual({}); + }); + + it("should throw when AssetAmount has no asset", async () => { + const scheme = new AuthCaptureEvmScheme(); + await expect( + scheme.parsePrice({ asset: "", amount: "42000" }, "eip155:84532"), + ).rejects.toThrow("Asset address must be specified"); + }); + + it("should throw for unsupported network", async () => { + const scheme = new AuthCaptureEvmScheme(); + await expect(scheme.parsePrice("$1.00", "eip155:99999")).rejects.toThrow( + "No default asset configured for network", + ); + }); + + it("should propagate assetTransferMethod from default-asset table for permit2 chains", async () => { + // Mezo testnet defaults to mUSD which uses permit2 and supports EIP-2612, + // so name/version remain (for the EIP-2612 sig) and assetTransferMethod is propagated. + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$1.00", "eip155:31611"); + + expect(result.asset).toBe("0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503"); + expect(result.extra).toMatchObject({ + assetTransferMethod: "permit2", + name: "Mezo USD", + version: "1", + }); + }); + }); + + describe("registerMoneyParser", () => { + it("should use custom parser when it returns a result", async () => { + const scheme = new AuthCaptureEvmScheme(); + scheme.registerMoneyParser(async (amount, _network) => ({ + asset: "0xCustomToken", + amount: String(amount * 1e18), + extra: { name: "Custom", version: "1" }, + })); + + const result = await scheme.parsePrice("$1.00", "eip155:84532"); + + expect(result.asset).toBe("0xCustomToken"); + expect(result.amount).toBe(String(1e18)); + expect(result.extra).toEqual({ name: "Custom", version: "1" }); + }); + + it("should fall through to default when custom parser returns null", async () => { + const scheme = new AuthCaptureEvmScheme(); + scheme.registerMoneyParser(async () => null); + + const result = await scheme.parsePrice("$1.00", "eip155:84532"); + + expect(result.asset).toBe(BASE_SEPOLIA_USDC); + expect(result.amount).toBe("1000000"); + expect(result.extra).toEqual({ name: "USDC", version: "2" }); + }); + + it("should try parsers in registration order", async () => { + const scheme = new AuthCaptureEvmScheme(); + scheme.registerMoneyParser(async () => null); + scheme.registerMoneyParser(async (amount, _network) => ({ + asset: "0xSecondParser", + amount: String(amount * 100), + extra: {}, + })); + + const result = await scheme.parsePrice("$1.00", "eip155:84532"); + + expect(result.asset).toBe("0xSecondParser"); + expect(result.amount).toBe("100"); + }); + }); + + describe("enhancePaymentRequirements", () => { + // A complete `extra` carrying every field `isAuthCaptureExtra` requires. + // Use this in tests that aren't exercising the fail-fast validation path, + // so the assertion against missing-field rejection doesn't fire and the + // test can focus on whatever behavior it's actually trying to cover. + // Passing `undefined` for a key removes it (vs. spreading, which would + // leave a `key: undefined` entry that overrides supportedKind on merge). + const completeExtra = (overrides: Record = {}) => { + const out: Record = { + captureAuthorizer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + captureDeadlineSeconds: 30 * 86400, + refundDeadlineSeconds: 60 * 86400, + feeRecipient: "0x0000000000000000000000000000000000000000", + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + captureMode: "deferred", + receiverAuthorizer: zeroAddress, + }; + for (const [k, v] of Object.entries(overrides)) { + if (v === undefined) delete out[k]; + else out[k] = v; + } + return out; + }; + + const baseRequirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0x1234567890123456789012345678901234567890", + maxTimeoutSeconds: 300, + extra: completeExtra(), + }; + + it("should merge extra fields from supportedKind", async () => { + const scheme = new AuthCaptureEvmScheme(); + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { + fromSupported1: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + fromSupported2: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + }; + + const result = await scheme.enhancePaymentRequirements(baseRequirements, supportedKind, []); + + expect(result.extra).toMatchObject({ + fromSupported1: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + fromSupported2: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }); + }); + + it("should not mirror facilitator operators allowlist onto 402 extra", async () => { + const scheme = new AuthCaptureEvmScheme(); + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { + operators: [{ address: "*", operatorType: "custom" }], + }, + }; + + const result = await scheme.enhancePaymentRequirements(baseRequirements, supportedKind, []); + + expect(result.extra).not.toHaveProperty("operators"); + }); + + it("should preserve existing extra fields from requirements", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ customField: "custom-value" }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { fromSupported: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + }; + + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + expect(result.extra).toMatchObject({ + fromSupported: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + customField: "custom-value", + }); + }); + + it("should let requirements extra override supportedKind extra", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ sharedKey: "0xcccccccccccccccccccccccccccccccccccccccc" }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { sharedKey: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + }; + + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + expect(result.extra?.sharedKey).toBe("0xcccccccccccccccccccccccccccccccccccccccc"); + }); + + it("should copy captureAuthorizer from supportedKind.extra for delegated routes", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureAuthorizer: undefined, operatorType: "delegated" }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { captureAuthorizer: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, + }; + + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + expect(result.extra?.captureAuthorizer).toBe("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + }); + + it("should throw when delegated captureAuthorizer is missing from route and supportedKind", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureAuthorizer: undefined, operatorType: "delegated" }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureAuthorizer/); + }); + + it("should not inherit facilitator-advertised captureAuthorizer on custom routes", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureAuthorizer: undefined, operatorType: "custom" }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { captureAuthorizer: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" }, + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/extra\.captureAuthorizer/); + }); + + it("should preserve all original requirement fields", async () => { + const scheme = new AuthCaptureEvmScheme(); + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + const result = await scheme.enhancePaymentRequirements(baseRequirements, supportedKind, []); + + expect(result.scheme).toBe("auth-capture"); + expect(result.network).toBe("eip155:84532"); + expect(result.amount).toBe("1000000"); + expect(result.asset).toBe(BASE_SEPOLIA_USDC); + expect(result.payTo).toBe("0x1234567890123456789012345678901234567890"); + }); + + it("should declare captureDeadline and refundDeadline as dynamic extra fields", () => { + const scheme = new AuthCaptureEvmScheme(); + expect(scheme.dynamicExtraFields).toEqual(["captureDeadline", "refundDeadline"]); + }); + + it("should convert captureDeadlineSeconds and refundDeadlineSeconds to absolute deadlines, stripping the offset keys", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: 600, + refundDeadlineSeconds: 1200, + }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + const now = Math.floor(Date.now() / 1000); + const bucketStart = Math.floor(now / 60) * 60; + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + const captureDeadline = result.extra?.captureDeadline as number; + const refundDeadline = result.extra?.refundDeadline as number; + + expect(captureDeadline).toBe(bucketStart + 600); + expect(refundDeadline).toBe(bucketStart + 1200); + + expect(result.extra).not.toHaveProperty("captureDeadlineSeconds"); + expect(result.extra).not.toHaveProperty("refundDeadlineSeconds"); + }); + + it("should throw when mixing an absolute deadline with a relative offset", async () => { + const scheme = new AuthCaptureEvmScheme(); + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + const reqs = { + ...baseRequirements, + extra: completeExtra({ + captureDeadline: 1700000000, + captureDeadlineSeconds: undefined, + refundDeadlineSeconds: 60, + }), + }; + await expect(scheme.enhancePaymentRequirements(reqs, supportedKind, [])).rejects.toThrow( + /both absolute deadlines/, + ); + }); + + it("should throw when both absolute and relative deadline pairs are present", async () => { + const scheme = new AuthCaptureEvmScheme(); + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadline: 1_700_000_000, + refundDeadline: 1_800_000_000, + captureDeadlineSeconds: 600, + refundDeadlineSeconds: 1_200, + }), + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/not a mix/); + }); + + it("should use absolute captureDeadline / refundDeadline when both are set without offsets", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: undefined, + refundDeadlineSeconds: undefined, + captureDeadline: 1700000000, + refundDeadline: 1800000000, + }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + expect(result.extra?.captureDeadline).toBe(1700000000); + expect(result.extra?.refundDeadline).toBe(1800000000); + expect(result.extra).not.toHaveProperty("captureDeadlineSeconds"); + expect(result.extra).not.toHaveProperty("refundDeadlineSeconds"); + }); + + it("should keep relative deadlines stable across enhance calls within the same bucket", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: 1, + refundDeadlineSeconds: 2, + }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + const first = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + await new Promise(resolve => setTimeout(resolve, 1100)); + const second = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + expect(second.extra?.captureDeadline).toBe(first.extra?.captureDeadline); + expect(second.extra?.refundDeadline).toBe(first.extra?.refundDeadline); + }); + + it("should align relative deadlines to the offset bucket start", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: 600, + refundDeadlineSeconds: 1_200, + }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + const now = Math.floor(Date.now() / 1000); + const bucketStart = Math.floor(now / 60) * 60; + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + expect(result.extra?.captureDeadline).toBe(bucketStart + 600); + expect(result.extra?.refundDeadline).toBe(bucketStart + 1_200); + }); + + it("should throw on non-positive captureDeadlineSeconds", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureDeadlineSeconds: 0 }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureDeadlineSeconds/); + }); + + it("should throw on non-positive refundDeadlineSeconds", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ refundDeadlineSeconds: -1 }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/refundDeadlineSeconds/); + }); + + it("should throw on non-finite offset", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureDeadlineSeconds: Number.NaN }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureDeadlineSeconds/); + }); + + it("should throw on non-number offset", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureDeadlineSeconds: "30d" }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureDeadlineSeconds/); + }); + + it("should accept offsets from supportedKind.extra (facilitator-injected) when not set in requirements", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + // Drop the offsets from requirements so supportedKind's offsets are what gets used. + extra: completeExtra({ + captureDeadlineSeconds: undefined, + refundDeadlineSeconds: undefined, + }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { + captureDeadlineSeconds: 600, + refundDeadlineSeconds: 1200, + }, + }; + + const now = Math.floor(Date.now() / 1000); + const bucketStart = Math.floor(now / 60) * 60; + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + const captureDeadline = result.extra?.captureDeadline as number; + const refundDeadline = result.extra?.refundDeadline as number; + + expect(captureDeadline).toBe(bucketStart + 600); + expect(refundDeadline).toBe(bucketStart + 1200); + }); + }); + + describe("enhancePaymentRequirements - fail-fast field validation", () => { + const completeExtra = (overrides: Record = {}) => { + const out: Record = { + captureAuthorizer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + captureDeadlineSeconds: 30 * 86400, + refundDeadlineSeconds: 60 * 86400, + feeRecipient: "0x0000000000000000000000000000000000000000", + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + captureMode: "deferred", + receiverAuthorizer: zeroAddress, + }; + for (const [k, v] of Object.entries(overrides)) { + if (v === undefined) delete out[k]; + else out[k] = v; + } + return out; + }; + + const baseRequirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0x1234567890123456789012345678901234567890", + maxTimeoutSeconds: 300, + }; + + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + for (const field of ["feeRecipient", "minFeeBps", "maxFeeBps"] as const) { + it(`should throw when extra.${field} is missing`, async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ [field]: undefined }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(new RegExp(`extra\\.${field}`)); + }); + } + + it("should throw when extra.captureAuthorizer is missing for custom operatorType", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureAuthorizer: undefined, operatorType: "custom" }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/extra\.captureAuthorizer/); + }); + + it("should throw when neither captureDeadlineSeconds nor captureDeadline is provided", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: undefined, + refundDeadlineSeconds: undefined, + }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/extra\.captureDeadline/); + }); + + it("should throw the mix error when only one relative deadline offset is set", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + refundDeadlineSeconds: undefined, + }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/both absolute deadlines.*both relative offsets/); + }); + + it("should throw when captureAuthorizer is the wrong type", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureAuthorizer: 42 }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureAuthorizer/); + }); + + it("should include the path-to-fix hint in the deadline error message", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: undefined, + refundDeadlineSeconds: undefined, + }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureDeadlineSeconds.*captureDeadline/); + }); + + it("should not fail-fast on missing name (auto-populated by parsePrice for decimal pricing; wire-side rejection for AssetAmount path)", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ name: undefined }), + }; + // Merchant uses decimal pricing → name is auto-populated by parsePrice → no need to throw here. + // Merchant uses a custom AssetAmount and forgets name → facilitator catches with invalid_auth_capture_extra. + // Either way enhance does not throw on missing name. + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).resolves.not.toThrow(); + }); + + it("should not fail-fast on missing version (same rationale as name)", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ version: undefined }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).resolves.not.toThrow(); + }); + + it("should throw when autoCapture is present", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ autoCapture: true }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/autoCapture/); + }); + + it("should derive receiverAuthorizer from the signer when the route omits it", async () => { + const signerAddress = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const scheme = new AuthCaptureEvmScheme({ + receiverAuthorizerSigner: { address: signerAddress, signTypedData: vi.fn() }, + }); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureMode: undefined }), + }; + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + expect(result.extra?.receiverAuthorizer).toBe(signerAddress); + }); + + it("should let the signer win over a facilitator-advertised receiverAuthorizer", async () => { + const signerAddress = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const scheme = new AuthCaptureEvmScheme({ + receiverAuthorizerSigner: { address: signerAddress, signTypedData: vi.fn() }, + }); + const result = await scheme.enhancePaymentRequirements( + { ...baseRequirements, extra: completeExtra({ captureMode: "deferred" }) }, + { + ...supportedKind, + extra: { receiverAuthorizer: "0x9999999999999999999999999999999999999999" }, + }, + [], + ); + expect(result.extra?.receiverAuthorizer).toBe(signerAddress); + }); + + it("should throw when the route sets a conflicting non-zero receiverAuthorizer", async () => { + const signerAddress = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const scheme = new AuthCaptureEvmScheme({ + receiverAuthorizerSigner: { address: signerAddress, signTypedData: vi.fn() }, + }); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureMode: "deferred", + receiverAuthorizer: "0x9999999999999999999999999999999999999999", + }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/does not match the scheme's receiverAuthorizerSigner/); + }); + + it("should throw on escrow sync with an explicit zero receiverAuthorizer", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureMode: undefined }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/sync routes require a non-zero receiverAuthorizer/); + }); + + it("should throw when captureMode is set on an authorization route", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ paymentFlow: "authorization", captureMode: "sync" }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureMode/); + }); + + it("should throw on an authorization route without a receiver authorizer", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ paymentFlow: "authorization", captureMode: undefined }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/requires a non-zero receiverAuthorizer/); + }); + + it("should derive the required charge authorizer from the configured signer", async () => { + const signerAddress = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const scheme = new AuthCaptureEvmScheme({ + receiverAuthorizerSigner: { address: signerAddress, signTypedData: vi.fn() }, + }); + const requirements = { + ...baseRequirements, + extra: completeExtra({ paymentFlow: "authorization", captureMode: undefined }), + }; + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + expect(result.extra?.receiverAuthorizer).toBe(signerAddress); + }); + + it("should throw on escrow sync for a custom operator", async () => { + const scheme = new AuthCaptureEvmScheme({ + receiverAuthorizerSigner: { + address: "0x1111111111111111111111111111111111111111", + signTypedData: vi.fn(), + }, + }); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureMode: undefined, operatorType: "custom" }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/collect-only/); + }); + + it("should allow escrow deferred for a custom operator", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.enhancePaymentRequirements( + { ...baseRequirements, extra: completeExtra({ operatorType: "custom" }) }, + supportedKind, + [], + ); + expect(result.extra?.captureMode).toBe("deferred"); + expect(result.extra?.operatorType).toBe("custom"); + }); + + it("should write paymentFlow escrow onto extra for the default route", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.enhancePaymentRequirements( + { ...baseRequirements, extra: completeExtra() }, + supportedKind, + [], + ); + expect(result.extra?.paymentFlow).toBe("escrow"); + expect(result.extra?.captureMode).toBe("deferred"); + }); + }); + + describe("enhancePaymentRequirements - receiver authorizer resolution", () => { + const FACILITATOR_AUTHORIZER = "0x2222222222222222222222222222222222222222" as `0x${string}`; + const SIGNER_ADDRESS = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const OTHER = "0x9999999999999999999999999999999999999999" as `0x${string}`; + + const route = (overrides: Record = {}) => ({ + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0x1234567890123456789012345678901234567890", + maxTimeoutSeconds: 300, + extra: { + captureAuthorizer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: zeroAddress, + minFeeBps: 0, + maxFeeBps: 0, + name: "USDC", + version: "2", + ...overrides, + }, + }); + const kind = (extra?: Record) => ({ + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra, + }); + const signerScheme = () => + new AuthCaptureEvmScheme({ + receiverAuthorizerSigner: { address: SIGNER_ADDRESS, signTypedData: vi.fn() }, + }); + + it("defaults an omitted route authorizer to the facilitator-advertised one", async () => { + const result = await new AuthCaptureEvmScheme().enhancePaymentRequirements( + route(), + kind({ receiverAuthorizer: FACILITATOR_AUTHORIZER }), + [], + ); + expect(result.extra?.receiverAuthorizer).toBe(FACILITATOR_AUTHORIZER); + expect(result.extra?.captureMode).toBe("sync"); + }); + + it("accepts a route authorizer equal to the facilitator-advertised one", async () => { + const result = await new AuthCaptureEvmScheme().enhancePaymentRequirements( + route({ receiverAuthorizer: FACILITATOR_AUTHORIZER.toLowerCase() }), + kind({ receiverAuthorizer: FACILITATOR_AUTHORIZER }), + [], + ); + expect(result.extra?.receiverAuthorizer).toBe(FACILITATOR_AUTHORIZER); + }); + + it("lets the scheme signer win over a facilitator-advertised authorizer", async () => { + const result = await signerScheme().enhancePaymentRequirements( + route(), + kind({ receiverAuthorizer: FACILITATOR_AUTHORIZER }), + [], + ); + expect(result.extra?.receiverAuthorizer).toBe(SIGNER_ADDRESS); + }); + + it("throws naming all three fixes when nothing can authorize", async () => { + await expect( + new AuthCaptureEvmScheme().enhancePaymentRequirements( + route({ captureMode: "deferred" }), + kind(), + [], + ), + ).rejects.toThrow(/receiverAuthorizerSigner.*delegates the authorizer.*zeroAddress/s); + }); + + it("throws when the route names a non-zero authorizer nobody can sign for", async () => { + await expect( + new AuthCaptureEvmScheme().enhancePaymentRequirements( + route({ receiverAuthorizer: OTHER }), + kind({ receiverAuthorizer: FACILITATOR_AUTHORIZER }), + [], + ), + ).rejects.toThrow(/can not be signed/); + }); + + it("allows an explicit zero authorizer for escrow deferred, ignoring the advertisement", async () => { + const result = await new AuthCaptureEvmScheme().enhancePaymentRequirements( + route({ receiverAuthorizer: zeroAddress, captureMode: "deferred" }), + kind({ receiverAuthorizer: FACILITATOR_AUTHORIZER }), + [], + ); + expect(result.extra?.receiverAuthorizer).toBe(zeroAddress); + }); + + it("rejects an explicit zero authorizer on escrow sync and on authorization", async () => { + const scheme = new AuthCaptureEvmScheme(); + await expect( + scheme.enhancePaymentRequirements(route({ receiverAuthorizer: zeroAddress }), kind(), []), + ).rejects.toThrow(/non-zero receiverAuthorizer/); + await expect( + scheme.enhancePaymentRequirements( + route({ receiverAuthorizer: zeroAddress, paymentFlow: "authorization" }), + kind(), + [], + ), + ).rejects.toThrow(/non-zero receiverAuthorizer/); + }); + + it("keeps the signer's conflict check against a different route authorizer", async () => { + await expect( + signerScheme().enhancePaymentRequirements(route({ receiverAuthorizer: OTHER }), kind(), []), + ).rejects.toThrow(/does not match the scheme's receiverAuthorizerSigner/); + }); + + it("delegates an authorization route to the facilitator authorizer", async () => { + const result = await new AuthCaptureEvmScheme().enhancePaymentRequirements( + route({ paymentFlow: "authorization" }), + kind({ receiverAuthorizer: FACILITATOR_AUTHORIZER }), + [], + ); + expect(result.extra?.receiverAuthorizer).toBe(FACILITATOR_AUTHORIZER); + expect(result.extra?.paymentFlow).toBe("authorization"); + }); + + it("ignores a zero facilitator advertisement", async () => { + await expect( + new AuthCaptureEvmScheme().enhancePaymentRequirements( + route(), + kind({ receiverAuthorizer: zeroAddress }), + [], + ), + ).rejects.toThrow(/has no receiverAuthorizer/); + }); + }); + + describe("validateFacilitatorSupport", () => { + const NETWORK = "eip155:84532" as const; + const CAPTURE_AUTHORIZER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const RECEIVER_AUTHORIZER = "0x2222222222222222222222222222222222222222"; + + function supportedKind(extra?: Record) { + return { x402Version: 2, scheme: "auth-capture", network: NETWORK, extra }; + } + + it("returns a problem when the facilitator omits captureAuthorizer", () => { + const scheme = new AuthCaptureEvmScheme(); + const problem = scheme.validateFacilitatorSupport( + NETWORK, + supportedKind({ receiverAuthorizer: RECEIVER_AUTHORIZER }), + [], + ); + expect(problem).toMatch(/captureAuthorizer/); + }); + + it("returns a problem when no signer and the facilitator omits receiverAuthorizer", () => { + const scheme = new AuthCaptureEvmScheme(); + const problem = scheme.validateFacilitatorSupport( + NETWORK, + supportedKind({ captureAuthorizer: CAPTURE_AUTHORIZER }), + [], + ); + expect(problem).toMatch(/receiverAuthorizer/); + }); + + it("returns void when the server has a receiver-authorizer signer", () => { + const scheme = new AuthCaptureEvmScheme({ + receiverAuthorizerSigner: { + address: "0x1111111111111111111111111111111111111111", + signTypedData: vi.fn(), + }, + }); + expect( + scheme.validateFacilitatorSupport( + NETWORK, + supportedKind({ captureAuthorizer: CAPTURE_AUTHORIZER }), + [], + ), + ).toBeUndefined(); + }); + + it("returns void when the facilitator advertises capture and receiver authorizers", () => { + const scheme = new AuthCaptureEvmScheme(); + expect( + scheme.validateFacilitatorSupport( + NETWORK, + supportedKind({ + captureAuthorizer: CAPTURE_AUTHORIZER, + receiverAuthorizer: RECEIVER_AUTHORIZER, + }), + [], + ), + ).toBeUndefined(); + }); + + it("returns void for collect-only merchants when collectOnlyRoutes is set", () => { + const scheme = new AuthCaptureEvmScheme({ collectOnlyRoutes: true }); + expect( + scheme.validateFacilitatorSupport( + NETWORK, + supportedKind({ captureAuthorizer: CAPTURE_AUTHORIZER }), + [], + ), + ).toBeUndefined(); + }); + }); + + describe("scheme property", () => { + it('should have scheme set to "auth-capture"', () => { + const scheme = new AuthCaptureEvmScheme(); + expect(scheme.scheme).toBe("auth-capture"); + }); + }); + + describe("getters", () => { + it("should default storage and leave the receiver-authorizer signer unset", () => { + const scheme = new AuthCaptureEvmScheme(); + expect(scheme.getStorage()).toBeInstanceOf(InMemoryAuthorizedPaymentStorage); + expect(scheme.getReceiverAuthorizerSigner()).toBeUndefined(); + }); + + it("should return the configured storage and signer", () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const signer = { + address: "0x1111111111111111111111111111111111111111" as `0x${string}`, + signTypedData: vi.fn(), + }; + const scheme = new AuthCaptureEvmScheme({ + storage, + receiverAuthorizerSigner: signer, + }); + expect(scheme.getStorage()).toBe(storage); + expect(scheme.getReceiverAuthorizerSigner()).toBe(signer); + }); + }); + + describe("payment flow declaration", () => { + it("should default to eip3009 and declare escrow (default) plus authorization for both collectors", () => { + const scheme = new AuthCaptureEvmScheme(); + + expect(scheme.defaultAssetTransferMethod).toBe("eip3009"); + expect(scheme.paymentFlows).toEqual({ + eip3009: { supported: ["escrow", "authorization"], default: "escrow" }, + permit2: { supported: ["escrow", "authorization"], default: "escrow" }, + }); + }); + }); + + describe("getAssetDecimals", () => { + it("should return decimals for a known default asset", () => { + const scheme = new AuthCaptureEvmScheme(); + + expect(scheme.getAssetDecimals(BASE_SEPOLIA_USDC, "eip155:84532")).toBe(6); + }); + + it("should return undefined for an unknown asset", () => { + const scheme = new AuthCaptureEvmScheme(); + + expect( + scheme.getAssetDecimals("0x9999999999999999999999999999999999999999", "eip155:84532"), + ).toBeUndefined(); + }); + }); + + describe("authorized payment storage and helpers", () => { + const hash = ("0x" + "11".repeat(32)) as `0x${string}`; + const saltNonce = ("0x" + "22".repeat(32)) as `0x${string}`; + const receiverAuthorizer = "0x1111111111111111111111111111111111111111" as `0x${string}`; + const payer = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as `0x${string}`; + + function sampleRecord(overrides: Partial = {}): AuthorizedPayment { + return { + paymentInfoHash: hash, + paymentInfo: { + operator: "0x1234567890123456789012345678901234567890" as `0x${string}`, + payer, + receiver: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" as `0x${string}`, + token: BASE_SEPOLIA_USDC as `0x${string}`, + maxAmount: "1000000", + preApprovalExpiry: 1, + authorizationExpiry: 2, + refundExpiry: 3, + minFeeBps: 0, + maxFeeBps: 0, + feeReceiver: "0x0000000000000000000000000000000000000000" as `0x${string}`, + salt: ("0x" + "00".repeat(32)) as `0x${string}`, + }, + saltNonce, + receiverAuthorizer, + policy: "0x0000000000000000000000000000000000000000" as `0x${string}`, + network: "eip155:84532", + capturableAmount: "1000000", + refundableAmount: "0", + collectTransaction: "0xabc", + createdAt: Date.now(), + name: "USDC", + version: "2", + paymentFlow: "escrow", + operatorType: "delegated", + assetTransferMethod: "eip3009", + authCaptureEscrow: AUTH_CAPTURE_ESCROW_ADDRESS, + ...overrides, + }; + } + + function lifecycleScheme(storage: InMemoryAuthorizedPaymentStorage, settle = vi.fn()) { + settle.mockResolvedValue({ + success: true, + transaction: "0xtx", + network: "eip155:84532", + payer, + amount: "500000", + }); + const authorizerSigner = { + address: receiverAuthorizer, + signTypedData: vi.fn().mockResolvedValue("0xsig" as `0x${string}`), + }; + const facilitator = { settle } as unknown as FacilitatorClient; + const scheme = new AuthCaptureEvmScheme({ + storage, + receiverAuthorizerSigner: authorizerSigner, + }); + return { + scheme, + lifecycle: scheme.createLifecycleManager(facilitator), + settle, + authorizerSigner, + }; + } + + it("should persist and list records through InMemoryAuthorizedPaymentStorage", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const record = sampleRecord(); + await storage.update(hash, () => record); + expect(await storage.get(hash)).toEqual(record); + expect(await storage.list()).toHaveLength(1); + }); + + it("should throw when capture is called without a stored record", async () => { + const { lifecycle } = lifecycleScheme(new InMemoryAuthorizedPaymentStorage()); + await expect(lifecycle.capture(hash)).rejects.toThrow(/no authorized payment/); + }); + + it("should send unsigned lifecycle payloads when the authorizer is delegated to the facilitator", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + await storage.update(hash, () => sampleRecord()); + const settle = vi.fn().mockResolvedValue({ + success: true, + transaction: "0xtx", + network: "eip155:84532", + payer, + }); + const lifecycle = new AuthCaptureEvmScheme({ storage }).createLifecycleManager({ + settle, + } as unknown as FacilitatorClient); + + await lifecycle.capture(hash, { amount: "500000", voidRemainder: true }); + await lifecycle.voidPayment(hash); + await lifecycle.refund(hash, { amount: "1" }); + + const [capture, voidPayload, refund] = settle.mock.calls.map(call => call[0].payload); + expect(capture).toMatchObject({ type: "capture", amount: "500000", voidRemainder: true }); + expect(capture.authorizerSignature).toBeUndefined(); + expect(capture.voidAuthorizerSignature).toBeUndefined(); + expect(voidPayload.type).toBe("void"); + expect(voidPayload.authorizerSignature).toBeUndefined(); + expect(refund.type).toBe("refund"); + expect(refund.authorizerSignature).toBeUndefined(); + }); + + it("should throw that lifecycle is out of band for a collect-only payment", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + await storage.update(hash, () => sampleRecord({ receiverAuthorizer: zeroAddress })); + const lifecycle = new AuthCaptureEvmScheme({ storage }).createLifecycleManager({ + settle: vi.fn(), + } as unknown as FacilitatorClient); + await expect(lifecycle.capture(hash)).rejects.toThrow(/lifecycle is out of band/); + await expect(lifecycle.voidPayment(hash)).rejects.toThrow(/lifecycle is out of band/); + await expect(lifecycle.refund(hash, { amount: "1" })).rejects.toThrow( + /lifecycle is out of band/, + ); + }); + + it("should refuse capture, void and refund for a custom operator without calling the facilitator", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + await storage.update(hash, () => sampleRecord({ operatorType: "custom" })); + const { lifecycle, settle } = lifecycleScheme(storage); + const unavailable = new RegExp(Errors.ErrServerLifecycleUnavailable); + + await expect(lifecycle.capture(hash)).rejects.toThrow(unavailable); + await expect(lifecycle.voidPayment(hash)).rejects.toThrow(unavailable); + await expect(lifecycle.refund(hash, { amount: "1" })).rejects.toThrow(unavailable); + expect(settle).not.toHaveBeenCalled(); + }); + + it("should refuse capture and void but allow refund for an authorization-flow payment", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + await storage.update(hash, () => + sampleRecord({ + paymentFlow: "authorization", + capturableAmount: "0", + refundableAmount: "1000000", + }), + ); + const { lifecycle, settle } = lifecycleScheme(storage); + const unavailable = new RegExp(Errors.ErrServerLifecycleUnavailable); + + await expect(lifecycle.capture(hash)).rejects.toThrow(unavailable); + await expect(lifecycle.voidPayment(hash)).rejects.toThrow(unavailable); + expect(settle).not.toHaveBeenCalled(); + + await lifecycle.refund(hash, { amount: "100" }); + expect(settle).toHaveBeenCalledOnce(); + const [paymentPayload, requirements] = settle.mock.calls[0]; + expect(paymentPayload.payload.type).toBe("refund"); + expect(requirements.extra.paymentFlow).toBe("authorization"); + expect(requirements.extra).not.toHaveProperty("captureMode"); + }); + + it("should send captureMode deferred for an escrow-flow payment", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + await storage.update(hash, () => sampleRecord()); + const { lifecycle, settle } = lifecycleScheme(storage); + + await lifecycle.capture(hash, { amount: "500000" }); + expect(settle.mock.calls[0][1].extra.captureMode).toBe("deferred"); + }); + + it("should refuse lifecycle when the record has no saltNonce", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + await storage.update(hash, () => sampleRecord({ saltNonce: undefined })); + const { lifecycle, settle } = lifecycleScheme(storage); + + await expect(lifecycle.voidPayment(hash)).rejects.toThrow( + new RegExp(Errors.ErrServerLifecycleUnavailable), + ); + expect(settle).not.toHaveBeenCalled(); + }); + + it("should capture through the facilitator and write remaining balances", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + await storage.update(hash, () => sampleRecord()); + const { lifecycle, settle } = lifecycleScheme(storage); + const result = await lifecycle.capture(hash, { amount: "500000" }); + expect(result.success).toBe(true); + expect(settle).toHaveBeenCalledOnce(); + const payload = settle.mock.calls[0][0].payload; + expect(payload.type).toBe("capture"); + expect(payload.amount).toBe("500000"); + expect(payload.voidAuthorizerSignature).toBeUndefined(); + const updated = await lifecycle.getAuthorizedPayment(hash); + expect(updated?.capturableAmount).toBe("500000"); + expect(updated?.refundableAmount).toBe("500000"); + }); + + it("should attach voidAuthorizerSignature and zero capturable on voidRemainder", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + await storage.update(hash, () => sampleRecord()); + const { lifecycle, settle } = lifecycleScheme(storage); + await lifecycle.capture(hash, { amount: "500000", voidRemainder: true }); + const payload = settle.mock.calls[0][0].payload; + expect(payload.voidAuthorizerSignature).toBe("0xsig"); + const updated = await lifecycle.getAuthorizedPayment(hash); + expect(updated?.capturableAmount).toBe("0"); + expect(updated?.refundableAmount).toBe("500000"); + }); + + it("should void remaining hold through voidPayment", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + await storage.update(hash, () => sampleRecord()); + const { lifecycle, settle } = lifecycleScheme(storage); + await lifecycle.voidPayment(hash); + expect(settle.mock.calls[0][0].payload.type).toBe("void"); + const updated = await lifecycle.getAuthorizedPayment(hash); + expect(updated?.capturableAmount).toBe("0"); + }); + + it("should refund captured funds and decrement refundableAmount", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + await storage.update(hash, () => + sampleRecord({ capturableAmount: "0", refundableAmount: "1000000" }), + ); + const { lifecycle, settle } = lifecycleScheme(storage); + await lifecycle.refund(hash, { amount: "250000" }); + expect(settle.mock.calls[0][0].payload.type).toBe("refund"); + const updated = await lifecycle.getAuthorizedPayment(hash); + expect(updated?.refundableAmount).toBe("750000"); + }); + }); + + describe("schemeHooks — deferred skip and persist", () => { + const future = Math.floor(Date.now() / 1000) + 86400; + const extra = { + captureAuthorizer: "0x1234567890123456789012345678901234567890", + captureDeadline: future, + refundDeadline: future + 86400, + feeRecipient: "0x0000000000000000000000000000000000000000", + minFeeBps: 0, + maxFeeBps: 0, + name: "USDC", + version: "2", + paymentFlow: "escrow" as const, + captureMode: "deferred" as const, + }; + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra, + }; + const paymentPayload = { + x402Version: 2, + accepted: requirements, + payload: { + authorization: { + from: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(future), + nonce: "0x" + "33".repeat(32), + }, + signature: "0xabcd", + salt: "0x" + "44".repeat(32), + }, + }; + const authorizeResult = { + success: true, + transaction: "0xauthorize", + network: "eip155:84532", + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }; + + it("should skip the after-handler settle and echo the authorize receipt", async () => { + const scheme = new AuthCaptureEvmScheme(); + await scheme.schemeHooks.onAfterSettle!({ + phase: "before-handler", + paymentPayload, + requirements, + declaredExtensions: {}, + result: authorizeResult, + } as never); + const skip = await scheme.schemeHooks.onBeforeSettle!({ + phase: "after-handler", + paymentPayload, + requirements, + declaredExtensions: {}, + } as never); + expect(skip).toEqual({ skip: true, result: authorizeResult }); + }); + + it("should persist an authorized payment after a successful authorize settle", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const scheme = new AuthCaptureEvmScheme({ storage }); + await scheme.schemeHooks.onAfterSettle!({ + phase: "before-handler", + paymentPayload, + requirements, + declaredExtensions: {}, + result: authorizeResult, + } as never); + const listed = await scheme.getStorage().list(); + expect(listed).toHaveLength(1); + expect(listed[0]?.capturableAmount).toBe("1000000"); + expect(listed[0]?.refundableAmount).toBe("0"); + expect(listed[0]?.collectTransaction).toBe("0xauthorize"); + expect(listed[0]?.authCaptureEscrow).toBe(AUTH_CAPTURE_ESCROW_ADDRESS); + }); + + it("should persist a v1.0 escrow pin and reconstruct it on lifecycle capture", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const authorizerSigner = { + address: "0x1111111111111111111111111111111111111111" as `0x${string}`, + signTypedData: vi.fn().mockResolvedValue("0xsig" as `0x${string}`), + }; + const settle = vi.fn().mockResolvedValue({ + success: true, + transaction: "0xtx", + network: "eip155:84532", + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + amount: "1000000", + }); + const scheme = new AuthCaptureEvmScheme({ + storage, + receiverAuthorizerSigner: authorizerSigner, + }); + const pinnedExtra = { + ...extra, + authCaptureEscrow: AUTH_CAPTURE_ESCROW_V1_0_ADDRESS, + receiverAuthorizer: authorizerSigner.address, + }; + const pinnedRequirements = { ...requirements, extra: pinnedExtra }; + const saltNonce = ("0x" + "22".repeat(32)) as `0x${string}`; + await scheme.schemeHooks.onAfterSettle!({ + phase: "before-handler", + paymentPayload: { + ...paymentPayload, + accepted: pinnedRequirements, + payload: { ...paymentPayload.payload, saltNonce }, + }, + requirements: pinnedRequirements, + declaredExtensions: {}, + result: authorizeResult, + } as never); + + const [stored] = await storage.list(); + expect(stored?.authCaptureEscrow).toBe(AUTH_CAPTURE_ESCROW_V1_0_ADDRESS); + + const lifecycle = scheme.createLifecycleManager({ settle } as unknown as FacilitatorClient); + await lifecycle.capture(stored!.paymentInfoHash); + + expect(settle).toHaveBeenCalledOnce(); + const settledExtra = settle.mock.calls[0][1].extra as { authCaptureEscrow: string }; + expect(settledExtra.authCaptureEscrow).toBe(AUTH_CAPTURE_ESCROW_V1_0_ADDRESS); + expect(settle.mock.calls[0][0].payload).toMatchObject({ type: "capture", feeBps: 0 }); + expect(settle.mock.calls[0][0].payload.feeAmount).toBeUndefined(); + expect(authorizerSigner.signTypedData).toHaveBeenCalledWith( + expect.objectContaining({ types: CAPTURE_TYPES_V1_0, primaryType: "Capture" }), + ); + }); + + it("should leave balances alone when a retried settle persists the same payment", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const scheme = new AuthCaptureEvmScheme({ storage }); + const persist = () => + scheme.schemeHooks.onAfterSettle!({ + phase: "before-handler", + paymentPayload, + requirements, + declaredExtensions: {}, + result: authorizeResult, + } as never); + + await persist(); + // Stand in for an out-of-band capture that already moved the balances. + const [stored] = await storage.list(); + await storage.update(stored!.paymentInfoHash, current => ({ + ...current!, + capturableAmount: "400000", + refundableAmount: "600000", + })); + + await persist(); + + const listed = await storage.list(); + expect(listed).toHaveLength(1); + expect(listed[0]?.capturableAmount).toBe("400000"); + expect(listed[0]?.refundableAmount).toBe("600000"); + }); + + it("should not skip after-handler settle for sync escrow", async () => { + const scheme = new AuthCaptureEvmScheme(); + const syncRequirements = { + ...requirements, + extra: { + ...extra, + captureMode: "sync" as const, + receiverAuthorizer: "0x1111111111111111111111111111111111111111", + }, + }; + await scheme.schemeHooks.onAfterSettle!({ + phase: "before-handler", + paymentPayload, + requirements: syncRequirements, + declaredExtensions: {}, + result: authorizeResult, + } as never); + const skip = await scheme.schemeHooks.onBeforeSettle!({ + phase: "after-handler", + paymentPayload, + requirements: syncRequirements, + declaredExtensions: {}, + } as never); + expect(skip).toBeUndefined(); + }); + }); + + describe("enrichSettlementPayload", () => { + it("should add capture fields without re-emitting saltNonce", async () => { + const authorizerSigner = { + address: "0x1111111111111111111111111111111111111111" as `0x${string}`, + signTypedData: vi.fn().mockResolvedValue("0xsig" as `0x${string}`), + }; + const scheme = new AuthCaptureEvmScheme({ + receiverAuthorizerSigner: authorizerSigner, + }); + const future = Math.floor(Date.now() / 1000) + 86400; + const extra = { + captureAuthorizer: "0x1234567890123456789012345678901234567890", + captureDeadline: future, + refundDeadline: future + 86400, + feeRecipient: "0x0000000000000000000000000000000000000000", + minFeeBps: 0, + maxFeeBps: 0, + name: "USDC", + version: "2", + paymentFlow: "escrow" as const, + captureMode: "sync" as const, + receiverAuthorizer: authorizerSigner.address, + }; + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra, + }; + const enrichment = await scheme.enrichSettlementPayload({ + phase: "after-handler", + paymentPayload: { + x402Version: 2, + accepted: requirements, + payload: { + authorization: { + from: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(future), + nonce: "0x" + "33".repeat(32), + }, + signature: "0xabcd", + salt: "0x" + "44".repeat(32), + saltNonce: "0x" + "22".repeat(32), + }, + }, + requirements, + declaredExtensions: {}, + } as never); + expect(enrichment).toMatchObject({ + type: "capture", + amount: "1000000", + authorizerSignature: "0xsig", + }); + expect(enrichment).not.toHaveProperty("saltNonce"); + }); + + it("should keep authorized maxAmount in capture paymentInfo when settle amount is overridden", async () => { + const authorizerSigner = { + address: "0x1111111111111111111111111111111111111111" as `0x${string}`, + signTypedData: vi.fn().mockResolvedValue("0xsig" as `0x${string}`), + }; + const scheme = new AuthCaptureEvmScheme({ + receiverAuthorizerSigner: authorizerSigner, + }); + const future = Math.floor(Date.now() / 1000) + 86400; + const extra = { + captureAuthorizer: "0x1234567890123456789012345678901234567890", + captureDeadline: future, + refundDeadline: future + 86400, + feeRecipient: "0x0000000000000000000000000000000000000000", + minFeeBps: 0, + maxFeeBps: 0, + name: "USDC", + version: "2", + paymentFlow: "escrow" as const, + captureMode: "sync" as const, + receiverAuthorizer: authorizerSigner.address, + }; + const accepted = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra, + }; + const enrichment = await scheme.enrichSettlementPayload({ + phase: "after-handler", + paymentPayload: { + x402Version: 2, + accepted: { + ...accepted, + amount: "1", + payTo: "0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead", + }, + payload: { + authorization: { + from: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(future), + nonce: "0x" + "33".repeat(32), + }, + signature: "0xabcd", + salt: "0x" + "44".repeat(32), + saltNonce: "0x" + "22".repeat(32), + }, + }, + requirements: { ...accepted, amount: "980000" }, + declaredExtensions: {}, + } as never); + expect(enrichment).toMatchObject({ + type: "capture", + amount: "980000", + expectedCapturableAmount: "1000000", + }); + expect( + (enrichment as { paymentInfo: { maxAmount: string; receiver: string } }).paymentInfo, + ).toEqual( + expect.objectContaining({ + maxAmount: "1000000", + receiver: accepted.payTo, + }), + ); + expect((enrichment as { paymentInfo: { receiver: string } }).paymentInfo.receiver).not.toBe( + "0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead", + ); + }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/settlementHooks.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/settlementHooks.test.ts new file mode 100644 index 0000000000..16581c0f23 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/settlementHooks.test.ts @@ -0,0 +1,644 @@ +import { describe, it, expect, vi } from "vitest"; +import { zeroAddress } from "viem"; +import { AuthCaptureSettlementHooks } from "../../../src/auth-capture/server/settlementHooks"; +import { InMemoryAuthorizedPaymentStorage } from "../../../src/auth-capture/server/storage"; +import { + AUTH_CAPTURE_ESCROW_ADDRESS, + EIP3009_TOKEN_COLLECTOR_ADDRESS, + PERMIT2_TOKEN_COLLECTOR_ADDRESS, +} from "../../../src/auth-capture/constants"; +import { computePaymentInfoHash } from "../../../src/auth-capture/nonce"; +import type { PaymentInfoStruct } from "../../../src/auth-capture/types"; + +const BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; +const FUTURE = Math.floor(Date.now() / 1000) + 86400; + +function escrowExtra(overrides: Record = {}) { + return { + captureAuthorizer: "0x1234567890123456789012345678901234567890", + captureDeadline: FUTURE, + refundDeadline: FUTURE + 86400, + feeRecipient: "0x0000000000000000000000000000000000000000", + minFeeBps: 0, + maxFeeBps: 0, + name: "USDC", + version: "2", + paymentFlow: "escrow" as const, + captureMode: "sync" as const, + receiverAuthorizer: "0x1111111111111111111111111111111111111111", + ...overrides, + }; +} + +function collectPayload() { + return { + authorization: { + from: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(FUTURE), + nonce: "0x" + "33".repeat(32), + }, + signature: "0xabcd", + salt: "0x" + "44".repeat(32), + saltNonce: "0x" + "22".repeat(32), + }; +} + +describe("AuthCaptureSettlementHooks", () => { + const authorizerSigner = { + address: "0x1111111111111111111111111111111111111111" as `0x${string}`, + signTypedData: vi.fn().mockResolvedValue("0xsig" as `0x${string}`), + }; + + const delegatedRequirements = (extraOverrides: Record = {}) => ({ + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra: escrowExtra(extraOverrides), + }); + + it("does not enrich a collect-only (zero receiver authorizer) route", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + }); + const requirements = delegatedRequirements({ + captureMode: "deferred", + receiverAuthorizer: zeroAddress, + }); + for (const phase of ["after-handler", "cancel"] as const) { + expect( + await hooks.enrichSettlementPayload({ + phase, + paymentPayload: { payload: collectPayload() }, + requirements, + declaredExtensions: {}, + } as never), + ).toBeUndefined(); + } + }); + + it("enriches an unsigned capture when the authorizer is delegated to the facilitator", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + }); + const enrichment = await hooks.enrichSettlementPayload({ + phase: "after-handler", + paymentPayload: { payload: collectPayload() }, + requirements: { ...delegatedRequirements(), amount: "400000" }, + declaredExtensions: {}, + } as never); + expect(enrichment).toMatchObject({ + type: "capture", + amount: "400000", + expectedCapturableAmount: "1000000", + expectedRefundableAmount: "0", + voidRemainder: true, + }); + expect(enrichment).not.toHaveProperty("authorizerSignature"); + expect(enrichment).not.toHaveProperty("voidAuthorizerSignature"); + }); + + it("omits voidRemainder for a full unsigned capture", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + }); + const enrichment = await hooks.enrichSettlementPayload({ + phase: "after-handler", + paymentPayload: { payload: collectPayload() }, + requirements: delegatedRequirements(), + declaredExtensions: {}, + } as never); + expect(enrichment).toMatchObject({ type: "capture", amount: "1000000" }); + expect(enrichment).not.toHaveProperty("voidRemainder"); + }); + + it("enriches an unsigned void on cancel when delegated", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + }); + const enrichment = await hooks.enrichSettlementPayload({ + phase: "cancel", + paymentPayload: { payload: collectPayload() }, + requirements: delegatedRequirements(), + declaredExtensions: {}, + } as never); + expect(enrichment).toMatchObject({ type: "void" }); + expect(enrichment).not.toHaveProperty("authorizerSignature"); + }); + + it("enriches unsigned charge completion fields when delegated", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + }); + const enrichment = await hooks.enrichSettlementPayload({ + phase: "after-handler", + paymentPayload: { payload: collectPayload() }, + requirements: delegatedRequirements({ paymentFlow: "authorization", captureMode: undefined }), + declaredExtensions: {}, + } as never); + expect(enrichment).toMatchObject({ amount: "1000000" }); + expect(enrichment).not.toHaveProperty("authorizerSignature"); + }); + + it("does not enrich before-handler or when extra is invalid", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + receiverAuthorizerSigner: authorizerSigner, + }); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 60, + extra: { bad: true }, + }; + expect( + await hooks.enrichSettlementPayload({ + phase: "before-handler", + paymentPayload: { payload: collectPayload() }, + requirements, + declaredExtensions: {}, + } as never), + ).toBeUndefined(); + expect( + await hooks.enrichSettlementPayload({ + phase: "after-handler", + paymentPayload: { payload: collectPayload() }, + requirements, + declaredExtensions: {}, + } as never), + ).toBeUndefined(); + }); + + it("returns void enrichment on cancel for escrow holds", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + receiverAuthorizerSigner: authorizerSigner, + }); + const extra = escrowExtra(); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra, + }; + const enrichment = await hooks.enrichSettlementPayload({ + phase: "cancel", + paymentPayload: { payload: collectPayload() }, + requirements, + declaredExtensions: {}, + } as never); + expect(enrichment).toMatchObject({ type: "void", authorizerSignature: "0xsig" }); + }); + + it("ignores cancel enrichment for authorization flow", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + receiverAuthorizerSigner: authorizerSigner, + }); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra: escrowExtra({ + paymentFlow: "authorization", + receiverAuthorizer: authorizerSigner.address, + }), + }; + expect( + await hooks.enrichSettlementPayload({ + phase: "cancel", + paymentPayload: { payload: collectPayload() }, + requirements, + declaredExtensions: {}, + } as never), + ).toBeUndefined(); + }); + + it("does not enrich sync capture on deferred escrow after-handler", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + receiverAuthorizerSigner: authorizerSigner, + }); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra: escrowExtra({ + captureMode: "deferred", + receiverAuthorizer: "0x0000000000000000000000000000000000000000", + }), + }; + expect( + await hooks.enrichSettlementPayload({ + phase: "after-handler", + paymentPayload: { payload: collectPayload() }, + requirements, + declaredExtensions: {}, + } as never), + ).toBeUndefined(); + }); + + it("enriches bound authorization routes with charge completion fields", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + receiverAuthorizerSigner: authorizerSigner, + }); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra: escrowExtra({ + paymentFlow: "authorization", + receiverAuthorizer: authorizerSigner.address, + }), + }; + const enrichment = await hooks.enrichSettlementPayload({ + phase: "after-handler", + paymentPayload: { payload: collectPayload() }, + requirements, + declaredExtensions: {}, + } as never); + expect(enrichment).toMatchObject({ + amount: "1000000", + authorizerSignature: "0xsig", + }); + }); + + it("settleOnCancel returns nothing when receiver authorizer is unset", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + receiverAuthorizerSigner: authorizerSigner, + }); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 60, + extra: escrowExtra({ receiverAuthorizer: zeroAddress }), + }; + expect( + await hooks.settleOnCancel({ + requirements, + paymentPayload: { payload: collectPayload() }, + } as never), + ).toBeUndefined(); + }); + + it("settleOnCancel returns requirements without a signer when the authorizer is delegated", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + }); + const requirements = delegatedRequirements(); + expect( + await hooks.settleOnCancel({ + requirements, + paymentPayload: { payload: collectPayload() }, + } as never), + ).toEqual(requirements); + }); + + it("settleOnCancel only returns requirements for escrow with receiver authorizer", async () => { + const hooks = new AuthCaptureSettlementHooks({ + storage: new InMemoryAuthorizedPaymentStorage(), + receiverAuthorizerSigner: authorizerSigner, + }); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 60, + extra: escrowExtra(), + }; + expect( + await hooks.settleOnCancel({ + requirements, + paymentPayload: { payload: collectPayload() }, + } as never), + ).toEqual(requirements); + expect( + await hooks.settleOnCancel({ + requirements: { + ...requirements, + extra: escrowExtra({ + paymentFlow: "authorization", + receiverAuthorizer: authorizerSigner.address, + }), + }, + paymentPayload: { payload: collectPayload() }, + } as never), + ).toBeUndefined(); + }); + + it("skips deferred after-handler settle when authorize receipt was stored", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const hooks = new AuthCaptureSettlementHooks({ + storage, + receiverAuthorizerSigner: authorizerSigner, + }); + const extra = escrowExtra({ + captureMode: "deferred", + receiverAuthorizer: "0x0000000000000000000000000000000000000000", + }); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra, + }; + const paymentPayload = { payload: collectPayload() }; + const authorizeResult = { + success: true, + transaction: "0xauthorize", + network: "eip155:84532", + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }; + await hooks.handleAfterSettle({ + phase: "before-handler", + paymentPayload, + requirements, + declaredExtensions: {}, + result: authorizeResult, + } as never); + const skip = await hooks.handleBeforeSettle({ + phase: "after-handler", + paymentPayload, + requirements, + declaredExtensions: {}, + } as never); + expect(skip).toEqual({ skip: true, result: authorizeResult }); + }); + + it("uses stored capturable balance when enriching sync capture", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const hooks = new AuthCaptureSettlementHooks({ + storage, + receiverAuthorizerSigner: authorizerSigner, + }); + const extra = escrowExtra(); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "500000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra, + }; + const collect = collectPayload(); + await hooks.handleAfterSettle({ + phase: "before-handler", + paymentPayload: { payload: collect }, + requirements, + declaredExtensions: {}, + result: { + success: true, + transaction: "0xauth", + network: "eip155:84532", + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + } as never); + const [stored] = await storage.list(); + await storage.update(stored!.paymentInfoHash, current => ({ + ...current!, + capturableAmount: "750000", + refundableAmount: "250000", + })); + + const enrichment = await hooks.enrichSettlementPayload({ + phase: "after-handler", + paymentPayload: { payload: collect }, + requirements, + declaredExtensions: {}, + } as never); + expect(enrichment).toMatchObject({ + type: "capture", + amount: "500000", + expectedCapturableAmount: "750000", + expectedRefundableAmount: "250000", + }); + }); + + it("persists charge results with refundable balance for authorization flow", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const hooks = new AuthCaptureSettlementHooks({ + storage, + receiverAuthorizerSigner: authorizerSigner, + }); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra: escrowExtra({ + paymentFlow: "authorization", + receiverAuthorizer: authorizerSigner.address, + assetTransferMethod: "permit2", + }), + }; + const collect = { + permit2Authorization: { + from: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + permitted: { token: BASE_SEPOLIA_USDC, amount: "1000000" }, + spender: PERMIT2_TOKEN_COLLECTOR_ADDRESS, + nonce: "99", + deadline: String(FUTURE), + }, + signature: "0xabcd", + salt: "0x" + "44".repeat(32), + }; + await hooks.handleAfterSettle({ + phase: "after-handler", + paymentPayload: { payload: collect }, + requirements, + declaredExtensions: {}, + result: { + success: true, + transaction: "0xcharge", + network: "eip155:84532", + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + amount: "1000000", + }, + } as never); + const [stored] = await storage.list(); + expect(stored?.capturableAmount).toBe("0"); + expect(stored?.refundableAmount).toBe("1000000"); + expect(stored?.assetTransferMethod).toBe("permit2"); + }); + + it("updates capturable balances after sync capture settle", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const hooks = new AuthCaptureSettlementHooks({ + storage, + receiverAuthorizerSigner: authorizerSigner, + }); + const extra = escrowExtra(); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra, + }; + const collect = collectPayload(); + await hooks.handleAfterSettle({ + phase: "before-handler", + paymentPayload: { payload: collect }, + requirements, + declaredExtensions: {}, + result: { + success: true, + transaction: "0xauth", + network: "eip155:84532", + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + } as never); + + const [stored] = await storage.list(); + const hash = stored!.paymentInfoHash; + const paymentInfo = stored!.paymentInfo; + await hooks.handleAfterSettle({ + phase: "after-handler", + paymentPayload: { + payload: { + type: "capture", + paymentInfo, + amount: "400000", + }, + }, + requirements, + declaredExtensions: {}, + result: { + success: true, + transaction: "0xcapture", + network: "eip155:84532", + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + amount: "400000", + }, + } as never); + + const updated = await storage.get(hash); + expect(updated?.capturableAmount).toBe("600000"); + expect(updated?.refundableAmount).toBe("400000"); + }); + + it("does not persist storage when authorize settle fails", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const hooks = new AuthCaptureSettlementHooks({ + storage, + receiverAuthorizerSigner: authorizerSigner, + }); + const extra = escrowExtra(); + const requirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 300, + extra, + }; + await hooks.handleAfterSettle({ + phase: "before-handler", + paymentPayload: { payload: collectPayload() }, + requirements, + declaredExtensions: {}, + result: { success: false, transaction: "", network: "eip155:84532", payer: "0x0" }, + } as never); + expect(await storage.list()).toHaveLength(0); + }); + + it("zeros capturable on successful cancel void settle", async () => { + const storage = new InMemoryAuthorizedPaymentStorage(); + const hooks = new AuthCaptureSettlementHooks({ + storage, + receiverAuthorizerSigner: authorizerSigner, + }); + const extra = escrowExtra(); + const paymentInfo: PaymentInfoStruct = { + operator: extra.captureAuthorizer, + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + receiver: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + token: BASE_SEPOLIA_USDC, + maxAmount: "1000000", + preApprovalExpiry: FUTURE - 60, + authorizationExpiry: FUTURE, + refundExpiry: FUTURE + 86400, + minFeeBps: 0, + maxFeeBps: 0, + feeReceiver: extra.feeRecipient, + salt: collectPayload().salt, + }; + const hash = computePaymentInfoHash(84532, paymentInfo, AUTH_CAPTURE_ESCROW_ADDRESS); + await storage.update(hash, () => ({ + paymentInfoHash: hash, + paymentInfo, + receiverAuthorizer: extra.receiverAuthorizer as `0x${string}`, + policy: "0x0000000000000000000000000000000000000000", + network: "eip155:84532", + capturableAmount: "1000000", + refundableAmount: "0", + collectTransaction: "0xauth", + createdAt: Date.now(), + name: "USDC", + version: "2", + paymentFlow: "escrow", + operatorType: "delegated", + assetTransferMethod: "eip3009", + authCaptureEscrow: AUTH_CAPTURE_ESCROW_ADDRESS, + })); + + await hooks.handleAfterSettle({ + phase: "cancel", + paymentPayload: { payload: { type: "void", paymentInfo } }, + requirements: { + scheme: "auth-capture", + network: "eip155:84532", + amount: "1", + asset: BASE_SEPOLIA_USDC, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 60, + extra, + }, + declaredExtensions: {}, + result: { + success: true, + transaction: "0xvoid", + network: "eip155:84532", + payer: paymentInfo.payer, + }, + } as never); + + expect((await storage.get(hash))?.capturableAmount).toBe("0"); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts index 1ccc447594..bd0ddda938 100644 --- a/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts @@ -304,6 +304,19 @@ describe("type guards", () => { ).toBe(true); }); + it("accepts v1.1 charge completion fields on a bound Permit2 payload", () => { + expect( + isPermit2Payload({ + ...validPermit2, + saltNonce: "0x0000000000000000000000000000000000000000000000000000000000000abc", + amount: "100", + feeAmount: "1", + feeReceiver: validExtra.feeRecipient, + authorizerSignature: "0xab", + }), + ).toBe(true); + }); + it("rejects a malformed charge group even with saltNonce", () => { expect( isPermit2Payload({ @@ -391,5 +404,55 @@ describe("type guards", () => { expect(isAuthCapturePayload({ type: "capture" })).toBe(false); expect(isAuthCapturePayload({ type: "authorize" })).toBe(false); }); + + it("accepts unsigned lifecycle payloads for a delegated authorizer", () => { + const base = { + paymentInfo: { + operator: "0x1111111111111111111111111111111111111111", + payer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + receiver: "0x2222222222222222222222222222222222222222", + token: "0x3333333333333333333333333333333333333333", + maxAmount: "1000000", + preApprovalExpiry: 1, + authorizationExpiry: 2, + refundExpiry: 3, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: "0x4444444444444444444444444444444444444444", + salt: "0x0000000000000000000000000000000000000000000000000000000000000001", + }, + saltNonce: "0x0000000000000000000000000000000000000000000000000000000000000abc", + }; + const capture = { + ...base, + type: "capture", + amount: "100", + feeAmount: "1", + feeReceiver: base.paymentInfo.feeReceiver, + expectedCapturableAmount: "100", + expectedRefundableAmount: "0", + }; + + expect(isCapturePayload(capture)).toBe(true); + expect(isCapturePayload({ ...capture, voidRemainder: true })).toBe(true); + expect(isVoidPayload({ ...base, type: "void" })).toBe(true); + expect( + isRefundPayload({ + ...base, + type: "refund", + amount: "50", + expectedCapturableAmount: "0", + expectedRefundableAmount: "50", + }), + ).toBe(true); + + // voidRemainder only stands in for a void signature the facilitator will produce. + expect(isCapturePayload({ ...capture, voidRemainder: false })).toBe(false); + expect( + isCapturePayload({ ...capture, voidRemainder: true, authorizerSignature: "0xab" }), + ).toBe(false); + expect(isCapturePayload({ ...capture, voidAuthorizerSignature: "0xab" })).toBe(false); + expect(isVoidPayload({ ...base, type: "void", voidRemainder: true })).toBe(false); + }); }); }); diff --git a/typescript/packages/mechanisms/evm/test/unit/exact/extensions-gas-sponsoring.test.ts b/typescript/packages/mechanisms/evm/test/unit/exact/extensions-gas-sponsoring.test.ts new file mode 100644 index 0000000000..438ac8476f --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/exact/extensions-gas-sponsoring.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; +import type { PaymentPayload } from "@x402/core/types"; +import { + EIP2612_GAS_SPONSORING_KEY, + ERC20_APPROVAL_GAS_SPONSORING_KEY, + extractEip2612GasSponsoringInfo, + extractErc20ApprovalGasSponsoringInfo, + validateEip2612GasSponsoringInfo, + validateErc20ApprovalGasSponsoringInfo, +} from "../../../src/exact/extensions"; +import { PERMIT2_ADDRESS } from "../../../src/constants"; + +const basePayload = { x402Version: 2, accepted: {}, payload: {} } as PaymentPayload; + +describe("gas sponsoring extension extraction", () => { + it("returns null when extension info is incomplete", () => { + const payload = { + ...basePayload, + extensions: { + [EIP2612_GAS_SPONSORING_KEY]: { info: { from: "0x" + "aa".repeat(20) } }, + }, + } as PaymentPayload; + expect(extractEip2612GasSponsoringInfo(payload)).toBeNull(); + expect( + extractErc20ApprovalGasSponsoringInfo({ + ...basePayload, + extensions: { + [ERC20_APPROVAL_GAS_SPONSORING_KEY]: { info: { from: "0x" + "bb".repeat(20) } }, + }, + } as PaymentPayload), + ).toBeNull(); + }); + + it("validates EIP-2612 and ERC-20 approval info formatting", () => { + const future = String(Math.floor(Date.now() / 1000) + 3600); + const sig = "aa".repeat(64) + "1b"; + const token = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + const from = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + expect( + validateEip2612GasSponsoringInfo({ + from, + asset: token, + spender: PERMIT2_ADDRESS, + amount: "1000", + nonce: "1", + deadline: future, + signature: `0x${sig}`, + version: "1", + }), + ).toBe(true); + expect( + validateErc20ApprovalGasSponsoringInfo({ + from, + asset: token, + spender: PERMIT2_ADDRESS, + amount: "1000", + signedTransaction: `0x${sig}`, + version: "1", + }), + ).toBe(true); + expect( + validateEip2612GasSponsoringInfo({ + from, + asset: token, + spender: PERMIT2_ADDRESS, + amount: "1000", + nonce: "1", + deadline: future, + signature: "not-hex", + version: "1", + }), + ).toBe(false); + }); + + it("extracts complete ERC-20 approval sponsoring info from the payment envelope", () => { + const token = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + const from = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const sig = "aa".repeat(64) + "1b"; + const payload = { + ...basePayload, + extensions: { + [ERC20_APPROVAL_GAS_SPONSORING_KEY]: { + info: { + from, + asset: token, + spender: PERMIT2_ADDRESS, + amount: "1000", + signedTransaction: `0x${sig}`, + version: "1", + }, + }, + }, + } as PaymentPayload; + expect(extractErc20ApprovalGasSponsoringInfo(payload)).toMatchObject({ from, asset: token }); + }); + + it("extracts complete EIP-2612 info from the payment envelope", () => { + const future = String(Math.floor(Date.now() / 1000) + 3600); + const sig = "aa".repeat(64) + "1b"; + const token = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + const from = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const payload = { + ...basePayload, + extensions: { + [EIP2612_GAS_SPONSORING_KEY]: { + info: { + from, + asset: token, + spender: PERMIT2_ADDRESS, + amount: "1000", + nonce: "1", + deadline: future, + signature: `0x${sig}`, + version: "1", + }, + }, + }, + } as PaymentPayload; + expect(extractEip2612GasSponsoringInfo(payload)).toMatchObject({ from, asset: token }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/index-barrels.test.ts b/typescript/packages/mechanisms/evm/test/unit/index-barrels.test.ts new file mode 100644 index 0000000000..fbd1098e2b --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/index-barrels.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import * as authCaptureFacilitator from "../../src/auth-capture/facilitator/index"; +import * as batchSettlementClient from "../../src/batch-settlement/client/index"; +import * as batchSettlementFacilitator from "../../src/batch-settlement/facilitator/index"; +import * as batchSettlementServer from "../../src/batch-settlement/server/index"; +import * as exactFacilitator from "../../src/exact/facilitator/index"; +import * as exactServer from "../../src/exact/server/index"; +import * as exactV1Client from "../../src/exact/v1/client/index"; +import * as exactV1Facilitator from "../../src/exact/v1/facilitator/index"; + +describe("package index barrels", () => { + it("re-export scheme entrypoints", () => { + expect(authCaptureFacilitator.AuthCaptureEvmScheme).toBeTypeOf("function"); + expect(batchSettlementClient.BatchSettlementEvmScheme).toBeTypeOf("function"); + expect(batchSettlementFacilitator.BatchSettlementEvmScheme).toBeTypeOf("function"); + expect(batchSettlementServer.BatchSettlementEvmScheme).toBeTypeOf("function"); + expect(exactFacilitator.ExactEvmScheme).toBeTypeOf("function"); + expect(exactServer.ExactEvmScheme).toBeTypeOf("function"); + expect(exactV1Client.ExactEvmSchemeV1).toBeTypeOf("function"); + expect(exactV1Facilitator.ExactEvmSchemeV1).toBeTypeOf("function"); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/settlement-pending-reason.test.ts b/typescript/packages/mechanisms/evm/test/unit/settlement-pending-reason.test.ts index 383dcc826f..b77c9dc2d5 100644 --- a/typescript/packages/mechanisms/evm/test/unit/settlement-pending-reason.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/settlement-pending-reason.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { ErrSettlementPending } from "../../src/exact/facilitator/errors"; +import { ErrSettlementPending as ExactErrSettlementPending } from "../../src/exact/facilitator/errors"; +import { ErrSettlementPending as AuthCaptureErrSettlementPending } from "../../src/auth-capture/errors"; // The transport layer (@x402/core http, and the Go/Python servers) hardcodes the wire // literal "settlement_pending" for its failure-transaction sanitizer, mirrored to avoid a @@ -8,6 +9,7 @@ import { ErrSettlementPending } from "../../src/exact/facilitator/errors"; // hash off a genuinely pending settlement (or leak it for a terminal failure). describe("ErrSettlementPending wire contract", () => { it("equals the settlement_pending wire literal the transport layers mirror", () => { - expect(ErrSettlementPending).toBe("settlement_pending"); + expect(ExactErrSettlementPending).toBe("settlement_pending"); + expect(AuthCaptureErrSettlementPending).toBe("settlement_pending"); }); }); diff --git a/typescript/packages/mechanisms/evm/test/unit/shared/permit2-allowance.test.ts b/typescript/packages/mechanisms/evm/test/unit/shared/permit2-allowance.test.ts new file mode 100644 index 0000000000..fd3bbacbca --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/shared/permit2-allowance.test.ts @@ -0,0 +1,237 @@ +import { describe, it, expect, vi } from "vitest"; +import { zeroAddress } from "viem"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { + verifyPermit2Allowance, + validateEip2612PermitForPayment, + simulatePermit2Settle, +} from "../../../src/shared/permit2"; +import { PERMIT2_ADDRESS } from "../../../src/constants"; +import { EIP2612_GAS_SPONSORING_KEY } from "../../../src/exact/extensions"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; + +const PAYER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as `0x${string}`; +const TOKEN = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; + +const requirements: PaymentRequirements = { + scheme: "exact", + network: "eip155:84532", + amount: "1000000", + asset: TOKEN, + payTo: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + maxTimeoutSeconds: 60, + extra: {}, +}; + +const basePayload = { x402Version: 2, accepted: requirements, payload: {} } as PaymentPayload; + +function eip2612Payload(from = PAYER) { + const future = String(Math.floor(Date.now() / 1000) + 3600); + const sig = "aa".repeat(64) + "1b"; + return { + ...basePayload, + extensions: { + [EIP2612_GAS_SPONSORING_KEY]: { + info: { + from, + asset: TOKEN, + spender: PERMIT2_ADDRESS, + amount: "2000000", + nonce: "1", + deadline: future, + signature: `0x${sig}`, + version: "1", + }, + }, + }, + } as PaymentPayload; +} + +function mockSigner(allowance: bigint | "throw"): FacilitatorEvmSigner { + return { + getAddresses: () => [PAYER], + readContract: vi.fn().mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "allowance") { + if (allowance === "throw") throw new Error("rpc down"); + return allowance; + } + return 0n; + }), + } as FacilitatorEvmSigner; +} + +describe("verifyPermit2Allowance", () => { + it("returns null when Permit2 allowance already covers the payment amount", async () => { + const result = await verifyPermit2Allowance( + mockSigner(2_000_000n), + basePayload, + requirements, + PAYER, + TOKEN, + ); + expect(result).toBeNull(); + }); + + it("requires allowance when the read succeeds but balance is insufficient and no extension is present", async () => { + const result = await verifyPermit2Allowance( + mockSigner(0n), + basePayload, + requirements, + PAYER, + TOKEN, + ); + expect(result).toEqual({ + isValid: false, + invalidReason: "permit2_allowance_required", + payer: PAYER, + }); + }); + + it("fails closed when allowance cannot be read and no gas-sponsoring extension applies", async () => { + const result = await verifyPermit2Allowance( + mockSigner("throw"), + basePayload, + requirements, + PAYER, + TOKEN, + ); + expect(result).toEqual({ + isValid: false, + invalidReason: "permit2_allowance_required", + payer: PAYER, + }); + }); + + it("continues verification when allowance is low but a valid EIP-2612 extension is present", async () => { + const result = await verifyPermit2Allowance( + mockSigner(0n), + eip2612Payload(), + requirements, + PAYER, + TOKEN, + ); + expect(result).toBeNull(); + }); + + it("surfaces EIP-2612 validation failures instead of a generic allowance error", async () => { + const result = await verifyPermit2Allowance( + mockSigner(0n), + eip2612Payload("0x0000000000000000000000000000000000000001"), + requirements, + PAYER, + TOKEN, + ); + expect(result).toMatchObject({ + isValid: false, + invalidReason: "eip2612_from_mismatch", + payer: PAYER, + }); + }); + + it("uses EIP-2612 sponsoring in the allowance-read error path when RPC fails", async () => { + const result = await verifyPermit2Allowance( + mockSigner("throw"), + eip2612Payload(), + requirements, + PAYER, + TOKEN, + ); + expect(result).toBeNull(); + }); + + it("reads allowance against the canonical Permit2 contract address", async () => { + const signer = mockSigner(2_000_000n); + await verifyPermit2Allowance(signer, basePayload, requirements, PAYER, TOKEN); + expect(signer.readContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: TOKEN, + functionName: "allowance", + args: [PAYER, PERMIT2_ADDRESS], + }), + ); + }); +}); + +describe("simulatePermit2Settle", () => { + it("returns true when eth_call simulation succeeds", async () => { + const signer = { + readContract: vi.fn().mockResolvedValue(undefined), + } as FacilitatorEvmSigner; + const ok = await simulatePermit2Settle( + { proxyAddress: "0x1111111111111111111111111111111111111111", proxyABI: [] }, + signer, + [], + ); + expect(ok).toBe(true); + }); + + it("returns false when eth_call simulation reverts", async () => { + const signer = { + readContract: vi.fn().mockRejectedValue(new Error("revert")), + } as FacilitatorEvmSigner; + const ok = await simulatePermit2Settle( + { proxyAddress: "0x1111111111111111111111111111111111111111", proxyABI: [] }, + signer, + [], + ); + expect(ok).toBe(false); + }); +}); + +describe("validateEip2612PermitForPayment", () => { + const future = String(Math.floor(Date.now() / 1000) + 3600); + const sig = "aa".repeat(64) + "1b"; + const valid = { + from: PAYER, + asset: TOKEN, + spender: PERMIT2_ADDRESS, + amount: "1000", + nonce: "1", + deadline: future, + signature: `0x${sig}`, + version: "1", + }; + + it("rejects malformed extension info before checking payer binding", () => { + const result = validateEip2612PermitForPayment( + { + from: "not-an-address", + asset: TOKEN, + spender: PERMIT2_ADDRESS, + amount: "1", + nonce: "1", + deadline: "9999999999", + signature: "0xab", + version: "1", + }, + PAYER, + TOKEN, + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_eip2612_extension_format"); + }); + + it("rejects EIP-2612 permits that do not match the payer, token, or Permit2 spender", () => { + expect(validateEip2612PermitForPayment({ ...valid, from: zeroAddress }, PAYER, TOKEN)).toEqual({ + isValid: false, + invalidReason: "eip2612_from_mismatch", + }); + expect( + validateEip2612PermitForPayment( + { ...valid, asset: "0x0000000000000000000000000000000000000001" }, + PAYER, + TOKEN, + ).invalidReason, + ).toBe("eip2612_asset_mismatch"); + expect( + validateEip2612PermitForPayment( + { ...valid, spender: "0x0000000000000000000000000000000000000002" }, + PAYER, + TOKEN, + ).invalidReason, + ).toBe("eip2612_spender_not_permit2"); + expect( + validateEip2612PermitForPayment({ ...valid, deadline: "1" }, PAYER, TOKEN).invalidReason, + ).toBe("eip2612_deadline_expired"); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/utils.test.ts b/typescript/packages/mechanisms/evm/test/unit/utils.test.ts index 7f6bd24e20..6fd8933a70 100644 --- a/typescript/packages/mechanisms/evm/test/unit/utils.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/utils.test.ts @@ -3,6 +3,7 @@ import { getEvmChainId, createNonce, finalHashFromTwoRequestSend, + invalidBroadcastHashResponse, isValidTxHash, truncateErrorMessage, MAX_ERROR_MESSAGE_LENGTH, @@ -166,6 +167,14 @@ describe("EVM Utils", () => { }); }); + describe("invalidBroadcastHashResponse", () => { + it("formats non-string signer payloads in the error message", () => { + const response = invalidBroadcastHashResponse({ code: 1 }, "bad_hash", "eip155:84532"); + expect(response.success).toBe(false); + expect(response.errorMessage).toBe('signer returned an invalid transaction hash: {"code":1}'); + }); + }); + describe("finalHashFromTwoRequestSend", () => { it("returns the only hash from an atomic bundle", () => { expect(finalHashFromTwoRequestSend(["0xaa"])).toBe("0xaa"); @@ -217,6 +226,7 @@ describe("builder-code data suffix", () => { expect(appendDataSuffix("0xabcd")).toBe("0xabcd"); expect(appendDataSuffix("0xabcd", "0x")).toBe("0xabcd"); expect(appendDataSuffix("0xabcd", "0xef")).toBe("0xabcdef"); + expect(appendDataSuffix("0xabcd", "beef")).toBe("0xabcdbeef"); }); it("returns a single suffix unchanged", async () => { @@ -247,4 +257,61 @@ describe("builder-code data suffix", () => { ), ).toBe("0xdead"); }); + + it("awaits async buildDataSuffix results", async () => { + const ctx = { + paymentPayload: { + x402Version: 2, + accepted: { scheme: "exact", network: "eip155:84532" }, + payload: {}, + }, + paymentRequirements: { + scheme: "exact", + network: "eip155:84532", + amount: "1", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x1234567890123456789012345678901234567890", + maxTimeoutSeconds: 1, + }, + }; + expect( + await resolveDataSuffix( + { + getExtension: () => ({ + key: "builder-code", + buildDataSuffix: async () => "0xasync", + }), + }, + ctx, + ), + ).toBe("0xasync"); + }); + + it("ignores extensions that do not implement buildDataSuffix", async () => { + const ctx = { + paymentPayload: { + x402Version: 2, + accepted: { scheme: "exact", network: "eip155:84532" }, + payload: {}, + }, + paymentRequirements: { + scheme: "exact", + network: "eip155:84532", + amount: "1", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x1234567890123456789012345678901234567890", + maxTimeoutSeconds: 1, + }, + }; + expect( + await resolveDataSuffix( + { + getExtension: () => ({ + key: "builder-code", + }), + }, + ctx, + ), + ).toBeUndefined(); + }); }); diff --git a/typescript/packages/mechanisms/evm/tsup.config.ts b/typescript/packages/mechanisms/evm/tsup.config.ts index 78836c236f..3fcacdc278 100644 --- a/typescript/packages/mechanisms/evm/tsup.config.ts +++ b/typescript/packages/mechanisms/evm/tsup.config.ts @@ -19,6 +19,8 @@ const baseConfig = { "batch-settlement/server/redis-storage": "src/batch-settlement/server/redisStorage.ts", "batch-settlement/facilitator/index": "src/batch-settlement/facilitator/index.ts", "auth-capture/client/index": "src/auth-capture/client/index.ts", + "auth-capture/server/index": "src/auth-capture/server/index.ts", + "auth-capture/facilitator/index": "src/auth-capture/facilitator/index.ts", }, dts: { resolve: true,