diff --git a/e2e/README.md b/e2e/README.md index 6675e1f768..4a4c9b5584 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -53,6 +53,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, Aptos, and/or Hedera networks +- **Payment schemes** (when multiple apply) - `exact`, `upto`, or `batch-settlement` Every valid combination of your selections will be tested. For example, selecting 2 facilitators, 3 servers, and 2 clients will generate and run all compatible test scenarios. @@ -134,6 +135,14 @@ FACILITATOR_HEDERA_PRIVATE_KEY=0x... # Hedera ECDSA private key for facilitator FACILITATOR_STELLAR_PRIVATE_KEY=... # Stellar private key for facilitator ``` +Optional environment variables (batch-settlement scheme): + +```bash +SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=0x... # server-side self-managed claim/refund signer +CLIENT_EVM_VOUCHER_SIGNER_PRIVATE_KEY=0x... # EOA the client uses to sign vouchers +BATCH_SETTLEMENT_RECOVERY=true # test client state-loss recovery scenario (default: true) +``` + ### Account Setup Instructions #### Stellar Testnet diff --git a/e2e/clients/axios/index.ts b/e2e/clients/axios/index.ts index be58cf87f5..5b16a67e9f 100644 --- a/e2e/clients/axios/index.ts +++ b/e2e/clients/axios/index.ts @@ -9,6 +9,7 @@ import { UptoEvmScheme as UptoEvmClientScheme, type UptoEvmSchemeOptions, } from "@x402/evm/upto/client"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; import { ExactEvmSchemeV1 } from "@x402/evm/v1"; import { toClientEvmSigner } from "@x402/evm"; import { ExactSvmScheme } from "@x402/svm/exact/client"; @@ -54,6 +55,23 @@ const uptoSchemeOptions: UptoEvmSchemeOptions | undefined = process.env.EVM_RPC_ ? { rpcUrl: process.env.EVM_RPC_URL } : undefined; +// Batch-settlement scheme uses a per-scenario salt (CHANNEL_SALT) so concurrent +// e2e runs don't collide on the same on-chain channel id. An optional voucher +// signer (EVM_VOUCHER_SIGNER_PRIVATE_KEY) exercises the alt-EOA voucher branch +// while deposits keep using the main client signer. +const channelSalt = process.env.CHANNEL_SALT as `0x${string}` | undefined; +const voucherSignerKey = process.env.EVM_VOUCHER_SIGNER_PRIVATE_KEY as + | `0x${string}` + | undefined; +const voucherSigner = voucherSignerKey + ? toClientEvmSigner(privateKeyToAccount(voucherSignerKey), publicClient) + : undefined; +const batchSettlementOptions = + channelSalt || voucherSigner + ? { ...(channelSalt ? { salt: channelSalt } : {}), ...(voucherSigner ? { voucherSigner } : {}) } + : undefined; +const batchSettlementScheme = new BatchSettlementEvmScheme(evmSigner, batchSettlementOptions); + // Initialize Aptos signer if key is provided let aptosAccount: Account | undefined; if (process.env.APTOS_PRIVATE_KEY) { @@ -93,6 +111,7 @@ if (process.env.AVM_PRIVATE_KEY) { const client = new x402Client() .register("eip155:*", new ExactEvmScheme(evmSigner, evmSchemeOptions)) .register("eip155:*", new UptoEvmClientScheme(evmSigner, uptoSchemeOptions)) + .register("eip155:*", batchSettlementScheme) .registerV1("base-sepolia", new ExactEvmSchemeV1(evmSigner)) .registerV1("base", new ExactEvmSchemeV1(evmSigner)) .register("solana:*", new ExactSvmScheme(svmSigner)) @@ -113,46 +132,119 @@ if (avmSigner) { const axiosWithPayment = wrapAxiosWithPayment(axios.create(), client); -axiosWithPayment - .get(url) - .then(async response => { - const data = response.data; - // Check both v2 (PAYMENT-RESPONSE) and v1 (X-PAYMENT-RESPONSE) headers - const paymentResponse = - response.headers["payment-response"] || response.headers["x-payment-response"]; - - if (!paymentResponse) { - // No payment was required - const result = { - success: true, - data: data, - status_code: response.status, - }; - console.log(JSON.stringify(result)); - process.exit(0); - return; - } - - const decodedPaymentResponse = decodePaymentResponseHeader(paymentResponse); - - const result = { - success: decodedPaymentResponse.success, - data: data, - status_code: response.status, - payment_response: decodedPaymentResponse, - }; +const batchSettlementPhase = process.env.BATCH_SETTLEMENT_PHASE as + | "initial" + | "recovery-refund" + | "full" + | undefined; + +/** + * Issues a single paid request and returns the parsed result. + * + * @returns Structured result with response data and decoded payment-response. + */ +interface RequestResult { + success: boolean; + data: unknown; + status_code: number; + payment_response?: any; +} + +async function issueRequest(): Promise { + const response = await axiosWithPayment.get(url); + const paymentResponseHeader = + response.headers["payment-response"] || response.headers["x-payment-response"]; + + if (!paymentResponseHeader) { + return { success: true, data: response.data, status_code: response.status }; + } + + const decodedPaymentResponse = decodePaymentResponseHeader(paymentResponseHeader); + return { + success: decodedPaymentResponse.success, + data: response.data, + status_code: response.status, + payment_response: decodedPaymentResponse, + }; +} + +function aggregateBatchResult( + phase: "initial" | "recovery-refund" | "full", + results: RequestResult[], + details: Record, +) { + const last = results[results.length - 1]!; + return { + success: results.every(result => result.success), + data: { + batchSettlement: { + phase, + requests: results, + ...details, + }, + }, + status_code: last.status_code, + payment_response: last.payment_response, + }; +} - // Output structured result as JSON for proxy to parse +try { + if (!batchSettlementPhase) { + const result = await issueRequest(); console.log(JSON.stringify(result)); process.exit(0); - }) - .catch(error => { - console.error( - JSON.stringify({ - success: false, - error: error.message || "Request failed", - status_code: error.response?.status || 500, - }), + } + + if (batchSettlementPhase === "initial") { + const deposit = await issueRequest(); + const voucher = await issueRequest(); + console.log(JSON.stringify(aggregateBatchResult("initial", [deposit, voucher], { deposit, voucher }))); + process.exit(0); + } + + if (batchSettlementPhase === "recovery-refund") { + const recoveryVoucher = await issueRequest(); + const refundSettle = await batchSettlementScheme.refund(url); + const refund = { + success: refundSettle.success, + data: { refund: true }, + status_code: 200, + payment_response: refundSettle, + }; + console.log( + JSON.stringify( + aggregateBatchResult("recovery-refund", [recoveryVoucher, refund], { + recoveryVoucher, + refund, + }), + ), ); - process.exit(1); - }); + process.exit(0); + } + + if (batchSettlementPhase === "full") { + const deposit = await issueRequest(); + const voucher = await issueRequest(); + const refundSettle = await batchSettlementScheme.refund(url); + const refund = { + success: refundSettle.success, + data: { refund: true }, + status_code: 200, + payment_response: refundSettle, + }; + console.log(JSON.stringify(aggregateBatchResult("full", [deposit, voucher, refund], { deposit, voucher, refund }))); + process.exit(0); + } + + throw new Error(`Unknown BATCH_SETTLEMENT_PHASE: ${batchSettlementPhase}`); +} catch (error: unknown) { + const err = error as { message?: string; response?: { status?: number } }; + console.error( + JSON.stringify({ + success: false, + error: err.message || "Request failed", + status_code: err.response?.status || 500, + }), + ); + process.exit(1); +} diff --git a/e2e/clients/axios/test.config.json b/e2e/clients/axios/test.config.json index a188e74af6..0ba8246dad 100644 --- a/e2e/clients/axios/test.config.json +++ b/e2e/clients/axios/test.config.json @@ -15,10 +15,9 @@ 2 ], "evm": { - "transferMethods": [ + "assetTransferMethods": [ "eip3009", - "permit2", - "upto" + "permit2" ] }, "extensions": [ @@ -39,7 +38,11 @@ "HEDERA_PRIVATE_KEY", "HEDERA_NETWORK", "HEDERA_NODE_URL", - "STELLAR_PRIVATE_KEY" + "STELLAR_PRIVATE_KEY", + "CHANNEL_SALT", + "BATCH_SETTLEMENT_PHASE", + "BATCH_SETTLEMENT_RECOVERY", + "EVM_VOUCHER_SIGNER_PRIVATE_KEY" ] } } \ No newline at end of file diff --git a/e2e/clients/fetch/index.ts b/e2e/clients/fetch/index.ts index 12611eb637..da3164a42f 100644 --- a/e2e/clients/fetch/index.ts +++ b/e2e/clients/fetch/index.ts @@ -8,6 +8,7 @@ import { UptoEvmScheme as UptoEvmClientScheme, type UptoEvmSchemeOptions, } from "@x402/evm/upto/client"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; import { ExactEvmSchemeV1 } from "@x402/evm/v1"; import { toClientEvmSigner } from "@x402/evm"; import { ExactSvmScheme } from "@x402/svm/exact/client"; @@ -53,6 +54,23 @@ const uptoSchemeOptions: UptoEvmSchemeOptions | undefined = process.env.EVM_RPC_ ? { rpcUrl: process.env.EVM_RPC_URL } : undefined; +// Batch-settlement scheme uses a per-scenario salt (CHANNEL_SALT) so concurrent +// e2e runs don't collide on the same on-chain channel id. An optional voucher +// signer (EVM_VOUCHER_SIGNER_PRIVATE_KEY) exercises the alt-EOA voucher branch +// while deposits keep using the main client signer. +const channelSalt = process.env.CHANNEL_SALT as `0x${string}` | undefined; +const voucherSignerKey = process.env.EVM_VOUCHER_SIGNER_PRIVATE_KEY as + | `0x${string}` + | undefined; +const voucherSigner = voucherSignerKey + ? toClientEvmSigner(privateKeyToAccount(voucherSignerKey), publicClient) + : undefined; +const batchSettlementOptions = + channelSalt || voucherSigner + ? { ...(channelSalt ? { salt: channelSalt } : {}), ...(voucherSigner ? { voucherSigner } : {}) } + : undefined; +const batchSettlementScheme = new BatchSettlementEvmScheme(evmSigner, batchSettlementOptions); + // Initialize Aptos signer if key is provided let aptosAccount: Account | undefined; if (process.env.APTOS_PRIVATE_KEY) { @@ -92,6 +110,7 @@ if (process.env.AVM_PRIVATE_KEY) { const client = new x402Client() .register("eip155:*", new ExactEvmScheme(evmSigner, evmSchemeOptions)) .register("eip155:*", new UptoEvmClientScheme(evmSigner, uptoSchemeOptions)) + .register("eip155:*", batchSettlementScheme) .registerV1("base-sepolia", new ExactEvmSchemeV1(evmSigner)) .registerV1("base", new ExactEvmSchemeV1(evmSigner)) .register("solana:*", new ExactSvmScheme(svmSigner)) @@ -111,35 +130,108 @@ if (avmSigner) { } const fetchWithPayment = wrapFetchWithPayment(fetch, client); +const httpClient = new x402HTTPClient(client); + +const batchSettlementPhase = process.env.BATCH_SETTLEMENT_PHASE as + | "initial" + | "recovery-refund" + | "full" + | undefined; + +/** + * Issues a single paid request and returns the parsed result. + * + * @returns Structured result with response data and decoded payment-response. + */ +interface RequestResult { + success: boolean; + data: unknown; + status_code: number; + payment_response?: any; +} -fetchWithPayment(url, { - method: "GET", -}).then(async response => { +async function issueRequest(): Promise { + const response = await fetchWithPayment(url, { method: "GET" }); const data = await response.json(); - const paymentResponse = new x402HTTPClient(client).getPaymentSettleResponse(name => - response.headers.get(name), - ); + const paymentResponse = httpClient.getPaymentSettleResponse(name => response.headers.get(name)); if (!paymentResponse) { - // No payment was required - const result = { - success: true, - data: data, - status_code: response.status, - }; - console.log(JSON.stringify(result)); - process.exit(0); - return; + return { success: true, data, status_code: response.status }; } - const result = { + return { success: paymentResponse.success, - data: data, + data, status_code: response.status, payment_response: paymentResponse, }; +} + +function aggregateBatchResult( + phase: "initial" | "recovery-refund" | "full", + results: RequestResult[], + details: Record, +) { + const last = results[results.length - 1]!; + return { + success: results.every(result => result.success), + data: { + batchSettlement: { + phase, + requests: results, + ...details, + }, + }, + status_code: last.status_code, + payment_response: last.payment_response, + }; +} - // Output structured result as JSON for proxy to parse +if (!batchSettlementPhase) { + const result = await issueRequest(); console.log(JSON.stringify(result)); process.exit(0); -}); +} + +if (batchSettlementPhase === "initial") { + const deposit = await issueRequest(); + const voucher = await issueRequest(); + console.log(JSON.stringify(aggregateBatchResult("initial", [deposit, voucher], { deposit, voucher }))); + process.exit(0); +} + +if (batchSettlementPhase === "recovery-refund") { + const recoveryVoucher = await issueRequest(); + const refundSettle = await batchSettlementScheme.refund(url); + const refund = { + success: refundSettle.success, + data: { refund: true }, + status_code: 200, + payment_response: refundSettle, + }; + console.log( + JSON.stringify( + aggregateBatchResult("recovery-refund", [recoveryVoucher, refund], { + recoveryVoucher, + refund, + }), + ), + ); + process.exit(0); +} + +if (batchSettlementPhase === "full") { + const deposit = await issueRequest(); + const voucher = await issueRequest(); + const refundSettle = await batchSettlementScheme.refund(url); + const refund = { + success: refundSettle.success, + data: { refund: true }, + status_code: 200, + payment_response: refundSettle, + }; + console.log(JSON.stringify(aggregateBatchResult("full", [deposit, voucher, refund], { deposit, voucher, refund }))); + process.exit(0); +} + +throw new Error(`Unknown BATCH_SETTLEMENT_PHASE: ${batchSettlementPhase}`); diff --git a/e2e/clients/fetch/test.config.json b/e2e/clients/fetch/test.config.json index ae3b7b16a0..678cbd4d83 100644 --- a/e2e/clients/fetch/test.config.json +++ b/e2e/clients/fetch/test.config.json @@ -15,10 +15,9 @@ 2 ], "evm": { - "transferMethods": [ + "assetTransferMethods": [ "eip3009", - "permit2", - "upto" + "permit2" ] }, "extensions": [ @@ -39,7 +38,11 @@ "HEDERA_PRIVATE_KEY", "HEDERA_NETWORK", "HEDERA_NODE_URL", - "STELLAR_PRIVATE_KEY" + "STELLAR_PRIVATE_KEY", + "CHANNEL_SALT", + "BATCH_SETTLEMENT_PHASE", + "BATCH_SETTLEMENT_RECOVERY", + "EVM_VOUCHER_SIGNER_PRIVATE_KEY" ] } } diff --git a/e2e/clients/go-http/test.config.json b/e2e/clients/go-http/test.config.json index 07d5f8cb4d..9bb3120ba0 100644 --- a/e2e/clients/go-http/test.config.json +++ b/e2e/clients/go-http/test.config.json @@ -10,12 +10,14 @@ 1, 2 ], - "schemes": ["exact", "upto"], + "schemes": [ + "exact", + "upto" + ], "evm": { - "transferMethods": [ + "assetTransferMethods": [ "eip3009", - "permit2", - "upto" + "permit2" ] }, "extensions": [ @@ -33,4 +35,4 @@ "EVM_RPC_URL" ] } -} \ No newline at end of file +} diff --git a/e2e/clients/httpx/test.config.json b/e2e/clients/httpx/test.config.json index f939ddfdae..58f5e60d7b 100644 --- a/e2e/clients/httpx/test.config.json +++ b/e2e/clients/httpx/test.config.json @@ -10,9 +10,15 @@ 1, 2 ], - "schemes": ["exact", "upto"], + "schemes": [ + "exact", + "upto" + ], "evm": { - "transferMethods": ["eip3009", "permit2", "upto"] + "assetTransferMethods": [ + "eip3009", + "permit2" + ] }, "extensions": [ "eip2612GasSponsoring", diff --git a/e2e/clients/mcp-go/test.config.json b/e2e/clients/mcp-go/test.config.json index 74bb183db1..fc2878eb5e 100644 --- a/e2e/clients/mcp-go/test.config.json +++ b/e2e/clients/mcp-go/test.config.json @@ -10,7 +10,10 @@ 2 ], "evm": { - "transferMethods": ["eip3009", "permit2"] + "assetTransferMethods": [ + "eip3009", + "permit2" + ] }, "environment": { "required": [ diff --git a/e2e/clients/mcp-python/test.config.json b/e2e/clients/mcp-python/test.config.json index bab065c9bc..3f61f78794 100644 --- a/e2e/clients/mcp-python/test.config.json +++ b/e2e/clients/mcp-python/test.config.json @@ -10,7 +10,9 @@ 2 ], "evm": { - "transferMethods": ["eip3009"] + "assetTransferMethods": [ + "eip3009" + ] }, "environment": { "required": [ diff --git a/e2e/clients/mcp-typescript/test.config.json b/e2e/clients/mcp-typescript/test.config.json index 5f2b7c7e35..016d8b59ae 100644 --- a/e2e/clients/mcp-typescript/test.config.json +++ b/e2e/clients/mcp-typescript/test.config.json @@ -10,7 +10,10 @@ 2 ], "evm": { - "transferMethods": ["eip3009", "permit2"] + "assetTransferMethods": [ + "eip3009", + "permit2" + ] }, "environment": { "required": [ diff --git a/e2e/clients/requests/test.config.json b/e2e/clients/requests/test.config.json index 44b46e62ec..e44498bd21 100644 --- a/e2e/clients/requests/test.config.json +++ b/e2e/clients/requests/test.config.json @@ -10,9 +10,15 @@ 1, 2 ], - "schemes": ["exact", "upto"], + "schemes": [ + "exact", + "upto" + ], "evm": { - "transferMethods": ["eip3009", "permit2", "upto"] + "assetTransferMethods": [ + "eip3009", + "permit2" + ] }, "extensions": [ "eip2612GasSponsoring", diff --git a/e2e/clients/text-client-protocol.txt b/e2e/clients/text-client-protocol.txt index 8b362d0d74..6dd26ad0eb 100644 --- a/e2e/clients/text-client-protocol.txt +++ b/e2e/clients/text-client-protocol.txt @@ -15,13 +15,15 @@ Clients must declare which protocol families they support in their test.config.j Clients must declare which x402 protocol versions they support using the `x402Versions` field: - **x402Versions**: Array of supported x402 protocol versions (e.g., [1, 2]) -## EVM Transfer Method Support -Clients that support the EVM protocol family must declare which transfer methods they support using the `evm.transferMethods` field: -- **eip3009**: EIP-3009 transferWithAuthorization -- **permit2**: Uniswap Permit2 approval-based transfer +## EVM asset transfer method support +Clients that support the EVM protocol family must declare which **asset authorization** paths they implement using **`evm.assetTransferMethods`**: +- **eip3009**: EIP-3009 `transferWithAuthorization` +- **permit2**: Uniswap Permit2 + +Payment **schemes** (exact / upto / batch-settlement) are chosen per server endpoint; the client is matched only on **`assetTransferMethod`**. If the `evm` field is omitted, the client is assumed to only support `["eip3009"]`. -The test suite uses this to skip scenarios where a server endpoint requires a transfer method the client does not support. +The test suite uses this to skip scenarios where a server endpoint requires an asset transfer method the client does not support. Example configuration: ```json @@ -32,7 +34,7 @@ Example configuration: "protocolFamilies": ["evm"], "x402Versions": [1, 2], "evm": { - "transferMethods": ["eip3009", "permit2"] + "assetTransferMethods": ["eip3009", "permit2"] }, "environment": { "required": ["EVM_PRIVATE_KEY", "RESOURCE_SERVER_URL", "ENDPOINT_PATH"], @@ -50,7 +52,7 @@ Multi-protocol client example: "protocolFamilies": ["evm", "svm"], "x402Versions": [1, 2], "evm": { - "transferMethods": ["eip3009", "permit2"] + "assetTransferMethods": ["eip3009", "permit2"] }, "environment": { "required": ["EVM_PRIVATE_KEY", "SVM_PRIVATE_KEY", "RESOURCE_SERVER_URL", "ENDPOINT_PATH"], @@ -68,7 +70,7 @@ Python client example (eip3009 only): "protocolFamilies": ["evm"], "x402Versions": [1, 2], "evm": { - "transferMethods": ["eip3009"] + "assetTransferMethods": ["eip3009"] }, "environment": { "required": ["EVM_PRIVATE_KEY", "RESOURCE_SERVER_URL", "ENDPOINT_PATH"], diff --git a/e2e/facilitators/go/test.config.json b/e2e/facilitators/go/test.config.json index 8d1efd7fca..28c9cb4911 100644 --- a/e2e/facilitators/go/test.config.json +++ b/e2e/facilitators/go/test.config.json @@ -15,9 +15,15 @@ "eip2612GasSponsoring", "erc20ApprovalGasSponsoring" ], - "schemes": ["exact", "upto"], + "schemes": [ + "exact", + "upto" + ], "evm": { - "transferMethods": ["eip3009", "permit2", "upto"] + "assetTransferMethods": [ + "eip3009", + "permit2" + ] }, "environment": { "required": [ @@ -27,4 +33,4 @@ ], "optional": [] } -} \ No newline at end of file +} diff --git a/e2e/facilitators/python/test.config.json b/e2e/facilitators/python/test.config.json index fa94d3db06..d1714eadc8 100644 --- a/e2e/facilitators/python/test.config.json +++ b/e2e/facilitators/python/test.config.json @@ -15,9 +15,15 @@ "eip2612GasSponsoring", "erc20ApprovalGasSponsoring" ], - "schemes": ["exact", "upto"], + "schemes": [ + "exact", + "upto" + ], "evm": { - "transferMethods": ["eip3009", "permit2", "upto"] + "assetTransferMethods": [ + "eip3009", + "permit2" + ] }, "environment": { "required": [ diff --git a/e2e/facilitators/text-facilitator-protocol.txt b/e2e/facilitators/text-facilitator-protocol.txt index 1779205ec0..956e73a1c6 100644 --- a/e2e/facilitators/text-facilitator-protocol.txt +++ b/e2e/facilitators/text-facilitator-protocol.txt @@ -20,13 +20,15 @@ Facilitators must declare which x402 protocol versions they support using the `x Facilitators must declare which protocol extensions they support using the `extensions` field: - **extensions**: Array of supported extension names (e.g., ["bazaar"]) -## EVM Transfer Method Support -Facilitators that support the EVM protocol family must declare which transfer methods they support using the `evm.transferMethods` field: -- **eip3009**: EIP-3009 transferWithAuthorization -- **permit2**: Uniswap Permit2 approval-based transfer +## EVM asset transfer method support +Facilitators that support the EVM protocol family must declare which **asset authorization** paths they implement using **`evm.assetTransferMethods`**: +- **eip3009**: EIP-3009 `transferWithAuthorization` +- **permit2**: Uniswap Permit2 + +Payment **schemes** (exact / upto / batch-settlement) are chosen per server endpoint; the facilitator is matched only on **`assetTransferMethod`**. If the `evm` field is omitted, the facilitator is assumed to only support `["eip3009"]`. -The test suite uses this to skip scenarios where a server endpoint requires a transfer method the facilitator does not support. +The test suite uses this to skip scenarios where a server endpoint requires an asset transfer method the facilitator does not support. Example configuration: ```json @@ -38,7 +40,7 @@ Example configuration: "x402Versions": [2], "extensions": ["bazaar"], "evm": { - "transferMethods": ["eip3009", "permit2"] + "assetTransferMethods": ["eip3009", "permit2"] }, "environment": { "required": ["PORT", "EVM_PRIVATE_KEY", "EVM_NETWORK"], @@ -57,7 +59,7 @@ Python facilitator example (eip3009 only): "x402Versions": [2], "extensions": ["bazaar"], "evm": { - "transferMethods": ["eip3009"] + "assetTransferMethods": ["eip3009"] }, "environment": { "required": ["PORT", "EVM_PRIVATE_KEY", "SVM_PRIVATE_KEY", "APTOS_PRIVATE_KEY"], diff --git a/e2e/facilitators/typescript/index.ts b/e2e/facilitators/typescript/index.ts index ce4210a4d0..ab9d3c106a 100644 --- a/e2e/facilitators/typescript/index.ts +++ b/e2e/facilitators/typescript/index.ts @@ -31,7 +31,8 @@ import { SettleResponse, VerifyResponse, } from "@x402/core/types"; -import { toFacilitatorEvmSigner } from "@x402/evm"; +import { type AuthorizerSigner, toFacilitatorEvmSigner } from "@x402/evm"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/facilitator"; import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; import { UptoEvmScheme } from "@x402/evm/upto/facilitator"; import { ExactEvmSchemeV1 } from "@x402/evm/exact/v1/facilitator"; @@ -66,6 +67,7 @@ import express from "express"; import { createWalletClient, http, + nonceManager, publicActions, Chain, parseTransaction, @@ -133,9 +135,25 @@ if (!process.env.SVM_PRIVATE_KEY) { // Initialize the EVM account from private key const evmAccount = privateKeyToAccount( process.env.EVM_PRIVATE_KEY as `0x${string}`, + { nonceManager }, ); console.info(`EVM Facilitator account: ${evmAccount.address}`); +// Dedicated receiver authorizer for the batch-settlement scheme (falls back to EVM_PRIVATE_KEY) +const receiverAuthorizerPrivateKey = + process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY ?? process.env.EVM_PRIVATE_KEY; +const authorizerAccount = privateKeyToAccount( + receiverAuthorizerPrivateKey as `0x${string}`, +); +const authorizerSigner: AuthorizerSigner = { + address: authorizerAccount.address, + signTypedData: (params) => + authorizerAccount.signTypedData( + params as Parameters[0], + ), +}; +console.info(`EVM Receiver Authorizer: ${authorizerSigner.address}`); + // Initialize the SVM account from private key const svmAccount = await createKeyPairSignerFromBytes( base58.decode(process.env.SVM_PRIVATE_KEY as string), @@ -258,9 +276,9 @@ const svmSigner = toFacilitatorSvmSigner( // Pass custom RPC URL if provided const aptosSigner = aptosAccount ? toFacilitatorAptosSigner( - aptosAccount, - APTOS_RPC_URL ? { defaultRpcUrl: APTOS_RPC_URL } : undefined, - ) + aptosAccount, + APTOS_RPC_URL ? { defaultRpcUrl: APTOS_RPC_URL } : undefined, + ) : undefined; const verifiedPayments = new Map(); @@ -273,12 +291,137 @@ function createPaymentHash(paymentPayload: PaymentPayload): string { .digest("hex"); } +function isBatchSettlementScheme(requirements: PaymentRequirements): boolean { + return requirements.scheme === "batch-settlement"; +} + +// For batch-settlement payloads the action lives at payload.payload.type +// (deposit / voucher) or payload.payload.settleAction (claimWithSignature / +// settle / refundWithSignature). Used by onAfterSettle to detect deposits. +function extractPayloadAction(paymentPayload: PaymentPayload): string { + const inner = paymentPayload.payload as Record | undefined; + if (!inner || typeof inner !== "object") { + return "n/a"; + } + if (typeof inner.type === "string") { + return inner.type; + } + if (typeof inner.settleAction === "string") { + return inner.settleAction; + } + return "n/a"; +} + +// Minimal ABI fragment for reading channel state from the BatchSettlement contract +const BATCH_SETTLEMENT_ADDRESS = "0x4020e07E964De72a79367828c9C6140fcaE00003" as const; +const channelsAbi = [ + { + type: "function", + name: "channels", + stateMutability: "view", + inputs: [{ name: "channelId", type: "bytes32" }], + outputs: [ + { name: "balance", type: "uint256" }, + { name: "totalClaimed", type: "uint256" }, + ], + }, +] as const; + +async function readChannelBalance(channelId: `0x${string}`): Promise { + const result = (await viemClient.readContract({ + address: BATCH_SETTLEMENT_ADDRESS, + abi: channelsAbi, + functionName: "channels", + args: [channelId], + })) as readonly [bigint, bigint]; + return result[0]; +} + +// Avoid stale state after a deposit is mined +async function waitForChannelDepositConfirmed( + channelId: `0x${string}`, + expectedMinBalance: bigint, + options: { initialDelayMs?: number; maxDelayMs?: number; timeoutMs?: number } = {}, +): Promise { + const initialDelayMs = options.initialDelayMs ?? 250; + const maxDelayMs = options.maxDelayMs ?? 4_000; + const timeoutMs = options.timeoutMs ?? 30_000; + + const startedAt = Date.now(); + let delayMs = initialDelayMs; + let attempt = 0; + let lastBalance = 0n; + + while (Date.now() - startedAt < timeoutMs) { + attempt += 1; + try { + lastBalance = await readChannelBalance(channelId); + } catch (err) { + console.warn( + `⏳ deposit confirm: read failed on attempt ${attempt} for channel ${channelId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + if (lastBalance >= expectedMinBalance) { + console.log( + `⏳ deposit confirm: channel ${channelId} balance=${lastBalance} (>= ${expectedMinBalance}) after ${attempt} attempt(s) in ${Date.now() - startedAt}ms`, + ); + return; + } + + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs = Math.min(delayMs * 2, maxDelayMs); + } + + throw new Error( + `deposit_confirm_timeout: channel ${channelId} balance=${lastBalance} (< ${expectedMinBalance}) after ${attempt} attempt(s) in ${Date.now() - startedAt}ms`, + ); +} + +async function waitForBatchSettlementDepositConfirmed( + extra: Record | undefined, +): Promise { + const channelId = typeof extra?.channelId === "string" ? (extra.channelId as `0x${string}`) : undefined; + const balanceStr = typeof extra?.balance === "string" ? extra.balance : undefined; + + if (!channelId || !balanceStr) { + console.warn( + "⏳ deposit confirm: settle response missing channelId/balance, skipping on-chain wait", + ); + return; + } + + let expectedMinBalance: bigint; + try { + expectedMinBalance = BigInt(balanceStr); + } catch { + console.warn(`⏳ deposit confirm: unparseable balance ${balanceStr}, skipping wait`); + return; + } + + if (expectedMinBalance === 0n) { + return; + } + + try { + await waitForChannelDepositConfirmed(channelId, expectedMinBalance); + } catch (err) { + console.error( + `⏳ deposit confirm: ${err instanceof Error ? err.message : String(err)} — proceeding anyway`, + ); + } +} + const facilitator = new x402Facilitator(); // Register EVM, SVM, Aptos, and Hedera schemes (v2 + v1 where applicable) facilitator .register(EVM_NETWORK as Network, new ExactEvmScheme(evmSigner)) .register(EVM_NETWORK as Network, new UptoEvmScheme(evmSigner)) + .register( + EVM_NETWORK as Network, + new BatchSettlementEvmScheme(evmSigner, authorizerSigner), + ) .registerV1(EVM_V1_NETWORKS as Network[], new ExactEvmSchemeV1(evmSigner)) .register(SVM_NETWORK as Network, new ExactSvmScheme(svmSigner)) .registerV1(SVM_V1_NETWORKS as Network[], new ExactSvmSchemeV1(svmSigner)); @@ -307,19 +450,6 @@ if (stellarSigner) { ); } -const PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3" as const; -const erc20AllowanceAbi = [ - { - inputs: [ - { name: "owner", type: "address" }, - { name: "spender", type: "address" }, - ], - name: "allowance", - outputs: [{ name: "", type: "uint256" }], - stateMutability: "view", - type: "function", - }, -] as const; const erc20ApprovalSigner = { ...evmSigner, @@ -415,6 +545,10 @@ facilitator }) .onBeforeSettle(async (context) => { // Hook 3: Validate payment was previously verified + if (isBatchSettlementScheme(context.requirements)) { + return; + } + const paymentHash = createPaymentHash(context.paymentPayload); const verificationTimestamp = verifiedPayments.get(paymentHash); @@ -437,17 +571,30 @@ facilitator }) .onAfterSettle(async (context) => { // Hook 4: Clean up verified payment tracking after settlement - const paymentHash = createPaymentHash(context.paymentPayload); - verifiedPayments.delete(paymentHash); + if (!isBatchSettlementScheme(context.requirements)) { + const paymentHash = createPaymentHash(context.paymentPayload); + verifiedPayments.delete(paymentHash); + } if (context.result.success) { console.log(`✅ Settlement completed: ${context.result.transaction}`); } + + // For batch-settlement deposits, wait for the deposit to be confirmed onchain + if ( + isBatchSettlementScheme(context.requirements) && + context.result.success && + extractPayloadAction(context.paymentPayload) === "deposit" + ) { + await waitForBatchSettlementDepositConfirmed(context.result.extra); + } }) .onSettleFailure(async (context) => { // Hook 5: Clean up on settlement failure too - const paymentHash = createPaymentHash(context.paymentPayload); - verifiedPayments.delete(paymentHash); + if (!isBatchSettlementScheme(context.requirements)) { + const paymentHash = createPaymentHash(context.paymentPayload); + verifiedPayments.delete(paymentHash); + } console.error(`❌ Settlement failed: ${context.error.message}`); }); diff --git a/e2e/facilitators/typescript/test.config.json b/e2e/facilitators/typescript/test.config.json index 66d047ffed..45314b4a85 100644 --- a/e2e/facilitators/typescript/test.config.json +++ b/e2e/facilitators/typescript/test.config.json @@ -20,7 +20,10 @@ "erc20ApprovalGasSponsoring" ], "evm": { - "transferMethods": ["eip3009", "permit2", "upto"] + "assetTransferMethods": [ + "eip3009", + "permit2" + ] }, "environment": { "required": [ @@ -40,7 +43,8 @@ "APTOS_NETWORK", "HEDERA_NETWORK", "HEDERA_NODE_URL", - "STELLAR_NETWORK" + "STELLAR_NETWORK", + "EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY" ] } } diff --git a/e2e/servers/echo/test.config.json b/e2e/servers/echo/test.config.json index 74682bc629..7e55cdd639 100644 --- a/e2e/servers/echo/test.config.json +++ b/e2e/servers/echo/test.config.json @@ -16,7 +16,8 @@ "description": "Protected endpoint requiring EIP-3009 payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/exact/evm/permit2", @@ -24,8 +25,11 @@ "description": "Protected endpoint requiring Permit2 payment (standard settle, no gas sponsoring)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "permit2Direct": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/exact/evm/permit2-eip2612GasSponsoring", @@ -33,8 +37,11 @@ "description": "Protected endpoint requiring Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "coldstart": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2-erc20ApprovalGasSponsoring", @@ -42,9 +49,14 @@ "description": "Protected endpoint requiring Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2", @@ -52,8 +64,11 @@ "description": "Protected endpoint requiring upto Permit2 payment (usage-based settlement)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "permit2Direct": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/exact/svm", diff --git a/e2e/servers/express/index.ts b/e2e/servers/express/index.ts index 80c20d83a1..ad1915b305 100644 --- a/e2e/servers/express/index.ts +++ b/e2e/servers/express/index.ts @@ -4,6 +4,7 @@ import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server"; 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 { ExactSvmScheme } from "@x402/svm/exact/server"; import { ExactAptosScheme } from "@x402/aptos/exact/server"; import { ExactHederaScheme } from "@x402/hedera/exact/server"; @@ -14,6 +15,7 @@ import { declareErc20ApprovalGasSponsoringExtension, } from "@x402/extensions"; import dotenv from "dotenv"; +import { privateKeyToAccount } from "viem/accounts"; dotenv.config(); @@ -77,6 +79,21 @@ if (AVM_PAYEE_ADDRESS) { } server.register("eip155:*", new ExactEvmScheme()); server.register("eip155:*", new UptoEvmScheme()); + +// Register batch-settlement scheme for the EVM payee. +// e2e flow does NOT use ChannelManager — settle actions are handled inline. +const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY as + | `0x${string}` + | undefined; +const receiverAuthorizerSigner = receiverAuthorizerPrivateKey + ? privateKeyToAccount(receiverAuthorizerPrivateKey) + : undefined; +server.register( + "eip155:*", + new BatchSettlementEvmScheme(EVM_PAYEE_ADDRESS, { + ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), + }), +); server.register("solana:*", new ExactSvmScheme()); if (APTOS_PAYEE_ADDRESS) { server.register("aptos:*", new ExactAptosScheme()); @@ -191,6 +208,59 @@ app.use( }, } : {}), + "GET /batch-settlement/evm/eip3009": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + price: "$0.001", + network: EVM_NETWORK, + }, + }, + "GET /batch-settlement/evm/permit2": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: { + amount: "1000", + asset: EVM_PERMIT2_ASSET, + extra: { + assetTransferMethod: "permit2", + name: EVM_NETWORK == "eip155:84532" ? "USDC" : "USD Coin", + version: "2", + }, + }, + }, + }, + "GET /batch-settlement/evm/permit2-eip2612GasSponsoring": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: "$0.001", + extra: { assetTransferMethod: "permit2" }, + }, + extensions: { + ...declareEip2612GasSponsoringExtension(), + }, + }, + "GET /batch-settlement/evm/permit2-erc20ApprovalGasSponsoring": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: { + amount: "1000", + asset: EVM_PERMIT2_ASSET, + extra: { + assetTransferMethod: "permit2", + }, + }, + }, + extensions: { + ...declareErc20ApprovalGasSponsoringExtension(), + }, + }, "GET /exact/evm/eip3009": { accepts: { payTo: EVM_PAYEE_ADDRESS, @@ -492,6 +562,41 @@ app.get("/exact/avm", (req, res) => { }); }); +/** + * Protected batch-settlement endpoint — exercised by repeated voucher requests + * over a single payment channel followed by an optional cooperative refund. + */ +app.get("/batch-settlement/evm/eip3009", (req, res) => { + res.json({ + message: "Batch-settlement endpoint accessed successfully", + timestamp: new Date().toISOString(), + }); +}); + +app.get("/batch-settlement/evm/permit2", (req, res) => { + res.json({ + message: "Batch-settlement Permit2 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "batch-settlement-permit2", + }); +}); + +app.get("/batch-settlement/evm/permit2-eip2612GasSponsoring", (req, res) => { + res.json({ + message: "Batch-settlement Permit2 EIP-2612 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "batch-settlement-permit2-eip2612", + }); +}); + +app.get("/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring", (req, res) => { + res.json({ + message: "Batch-settlement Permit2 ERC-20 approval endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "batch-settlement-permit2-erc20-approval", + }); +}); + /** * Protected endpoint - requires payment to access * @@ -683,6 +788,10 @@ app.listen(parseInt(PORT), () => { ║ Endpoints: ║ ║ • GET /exact/avm (AVM) ║ ║ • GET /exact/evm/eip3009 (EVM EIP-3009) ║ +║ • GET /batch-settlement/evm/eip3009 (Batch-settlement) ║ +║ • GET /batch-settlement/evm/permit2 (Batch Permit2) ║ +║ • GET /batch-settlement/evm/permit2-eip2612GasSponsoring ║ +║ • GET /batch-settlement/evm/permit2-erc20ApprovalGasSponsoring ║ ║ • GET /exact/evm/permit2 (Permit2) ║ ║ • GET /exact/evm/permit2-eip2612GasSponsoring ║ ║ • GET /exact/evm/permit2-erc20ApprovalGasSponsoring ║ diff --git a/e2e/servers/express/test.config.json b/e2e/servers/express/test.config.json index f82a636502..c46e6a6f33 100644 --- a/e2e/servers/express/test.config.json +++ b/e2e/servers/express/test.config.json @@ -3,8 +3,11 @@ "type": "server", "language": "typescript", "x402Version": 2, - "extensions": ["bazaar", "eip2612GasSponsoring", "erc20ApprovalGasSponsoring"], - + "extensions": [ + "bazaar", + "eip2612GasSponsoring", + "erc20ApprovalGasSponsoring" + ], "endpoints": [ { "path": "/exact/avm", @@ -19,7 +22,56 @@ "description": "Protected endpoint requiring EIP-3009 payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" + }, + { + "path": "/batch-settlement/evm/eip3009", + "method": "GET", + "description": "Protected endpoint exercised by deposit + voucher + recovery voucher + cooperative refund", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "eip3009" + }, + { + "path": "/batch-settlement/evm/permit2", + "method": "GET", + "description": "Batch-settlement Permit2 direct endpoint (pre-approved Permit2)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } + }, + { + "path": "/batch-settlement/evm/permit2-eip2612GasSponsoring", + "method": "GET", + "description": "Batch-settlement Permit2 endpoint with EIP-2612 gas sponsoring", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } + }, + { + "path": "/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring", + "method": "GET", + "description": "Batch-settlement Permit2 endpoint with ERC-20 approval gas sponsoring", + "requiresPayment": true, + "protocolFamily": "evm", + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2", @@ -27,8 +79,11 @@ "description": "Protected endpoint requiring Permit2 payment (standard settle, no gas sponsoring)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "permit2Direct": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/exact/evm/permit2-eip2612GasSponsoring", @@ -36,8 +91,11 @@ "description": "Protected endpoint requiring Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "coldstart": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2-erc20ApprovalGasSponsoring", @@ -45,9 +103,14 @@ "description": "Protected endpoint requiring Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2", @@ -55,8 +118,11 @@ "description": "Protected endpoint requiring Upto Permit2 payment (standard settle, no gas sponsoring)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "permit2Direct": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/upto/evm/permit2-eip2612GasSponsoring", @@ -64,8 +130,11 @@ "description": "Protected endpoint requiring Upto Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "coldstart": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2-erc20ApprovalGasSponsoring", @@ -73,9 +142,14 @@ "description": "Protected endpoint requiring Upto Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/svm", @@ -119,7 +193,18 @@ } ], "environment": { - "required": ["PORT", "EVM_PAYEE_ADDRESS", "SVM_PAYEE_ADDRESS", "FACILITATOR_URL"], - "optional": ["AVM_PAYEE_ADDRESS", "APTOS_PAYEE_ADDRESS", "HEDERA_PAYEE_ADDRESS", "STELLAR_PAYEE_ADDRESS"] + "required": [ + "PORT", + "EVM_PAYEE_ADDRESS", + "SVM_PAYEE_ADDRESS", + "FACILITATOR_URL" + ], + "optional": [ + "AVM_PAYEE_ADDRESS", + "APTOS_PAYEE_ADDRESS", + "HEDERA_PAYEE_ADDRESS", + "STELLAR_PAYEE_ADDRESS", + "EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY" + ] } -} +} \ No newline at end of file diff --git a/e2e/servers/fastapi/test.config.json b/e2e/servers/fastapi/test.config.json index 31f9adcfda..9546bbbe63 100644 --- a/e2e/servers/fastapi/test.config.json +++ b/e2e/servers/fastapi/test.config.json @@ -8,8 +8,16 @@ "eip2612GasSponsoring", "erc20ApprovalGasSponsoring" ], - "schemes": ["exact", "upto"], - "evm": { "transferMethods": ["eip3009", "permit2", "upto"] }, + "schemes": [ + "exact", + "upto" + ], + "evm": { + "assetTransferMethods": [ + "eip3009", + "permit2" + ] + }, "description": "Python FastAPI server with x402 v2 payment middleware", "endpoints": [ { @@ -18,7 +26,8 @@ "description": "Protected endpoint requiring EIP-3009 payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/exact/svm", @@ -33,9 +42,14 @@ "description": "Protected endpoint requiring Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["eip2612GasSponsoring"], - "coldstart": true + "extensions": [ + "eip2612GasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2-erc20ApprovalGasSponsoring", @@ -43,9 +57,14 @@ "description": "Protected endpoint requiring Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2", @@ -53,8 +72,11 @@ "description": "Protected endpoint requiring upto Permit2 payment (partial settlement)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "permit2Direct": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/upto/evm/permit2-eip2612GasSponsoring", @@ -62,8 +84,11 @@ "description": "Protected endpoint requiring upto Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "coldstart": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2-erc20ApprovalGasSponsoring", @@ -71,9 +96,14 @@ "description": "Protected endpoint requiring upto Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/health", diff --git a/e2e/servers/fastify/index.ts b/e2e/servers/fastify/index.ts index 807dc9fdcb..0b9863a9c7 100644 --- a/e2e/servers/fastify/index.ts +++ b/e2e/servers/fastify/index.ts @@ -3,6 +3,7 @@ import { paymentMiddleware, setSettlementOverrides } from "@x402/fastify"; import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server"; import { ExactEvmScheme } from "@x402/evm/exact/server"; import { UptoEvmScheme } from "@x402/evm/upto/server"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; import { ExactSvmScheme } from "@x402/svm/exact/server"; import { ExactAptosScheme } from "@x402/aptos/exact/server"; import { ExactHederaScheme } from "@x402/hedera/exact/server"; @@ -14,6 +15,7 @@ import { declareErc20ApprovalGasSponsoringExtension, } from "@x402/extensions"; import dotenv from "dotenv"; +import { privateKeyToAccount } from "viem/accounts"; dotenv.config(); @@ -73,6 +75,21 @@ if (AVM_PAYEE_ADDRESS) { } server.register("eip155:*", new ExactEvmScheme()); server.register("eip155:*", new UptoEvmScheme()); + +// Register batch-settlement scheme for the EVM payee. +// e2e flow does NOT use ChannelManager — settle actions are handled inline. +const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY as + | `0x${string}` + | undefined; +const receiverAuthorizerSigner = receiverAuthorizerPrivateKey + ? privateKeyToAccount(receiverAuthorizerPrivateKey) + : undefined; +server.register( + "eip155:*", + new BatchSettlementEvmScheme(EVM_PAYEE_ADDRESS, { + ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), + }), +); server.register("solana:*", new ExactSvmScheme()); if (APTOS_PAYEE_ADDRESS) { server.register("aptos:*", new ExactAptosScheme()); @@ -136,33 +153,86 @@ paymentMiddleware( // Route-specific payment configuration ...(AVM_PAYEE_ADDRESS ? { - "GET /exact/avm": { - accepts: { - payTo: AVM_PAYEE_ADDRESS, - scheme: "exact", - price: "$0.001", - network: AVM_NETWORK, - }, - extensions: { - ...declareDiscoveryExtension({ - output: { - example: { - message: "Protected endpoint accessed successfully", - timestamp: "2024-01-01T00:00:00Z", - }, - schema: { - properties: { - message: { type: "string" }, - timestamp: { type: "string" }, - }, - required: ["message", "timestamp"], + "GET /exact/avm": { + accepts: { + payTo: AVM_PAYEE_ADDRESS, + scheme: "exact", + price: "$0.001", + network: AVM_NETWORK, + }, + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + message: "Protected endpoint accessed successfully", + timestamp: "2024-01-01T00:00:00Z", + }, + schema: { + properties: { + message: { type: "string" }, + timestamp: { type: "string" }, }, + required: ["message", "timestamp"], }, - }), - }, + }, + }), }, - } + }, + } : {}), + "GET /batch-settlement/evm/eip3009": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + price: "$0.001", + network: EVM_NETWORK, + }, + }, + "GET /batch-settlement/evm/permit2": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: { + amount: "1000", + asset: EVM_PERMIT2_ASSET, + extra: { + assetTransferMethod: "permit2", + name: EVM_NETWORK == "eip155:84532" ? "USDC" : "USD Coin", + version: "2", + }, + }, + }, + }, + "GET /batch-settlement/evm/permit2-eip2612GasSponsoring": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: "$0.001", + extra: { assetTransferMethod: "permit2" }, + }, + extensions: { + ...declareEip2612GasSponsoringExtension(), + }, + }, + "GET /batch-settlement/evm/permit2-erc20ApprovalGasSponsoring": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: { + amount: "1000", + asset: EVM_PERMIT2_ASSET, + extra: { + assetTransferMethod: "permit2", + }, + }, + }, + extensions: { + ...declareErc20ApprovalGasSponsoringExtension(), + }, + }, "GET /exact/evm/eip3009": { accepts: { payTo: EVM_PAYEE_ADDRESS, @@ -247,32 +317,32 @@ paymentMiddleware( : {}), ...(APTOS_PAYEE_ADDRESS ? { - "GET /exact/aptos": { - accepts: { - payTo: APTOS_PAYEE_ADDRESS, - scheme: "exact", - price: "$0.001", - network: APTOS_NETWORK, - }, - extensions: { - ...declareDiscoveryExtension({ - output: { - example: { - message: "Protected endpoint accessed successfully", - timestamp: "2024-01-01T00:00:00Z", - }, - schema: { - properties: { - message: { type: "string" }, - timestamp: { type: "string" }, - }, - required: ["message", "timestamp"], + "GET /exact/aptos": { + accepts: { + payTo: APTOS_PAYEE_ADDRESS, + scheme: "exact", + price: "$0.001", + network: APTOS_NETWORK, + }, + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + message: "Protected endpoint accessed successfully", + timestamp: "2024-01-01T00:00:00Z", + }, + schema: { + properties: { + message: { type: "string" }, + timestamp: { type: "string" }, }, + required: ["message", "timestamp"], }, - }), - }, + }, + }), }, - } + }, + } : {}), // Permit2 standard/direct endpoint - no gas sponsoring, client must pre-approve Permit2 "GET /exact/evm/permit2": { @@ -415,37 +485,72 @@ paymentMiddleware( }, ...(STELLAR_PAYEE_ADDRESS ? { - "GET /exact/stellar": { - accepts: { - payTo: STELLAR_PAYEE_ADDRESS!, - scheme: "exact", - price: "$0.001", - network: STELLAR_NETWORK, - }, - extensions: { - ...declareDiscoveryExtension({ - output: { - example: { - message: "Protected Stellar endpoint accessed successfully", - timestamp: "2024-01-01T00:00:00Z", - }, - schema: { - properties: { - message: { type: "string" }, - timestamp: { type: "string" }, - }, - required: ["message", "timestamp"], + "GET /exact/stellar": { + accepts: { + payTo: STELLAR_PAYEE_ADDRESS!, + scheme: "exact", + price: "$0.001", + network: STELLAR_NETWORK, + }, + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + message: "Protected Stellar endpoint accessed successfully", + timestamp: "2024-01-01T00:00:00Z", + }, + schema: { + properties: { + message: { type: "string" }, + timestamp: { type: "string" }, }, + required: ["message", "timestamp"], }, - }), - }, + }, + }), }, - } + }, + } : {}), }, server, // Pass pre-configured server instance ); +/** + * Protected batch-settlement endpoint — exercised by repeated voucher requests + * over a single payment channel followed by an optional cooperative refund. + */ +app.get("/batch-settlement/evm/eip3009", async () => { + return { + message: "Batch-settlement endpoint accessed successfully", + timestamp: new Date().toISOString(), + }; +}); + +app.get("/batch-settlement/evm/permit2", async () => { + return { + message: "Batch-settlement Permit2 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "batch-settlement-permit2", + }; +}); + +app.get("/batch-settlement/evm/permit2-eip2612GasSponsoring", async () => { + return { + message: "Batch-settlement Permit2 EIP-2612 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "batch-settlement-permit2-eip2612", + }; +}); + +app.get("/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring", async () => { + return { + message: "Batch-settlement Permit2 ERC-20 approval endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "batch-settlement-permit2-erc20-approval", + }; +}); + /** * Protected endpoint - requires payment to access * diff --git a/e2e/servers/fastify/test.config.json b/e2e/servers/fastify/test.config.json index 8f960f6512..3473e02ed7 100644 --- a/e2e/servers/fastify/test.config.json +++ b/e2e/servers/fastify/test.config.json @@ -3,8 +3,11 @@ "type": "server", "language": "typescript", "x402Version": 2, - "extensions": ["bazaar", "eip2612GasSponsoring", "erc20ApprovalGasSponsoring"], - + "extensions": [ + "bazaar", + "eip2612GasSponsoring", + "erc20ApprovalGasSponsoring" + ], "endpoints": [ { "path": "/exact/avm", @@ -19,7 +22,56 @@ "description": "Protected endpoint requiring EIP-3009 payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" + }, + { + "path": "/batch-settlement/evm/eip3009", + "method": "GET", + "description": "Protected endpoint exercised by deposit + voucher + recovery voucher + cooperative refund", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "eip3009" + }, + { + "path": "/batch-settlement/evm/permit2", + "method": "GET", + "description": "Batch-settlement Permit2 direct endpoint (pre-approved Permit2)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } + }, + { + "path": "/batch-settlement/evm/permit2-eip2612GasSponsoring", + "method": "GET", + "description": "Batch-settlement Permit2 endpoint with EIP-2612 gas sponsoring", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } + }, + { + "path": "/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring", + "method": "GET", + "description": "Batch-settlement Permit2 endpoint with ERC-20 approval gas sponsoring", + "requiresPayment": true, + "protocolFamily": "evm", + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2", @@ -27,8 +79,11 @@ "description": "Protected endpoint requiring Permit2 payment (standard settle, no gas sponsoring)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "permit2Direct": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/exact/evm/permit2-eip2612GasSponsoring", @@ -36,8 +91,11 @@ "description": "Protected endpoint requiring Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "coldstart": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2-erc20ApprovalGasSponsoring", @@ -45,9 +103,14 @@ "description": "Protected endpoint requiring Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2", @@ -55,8 +118,11 @@ "description": "Protected endpoint requiring upto Permit2 payment (direct, client must pre-approve)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "permit2Direct": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/upto/evm/permit2-eip2612GasSponsoring", @@ -64,8 +130,11 @@ "description": "Protected endpoint requiring upto Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "coldstart": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2-erc20ApprovalGasSponsoring", @@ -73,9 +142,14 @@ "description": "Protected endpoint requiring upto Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/svm", @@ -119,7 +193,18 @@ } ], "environment": { - "required": ["PORT", "EVM_PAYEE_ADDRESS", "SVM_PAYEE_ADDRESS", "FACILITATOR_URL"], - "optional": ["AVM_PAYEE_ADDRESS", "APTOS_PAYEE_ADDRESS", "HEDERA_PAYEE_ADDRESS", "STELLAR_PAYEE_ADDRESS"] + "required": [ + "PORT", + "EVM_PAYEE_ADDRESS", + "SVM_PAYEE_ADDRESS", + "FACILITATOR_URL" + ], + "optional": [ + "AVM_PAYEE_ADDRESS", + "APTOS_PAYEE_ADDRESS", + "HEDERA_PAYEE_ADDRESS", + "STELLAR_PAYEE_ADDRESS", + "EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY" + ] } } diff --git a/e2e/servers/flask/test.config.json b/e2e/servers/flask/test.config.json index a09e15f59c..3f5b1d68e8 100644 --- a/e2e/servers/flask/test.config.json +++ b/e2e/servers/flask/test.config.json @@ -8,8 +8,16 @@ "eip2612GasSponsoring", "erc20ApprovalGasSponsoring" ], - "schemes": ["exact", "upto"], - "evm": { "transferMethods": ["eip3009", "permit2", "upto"] }, + "schemes": [ + "exact", + "upto" + ], + "evm": { + "assetTransferMethods": [ + "eip3009", + "permit2" + ] + }, "description": "Python Flask server with x402 v2 payment middleware", "endpoints": [ { @@ -18,7 +26,8 @@ "description": "Protected endpoint requiring EIP-3009 payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/exact/svm", @@ -33,9 +42,14 @@ "description": "Protected endpoint requiring Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["eip2612GasSponsoring"], - "coldstart": true + "extensions": [ + "eip2612GasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2-erc20ApprovalGasSponsoring", @@ -43,9 +57,14 @@ "description": "Protected endpoint requiring Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2", @@ -53,8 +72,11 @@ "description": "Protected endpoint requiring upto Permit2 payment (partial settlement)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "permit2Direct": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/upto/evm/permit2-eip2612GasSponsoring", @@ -62,8 +84,11 @@ "description": "Protected endpoint requiring upto Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "coldstart": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2-erc20ApprovalGasSponsoring", @@ -71,9 +96,14 @@ "description": "Protected endpoint requiring upto Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/health", diff --git a/e2e/servers/gin/test.config.json b/e2e/servers/gin/test.config.json index 5e434fbfc3..6c6a99a2a4 100644 --- a/e2e/servers/gin/test.config.json +++ b/e2e/servers/gin/test.config.json @@ -16,7 +16,8 @@ "description": "Protected endpoint requiring EIP-3009 payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/exact/evm/permit2", @@ -24,8 +25,11 @@ "description": "Protected endpoint requiring Permit2 payment (standard settle, no gas sponsoring)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "permit2Direct": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/exact/evm/permit2-eip2612GasSponsoring", @@ -33,8 +37,11 @@ "description": "Protected endpoint requiring Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "coldstart": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2-erc20ApprovalGasSponsoring", @@ -42,9 +49,14 @@ "description": "Protected endpoint requiring Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2", @@ -52,8 +64,11 @@ "description": "Protected endpoint requiring upto Permit2 payment (usage-based settlement)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "permit2Direct": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/exact/svm", diff --git a/e2e/servers/hono/index.ts b/e2e/servers/hono/index.ts index 0e24b59477..8a31c20004 100644 --- a/e2e/servers/hono/index.ts +++ b/e2e/servers/hono/index.ts @@ -4,6 +4,7 @@ import { paymentMiddleware, setSettlementOverrides } from "@x402/hono"; import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server"; import { ExactEvmScheme } from "@x402/evm/exact/server"; import { UptoEvmScheme } from "@x402/evm/upto/server"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; import { ExactSvmScheme } from "@x402/svm/exact/server"; import { ExactAptosScheme } from "@x402/aptos/exact/server"; import { ExactHederaScheme } from "@x402/hedera/exact/server"; @@ -15,6 +16,7 @@ import { declareErc20ApprovalGasSponsoringExtension, } from "@x402/extensions"; import dotenv from "dotenv"; +import { privateKeyToAccount } from "viem/accounts"; dotenv.config(); @@ -78,6 +80,21 @@ if (AVM_PAYEE_ADDRESS) { } x402Server.register("eip155:*", new ExactEvmScheme()); x402Server.register("eip155:*", new UptoEvmScheme()); + +// Register batch-settlement scheme for the EVM payee. +// e2e flow does NOT use ChannelManager — settle actions are handled inline. +const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY as + | `0x${string}` + | undefined; +const receiverAuthorizerSigner = receiverAuthorizerPrivateKey + ? privateKeyToAccount(receiverAuthorizerPrivateKey) + : undefined; +x402Server.register( + "eip155:*", + new BatchSettlementEvmScheme(EVM_PAYEE_ADDRESS, { + ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), + }), +); x402Server.register("solana:*", new ExactSvmScheme()); if (APTOS_PAYEE_ADDRESS) { x402Server.register("aptos:*", new ExactAptosScheme()); @@ -202,6 +219,59 @@ app.use( }, } : {}), + "GET /batch-settlement/evm/eip3009": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + price: "$0.001", + network: EVM_NETWORK, + }, + }, + "GET /batch-settlement/evm/permit2": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: { + amount: "1000", + asset: EVM_PERMIT2_ASSET, + extra: { + assetTransferMethod: "permit2", + name: EVM_NETWORK == "eip155:84532" ? "USDC" : "USD Coin", + version: "2", + }, + }, + }, + }, + "GET /batch-settlement/evm/permit2-eip2612GasSponsoring": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: "$0.001", + extra: { assetTransferMethod: "permit2" }, + }, + extensions: { + ...declareEip2612GasSponsoringExtension(), + }, + }, + "GET /batch-settlement/evm/permit2-erc20ApprovalGasSponsoring": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: { + amount: "1000", + asset: EVM_PERMIT2_ASSET, + extra: { + assetTransferMethod: "permit2", + }, + }, + }, + extensions: { + ...declareErc20ApprovalGasSponsoringExtension(), + }, + }, "GET /exact/evm/eip3009": { accepts: { payTo: EVM_PAYEE_ADDRESS, @@ -487,6 +557,41 @@ app.use( ), ); +/** + * Protected batch-settlement endpoint — exercised by repeated voucher requests + * over a single payment channel followed by an optional cooperative refund. + */ +app.get("/batch-settlement/evm/eip3009", c => { + return c.json({ + message: "Batch-settlement endpoint accessed successfully", + timestamp: new Date().toISOString(), + }); +}); + +app.get("/batch-settlement/evm/permit2", c => { + return c.json({ + message: "Batch-settlement Permit2 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "batch-settlement-permit2", + }); +}); + +app.get("/batch-settlement/evm/permit2-eip2612GasSponsoring", c => { + return c.json({ + message: "Batch-settlement Permit2 EIP-2612 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "batch-settlement-permit2-eip2612", + }); +}); + +app.get("/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring", c => { + return c.json({ + message: "Batch-settlement Permit2 ERC-20 approval endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "batch-settlement-permit2-erc20-approval", + }); +}); + /** * Protected endpoint - requires payment to access * diff --git a/e2e/servers/hono/test.config.json b/e2e/servers/hono/test.config.json index 31f8952da9..ecc20da640 100644 --- a/e2e/servers/hono/test.config.json +++ b/e2e/servers/hono/test.config.json @@ -3,8 +3,11 @@ "type": "server", "language": "typescript", "x402Version": 2, - "extensions": ["bazaar", "eip2612GasSponsoring", "erc20ApprovalGasSponsoring"], - + "extensions": [ + "bazaar", + "eip2612GasSponsoring", + "erc20ApprovalGasSponsoring" + ], "endpoints": [ { "path": "/exact/avm", @@ -19,7 +22,56 @@ "description": "Protected endpoint requiring EIP-3009 payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" + }, + { + "path": "/batch-settlement/evm/eip3009", + "method": "GET", + "description": "Protected endpoint exercised by deposit + voucher + recovery voucher + cooperative refund", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "eip3009" + }, + { + "path": "/batch-settlement/evm/permit2", + "method": "GET", + "description": "Batch-settlement Permit2 direct endpoint (pre-approved Permit2)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } + }, + { + "path": "/batch-settlement/evm/permit2-eip2612GasSponsoring", + "method": "GET", + "description": "Batch-settlement Permit2 endpoint with EIP-2612 gas sponsoring", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } + }, + { + "path": "/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring", + "method": "GET", + "description": "Batch-settlement Permit2 endpoint with ERC-20 approval gas sponsoring", + "requiresPayment": true, + "protocolFamily": "evm", + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2", @@ -27,8 +79,11 @@ "description": "Protected endpoint requiring Permit2 payment (standard settle, no gas sponsoring)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "permit2Direct": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/exact/evm/permit2-eip2612GasSponsoring", @@ -36,8 +91,11 @@ "description": "Protected endpoint requiring Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "coldstart": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2-erc20ApprovalGasSponsoring", @@ -45,9 +103,14 @@ "description": "Protected endpoint requiring Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2", @@ -55,8 +118,11 @@ "description": "Protected endpoint requiring upto Permit2 payment (direct, client must pre-approve)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "permit2Direct": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/upto/evm/permit2-eip2612GasSponsoring", @@ -64,8 +130,11 @@ "description": "Protected endpoint requiring Upto Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "coldstart": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2-erc20ApprovalGasSponsoring", @@ -73,9 +142,14 @@ "description": "Protected endpoint requiring Upto Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/svm", @@ -119,7 +193,18 @@ } ], "environment": { - "required": ["PORT", "EVM_PAYEE_ADDRESS", "SVM_PAYEE_ADDRESS", "FACILITATOR_URL"], - "optional": ["AVM_PAYEE_ADDRESS", "APTOS_PAYEE_ADDRESS", "HEDERA_PAYEE_ADDRESS", "STELLAR_PAYEE_ADDRESS"] + "required": [ + "PORT", + "EVM_PAYEE_ADDRESS", + "SVM_PAYEE_ADDRESS", + "FACILITATOR_URL" + ], + "optional": [ + "AVM_PAYEE_ADDRESS", + "APTOS_PAYEE_ADDRESS", + "HEDERA_PAYEE_ADDRESS", + "STELLAR_PAYEE_ADDRESS", + "EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY" + ] } -} +} \ No newline at end of file diff --git a/e2e/servers/mcp-go/test.config.json b/e2e/servers/mcp-go/test.config.json index 4156eaa107..c82a7a7308 100644 --- a/e2e/servers/mcp-go/test.config.json +++ b/e2e/servers/mcp-go/test.config.json @@ -4,7 +4,9 @@ "transport": "mcp", "language": "go", "x402Version": 2, - "extensions": ["bazaar"], + "extensions": [ + "bazaar" + ], "endpoints": [ { "path": "get_weather", @@ -14,7 +16,8 @@ "description": "Paid weather tool via MCP transport", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/health", diff --git a/e2e/servers/mcp-python/test.config.json b/e2e/servers/mcp-python/test.config.json index 7590449b8c..82686cd43c 100644 --- a/e2e/servers/mcp-python/test.config.json +++ b/e2e/servers/mcp-python/test.config.json @@ -4,7 +4,9 @@ "transport": "mcp", "language": "python", "x402Version": 2, - "extensions": ["bazaar"], + "extensions": [ + "bazaar" + ], "endpoints": [ { "path": "get_weather", @@ -14,7 +16,8 @@ "description": "Paid weather tool via MCP transport", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/health", diff --git a/e2e/servers/mcp-typescript/test.config.json b/e2e/servers/mcp-typescript/test.config.json index a56c44f881..5b83d8b598 100644 --- a/e2e/servers/mcp-typescript/test.config.json +++ b/e2e/servers/mcp-typescript/test.config.json @@ -4,7 +4,9 @@ "transport": "mcp", "language": "typescript", "x402Version": 2, - "extensions": ["bazaar"], + "extensions": [ + "bazaar" + ], "endpoints": [ { "path": "get_weather", @@ -14,7 +16,8 @@ "description": "Paid weather tool via MCP transport", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/health", diff --git a/e2e/servers/nethttp/test.config.json b/e2e/servers/nethttp/test.config.json index a77eb5bbb6..0967ccba4a 100644 --- a/e2e/servers/nethttp/test.config.json +++ b/e2e/servers/nethttp/test.config.json @@ -8,7 +8,12 @@ "eip2612GasSponsoring", "erc20ApprovalGasSponsoring" ], - "evm": { "transferMethods": ["eip3009", "permit2"] }, + "evm": { + "assetTransferMethods": [ + "eip3009", + "permit2" + ] + }, "description": "Go net/http server with x402 v2 payment middleware", "endpoints": [ { @@ -17,7 +22,8 @@ "description": "Protected endpoint requiring EIP-3009 payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/exact/evm/permit2", @@ -25,8 +31,11 @@ "description": "Protected endpoint requiring Permit2 payment (standard settle, no gas sponsoring)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "permit2Direct": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/exact/evm/permit2-eip2612GasSponsoring", @@ -34,8 +43,11 @@ "description": "Protected endpoint requiring Permit2 payment with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "coldstart": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/exact/evm/permit2-erc20ApprovalGasSponsoring", @@ -43,9 +55,14 @@ "description": "Protected endpoint requiring Permit2 payment with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/upto/evm/permit2", @@ -53,8 +70,11 @@ "description": "Protected endpoint requiring upto Permit2 payment (usage-based settlement)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "permit2Direct": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/exact/svm", diff --git a/e2e/servers/next/app/api/batch-settlement/evm/eip3009/withx402/route.ts b/e2e/servers/next/app/api/batch-settlement/evm/eip3009/withx402/route.ts new file mode 100644 index 0000000000..cebac6c6b5 --- /dev/null +++ b/e2e/servers/next/app/api/batch-settlement/evm/eip3009/withx402/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withX402 } from "@x402/next"; +import { server, EVM_PAYEE_ADDRESS, EVM_NETWORK } from "@/proxy"; + +const handler = async (_: NextRequest) => { + return NextResponse.json({ + message: "Batch-settlement endpoint accessed successfully (withX402)", + timestamp: new Date().toISOString(), + }); +}; + +/** + * Protected batch-settlement EVM endpoint using the withX402 wrapper. + */ +export const GET = withX402( + handler, + { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + price: "$0.001", + network: EVM_NETWORK, + }, + }, + server, +); diff --git a/e2e/servers/next/proxy.ts b/e2e/servers/next/proxy.ts index edc66d10de..0f16f53857 100644 --- a/e2e/servers/next/proxy.ts +++ b/e2e/servers/next/proxy.ts @@ -2,6 +2,8 @@ import { paymentProxy } from "@x402/next"; import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server"; import { ExactEvmScheme } from "@x402/evm/exact/server"; import { UptoEvmScheme } from "@x402/evm/upto/server"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; +import { privateKeyToAccount } from "viem/accounts"; import { ExactSvmScheme } from "@x402/svm/exact/server"; import { ExactAptosScheme } from "@x402/aptos/exact/server"; import { ExactHederaScheme } from "@x402/hedera/exact/server"; @@ -53,6 +55,21 @@ if (AVM_PAYEE_ADDRESS) { } server.register("eip155:*", new ExactEvmScheme()); server.register("eip155:*", new UptoEvmScheme()); + +// Register batch-settlement scheme for the EVM payee. +// e2e flow does NOT use ChannelManager — settle actions are handled inline. +const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY as + | `0x${string}` + | undefined; +const receiverAuthorizerSigner = receiverAuthorizerPrivateKey + ? privateKeyToAccount(receiverAuthorizerPrivateKey) + : undefined; +server.register( + "eip155:*", + new BatchSettlementEvmScheme(EVM_PAYEE_ADDRESS, { + ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), + }), +); server.register("solana:*", new ExactSvmScheme()); if (APTOS_PAYEE_ADDRESS) { server.register("aptos:*", new ExactAptosScheme()); @@ -71,6 +88,59 @@ console.log(`Using remote facilitator at: ${facilitatorUrl}`); export const proxy = paymentProxy( { + "/api/batch-settlement/evm/eip3009/proxy": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + price: "$0.001", + network: EVM_NETWORK, + }, + }, + "/api/batch-settlement/evm/permit2/proxy": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: { + amount: "1000", + asset: EVM_PERMIT2_ASSET, + extra: { + assetTransferMethod: "permit2", + name: EVM_NETWORK == "eip155:84532" ? "USDC" : "USD Coin", + version: "2", + }, + }, + }, + }, + "/api/batch-settlement/evm/permit2-eip2612GasSponsoring/proxy": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: "$0.001", + extra: { assetTransferMethod: "permit2" }, + }, + extensions: { + ...declareEip2612GasSponsoringExtension(), + }, + }, + "/api/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring/proxy": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "batch-settlement", + network: EVM_NETWORK, + price: { + amount: "1000", + asset: EVM_PERMIT2_ASSET, + extra: { + assetTransferMethod: "permit2", + }, + }, + }, + extensions: { + ...declareErc20ApprovalGasSponsoringExtension(), + }, + }, "/api/exact/evm/eip3009/proxy": { accepts: { payTo: EVM_PAYEE_ADDRESS, @@ -123,61 +193,61 @@ export const proxy = paymentProxy( }, ...(AVM_PAYEE_ADDRESS ? { - "/api/exact/avm": { - accepts: { - payTo: AVM_PAYEE_ADDRESS, - scheme: "exact", - price: "$0.001", - network: AVM_NETWORK, - }, - extensions: { - ...declareDiscoveryExtension({ - output: { - example: { - message: "Protected endpoint accessed successfully", - timestamp: "2024-01-01T00:00:00Z", - }, - schema: { - properties: { - message: { type: "string" }, - timestamp: { type: "string" }, - }, - required: ["message", "timestamp"], + "/api/exact/avm": { + accepts: { + payTo: AVM_PAYEE_ADDRESS, + scheme: "exact", + price: "$0.001", + network: AVM_NETWORK, + }, + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + message: "Protected endpoint accessed successfully", + timestamp: "2024-01-01T00:00:00Z", + }, + schema: { + properties: { + message: { type: "string" }, + timestamp: { type: "string" }, }, + required: ["message", "timestamp"], }, - }), - }, + }, + }), }, - } + }, + } : {}), ...(APTOS_PAYEE_ADDRESS ? { - "/api/exact/aptos": { - accepts: { - payTo: APTOS_PAYEE_ADDRESS, - scheme: "exact", - price: "$0.001", - network: APTOS_NETWORK, - }, - extensions: { - ...declareDiscoveryExtension({ - output: { - example: { - message: "Protected endpoint accessed successfully", - timestamp: "2024-01-01T00:00:00Z", - }, - schema: { - properties: { - message: { type: "string" }, - timestamp: { type: "string" }, - }, - required: ["message", "timestamp"], + "/api/exact/aptos": { + accepts: { + payTo: APTOS_PAYEE_ADDRESS, + scheme: "exact", + price: "$0.001", + network: APTOS_NETWORK, + }, + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + message: "Protected endpoint accessed successfully", + timestamp: "2024-01-01T00:00:00Z", + }, + schema: { + properties: { + message: { type: "string" }, + timestamp: { type: "string" }, }, + required: ["message", "timestamp"], }, - }), - }, + }, + }), }, - } + }, + } : {}), ...(HEDERA_PAYEE_ADDRESS ? { @@ -213,32 +283,32 @@ export const proxy = paymentProxy( : {}), ...(STELLAR_PAYEE_ADDRESS ? { - "/api/exact/stellar": { - accepts: { - payTo: STELLAR_PAYEE_ADDRESS, - scheme: "exact", - price: "$0.001", - network: STELLAR_NETWORK, - }, - extensions: { - ...declareDiscoveryExtension({ - output: { - example: { - message: "Protected endpoint accessed successfully", - timestamp: "2024-01-01T00:00:00Z", - }, - schema: { - properties: { - message: { type: "string" }, - timestamp: { type: "string" }, - }, - required: ["message", "timestamp"], + "/api/exact/stellar": { + accepts: { + payTo: STELLAR_PAYEE_ADDRESS, + scheme: "exact", + price: "$0.001", + network: STELLAR_NETWORK, + }, + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + message: "Protected endpoint accessed successfully", + timestamp: "2024-01-01T00:00:00Z", + }, + schema: { + properties: { + message: { type: "string" }, + timestamp: { type: "string" }, }, + required: ["message", "timestamp"], }, - }), - }, + }, + }), }, - } + }, + } : {}), "/api/exact/evm/permit2/proxy": { accepts: { @@ -389,5 +459,9 @@ export const config = { "/api/upto/evm/permit2", "/api/upto/evm/permit2-eip2612GasSponsoring", "/api/upto/evm/permit2-erc20ApprovalGasSponsoring", + "/api/batch-settlement/evm/eip3009/proxy", + "/api/batch-settlement/evm/permit2/proxy", + "/api/batch-settlement/evm/permit2-eip2612GasSponsoring/proxy", + "/api/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring/proxy", ], }; diff --git a/e2e/servers/next/test.config.json b/e2e/servers/next/test.config.json index 326d9f78b0..bd9507ad08 100644 --- a/e2e/servers/next/test.config.json +++ b/e2e/servers/next/test.config.json @@ -3,8 +3,11 @@ "type": "server", "language": "typescript", "x402Version": 2, - "extensions": ["bazaar", "eip2612GasSponsoring", "erc20ApprovalGasSponsoring"], - + "extensions": [ + "bazaar", + "eip2612GasSponsoring", + "erc20ApprovalGasSponsoring" + ], "endpoints": [ { "path": "/api/exact/evm/eip3009/proxy", @@ -12,7 +15,8 @@ "description": "EVM EIP-3009 endpoint using proxy middleware", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/api/exact/evm/permit2/proxy", @@ -20,8 +24,11 @@ "description": "EVM Permit2 direct endpoint (standard settle, no gas sponsoring)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "permit2Direct": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/api/exact/evm/permit2-eip2612GasSponsoring/proxy", @@ -29,8 +36,11 @@ "description": "EVM Permit2 endpoint with EIP-2612 gas sponsoring using proxy middleware", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "coldstart": true + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/api/exact/evm/permit2-erc20ApprovalGasSponsoring/proxy", @@ -38,9 +48,14 @@ "description": "EVM Permit2 endpoint with ERC-20 approval gas sponsoring using proxy middleware", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/api/upto/evm/permit2", @@ -48,8 +63,11 @@ "description": "Protected Upto Permit2 endpoint (direct, client must pre-approve)", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "permit2Direct": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/api/upto/evm/permit2-eip2612GasSponsoring", @@ -57,8 +75,11 @@ "description": "Protected Upto Permit2 endpoint with EIP-2612 gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "coldstart": true + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/api/upto/evm/permit2-erc20ApprovalGasSponsoring", @@ -66,9 +87,14 @@ "description": "Protected Upto Permit2 endpoint with ERC-20 approval gas sponsoring", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "upto", - "extensions": ["erc20ApprovalGasSponsoring"], - "coldstart": true + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "upto", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } }, { "path": "/api/exact/svm", @@ -111,7 +137,65 @@ "description": "EVM EIP-3009 endpoint using withX402 wrapper", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" + }, + { + "path": "/api/batch-settlement/evm/eip3009/proxy", + "method": "GET", + "description": "Batch-settlement EVM endpoint (proxy middleware): deposit + voucher + recovery voucher + cooperative refund", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "eip3009" + }, + { + "path": "/api/batch-settlement/evm/permit2/proxy", + "method": "GET", + "description": "Batch-settlement Permit2 direct endpoint (proxy middleware, pre-approved Permit2)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } + }, + { + "path": "/api/batch-settlement/evm/permit2-eip2612GasSponsoring/proxy", + "method": "GET", + "description": "Batch-settlement Permit2 endpoint with EIP-2612 gas sponsoring using proxy middleware", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } + }, + { + "path": "/api/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring/proxy", + "method": "GET", + "description": "Batch-settlement Permit2 endpoint with ERC-20 approval gas sponsoring using proxy middleware", + "requiresPayment": true, + "protocolFamily": "evm", + "extensions": [ + "erc20ApprovalGasSponsoring" + ], + "scheme": "batch-settlement", + "assetTransferMethod": "permit2", + "schemeOptions": { + "coldstart": true + } + }, + { + "path": "/api/batch-settlement/evm/eip3009/withx402", + "method": "GET", + "description": "Batch-settlement EVM endpoint (withX402 wrapper): deposit + voucher + recovery voucher + cooperative refund", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "batch-settlement", + "assetTransferMethod": "eip3009" }, { "path": "/api/exact/svm/withx402", @@ -148,7 +232,18 @@ } ], "environment": { - "required": ["PORT", "EVM_PAYEE_ADDRESS", "SVM_PAYEE_ADDRESS", "FACILITATOR_URL"], - "optional": ["AVM_PAYEE_ADDRESS", "APTOS_PAYEE_ADDRESS", "HEDERA_PAYEE_ADDRESS", "STELLAR_PAYEE_ADDRESS"] + "required": [ + "PORT", + "EVM_PAYEE_ADDRESS", + "SVM_PAYEE_ADDRESS", + "FACILITATOR_URL" + ], + "optional": [ + "AVM_PAYEE_ADDRESS", + "APTOS_PAYEE_ADDRESS", + "HEDERA_PAYEE_ADDRESS", + "STELLAR_PAYEE_ADDRESS", + "EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY" + ] } } diff --git a/e2e/servers/text-server-protocol.txt b/e2e/servers/text-server-protocol.txt index 956e573b0c..af4d20706a 100644 --- a/e2e/servers/text-server-protocol.txt +++ b/e2e/servers/text-server-protocol.txt @@ -21,17 +21,18 @@ Servers may declare which protocol extensions they support using the `extensions ## Coldstart Tests Some endpoints require special pre-test state setup before they can be exercised (e.g., revoking a Permit2 approval so that the gas-sponsoring extension path is triggered). This setup is expensive: it involves funding the client wallet, submitting on-chain transactions, and draining ETH back. -Endpoints that require this setup declare `"coldstart": true` in their test config. The test runner treats the **first** test for a given endpoint path within a combo as the "coldstart" test — it performs the full setup. Subsequent tests for the same endpoint path within the same combo skip the setup and run directly, exercising the "warm" (already-approved) code path. +Endpoints that require this setup set **`"coldstart": true`** inside **`schemeOptions`** on that endpoint. The test runner treats the **first** test for a given endpoint path within a combo as the "coldstart" test — it performs the full setup. Subsequent tests for the same endpoint path within the same combo skip the setup and run directly, exercising the "warm" (already-approved) code path. -All explicit gas sponsorship extension endpoints (EIP-2612 and ERC-20 approval) should have `coldstart` enabled, as they must succeed without the user having gas or approval ahead of time. +All explicit gas sponsorship extension endpoints (EIP-2612 and ERC-20 approval) should have **`coldstart`** enabled under **`schemeOptions`**, as they must succeed without the user having gas or approval ahead of time. -## EVM Transfer Method -For EVM endpoints, servers must declare the `transferMethod` used for the payment transfer: -- **eip3009**: EIP-3009 transferWithAuthorization (default if omitted) -- **permit2**: Uniswap Permit2 approval-based transfer +## EVM scheme and asset transfer method +For EVM paid endpoints, servers must declare: +- **`scheme`**: Payment scheme — **`exact`** (single settle), **`upto`** (usage-based settle), or **`batch-settlement`** (deposit + vouchers + refund). If omitted, the harness defaults to **`exact`**. +- **`assetTransferMethod`**: **`eip3009`** vs **`permit2`**. If omitted: **`upto`** endpoints default to **`permit2`** (no EIP-3009 path); **`exact`** and **`batch-settlement`** default to **`eip3009`**. +- **`schemeOptions`** (optional): Harness knobs as **`SchemeOptions`** — **`Permit2SchemeOptions`** (`permit2Direct`, `coldstart`) for exact/upto and the same optional Permit2 knobs for batch-settlement. +- **`BATCH_SETTLEMENT_RECOVERY`** (optional env): Defaults to enabled. Set to `false`, `0`, `no`, or `off` to run batch-settlement as deposit + voucher + refund without a fresh client process recovery step. -The `transferMethod` field is only applicable to endpoints with `"protocolFamily": "evm"`. -The test suite uses this field to ensure the client and facilitator both support the required transfer method before generating a test scenario. +These fields apply only when `"protocolFamily": "evm"`. Discovery matches **`assetTransferMethod`** against each client/facilitator’s `evm.assetTransferMethods`; **`scheme`** is used for filters such as `--schemes=`. Example configuration: ```json @@ -48,7 +49,8 @@ Example configuration: "description": "Protected endpoint requiring EIP-3009 payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/protected-permit2", @@ -56,7 +58,11 @@ Example configuration: "description": "Protected endpoint requiring Permit2 payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "permit2" + "scheme": "exact", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } }, { "path": "/health", @@ -82,7 +88,8 @@ Multi-protocol server example: "description": "Protected endpoint requiring EVM payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/svm-protected", diff --git a/e2e/src/cli/args.ts b/e2e/src/cli/args.ts index 1570291eeb..07b60ad2a5 100644 --- a/e2e/src/cli/args.ts +++ b/e2e/src/cli/args.ts @@ -44,6 +44,7 @@ export function parseArgs(): ParsedArgs { arg.startsWith('--extensions=') || arg.startsWith('--versions=') || arg.startsWith('--families=') || + arg.startsWith('--schemes=') || arg.startsWith('--endpoints=') ); @@ -94,6 +95,7 @@ export function parseArgs(): ParsedArgs { const extensions = parseListArg(args, '--extensions'); const versions = parseListArg(args, '--versions')?.map(v => parseInt(v)); const families = parseListArg(args, '--families'); + const schemes = parseListArg(args, '--schemes'); const endpoints = parseListArg(args, '--endpoints'); return { @@ -109,6 +111,7 @@ export function parseArgs(): ParsedArgs { extensions, versions, protocolFamilies: families, + schemes, endpoints, }, showHelp: false, @@ -147,6 +150,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)'); + console.log(' --schemes= Payment schemes: exact, upto, batch-settlement'); console.log(' --endpoints= Comma-separated endpoint paths or regex patterns (auto-anchored)'); console.log(''); console.log('Options:'); @@ -168,6 +172,7 @@ export function printHelp(): void { console.log(' pnpm test --mainnet --facilitators=go --servers=express # Mainnet programmatic'); console.log(" pnpm test --testnet --endpoints='/protected' # Exact path match"); console.log(" pnpm test --testnet --endpoints='/protected-permit2.*' # Regex: all permit2 routes"); + console.log(' pnpm test --testnet --schemes=exact,batch-settlement # Only those payment schemes'); console.log(' pnpm test --testnet --min --parallel -v # Parallel mode'); console.log(' pnpm test --testnet --min --parallel --concurrency=2 -v # Limited concurrency'); console.log(''); diff --git a/e2e/src/cli/filters.ts b/e2e/src/cli/filters.ts index bf300f3cd9..c60261d5fa 100644 --- a/e2e/src/cli/filters.ts +++ b/e2e/src/cli/filters.ts @@ -1,4 +1,23 @@ -import { TestScenario } from '../types'; +import { TestScenario, endpointPaymentScheme } from '../types'; + +/** x402 payment scheme for filtering (non-EVM counts as exact). */ +export type PaymentSchemeKind = 'exact' | 'upto' | 'batch-settlement'; + +/** + * Classify a scenario's payment scheme for filtering (`endpoint.scheme`, default `exact` on EVM). + */ +export function getScenarioPaymentScheme(scenario: TestScenario): PaymentSchemeKind { + if (scenario.protocolFamily !== 'evm') { + return 'exact'; + } + return endpointPaymentScheme(scenario.endpoint) ?? 'exact'; +} + +export function getUniquePaymentSchemes(scenarios: TestScenario[]): PaymentSchemeKind[] { + const set = new Set(); + scenarios.forEach(s => set.add(getScenarioPaymentScheme(s))); + return Array.from(set).sort(); +} export interface TestFilters { transports?: string[]; @@ -8,6 +27,7 @@ export interface TestFilters { extensions?: string[]; // For test output control (doesn't filter scenarios) versions?: number[]; protocolFamilies?: string[]; + schemes?: string[]; endpoints?: string[]; // Regex patterns to filter by endpoint path } @@ -65,6 +85,15 @@ export function filterScenarios( } } + // Payment scheme filter + if (filters.schemes && filters.schemes.length > 0) { + const normalized = filters.schemes.map(s => s.trim().toLowerCase()); + const kind = getScenarioPaymentScheme(scenario); + if (!normalized.includes(kind)) { + return false; + } + } + // Endpoint filter — each entry is treated as a regex pattern. // Patterns are auto-anchored (^...$) so that "/protected" matches only // that exact path. To match a prefix, use "/protected.*"; for a substring diff --git a/e2e/src/cli/interactive.ts b/e2e/src/cli/interactive.ts index db4eab771e..27c77dbe67 100644 --- a/e2e/src/cli/interactive.ts +++ b/e2e/src/cli/interactive.ts @@ -1,6 +1,14 @@ import prompts from 'prompts'; import { DiscoveredClient, DiscoveredServer, DiscoveredFacilitator, TestScenario } from '../types'; -import { TestFilters, getUniqueVersions, getUniqueProtocolFamilies } from './filters'; +import { + TestFilters, + filterScenarios, + getUniqueVersions, + getUniqueProtocolFamilies, + getUniquePaymentSchemes, + getScenarioPaymentScheme, + PaymentSchemeKind, +} from './filters'; import { log } from '../logger'; import { NetworkMode, getNetworkModeDescription } from '../networks/networks'; @@ -280,7 +288,44 @@ export async function runInteractiveMode( selectedFamilies = availableFamilies; } - // Question 8: Endpoint filter (optional free-text, comma-separated regex patterns) + // Question 8 (CONDITIONAL): Payment scheme — exact vs upto vs batch-settlement (EVM transfer semantics) + const scenariosForScheme = filterScenarios(preliminaryScenarios, { + versions: selectedVersions, + protocolFamilies: selectedFamilies, + }); + const availableSchemes = getUniquePaymentSchemes(scenariosForScheme); + let selectedSchemes: string[] | undefined; + + if (availableSchemes.length > 1) { + const schemeChoices = availableSchemes.map((k: PaymentSchemeKind) => { + const count = scenariosForScheme.filter(s => getScenarioPaymentScheme(s) === k).length; + return { + title: `${k} (${count} scenarios)`, + value: k, + selected: true, + }; + }); + + const schemesResponse = await prompts({ + type: 'multiselect', + name: 'schemes', + message: 'Select payment schemes', + choices: schemeChoices, + min: 1, + hint: 'exact = eip3009/permit2-style; upto = usage-based; batch-settlement = voucher channel', + instructions: false, + }); + + if (!schemesResponse.schemes || schemesResponse.schemes.length === 0) { + return null; + } + + selectedSchemes = schemesResponse.schemes; + } else if (availableSchemes.length === 1) { + selectedSchemes = availableSchemes; + } + + // Question 9: Endpoint filter (optional free-text, comma-separated regex patterns) const endpointsResponse = await prompts({ type: 'text', name: 'endpoints', @@ -297,7 +342,7 @@ export async function runInteractiveMode( ? (endpointsResponse.endpoints as string).split(',').map((p: string) => p.trim()).filter((p: string) => p.length > 0) : undefined; - // Question 9: Select network mode (testnet/mainnet) - LAST question + // Question 10: Select network mode (testnet/mainnet) - LAST question // Skip if preselected via CLI flag let networkMode: NetworkMode; @@ -346,6 +391,7 @@ export async function runInteractiveMode( extensions: selectedExtensions, versions: selectedVersions, protocolFamilies: selectedFamilies, + schemes: selectedSchemes, endpoints: selectedEndpoints, networkMode, }; diff --git a/e2e/src/clients/generic-client.ts b/e2e/src/clients/generic-client.ts index 3dcc81049f..26cd09ebe8 100644 --- a/e2e/src/clients/generic-client.ts +++ b/e2e/src/clients/generic-client.ts @@ -33,6 +33,15 @@ export class GenericClientProxy extends BaseProxy implements ClientProxy { EVM_RPC_URL: config.evmRpcUrl, HEDERA_NETWORK: config.hederaNetwork, HEDERA_NODE_URL: config.hederaNodeUrl, + ...(config.batchSettlement + ? { + CHANNEL_SALT: config.batchSettlement.channelSalt, + BATCH_SETTLEMENT_PHASE: config.batchSettlement.phase, + ...(config.batchSettlement.voucherSignerPrivateKey + ? { EVM_VOUCHER_SIGNER_PRIVATE_KEY: config.batchSettlement.voucherSignerPrivateKey } + : {}), + } + : {}), } }; diff --git a/e2e/src/discovery.ts b/e2e/src/discovery.ts index 46ea72118b..593030258d 100644 --- a/e2e/src/discovery.ts +++ b/e2e/src/discovery.ts @@ -10,7 +10,8 @@ import { DiscoveredClient, DiscoveredFacilitator, TestScenario, - ProtocolFamily + ProtocolFamily, + endpointAssetTransferMethod, } from './types'; export class TestDiscovery { @@ -266,10 +267,10 @@ export class TestDiscovery { // For EVM endpoints, check transfer method compatibility with client if (endpointProtocolFamily === 'evm') { - const endpointTransferMethod = endpoint.transferMethod || 'eip3009'; - const clientTransferMethods = client.config.evm?.transferMethods || ['eip3009']; - if (!clientTransferMethods.includes(endpointTransferMethod)) { - verboseLog(` ⚠️ Skipping ${client.name} ↔ ${server.name} ${endpoint.path}: Transfer method mismatch (client supports [${clientTransferMethods.join(', ')}], endpoint requires ${endpointTransferMethod})`); + const endpointAtm = endpointAssetTransferMethod(endpoint)!; + const clientAssetMethods = client.config.evm?.assetTransferMethods || ['eip3009']; + if (!clientAssetMethods.includes(endpointAtm)) { + verboseLog(` ⚠️ Skipping ${client.name} ↔ ${server.name} ${endpoint.path}: Asset transfer method mismatch (client supports [${clientAssetMethods.join(', ')}], endpoint requires ${endpointAtm})`); continue; } } @@ -280,9 +281,9 @@ export class TestDiscovery { const supportsVersion = f.config.x402Versions?.includes(serverVersion); // For EVM, also check transfer method support if (endpointProtocolFamily === 'evm') { - const endpointTransferMethod = endpoint.transferMethod || 'eip3009'; - const facilTransferMethods = f.config.evm?.transferMethods || ['eip3009']; - if (!facilTransferMethods.includes(endpointTransferMethod)) return false; + const endpointAtm = endpointAssetTransferMethod(endpoint)!; + const facilAssetMethods = f.config.evm?.assetTransferMethods || ['eip3009']; + if (!facilAssetMethods.includes(endpointAtm)) return false; } return supportsProtocol && supportsVersion; }); @@ -333,8 +334,8 @@ export class TestDiscovery { const protocolFamilies = client.config.protocolFamilies || ['evm']; const versions = client.config.x402Versions || [1]; const transport = client.config.transport || 'http'; - const evmTransferMethods = client.config.evm?.transferMethods || ['eip3009']; - const evmInfo = protocolFamilies.includes('evm') ? ` evm:${evmTransferMethods.join(',')}` : ''; + const evmAssetMethods = client.config.evm?.assetTransferMethods || ['eip3009']; + const evmInfo = protocolFamilies.includes('evm') ? ` evm:${evmAssetMethods.join(',')}` : ''; const extInfo = client.config.extensions ? ` {${client.config.extensions.join(', ')}}` : ''; verboseLog(` - ${client.name} (${client.config.language}) [${transport}] v[${versions.join(', ')}] [${protocolFamilies.join(', ')}]${evmInfo}${extInfo}`); }); @@ -347,8 +348,8 @@ export class TestDiscovery { regularFacilitators.forEach(facilitator => { const protocolFamilies = facilitator.config.protocolFamilies || ['evm']; const versions = facilitator.config.x402Versions || [2]; - const evmTransferMethods = facilitator.config.evm?.transferMethods || ['eip3009']; - const evmInfo = protocolFamilies.includes('evm') ? ` evm:${evmTransferMethods.join(',')}` : ''; + const evmAssetMethods = facilitator.config.evm?.assetTransferMethods || ['eip3009']; + const evmInfo = protocolFamilies.includes('evm') ? ` evm:${evmAssetMethods.join(',')}` : ''; verboseLog(` - ${facilitator.name} (${facilitator.config.language}) v[${versions.join(', ')}] [${protocolFamilies.join(', ')}]${evmInfo}`); }); @@ -357,8 +358,8 @@ export class TestDiscovery { externalFacilitators.forEach(facilitator => { const protocolFamilies = facilitator.config.protocolFamilies || ['evm']; const versions = facilitator.config.x402Versions || [2]; - const evmTransferMethods = facilitator.config.evm?.transferMethods || ['eip3009']; - const evmInfo = protocolFamilies.includes('evm') ? ` evm:${evmTransferMethods.join(',')}` : ''; + const evmAssetMethods = facilitator.config.evm?.assetTransferMethods || ['eip3009']; + const evmInfo = protocolFamilies.includes('evm') ? ` evm:${evmAssetMethods.join(',')}` : ''; verboseLog(` - ${facilitator.name} (${facilitator.config.language}) v[${versions.join(', ')}] [${protocolFamilies.join(', ')}]${evmInfo}`); }); } diff --git a/e2e/src/networks/networks.ts b/e2e/src/networks/networks.ts index e4fa5cb37b..478b4cd9d3 100644 --- a/e2e/src/networks/networks.ts +++ b/e2e/src/networks/networks.ts @@ -106,6 +106,23 @@ export function getNetworkSet(mode: NetworkMode): NetworkSet { return NETWORK_SETS[mode]; } +/** + * Permit2-priced routes read `process.env.EVM_PERMIT2_ASSET` in server processes. + * Use the same resolution here and when spawning resource servers (`generic-server`) + * so cold-start revoke/approve targets the token those routes bill. + * + * Precedence: non-empty `EVM_PERMIT2_ASSET`, then `networks.evm.permit2Asset`. + * When the env var is unset, defaults are Base Sepolia USDC (`eip155:84532`) and + * Base mainnet USDC (`eip155:8453`) from {@link NETWORK_SETS}. + */ +export function resolveEvmPermit2Asset(networks: NetworkSet): string { + const fromEnv = process.env.EVM_PERMIT2_ASSET?.trim(); + if (fromEnv) { + return fromEnv; + } + return (networks.evm.permit2Asset ?? '').trim(); +} + /** * Get network config for a protocol family in a given mode * diff --git a/e2e/src/sampling.ts b/e2e/src/sampling.ts index 0c8dfb8441..dc719b4f41 100644 --- a/e2e/src/sampling.ts +++ b/e2e/src/sampling.ts @@ -1,4 +1,4 @@ -import { TestScenario } from './types'; +import { TestScenario, endpointAssetTransferMethod, endpointPaymentScheme } from './types'; import { log, verboseLog } from './logger'; /** @@ -31,13 +31,22 @@ export class CoverageTracker { /** * Generate a coverage key for an endpoint - * Format: "server-name-endpoint-path-protocolFamily-transferMethod-vVersion" - * + * Format: "server-name-endpoint-path-protocolFamily-scheme-assetMethod-vVersion" + * * This ensures each unique endpoint on a server is tested separately, - * including different EVM transfer methods (eip3009 vs permit2). + * including different EVM schemes and asset transfer methods. */ - private getEndpointCoverageKey(serverName: string, endpointPath: string, protocolFamily: string, version: number, transferMethod?: string): string { - const method = protocolFamily === 'evm' ? (transferMethod || 'eip3009') : ''; + private getEndpointCoverageKey( + serverName: string, + endpointPath: string, + protocolFamily: string, + version: number, + scenario: TestScenario, + ): string { + const method = + protocolFamily === 'evm' + ? `${endpointPaymentScheme(scenario.endpoint) ?? 'exact'}-${endpointAssetTransferMethod(scenario.endpoint) ?? 'eip3009'}` + : ''; return `${serverName}-${endpointPath}-${protocolFamily}${method ? `-${method}` : ''}-v${version}`; } @@ -77,7 +86,7 @@ export class CoverageTracker { scenario.endpoint.path, protocolFamily, version, - scenario.endpoint.transferMethod + scenario, ); // Check if ANY component hasn't been covered yet @@ -125,7 +134,7 @@ export class CoverageTracker { scenario.endpoint.path, protocolFamily, version, - scenario.endpoint.transferMethod + scenario, ); this.clientsCovered.add(clientKey); diff --git a/e2e/src/servers/generic-server.ts b/e2e/src/servers/generic-server.ts index 9573b17387..1f67b85b48 100644 --- a/e2e/src/servers/generic-server.ts +++ b/e2e/src/servers/generic-server.ts @@ -1,6 +1,7 @@ import { BaseProxy, RunConfig } from '../proxy-base'; import { ServerProxy, ServerConfig } from '../types'; import { verboseLog, errorLog } from '../logger'; +import { resolveEvmPermit2Asset } from '../networks/networks'; export interface ProtectedResponse { message: string; @@ -92,7 +93,7 @@ export class GenericServerProxy extends BaseProxy implements ServerProxy { EVM_NETWORK: evmNetwork, EVM_RPC_URL: config.networks.evm.rpcUrl, EVM_PAYEE_ADDRESS: config.evmPayTo, - EVM_PERMIT2_ASSET: config.networks.evm.permit2Asset || '', + EVM_PERMIT2_ASSET: resolveEvmPermit2Asset(config.networks), // SVM network config SVM_NETWORK: svmNetwork, @@ -127,6 +128,13 @@ export class GenericServerProxy extends BaseProxy implements ServerProxy { // Facilitator FACILITATOR_URL: config.facilitatorUrl || '', MOCK_FACILITATOR_URL: config.mockFacilitatorUrl || '', + + ...(config.batchSettlement + ? { + EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY: + config.batchSettlement.receiverAuthorizerPrivateKey, + } + : {}), } }; diff --git a/e2e/src/types.ts b/e2e/src/types.ts index d82cc2372a..7221b7d8b9 100644 --- a/e2e/src/types.ts +++ b/e2e/src/types.ts @@ -2,7 +2,50 @@ import type { NetworkSet } from './networks/networks'; export type ProtocolFamily = 'evm' | 'svm' | 'avm' | 'aptos' | 'hedera' | 'stellar'; export type Transport = 'http' | 'mcp'; -export type TransferMethod = 'eip3009' | 'permit2' | 'upto'; +export type PaymentScheme = 'exact' | 'upto' | 'batch-settlement'; +export type AssetTransferMethod = 'eip3009' | 'permit2'; + +/** + * Resolved asset transfer for an EVM endpoint. + */ +export function endpointAssetTransferMethod(endpoint: TestEndpoint): AssetTransferMethod | undefined { + const family = endpoint.protocolFamily ?? 'evm'; + if (family !== 'evm') { + return undefined; + } + if (endpoint.assetTransferMethod != null) { + return endpoint.assetTransferMethod; + } + const scheme = endpoint.scheme ?? 'exact'; + return scheme === 'upto' ? 'permit2' : 'eip3009'; +} + +/** + * Resolved payment scheme for an EVM endpoint. + * Defaults to `exact` when omitted (non-batch endpoints). + */ +export function endpointPaymentScheme(endpoint: TestEndpoint): PaymentScheme | undefined { + const family = endpoint.protocolFamily ?? 'evm'; + if (family !== 'evm') { + return undefined; + } + return endpoint.scheme ?? 'exact'; +} + +/** Harness knobs for exact / upto endpoints (Permit2 settle paths). */ +export interface Permit2SchemeOptions { + permit2Direct?: boolean; + coldstart?: boolean; +} + +/** Harness knobs for batch-settlement endpoints. */ +export type BatchSettlementSchemeOptions = Permit2SchemeOptions; + +export type SchemeOptions = Permit2SchemeOptions | BatchSettlementSchemeOptions; + +export function endpointUsesBatchSettlement(endpoint: TestEndpoint): boolean { + return endpoint.scheme === 'batch-settlement'; +} export interface ClientResult { success: boolean; @@ -12,6 +55,24 @@ export interface ClientResult { error?: string; } +/** Scheme-specific configs for a batch-settlement scenario. */ +export type BatchSettlementPhase = 'initial' | 'recovery-refund' | 'full'; + +export interface BatchSettlementClientConfig { + /** Per-scenario unique salt that derives the onchain channel id (avoids collisions across runs). */ + channelSalt: string; + /** Fixed e2e phase to run for this one-shot client process. */ + phase: BatchSettlementPhase; + /** Optional alternate EOA used to sign vouchers (deposits still use the main client signer). */ + voucherSignerPrivateKey?: string; +} + +/** Scheme-specific knobs the harness forwards to a server for a batch-settlement scenario. */ +export interface BatchSettlementServerConfig { + /** Optional EOA private key the server uses as a self-managed receiver authorizer. */ + receiverAuthorizerPrivateKey: string; +} + export interface ClientConfig { evmPrivateKey: string; svmPrivateKey: string; @@ -26,6 +87,7 @@ export interface ClientConfig { evmRpcUrl: string; hederaNetwork: string; hederaNodeUrl: string; + batchSettlement?: BatchSettlementClientConfig; } export interface ServerConfig { @@ -41,6 +103,7 @@ export interface ServerConfig { networks: NetworkSet; facilitatorUrl?: string; mockFacilitatorUrl?: string; + batchSettlement?: BatchSettlementServerConfig; } export interface ServerProxy { @@ -61,16 +124,14 @@ export interface TestEndpoint { description: string; requiresPayment?: boolean; protocolFamily?: ProtocolFamily; - transferMethod?: TransferMethod; + scheme?: PaymentScheme; + assetTransferMethod?: AssetTransferMethod; + schemeOptions?: SchemeOptions; extensions?: string[]; /** 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. */ mcpTransport?: 'streamable-http' | 'sse'; - /** True for Permit2 standard/direct settle - requires pre-approval (approve before test, not revoke) */ - permit2Direct?: boolean; - /** True for endpoints that require Permit2 revocation + fund/drain state setup before the first test (coldstart). */ - coldstart?: boolean; health?: boolean; close?: boolean; } @@ -85,7 +146,7 @@ export interface TestConfig { x402Versions?: number[]; extensions?: string[]; evm?: { - transferMethods: TransferMethod[]; + assetTransferMethods?: AssetTransferMethod[]; }; endpoints?: TestEndpoint[]; supportedMethods?: string[]; diff --git a/e2e/templates/server-ts/test.config.json b/e2e/templates/server-ts/test.config.json index 2747fb1714..f47ffdd804 100644 --- a/e2e/templates/server-ts/test.config.json +++ b/e2e/templates/server-ts/test.config.json @@ -13,7 +13,8 @@ "description": "Protected endpoint requiring payment", "requiresPayment": true, "protocolFamily": "evm", - "transferMethod": "eip3009" + "scheme": "exact", + "assetTransferMethod": "eip3009" }, { "path": "/health", @@ -36,4 +37,4 @@ ], "optional": [] } -} \ No newline at end of file +} diff --git a/e2e/templates/server/test.config.json b/e2e/templates/server/test.config.json index 33e32ba1da..7472a982bc 100644 --- a/e2e/templates/server/test.config.json +++ b/e2e/templates/server/test.config.json @@ -15,7 +15,8 @@ "description": "", "requiresPayment": true, "protocolFamily": "", - "transferMethod": " (EVM only)" + "scheme": " (EVM paid endpoints)", + "assetTransferMethod": "" }, { "path": "/health", @@ -39,4 +40,4 @@ ], "optional": [] } -} \ No newline at end of file +} diff --git a/e2e/test.ts b/e2e/test.ts index 8d1ace39de..b9f86bccd6 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -2,34 +2,42 @@ import { config } from 'dotenv'; import { spawn, execSync, ChildProcess } from 'child_process'; import { writeFileSync } from 'fs'; import { join } from 'path'; -import { createWalletClient, createPublicClient, http, parseEther, formatEther } from 'viem'; +import { createWalletClient, createPublicClient, http, parseEther, formatEther, toHex } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { base, baseSepolia } from 'viem/chains'; import { TestDiscovery } from './src/discovery'; -import { ClientConfig, ScenarioResult, ServerConfig, TestScenario } from './src/types'; +import { ClientConfig, ScenarioResult, ServerConfig, TestScenario, endpointAssetTransferMethod, endpointPaymentScheme, endpointUsesBatchSettlement } from './src/types'; import { config as loggerConfig, log, verboseLog, errorLog, close as closeLogger, createComboLogger } from './src/logger'; import { handleDiscoveryValidation, shouldRunDiscoveryValidation } from './extensions/bazaar'; import { parseArgs, printHelp } from './src/cli/args'; import { runInteractiveMode } from './src/cli/interactive'; import { filterScenarios, TestFilters, shouldShowExtensionOutput } from './src/cli/filters'; import { minimizeScenarios } from './src/sampling'; -import { getNetworkSet, NetworkMode, NetworkSet, getNetworkModeDescription } from './src/networks/networks'; +import { getNetworkSet, NetworkMode, getNetworkModeDescription, resolveEvmPermit2Asset } from './src/networks/networks'; import { GenericServerProxy } from './src/servers/generic-server'; import { Semaphore, FacilitatorLock } from './src/concurrency'; import { FacilitatorManager } from './src/facilitators/facilitator-manager'; import { waitForHealth } from './src/health'; -// Base Sepolia token addresses used by permit2 E2E tests -const USDC_BASE_SEPOLIA = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'; -const MOCK_ERC20_BASE_SEPOLIA = '0xeED520980fC7C7B4eB379B96d61CEdea2423005a'; +/** + * Generates a fresh 32-byte hex salt for a batch-settlement test scenario so + * concurrent runs don't collide on the same on-chain channel id. + * + * @returns Hex-encoded 32-byte salt prefixed with `0x`. + */ +function generateChannelSalt(): `0x${string}` { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return toHex(bytes); +} /** * Approve Permit2 so that the standard/direct settle path can be exercised. - * Grants unlimited Permit2 allowance for the given token (or USDC by default). + * Grants unlimited Permit2 allowance for the given token (permit2-approval script default if omitted). */ async function approvePermit2Approval(tokenAddress?: string): Promise { return new Promise((resolve) => { - const label = tokenAddress ? `token ${tokenAddress}` : 'USDC (default)'; + const label = tokenAddress ? `token ${tokenAddress}` : '(script default token)'; verboseLog(` 🔓 Approving Permit2 for ${label}...`); const args = ['scripts/permit2-approval.ts', 'approve']; @@ -75,12 +83,11 @@ async function approvePermit2Approval(tokenAddress?: string): Promise { /** * Revoke Permit2 approval so that gas sponsoring extensions are exercised. - * Sets the Permit2 allowance to 0 for the given token (or USDC by default), - * forcing the client into the EIP-2612 or ERC-20 approval extension path. + * Sets the Permit2 allowance to 0 for the given token (permit2-approval script default if omitted). */ async function revokePermit2Approval(tokenAddress?: string): Promise { return new Promise((resolve) => { - const label = tokenAddress ? `token ${tokenAddress}` : 'USDC (default)'; + const label = tokenAddress ? `token ${tokenAddress}` : '(script default token)'; verboseLog(` 🔓 Revoking Permit2 approval for ${label}...`); const args = ['scripts/permit2-approval.ts', 'revoke']; @@ -295,6 +302,24 @@ async function startServer( ); } +/** + * Returns true when the settle response omits the on-chain transaction hash + * because the request was settled off-chain (e.g. a batch-settlement voucher + * recorded by the receiver but not yet claimed). + * + * @param paymentResponse - Decoded payment-response payload from the server. + * @returns Whether to skip the transaction-hash presence assertion. + */ +function isOffchainSettleResponse(paymentResponse: any): boolean { + if (!paymentResponse) return false; + const extra = paymentResponse.extra ?? {}; + const channelState = extra.channelState ?? {}; + const isBatchSettlement = + (typeof extra.channelId === 'string' && extra.channelId.length > 0) || + (typeof channelState.channelId === 'string' && channelState.channelId.length > 0); + return isBatchSettlement; +} + async function runClientTest( client: any, callConfig: ClientConfig @@ -309,7 +334,6 @@ async function runClientTest( bufferLog(` 📞 Running client: ${JSON.stringify(callConfig, null, 2)}`); const result = await client.call(callConfig); bufferLog(` 📊 Client result: ${JSON.stringify(result, null, 2)}`); - // Check if the client execution succeeded if (!result.success) { return { @@ -347,8 +371,10 @@ async function runClientTest( }; } - // Payment should have a transaction hash - if (!paymentResponse.transaction) { + // Payment should have a transaction hash, except for off-chain settle + // responses (e.g. batch-settlement vouchers that the server records but + // does not yet claim on-chain). + if (!paymentResponse.transaction && !isOffchainSettleResponse(paymentResponse)) { return { success: false, error: 'Payment succeeded but no transaction hash returned', @@ -392,6 +418,61 @@ async function runClientTest( } } +type ClientTestResult = ScenarioResult & { verboseLogs?: string[] }; + +function getBatchStep(result: ClientTestResult, step: string): any { + return (result.data as any)?.batchSettlement?.[step]; +} + +function validateBatchPaymentStep( + result: ClientTestResult, + step: string, + label: string, + requireTransaction: boolean, +): string | undefined { + const stepResult = getBatchStep(result, step); + if (!stepResult) { + return `Batch-settlement ${label} result missing`; + } + + if (!stepResult.success) { + const reason = stepResult.payment_response?.errorReason || stepResult.error || 'unknown error'; + return `Batch-settlement ${label} failed: ${reason}`; + } + + const paymentResponse = stepResult.payment_response; + if (!paymentResponse) { + return `Batch-settlement ${label} missing payment response`; + } + + if (!paymentResponse.success) { + return `Batch-settlement ${label} payment failed: ${paymentResponse.errorReason || 'unknown error'}`; + } + + if (paymentResponse.errorReason) { + return `Batch-settlement ${label} payment has error reason: ${paymentResponse.errorReason}`; + } + + if (requireTransaction && !paymentResponse.transaction) { + return `Batch-settlement ${label} succeeded but no transaction hash returned`; + } + + if (!requireTransaction && !paymentResponse.transaction && !isOffchainSettleResponse(paymentResponse)) { + return `Batch-settlement ${label} succeeded but no transaction hash or channel state returned`; + } + + return undefined; +} + +function mergeVerboseLogs(...results: ClientTestResult[]): string[] { + return results.flatMap(result => result.verboseLogs ?? []); +} + +function envFlagDefaultTrue(value: string | undefined): boolean { + if (value === undefined) return true; + return !['0', 'false', 'no', 'off'].includes(value.toLowerCase()); +} + async function runTest() { // Show help if requested if (parsedArgs.showHelp) { @@ -426,6 +507,7 @@ async function runTest() { const facilitatorHederaAccountId = process.env.FACILITATOR_HEDERA_ACCOUNT_ID; const facilitatorHederaPrivateKey = process.env.FACILITATOR_HEDERA_PRIVATE_KEY; const facilitatorStellarPrivateKey = process.env.FACILITATOR_STELLAR_PRIVATE_KEY; + const batchSettlementRecovery = envFlagDefaultTrue(process.env.BATCH_SETTLEMENT_RECOVERY); if (!serverEvmAddress || !serverSvmAddress || !clientEvmPrivateKey || !clientSvmPrivateKey || !facilitatorEvmPrivateKey || !facilitatorSvmPrivateKey) { errorLog('❌ Missing required environment variables:'); errorLog(' SERVER_EVM_ADDRESS, SERVER_SVM_ADDRESS, CLIENT_EVM_PRIVATE_KEY, CLIENT_SVM_PRIVATE_KEY, FACILITATOR_EVM_PRIVATE_KEY, and FACILITATOR_SVM_PRIVATE_KEY must be set'); @@ -497,9 +579,17 @@ async function runTest() { // Get network configuration based on selected mode const networks = getNetworkSet(networkMode); + const evmPermit2Asset = resolveEvmPermit2Asset(networks); + + const permit2AssetSource = process.env.EVM_PERMIT2_ASSET?.trim() + ? 'EVM_PERMIT2_ASSET' + : networks.evm.permit2Asset + ? 'network default' + : 'unset'; log(`\n🌐 Network Mode: ${networkMode.toUpperCase()}`); log(` EVM: ${networks.evm.name} (${networks.evm.caip2})`); + log(` EVM Permit2 asset: ${evmPermit2Asset || '(missing)'} (${permit2AssetSource})`); log(` SVM: ${networks.svm.name} (${networks.svm.caip2})`); log(` APTOS: ${networks.aptos.name} (${networks.aptos.caip2})`); log(` HEDERA: ${networks.hedera.name} (${networks.hedera.caip2})`); @@ -540,37 +630,108 @@ async function runTest() { // Branch coverage assertions for EVM scenarios const evmScenarios = filteredScenarios.filter(s => s.protocolFamily === 'evm'); if (evmScenarios.length > 0) { - const hasEip3009 = evmScenarios.some(s => (s.endpoint.transferMethod || 'eip3009') === 'eip3009'); - const hasPermit2 = evmScenarios.some(s => s.endpoint.transferMethod === 'permit2'); - const hasPermit2Direct = evmScenarios.some(s => s.endpoint.transferMethod === 'permit2' && s.endpoint.permit2Direct === true); - const hasPermit2Eip2612 = evmScenarios.some(s => s.endpoint.transferMethod === 'permit2' && !s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring') && !s.endpoint.permit2Direct); - const hasPermit2Erc20 = evmScenarios.some(s => s.endpoint.transferMethod === 'permit2' && s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring')); + const hasExactEip3009 = evmScenarios.some( + s => endpointPaymentScheme(s.endpoint) === 'exact' && endpointAssetTransferMethod(s.endpoint) === 'eip3009', + ); + const hasExactPermit2 = evmScenarios.some( + s => endpointPaymentScheme(s.endpoint) === 'exact' && endpointAssetTransferMethod(s.endpoint) === 'permit2', + ); + const hasPermit2Direct = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'exact' && + endpointAssetTransferMethod(s.endpoint) === 'permit2' && + s.endpoint.schemeOptions?.permit2Direct === true, + ); + const hasPermit2Eip2612 = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'exact' && + endpointAssetTransferMethod(s.endpoint) === 'permit2' && + !s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring') && + s.endpoint.schemeOptions?.permit2Direct !== true, + ); + const hasPermit2Erc20 = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'exact' && + endpointAssetTransferMethod(s.endpoint) === 'permit2' && + s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring'), + ); + + const hasUpto = evmScenarios.some(s => endpointPaymentScheme(s.endpoint) === 'upto'); + const hasUptoDirect = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'upto' && s.endpoint.schemeOptions?.permit2Direct === true, + ); + const hasUptoEip2612 = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'upto' && + !s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring') && + s.endpoint.schemeOptions?.permit2Direct !== true, + ); + const hasUptoErc20 = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'upto' && + s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring'), + ); - const hasUpto = evmScenarios.some(s => s.endpoint.transferMethod === 'upto'); - const hasUptoDirect = evmScenarios.some(s => s.endpoint.transferMethod === 'upto' && s.endpoint.permit2Direct === true); - const hasUptoEip2612 = evmScenarios.some(s => s.endpoint.transferMethod === 'upto' && !s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring') && !s.endpoint.permit2Direct); - const hasUptoErc20 = evmScenarios.some(s => s.endpoint.transferMethod === 'upto' && s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring')); + const hasBatchSettlementEip3009 = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'batch-settlement' && + endpointAssetTransferMethod(s.endpoint) === 'eip3009', + ); + const hasBatchSettlementPermit2 = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'batch-settlement' && + endpointAssetTransferMethod(s.endpoint) === 'permit2', + ); + const hasBatchSettlementPermit2Direct = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'batch-settlement' && + endpointAssetTransferMethod(s.endpoint) === 'permit2' && + s.endpoint.schemeOptions?.permit2Direct === true, + ); + const hasBatchSettlementPermit2Eip2612 = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'batch-settlement' && + endpointAssetTransferMethod(s.endpoint) === 'permit2' && + !s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring') && + s.endpoint.schemeOptions?.permit2Direct !== true, + ); + const hasBatchSettlementPermit2Erc20 = evmScenarios.some( + s => + endpointPaymentScheme(s.endpoint) === 'batch-settlement' && + endpointAssetTransferMethod(s.endpoint) === 'permit2' && + s.endpoint.extensions?.includes('erc20ApprovalGasSponsoring'), + ); log('🔍 EVM Branch Coverage Check:'); - log(` EIP-3009 route: ${hasEip3009 ? '✅' : '❌ MISSING'}`); - log(` Permit2 route: ${hasPermit2 ? '✅' : '❌ MISSING'}`); - log(` Permit2+direct settle: ${hasPermit2Direct ? '✅' : '⚠️ not found'}`); - log(` Permit2+EIP2612 route: ${hasPermit2Eip2612 ? '✅' : '⚠️ not found (may be covered by permit2 route if eip2612 extension enabled)'}`); - log(` Permit2+ERC20 route: ${hasPermit2Erc20 ? '✅' : '⚠️ not found'}`); - log(` Upto route: ${hasUpto ? '✅' : '⚠️ not found'}`); - log(` Upto+direct settle: ${hasUptoDirect ? '✅' : '⚠️ not found'}`); - log(` Upto+EIP2612 route: ${hasUptoEip2612 ? '✅' : '⚠️ not found'}`); - log(` Upto+ERC20 route: ${hasUptoErc20 ? '✅' : '⚠️ not found'}`); + log(` Exact EIP-3009 route: ${hasExactEip3009 ? '✅' : '⚠️ not found'}`); + log(` Exact Permit2 route: ${hasExactPermit2 ? '✅' : '⚠️ not found'}`); + log(` Exact Permit2+direct settle: ${hasPermit2Direct ? '✅' : '⚠️ not found'}`); + log(` Exact Permit2+EIP2612 route: ${hasPermit2Eip2612 ? '✅' : '⚠️ not found (may be covered by permit2 route if eip2612 extension enabled)'}`); + log(` Exact Permit2+ERC20 route: ${hasPermit2Erc20 ? '✅' : '⚠️ not found'}`); + log(` Upto route: ${hasUpto ? '✅' : '⚠️ not found'}`); + log(` Upto+direct settle: ${hasUptoDirect ? '✅' : '⚠️ not found'}`); + log(` Upto+EIP2612 route: ${hasUptoEip2612 ? '✅' : '⚠️ not found'}`); + log(` Upto+ERC20 route: ${hasUptoErc20 ? '✅' : '⚠️ not found'}`); + log(` Batch-settlement EIP-3009: ${hasBatchSettlementEip3009 ? '✅' : '⚠️ not found'}`); + log(` Batch-settlement Permit2: ${hasBatchSettlementPermit2 ? '✅' : '⚠️ not found'}`); + log(` Batch-settlement+direct: ${hasBatchSettlementPermit2Direct ? '✅' : '⚠️ not found'}`); + log(` Batch-settlement+EIP2612: ${hasBatchSettlementPermit2Eip2612 ? '✅' : '⚠️ not found'}`); + log(` Batch-settlement+ERC20: ${hasBatchSettlementPermit2Erc20 ? '✅' : '⚠️ not found'}`); log(''); } // Auto-detect Permit2 scenarios (upto uses Permit2 under the hood) - const hasPermit2Scenarios = filteredScenarios.some( - (s) => s.endpoint.transferMethod === 'permit2' || s.endpoint.transferMethod === 'upto' - ); + const hasPermit2Scenarios = filteredScenarios.some(s => endpointAssetTransferMethod(s.endpoint) === 'permit2'); if (hasPermit2Scenarios) { log('🔐 Permit2 scenarios detected — revoke before gas-sponsored tests, approve before permit2-direct tests'); + if (!evmPermit2Asset) { + errorLog( + '❌ Permit2 scenarios need a token address: set EVM_PERMIT2_ASSET or networks.evm.permit2Asset for this mode.', + ); + process.exit(1); + } } // Collect unique facilitators and servers @@ -660,6 +821,8 @@ async function runTest() { passed: boolean; error?: string; transaction?: string; + depositTransaction?: string; + refundTransaction?: string; network?: string; } @@ -803,7 +966,9 @@ async function runTest() { const facilitatorLabel = scenario.facilitator ? ` via ${scenario.facilitator.name}` : ''; const testName = `${scenario.client.name} → ${scenario.server.name} → ${scenario.endpoint.path}${facilitatorLabel}`; - const clientConfig: ClientConfig = { + const isBatchSettlement = endpointUsesBatchSettlement(scenario.endpoint); + const voucherSignerPrivateKey = process.env.CLIENT_EVM_VOUCHER_SIGNER_PRIVATE_KEY; + const baseClientConfig: ClientConfig = { evmPrivateKey: clientEvmPrivateKey!, svmPrivateKey: clientSvmPrivateKey!, avmPrivateKey: clientAvmPrivateKey || '', @@ -821,7 +986,155 @@ async function runTest() { try { cLog.log(`🧪 Test #${localTestNumber}: ${testName}`); - const result = await runClientTest(scenario.client.proxy, clientConfig); + + if (isBatchSettlement) { + const channelSalt = generateChannelSalt(); + const batchBase = { + channelSalt, + ...(voucherSignerPrivateKey ? { voucherSignerPrivateKey } : {}), + }; + + if (!batchSettlementRecovery) { + const fullResult = await runClientTest(scenario.client.proxy, { + ...baseClientConfig, + batchSettlement: { ...batchBase, phase: 'full' }, + }); + const fullError = fullResult.success + ? validateBatchPaymentStep(fullResult, 'deposit', 'deposit', true) || + validateBatchPaymentStep(fullResult, 'voucher', 'voucher', false) || + validateBatchPaymentStep(fullResult, 'refund', 'refund', true) + : fullResult.error || 'Batch-settlement client phase failed'; + + const depositTransaction = getBatchStep(fullResult, 'deposit')?.payment_response?.transaction; + const refundTransaction = getBatchStep(fullResult, 'refund')?.payment_response?.transaction; + const network = + getBatchStep(fullResult, 'refund')?.payment_response?.network || + getBatchStep(fullResult, 'deposit')?.payment_response?.network || + fullResult.payment_response?.network; + + const detailedResult: DetailedTestResult = { + testNumber: localTestNumber, + client: scenario.client.name, + server: scenario.server.name, + endpoint: scenario.endpoint.path, + facilitator: scenario.facilitator?.name || 'none', + protocolFamily: scenario.protocolFamily, + passed: !fullError, + error: fullError, + transaction: refundTransaction || depositTransaction, + depositTransaction, + refundTransaction, + network, + }; + + if (fullError) { + cLog.log(` ❌ Test failed: ${fullError}`); + const verboseLogs = fullResult.verboseLogs ?? []; + if (verboseLogs.length > 0) { + cLog.log(` 🔍 Verbose logs:`); + verboseLogs.forEach(logLine => cLog.log(logLine)); + } + cLog.verboseLog(` 🔍 Error details: ${JSON.stringify(fullResult, null, 2)}`); + } else { + cLog.log(` ✅ Test passed`); + } + + return detailedResult; + } + + const initialResult = await runClientTest(scenario.client.proxy, { + ...baseClientConfig, + batchSettlement: { ...batchBase, phase: 'initial' }, + }); + const initialError = initialResult.success + ? validateBatchPaymentStep(initialResult, 'deposit', 'deposit', true) || + validateBatchPaymentStep(initialResult, 'voucher', 'voucher', false) + : initialResult.error || 'Initial batch-settlement client phase failed'; + + if (initialError) { + const detailedResult: DetailedTestResult = { + testNumber: localTestNumber, + client: scenario.client.name, + server: scenario.server.name, + endpoint: scenario.endpoint.path, + facilitator: scenario.facilitator?.name || 'none', + protocolFamily: scenario.protocolFamily, + passed: false, + error: initialError, + depositTransaction: getBatchStep(initialResult, 'deposit')?.payment_response?.transaction, + network: initialResult.payment_response?.network, + }; + cLog.log(` ❌ Test failed: ${initialError}`); + const verboseLogs = initialResult.verboseLogs ?? []; + if (verboseLogs.length > 0) { + cLog.log(` 🔍 Verbose logs:`); + verboseLogs.forEach(logLine => cLog.log(logLine)); + } + cLog.verboseLog(` 🔍 Error details: ${JSON.stringify(initialResult, null, 2)}`); + return detailedResult; + } + + const recoveryResult = await runClientTest(scenario.client.proxy, { + ...baseClientConfig, + batchSettlement: { ...batchBase, phase: 'recovery-refund' }, + }); + const recoveryError = recoveryResult.success + ? validateBatchPaymentStep(recoveryResult, 'recoveryVoucher', 'recovery voucher', false) || + validateBatchPaymentStep(recoveryResult, 'refund', 'refund', true) + : recoveryResult.error || 'Recovery/refund batch-settlement client phase failed'; + + const depositTransaction = getBatchStep(initialResult, 'deposit')?.payment_response?.transaction; + const refundTransaction = getBatchStep(recoveryResult, 'refund')?.payment_response?.transaction; + const network = + getBatchStep(recoveryResult, 'refund')?.payment_response?.network || + getBatchStep(initialResult, 'deposit')?.payment_response?.network || + recoveryResult.payment_response?.network || + initialResult.payment_response?.network; + + if (recoveryError) { + const detailedResult: DetailedTestResult = { + testNumber: localTestNumber, + client: scenario.client.name, + server: scenario.server.name, + endpoint: scenario.endpoint.path, + facilitator: scenario.facilitator?.name || 'none', + protocolFamily: scenario.protocolFamily, + passed: false, + error: recoveryError, + transaction: refundTransaction || depositTransaction, + depositTransaction, + refundTransaction, + network, + }; + cLog.log(` ❌ Test failed: ${recoveryError}`); + const verboseLogs = mergeVerboseLogs(initialResult, recoveryResult); + if (verboseLogs.length > 0) { + cLog.log(` 🔍 Verbose logs:`); + verboseLogs.forEach(logLine => cLog.log(logLine)); + } + cLog.verboseLog(` 🔍 Error details: ${JSON.stringify({ initialResult, recoveryResult }, null, 2)}`); + return detailedResult; + } + + const detailedResult: DetailedTestResult = { + testNumber: localTestNumber, + client: scenario.client.name, + server: scenario.server.name, + endpoint: scenario.endpoint.path, + facilitator: scenario.facilitator?.name || 'none', + protocolFamily: scenario.protocolFamily, + passed: true, + transaction: refundTransaction || depositTransaction, + depositTransaction, + refundTransaction, + network, + }; + + cLog.log(` ✅ Test passed`); + return detailedResult; + } + + const result = await runClientTest(scenario.client.proxy, baseClientConfig); const detailedResult: DetailedTestResult = { testNumber: localTestNumber, @@ -906,8 +1219,8 @@ async function runTest() { aptosPayTo: facilitatorSupportsAptos ? (serverAptosAddress || '') : '', hederaPayTo: facilitatorSupportsHedera && - facilitatorHederaAccountId && - facilitatorHederaPrivateKey + facilitatorHederaAccountId && + facilitatorHederaPrivateKey ? (serverHederaAddress || '') : '', hederaAsset: process.env.HEDERA_ASSET, @@ -916,6 +1229,16 @@ async function runTest() { networks, facilitatorUrl, mockFacilitatorUrl, + // Forward the optional receiver-authorizer EOA key so the server can + // self-manage batch-settlement claim/refund signatures when set. + ...(process.env.SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY + ? { + batchSettlement: { + receiverAuthorizerPrivateKey: + process.env.SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY, + }, + } + : {}), }; const started = await startServer(serverProxy, serverConfig); @@ -944,9 +1267,9 @@ async function runTest() { const tn = nextTestNumber(); const isEvm = scenario.protocolFamily === 'evm'; - if (scenario.endpoint.permit2Direct) { - await approvePermit2Approval(USDC_BASE_SEPOLIA); - } else if (scenario.endpoint.coldstart) { + if (scenario.endpoint.schemeOptions?.permit2Direct === true) { + await approvePermit2Approval(evmPermit2Asset); + } else if (scenario.endpoint.schemeOptions?.coldstart === true) { // Key on (client, path) so each client independently runs its own // fund → revoke → drain cycle. Without the client name, the second // client in a combo silently skips the coldstart and inherits @@ -954,14 +1277,10 @@ async function runTest() { const endpointKey = `${scenario.client.name}::${scenario.endpoint.path}`; if (!coldStartedEndpoints.has(endpointKey)) { coldStartedEndpoints.add(endpointKey); - const token = - scenario.endpoint.extensions?.includes('erc20ApprovalGasSponsoring') - ? MOCK_ERC20_BASE_SEPOLIA - : USDC_BASE_SEPOLIA; await fundClientForRevoke(); // Give fund tx 1s to propagate before submitting revoke (from client wallet) await new Promise(resolve => setTimeout(resolve, 1000)); - await revokePermit2Approval(token); + await revokePermit2Approval(evmPermit2Asset); // Give revoke tx 2s to propagate before drain reads pending nonce. // Load-balanced RPCs can return a stale pending nonce if queried // immediately after the revoke submission, causing the drain to @@ -1100,7 +1419,13 @@ async function runTest() { if (test.network) { log(` Network: ${test.network}`); } - if (test.transaction) { + if (test.depositTransaction) { + log(` Deposit Tx: ${test.depositTransaction}`); + } + if (test.refundTransaction) { + log(` Refund Tx: ${test.refundTransaction}`); + } + if (test.transaction && !test.depositTransaction && !test.refundTransaction) { log(` Tx: ${test.transaction}`); } }); diff --git a/examples/typescript/clients/batch-settlement/.env-local b/examples/typescript/clients/batch-settlement/.env-local new file mode 100644 index 0000000000..3d9888254f --- /dev/null +++ b/examples/typescript/clients/batch-settlement/.env-local @@ -0,0 +1,12 @@ +RESOURCE_SERVER_URL=http://localhost:4021 +ENDPOINT_PATH=/api/weather + +EVM_PRIVATE_KEY= +EVM_VOUCHER_SIGNER_PRIVATE_KEY= +CHANNEL_SALT= +STORAGE_DIR_DIR= + +DEPOSIT_MULTIPLIER= +NUMBER_OF_REQUESTS= +REFUND_AFTER_REQUESTS= +REFUND_AMOUNT= diff --git a/examples/typescript/clients/batch-settlement/.prettierignore b/examples/typescript/clients/batch-settlement/.prettierignore new file mode 100644 index 0000000000..5bd240ba90 --- /dev/null +++ b/examples/typescript/clients/batch-settlement/.prettierignore @@ -0,0 +1,8 @@ +docs/ +dist/ +node_modules/ +coverage/ +.github/ +src/client +**/**/*.json +*.md \ No newline at end of file diff --git a/examples/typescript/clients/batch-settlement/.prettierrc b/examples/typescript/clients/batch-settlement/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/clients/batch-settlement/.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/clients/batch-settlement/README.md b/examples/typescript/clients/batch-settlement/README.md new file mode 100644 index 0000000000..f5508eb833 --- /dev/null +++ b/examples/typescript/clients/batch-settlement/README.md @@ -0,0 +1,84 @@ +# Batch-Settlement Client Example + +Fetch-based client that pays for a sequence of requests over a single payment channel using the **batch-settlement** EVM scheme. The first request opens the channel with a deposit; subsequent requests pay with a fresh cumulative voucher. + + +See the [scheme specification](../../../../specs/schemes/batch-settlement/scheme_batch_settlement_evm.md) and the [scheme README](../../../../typescript/packages/mechanisms/evm/src/batch-settlement/README.md) for protocol details. + +## Voucher Signer Delegation + +By default, vouchers are signed by the same key as the payer (`EVM_PRIVATE_KEY`). Set `EVM_VOUCHER_SIGNER_PRIVATE_KEY` to delegate voucher signing to a dedicated EOA — its address is committed into the channel as the `payerAuthorizer`. + +```typescript +const voucherSigner = toClientEvmSigner(privateKeyToAccount(VOUCHER_KEY)); +const scheme = new BatchSettlementEvmScheme(signer, { voucherSigner }); +``` + +Use this when: + +- The payer key should only sign deposit authorizations. +- The payer is a smart wallet (EIP-1271). Delegating to an EOA voucher signer lets the facilitator verify vouchers with ECDSA recovery instead of an onchain `isValidSignature` call. + +## Deposit policy + +Use `depositStrategy` for app-specific deposit decisions. The strategy can: + +- **`undefined`** — use the SDK default (`depositAmount` in context). +- **`false`** — skip this deposit attempt. +- **Base-unit string or `bigint`** — custom amount; must be **≥ `minimumDepositAmount`** or the scheme throws. + +```typescript +const maxDeposit = 1_000_000n; + +const scheme = new BatchSettlementEvmScheme(signer, { + depositPolicy: { depositMultiplier }, + depositStrategy: ({ depositAmount }) => { + const amount = BigInt(depositAmount); + return amount > maxDeposit ? maxDeposit : undefined; + }, +}); +``` + +## Prerequisites + +- Node.js v20+, pnpm v10 +- A running [batch-settlement server](../../servers/batch-settlement) +- A funded EVM `EVM_PRIVATE_KEY` holding the deposit token (USDC on Base Sepolia by default) + +## Setup + +```bash +cp .env-local .env +# fill EVM_PRIVATE_KEY (and optionally EVM_VOUCHER_SIGNER_PRIVATE_KEY) + +cd ../../ +pnpm install && pnpm build +cd clients/batch-settlement + +pnpm start +``` + +## Concurrent requests + +Use the concurrent example to send requests over multiple channels in parallel. Each slot uses a unique salt derived from `CHANNEL_SALT`, so the server can serialize work per channel while still processing channels concurrently. + +```bash +CONCURRENCY=3 NUMBER_OF_ROUNDS=3 pnpm dev:concurrent +``` + +## Environment + +| Variable | Required | Description | +|----------|----------|-------------| +| `EVM_PRIVATE_KEY` | yes | Payer key (funds the deposit) | +| `EVM_VOUCHER_SIGNER_PRIVATE_KEY` | no | Dedicated voucher-signing EOA (committed as `payerAuthorizer`) | +| `RESOURCE_SERVER_URL` | no | Server base URL (default `http://localhost:4021`) | +| `ENDPOINT_PATH` | no | Path on the server (default `/weather`) | +| `CHANNEL_SALT` | no | `bytes32` salt for channel id; change to open a fresh channel | +| `DEPOSIT_MULTIPLIER` | no | Per-request deposit is payment amount × this multiplier (must be integer **≥ 3**; default `5`) | +| `STORAGE_DIR` | no | Persist client session state (defaults to in-memory) | +| `NUMBER_OF_REQUESTS` | no | How many paid requests to issue (default 3) | +| `CONCURRENCY` | no | How many channels to run in parallel in `pnpm dev:concurrent` (default 3) | +| `NUMBER_OF_ROUNDS` | no | How many concurrent rounds to run in `pnpm dev:concurrent` (default 3) | +| `REFUND_AFTER_REQUESTS` | no | If `true`, issue a self-contained refund via `scheme.refund(url)` after the request loop | +| `REFUND_AMOUNT` | no | Partial refund amount in base units; omit for a full refund | diff --git a/examples/typescript/clients/batch-settlement/eslint.config.js b/examples/typescript/clients/batch-settlement/eslint.config.js new file mode 100644 index 0000000000..ca28b5c47f --- /dev/null +++ b/examples/typescript/clients/batch-settlement/eslint.config.js @@ -0,0 +1,72 @@ +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", + 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/clients/batch-settlement/index.ts b/examples/typescript/clients/batch-settlement/index.ts new file mode 100644 index 0000000000..874c8158ac --- /dev/null +++ b/examples/typescript/clients/batch-settlement/index.ts @@ -0,0 +1,103 @@ +import { toClientEvmSigner } from "@x402/evm"; +import { + BatchSettlementEvmScheme, + FileClientChannelStorage, +} from "@x402/evm/batch-settlement/client"; +import { x402Client, wrapFetchWithPayment, x402HTTPClient } from "@x402/fetch"; +import { config } from "dotenv"; +import { createPublicClient, http } from "viem"; +import { baseSepolia } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; + +config(); + +const evmPrivateKey = process.env.EVM_PRIVATE_KEY as `0x${string}`; +const evmVoucherSignerPrivateKey = process.env.EVM_VOUCHER_SIGNER_PRIVATE_KEY as + | `0x${string}` + | undefined; +const baseURL = process.env.RESOURCE_SERVER_URL || "http://localhost:4021"; +const endpointPath = process.env.ENDPOINT_PATH || "/weather"; +const url = `${baseURL}${endpointPath}`; +const storageDir = process.env.STORAGE_DIR ?? process.env.STORAGE_DIR_DIR; +const channelSalt = (process.env.CHANNEL_SALT ?? + "0x0000000000000000000000000000000000000000000000000000000000000000") as `0x${string}`; +const numberOfRequests = Number(process.env.NUMBER_OF_REQUESTS ?? "3"); +const refundAfterRequests = process.env.REFUND_AFTER_REQUESTS === "true"; +const refundAmount = process.env.REFUND_AMOUNT; +const depositMultiplier = Number(process.env.DEPOSIT_MULTIPLIER ?? "5"); + +/** + * Runs sequential paid requests against the configured resource server endpoint. + * + * @returns Resolves after all configured requests complete. + */ +async function main(): Promise { + const account = privateKeyToAccount(evmPrivateKey); + const publicClient = createPublicClient({ + chain: baseSepolia, + transport: http(), + }); + const signer = toClientEvmSigner(account, publicClient); + + const voucherSigner = + evmVoucherSignerPrivateKey !== undefined + ? toClientEvmSigner(privateKeyToAccount(evmVoucherSignerPrivateKey)) + : undefined; + + const batchedScheme = new BatchSettlementEvmScheme(signer, { + depositPolicy: { + depositMultiplier, + }, + salt: channelSalt, + ...(voucherSigner ? { voucherSigner } : {}), + ...(storageDir ? { storage: new FileClientChannelStorage({ directory: storageDir }) } : {}), + }); + + const client = new x402Client(); + client.register("eip155:*", batchedScheme); + + const fetchWithPayment = wrapFetchWithPayment(fetch, client); + const httpClient = new x402HTTPClient(client); + + console.log(`Base URL: ${baseURL}, endpoint: ${endpointPath}`); + console.log("payer:", signer.address); + console.log("payerAuthorizer:", voucherSigner?.address ?? signer.address, "\n"); + + for (let i = 0; i < numberOfRequests; i++) { + const requestT0 = performance.now(); + + const response = await fetchWithPayment(url, { method: "GET" }); + const result = await httpClient.processResponse(response); + + if (result.kind === "success") { + console.log(`Request ${i + 1} — RESPONSE`); + console.log(result.body); + console.log(JSON.stringify(result.settleResponse, null, 2)); + } else { + console.log(`Request ${i + 1} — ${result.kind}`); + console.log(JSON.stringify(result, null, 2)); + } + console.log( + `Request ${i + 1} — completed in ${((performance.now() - requestT0) / 1000).toFixed(3)}s\n`, + ); + } + + if (refundAfterRequests) { + console.log( + refundAmount + ? `REQUESTING PARTIAL REFUND of ${refundAmount} base units` + : "REQUESTING FULL REFUND of remaining channel balance", + ); + const refundT0 = performance.now(); + const settle = await batchedScheme.refund(url, { + ...(refundAmount ? { amount: refundAmount } : {}), + }); + console.log(JSON.stringify(settle, null, 2)); + console.log(`Refund completed in ${((performance.now() - refundT0) / 1000).toFixed(3)}s`); + } +} + +main().catch(error => { + console.error(error?.response?.data?.error ?? error); + process.exit(1); +}); diff --git a/examples/typescript/clients/batch-settlement/package.json b/examples/typescript/clients/batch-settlement/package.json new file mode 100644 index 0000000000..974609b045 --- /dev/null +++ b/examples/typescript/clients/batch-settlement/package.json @@ -0,0 +1,33 @@ +{ + "name": "@x402/batch-settlement-client-example", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts", + "dev": "tsx index.ts", + "dev:concurrent": "tsx concurrent.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", + "lint:check": "eslint . --ext .ts" + }, + "dependencies": { + "@x402/evm": "workspace:*", + "@x402/fetch": "workspace:*", + "dotenv": "^16.4.7", + "viem": "^2.39.0" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^20.0.0", + "@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.7.0", + "typescript": "^5.3.0" + } +} diff --git a/examples/typescript/clients/batch-settlement/tsconfig.json b/examples/typescript/clients/batch-settlement/tsconfig.json new file mode 100644 index 0000000000..78f9479b1b --- /dev/null +++ b/examples/typescript/clients/batch-settlement/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": ["index.ts"] +} diff --git a/examples/typescript/facilitator/advanced/README.md b/examples/typescript/facilitator/advanced/README.md index cf44505d28..17cefc2b85 100644 --- a/examples/typescript/facilitator/advanced/README.md +++ b/examples/typescript/facilitator/advanced/README.md @@ -48,10 +48,10 @@ pnpm dev:gas-extensions # exact + upto with EIP-2612 and ERC-20 approval gas spo Each example demonstrates a specific advanced pattern: -| Example | Command | Description | -| -------------- | ----------------------- | -------------------------------------------------------- | -| `all-networks` | `pnpm dev:all-networks` | All supported networks with optional chain configuration | -| `bazaar` | `pnpm dev:bazaar` | Bazaar discovery extension for cataloging x402 resources | +| Example | Command | Description | +| ---------------- | ------------------------- | ------------------------------------------------------------------------- | +| `all-networks` | `pnpm dev:all-networks` | All supported networks with optional chain configuration | +| `bazaar` | `pnpm dev:bazaar` | Bazaar discovery extension for cataloging x402 resources | | `gas_extensions` | `pnpm dev:gas-extensions` | Base Sepolia `exact` + `upto` with both Permit2 gas-sponsoring extensions | ## API Endpoints diff --git a/examples/typescript/facilitator/advanced/gas_extensions.ts b/examples/typescript/facilitator/advanced/gas_extensions.ts index db757403ab..207740ca0c 100644 --- a/examples/typescript/facilitator/advanced/gas_extensions.ts +++ b/examples/typescript/facilitator/advanced/gas_extensions.ts @@ -117,13 +117,18 @@ facilitator.register(EVM_NETWORK, new UptoEvmScheme(evmSigner)); const erc20ApprovalSigner = { ...evmSigner, sendTransactions: async ( - transactions: (`0x${string}` | { to: `0x${string}`; data: `0x${string}`; gas?: bigint })[], + transactions: ( + | `0x${string}` + | { to: `0x${string}`; data: `0x${string}`; gas?: bigint } + )[], ): Promise<`0x${string}`[]> => { const hashes: `0x${string}`[] = []; for (const tx of transactions) { let hash: `0x${string}`; if (typeof tx === "string") { - hash = await viemClient.sendRawTransaction({ serializedTransaction: tx }); + hash = await viemClient.sendRawTransaction({ + serializedTransaction: tx, + }); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any hash = await viemClient.sendTransaction(tx as any); @@ -140,7 +145,9 @@ const erc20ApprovalSigner = { facilitator .registerExtension(EIP2612_GAS_SPONSORING) - .registerExtension(createErc20ApprovalGasSponsoringExtension(erc20ApprovalSigner)); + .registerExtension( + createErc20ApprovalGasSponsoringExtension(erc20ApprovalSigner), + ); const app = express(); app.use(express.json()); diff --git a/examples/typescript/facilitator/batch-settlement/.env-local b/examples/typescript/facilitator/batch-settlement/.env-local new file mode 100644 index 0000000000..d2d2af18bc --- /dev/null +++ b/examples/typescript/facilitator/batch-settlement/.env-local @@ -0,0 +1,3 @@ +PORT= +EVM_PRIVATE_KEY= +EVM_RPC_URL= \ No newline at end of file diff --git a/examples/typescript/facilitator/batch-settlement/README.md b/examples/typescript/facilitator/batch-settlement/README.md new file mode 100644 index 0000000000..a38cbdb7e3 --- /dev/null +++ b/examples/typescript/facilitator/batch-settlement/README.md @@ -0,0 +1,68 @@ +# Batch-Settlement Facilitator Example + +Express.js facilitator for the **batch-settlement** EVM scheme on Base Sepolia. It exposes standard x402 facilitator endpoints and submits the batch-settlement contract calls. + +See the [scheme specification](../../../../specs/schemes/batch-settlement/scheme_batch_settlement_evm.md) and the [scheme README](../../../../typescript/packages/mechanisms/evm/src/batch-settlement/README.md) for protocol details. + +## Two Signer Roles + +This example can use separate keys for relaying transactions and authorizing receiver actions: + +| Env var | Role | Onchain effect | +| ------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `EVM_PRIVATE_KEY` | **Relayer** — submits transactions | Pays gas for `deposit` / `claimWithSignature` / `settle` / `refundWithSignature` | +| `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` | **Receiver authorizer** — signs `ClaimBatch` and `Refund` EIP-712 messages | Address is committed into the channel identity for any server that delegates to this facilitator | + +If `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` is omitted, the relayer key is reused for both roles. In production, keep them separate so the receiver-authorizer key (which controls how much gets claimed) can be rotated independently of the gas-paying hot wallet. + +> The receiver-authorizer address is advertised under `kinds[].extra.receiverAuthorizer` in `GET /supported`. **Servers that delegate authorization to this facilitator bind that address into their channel config** — rotating the authorizer key requires opening new channels, so treat this address as long-lived. + +## Prerequisites + +- Node.js v20+, pnpm v10 +- Base Sepolia ETH on the **relayer** address (gas) +- Optional: a separate funded address for the **authorizer** (no gas required if relayer is separate) + +## Setup + +```bash +cp .env-local .env +# fill EVM_PRIVATE_KEY (and optionally EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY, EVM_RPC_URL) + +cd ../../ +pnpm install && pnpm build +cd facilitator/batch-settlement + +pnpm dev +``` + +The facilitator listens on `http://localhost:4022` by default (`PORT` env var to override). + +## API Surface + +Standard x402 facilitator endpoints: `POST /verify`, `POST /settle`, `GET /supported`. The `/settle` endpoint dispatches on `payload.type`: + +| Payload type | Triggered by | Contract call / effect | +| ------------ | ----------------------------- | ----------------------------------------------- | +| `deposit` | First request or top-up | Funds the channel via EIP-3009 or Permit2 | +| `claim` | Server batches voucher claims | Calls `claimWithSignature` (no transfer) | +| `settle` | Server sweeps unsettled funds | Calls `settle` to transfer claimed funds | +| `refund` | Cooperative refund | Calls `refundWithSignature` for unclaimed funds | + +`/verify` and `/settle` always return the onchain channel snapshot (`balance`, `totalClaimed`, `withdrawRequestedAt`, `refundNonce`) in the `extra` field — the resource server mirrors these into its session state. + +`GET /supported` advertises the receiver authorizer address: + +```json +{ + "kinds": [ + { + "x402Version": 2, + "scheme": "batch-settlement", + "network": "eip155:84532", + "extra": { "receiverAuthorizer": "0x..." } + } + ], + "signers": { "eip155:*": ["0x..."] } +} +``` diff --git a/examples/typescript/facilitator/batch-settlement/eslint.config.js b/examples/typescript/facilitator/batch-settlement/eslint.config.js new file mode 100644 index 0000000000..784ecd5435 --- /dev/null +++ b/examples/typescript/facilitator/batch-settlement/eslint.config.js @@ -0,0 +1,75 @@ +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", + 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/facilitator/batch-settlement/index.ts b/examples/typescript/facilitator/batch-settlement/index.ts new file mode 100644 index 0000000000..ace44172aa --- /dev/null +++ b/examples/typescript/facilitator/batch-settlement/index.ts @@ -0,0 +1,219 @@ +import { x402Facilitator } from "@x402/core/facilitator"; +import { + PaymentPayload, + PaymentRequirements, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import { type AuthorizerSigner, toFacilitatorEvmSigner } from "@x402/evm"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/facilitator"; +import dotenv from "dotenv"; +import express from "express"; +import { createWalletClient, http, nonceManager, publicActions } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; + +dotenv.config(); + +// Configuration +const PORT = process.env.PORT || "4022"; + +// Validate required environment variables +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; + +const receiverAuthorizerPrivateKey = + process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY ?? + process.env.EVM_PRIVATE_KEY; + +// Initialize the EVM account from private key (submits transactions) +const evmAccount = privateKeyToAccount( + process.env.EVM_PRIVATE_KEY as `0x${string}`, + { nonceManager }, +); + +// Dedicated receiverAuthorizer (signs ClaimBatch / Refund EIP-712 messages) +const authorizerAccount = privateKeyToAccount( + receiverAuthorizerPrivateKey as `0x${string}`, +); +const authorizerSigner: AuthorizerSigner = { + address: authorizerAccount.address, + signTypedData: (params) => + authorizerAccount.signTypedData( + params as Parameters[0], + ), +}; + +console.info(`EVM Facilitator account: ${evmAccount.address}`); +console.info(`EVM Receiver Authorizer: ${authorizerSigner.address}`); + +// Create a Viem client with both wallet and public capabilities +const viemClient = createWalletClient({ + account: evmAccount, + chain: baseSepolia, + transport: http(evmRpcUrl), +}).extend(publicActions); + +// Initialize the x402 Facilitator with EVM support +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]), + 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() + .onBeforeVerify(async (context) => { + console.log("Before verify", context); + }) + .onAfterVerify(async (context) => { + console.log("After verify", context); + }) + .onVerifyFailure(async (context) => { + console.log("Verify failure", context); + }) + .onBeforeSettle(async (context) => { + console.log("Before settle", context); + }) + .onAfterSettle(async (context) => { + console.log("After settle", context); + }) + .onSettleFailure(async (context) => { + console.log("Settle failure", context); + }); + +// Register EVM schemes (batched: deposit / voucher / claim / settle) +facilitator.register( + "eip155:84532", + new BatchSettlementEvmScheme(evmSigner, authorizerSigner), +); // Base Sepolia + +// Initialize Express app +const app = express(); +app.use(express.json()); + +/** + * POST /verify + * Verify a payment against requirements + * + * Note: Payment tracking and bazaar discovery are handled by lifecycle hooks + */ +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", + }); + } + + // Hooks will automatically: + // - Track verified payment (onAfterVerify) + // - Extract and catalog discovery info (onAfterVerify) + 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 + * + * Note: Verification validation and cleanup are handled by lifecycle hooks + */ +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", + }); + } + + // Hooks will automatically: + // - Validate payment was verified (onBeforeSettle - will abort if not) + // - Check verification timeout (onBeforeSettle) + // - Clean up tracking (onAfterSettle / onSettleFailure) + const response: SettleResponse = await facilitator.settle( + paymentPayload as PaymentPayload, + paymentRequirements as PaymentRequirements, + ); + + res.json(response); + } catch (error) { + console.error("Settle error:", error); + + // Check if this was an abort from hook + if ( + error instanceof Error && + error.message.includes("Settlement aborted:") + ) { + // Return a proper SettleResponse instead of 500 error + 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", + }); + } +}); + +// Start the server +app.listen(parseInt(PORT), () => { + console.log(`🚀 Facilitator listening on http://localhost:${PORT}`); + console.log(); +}); diff --git a/examples/typescript/facilitator/batch-settlement/package.json b/examples/typescript/facilitator/batch-settlement/package.json new file mode 100644 index 0000000000..612abc07e8 --- /dev/null +++ b/examples/typescript/facilitator/batch-settlement/package.json @@ -0,0 +1,35 @@ +{ + "name": "@x402/batch-settlement-facilitator-typescript", + "version": "2.0.0", + "type": "module", + "private": true, + "scripts": { + "start": "tsx index.ts", + "dev": "tsx watch index.ts", + "build": "tsc", + "lint": "eslint .", + "format": "prettier --write .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@x402/core": "workspace:*", + "@x402/evm": "workspace:*", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "viem": "^2.21.54" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/express": "^4.17.21", + "@types/node": "^22.10.1", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "eslint": "^9.15.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-prettier": "^5.2.6", + "prettier": "^3.3.3", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} diff --git a/examples/typescript/facilitator/batch-settlement/tsconfig.json b/examples/typescript/facilitator/batch-settlement/tsconfig.json new file mode 100644 index 0000000000..fc0e5250ed --- /dev/null +++ b/examples/typescript/facilitator/batch-settlement/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist" + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/.env-local b/examples/typescript/fullstack/next-batch-settlement-redis/.env-local new file mode 100644 index 0000000000..a2afa8e790 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/.env-local @@ -0,0 +1,4 @@ +EVM_ADDRESS= +EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= +FACILITATOR_URL=https://x402.org/facilitator +REDIS_URL= \ No newline at end of file diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/.prettierignore b/examples/typescript/fullstack/next-batch-settlement-redis/.prettierignore new file mode 100644 index 0000000000..406cfdb763 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/.prettierignore @@ -0,0 +1,9 @@ +docs/ +dist/ +node_modules/ +coverage/ +.github/ +.next/ +src/client +**/**/*.json +*.md \ No newline at end of file diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/.prettierrc b/examples/typescript/fullstack/next-batch-settlement-redis/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/.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/fullstack/next-batch-settlement-redis/README.md b/examples/typescript/fullstack/next-batch-settlement-redis/README.md new file mode 100644 index 0000000000..32ca906ba4 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/README.md @@ -0,0 +1,77 @@ +# Next.js batch-settlement (Redis storage) + +Next.js demo that exposes **`GET /api/weather`** behind `withX402` with the **batch-settlement** scheme. Channel state uses **`RedisChannelStorage`** (`@x402/evm/batch-settlement/server`), backed by the **`redis`** npm client via `lib/redisChannelClient.ts`. + +Parallels: + +- Response shape / `withX402` usage: `examples/typescript/fullstack/next/app/api/weather/route.ts` +- Batch-settlement Express example wiring: `examples/typescript/servers/batch-settlement/index.ts` +- Batch-settlement client: `examples/typescript/clients/batch-settlement` + +## Prerequisites + +- Node.js 20+, pnpm 10 +- Redis reachable from the app (`REDIS_URL`) +- A facilitator URL and receiver address (same variables as other examples) + +## Setup + +From `examples/typescript`: + +```bash +pnpm install && pnpm build +cd fullstack/next-batch-settlement-redis +``` + +Copy `.env-local` to `.env` (or create `.env`) and set: + +| Variable | Required | Description | +|----------|----------|-------------| +| `FACILITATOR_URL` | yes | Facilitator HTTP endpoint | +| `EVM_ADDRESS` | yes | Receiver `0x…` address | +| `REDIS_URL` | yes | e.g. `redis://127.0.0.1:6379` | +| `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` | no | Local receiver-authorizer signer (omit to use facilitator) | +| `DEFERRED_WITHDRAW_DELAY_SECONDS` | no | Defaults to `108000` (30 hours) | +| `CRON_SECRET` | no | Bearer token required by cron routes when set | + +```bash +pnpm dev +``` + +Paid endpoint: **`GET /api/weather`**. + +## Cron Jobs + +Run cron jobs locally before deploying: + +```bash +pnpm cron:claim +pnpm cron:settle +pnpm cron:claim-and-settle +``` + +`cron:claim` claims up to 100 vouchers per facilitator transaction. `cron:settle` settles already-claimed funds. `cron:claim-and-settle` does both in one operation and skips settlement when there are no claim transactions. + +The shared cron helpers accept the same `selectClaimChannels` option as the background `ChannelManager` runner, so custom jobs can claim a subset without duplicating claim batching or signing logic: + +```typescript +await runClaimAndSettleCron({ + maxClaimsPerBatch: 100, + selectClaimChannels: channels => + channels.filter(channel => channel.withdrawRequestedAt > 0), +}); +``` + +Vercel deployment uses `vercel.json` to call **`GET /api/cron/claim-and-settle`** once per day at `02:00 UTC`, which is compatible with Vercel Hobby cron limits. Set `CRON_SECRET` in Vercel to require `Authorization: Bearer ` for manual calls. + +With a daily cron, `DEFERRED_WITHDRAW_DELAY_SECONDS` must exceed the daily cadence plus Vercel's hourly scheduling precision and operational safety margin. The default 30-hour delay is intended for this deployment shape. More frequent claim, settle, or refund policies require Vercel Pro cron frequency or an external scheduler. + +## Files + +- `lib/server.ts` — facilitator client, `BatchSettlementEvmScheme` + `RedisChannelStorage` +- `lib/cron.ts` — shared claim, settle, and claim-and-settle cron implementations +- `lib/cronAuth.ts` — optional bearer-token check for cron routes +- `lib/redisChannelClient.ts` — lazy `redis` adapter implementing `RedisChannelStorageClient` +- `app/api/weather/route.ts` — `withX402` + batch-settlement (weather JSON, discovery extension) +- `app/api/cron/*/route.ts` — claim, settle, and claim-and-settle cron routes +- `scripts/cron.ts` — local cron runner diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/app/api/cron/claim-and-settle/route.ts b/examples/typescript/fullstack/next-batch-settlement-redis/app/api/cron/claim-and-settle/route.ts new file mode 100644 index 0000000000..592dcb1d44 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/app/api/cron/claim-and-settle/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { authorizeCronRequest } from "../../../../lib/cronAuth"; +import { runClaimAndSettleCron } from "../../../../lib/cron"; + +/** + * Runs the scheduled batch-settlement claim-and-settle job. + * + * @param request - Incoming cron request. + * @returns JSON claim-and-settle summary. + */ +export async function GET(request: NextRequest) { + const unauthorized = authorizeCronRequest(request); + if (unauthorized) { + return unauthorized; + } + + const summary = await runClaimAndSettleCron({ maxClaimsPerBatch: 100 }); + return NextResponse.json(summary); +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/app/api/cron/claim/route.ts b/examples/typescript/fullstack/next-batch-settlement-redis/app/api/cron/claim/route.ts new file mode 100644 index 0000000000..c6cb6ccc19 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/app/api/cron/claim/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { authorizeCronRequest } from "../../../../lib/cronAuth"; +import { runClaimCron } from "../../../../lib/cron"; + +/** + * Runs the scheduled batch-settlement claim job. + * + * @param request - Incoming cron request. + * @returns JSON claim summary. + */ +export async function GET(request: NextRequest) { + const unauthorized = authorizeCronRequest(request); + if (unauthorized) { + return unauthorized; + } + + const summary = await runClaimCron({ maxClaimsPerBatch: 100 }); + return NextResponse.json(summary); +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/app/api/cron/settle/route.ts b/examples/typescript/fullstack/next-batch-settlement-redis/app/api/cron/settle/route.ts new file mode 100644 index 0000000000..f6ac12f4b6 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/app/api/cron/settle/route.ts @@ -0,0 +1,20 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { authorizeCronRequest } from "../../../../lib/cronAuth"; +import { runSettleCron } from "../../../../lib/cron"; + +/** + * Runs the scheduled batch-settlement settle job. + * + * @param request - Incoming cron request. + * @returns JSON settle summary. + */ +export async function GET(request: NextRequest) { + const unauthorized = authorizeCronRequest(request); + if (unauthorized) { + return unauthorized; + } + + const summary = await runSettleCron(); + return NextResponse.json(summary); +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/app/api/weather/route.ts b/examples/typescript/fullstack/next-batch-settlement-redis/app/api/weather/route.ts new file mode 100644 index 0000000000..1824ef7255 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/app/api/weather/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withX402 } from "@x402/next"; +import { declareDiscoveryExtension } from "@x402/extensions/bazaar"; + +import { evmAddress, NETWORK, server } from "../../../lib/server"; + +const price = "$0.001"; + +/** + * Weather API handler for the batch-settlement Next example (API-only; no paywall HTML). + * + * @param _ - Incoming Next.js request + * @returns JSON response with weather data + */ +const handler = async (_: NextRequest) => { + return NextResponse.json( + { + report: { + weather: "sunny", + temperature: 72, + }, + }, + { status: 200 }, + ); +}; + +/** + * Protected weather API using `withX402` and batch-settlement (mirrors `fullstack/next` weather shape). + */ +export const GET = withX402( + handler, + { + accepts: [ + { + scheme: "batch-settlement", + price, + network: NETWORK, + payTo: evmAddress, + }, + ], + description: "Access to weather API", + mimeType: "application/json", + extensions: { + ...declareDiscoveryExtension({ + output: { + example: { + report: { + weather: "sunny", + temperature: 72, + }, + }, + }, + }), + }, + }, + server, +); diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/app/layout.tsx b/examples/typescript/fullstack/next-batch-settlement-redis/app/layout.tsx new file mode 100644 index 0000000000..297cd55346 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "x402 batch-settlement (Next.js API)", +}; + +/** + * Minimal root layout — this example is intended for `GET /api/weather` only. + */ +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/app/page.tsx b/examples/typescript/fullstack/next-batch-settlement-redis/app/page.tsx new file mode 100644 index 0000000000..ce39c96c06 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/app/page.tsx @@ -0,0 +1,6 @@ +/** + * Placeholder root page. Use `GET /api/weather` with the batch-settlement client example. + */ +export default function Home() { + return null; +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/eslint.config.js b/examples/typescript/fullstack/next-batch-settlement-redis/eslint.config.js new file mode 100644 index 0000000000..841134dc29 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/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/**", ".next/**", "next-env.d.ts"], + }, + { + 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/fullstack/next-batch-settlement-redis/lib/cron.ts b/examples/typescript/fullstack/next-batch-settlement-redis/lib/cron.ts new file mode 100644 index 0000000000..98ab163535 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/lib/cron.ts @@ -0,0 +1,87 @@ +import type { AutoSettlementContext, Channel } from "@x402/evm/batch-settlement/server"; + +import { channelManager } from "./server"; + +export interface ClaimCronOptions { + maxClaimsPerBatch?: number; + idleSecs?: number; + selectClaimChannels?: ( + channels: Channel[], + context: AutoSettlementContext, + ) => Channel[] | Promise; +} + +export type ClaimAndSettleCronOptions = ClaimCronOptions; + +export interface ClaimCronSummary { + claimBatches: number; + vouchers: number; + claimTransactions: string[]; +} + +export interface SettleCronSummary { + settleTransaction: string; +} + +export interface ClaimAndSettleCronSummary { + claimBatches: number; + vouchers: number; + claimTransactions: string[]; + settleTransaction?: string; +} + +/** + * Claims pending vouchers in cron-friendly batches. + * + * @param opts - Optional cron execution settings. + * @param opts.maxClaimsPerBatch - Max vouchers per facilitator claim transaction. + * @returns Compact claim summary. + */ +export async function runClaimCron(opts?: ClaimCronOptions): Promise { + const claims = await channelManager.claim({ + ...opts, + maxClaimsPerBatch: opts?.maxClaimsPerBatch ?? 100, + }); + + return { + claimBatches: claims.length, + vouchers: claims.reduce((total, claim) => total + claim.vouchers, 0), + claimTransactions: claims.map(claim => claim.transaction), + }; +} + +/** + * Settles already-claimed funds to the receiver. + * + * @returns Compact settle summary. + */ +export async function runSettleCron(): Promise { + const settle = await channelManager.settle(); + + return { + settleTransaction: settle.transaction, + }; +} + +/** + * Claims pending vouchers and settles them in one cron-friendly operation. + * + * @param opts - Optional cron execution settings. + * @param opts.maxClaimsPerBatch - Max vouchers per facilitator claim transaction. + * @returns Compact claim-and-settle summary. + */ +export async function runClaimAndSettleCron( + opts?: ClaimAndSettleCronOptions, +): Promise { + const { claims, settle } = await channelManager.claimAndSettle({ + ...opts, + maxClaimsPerBatch: opts?.maxClaimsPerBatch ?? 100, + }); + + return { + claimBatches: claims.length, + vouchers: claims.reduce((total, claim) => total + claim.vouchers, 0), + claimTransactions: claims.map(claim => claim.transaction), + ...(settle ? { settleTransaction: settle.transaction } : {}), + }; +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/lib/cronAuth.ts b/examples/typescript/fullstack/next-batch-settlement-redis/lib/cronAuth.ts new file mode 100644 index 0000000000..e5f452044e --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/lib/cronAuth.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; + +/** + * Checks the optional bearer secret used by cron endpoints. + * + * @param request - Incoming cron request. + * @returns Unauthorized response when the configured secret is missing or invalid. + */ +export function authorizeCronRequest(request: NextRequest): NextResponse | undefined { + const cronSecret = process.env.CRON_SECRET; + + if (!cronSecret) { + return undefined; + } + + const authorization = request.headers.get("authorization"); + if (authorization !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + return undefined; +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/lib/redisChannelClient.ts b/examples/typescript/fullstack/next-batch-settlement-redis/lib/redisChannelClient.ts new file mode 100644 index 0000000000..fc5047be46 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/lib/redisChannelClient.ts @@ -0,0 +1,96 @@ +import type { + RedisChannelStorageClient, + RedisEvalOptions, + RedisScanOptions, + RedisSetOptions, +} from "@x402/evm/batch-settlement/server"; +import { createClient } from "redis"; + +export type LazyRedisChannelStorageClient = RedisChannelStorageClient & { + /** Close the TCP connection */ + disconnect: () => Promise; +}; + +/** + * Wraps a lazily connected node-redis client as {@link RedisChannelStorageClient}. + * + * @param url - Redis connection URL (same shape as `REDIS_URL` / `redis://…`). + * @returns An adapter compatible with Redis-backed batch-settlement channel storage. + */ +export function createLazyRedisChannelStorageClient(url: string): LazyRedisChannelStorageClient { + const connect = async () => { + const client = createClient({ url }); + client.on("error", err => { + console.error("Redis client error:", err); + }); + await client.connect(); + return client; + }; + + let connecting: Promise>> | undefined; + + const ensureClient = () => { + if (!connecting) connecting = connect(); + return connecting; + }; + + const normalizeRedisString = (value: string | Buffer | null): string | null => { + if (value == null) return null; + return typeof value === "string" ? value : value.toString("utf8"); + }; + + const normalizeScanKey = (key: string | Buffer): string => + typeof key === "string" ? key : key.toString("utf8"); + + return { + disconnect: async () => { + if (!connecting) return; + try { + const c = await connecting; + if (c.isOpen) await c.quit(); + } catch { + // connect or quit failed — still drop the handle so the process can exit + } finally { + connecting = undefined; + } + }, + get: key => + ensureClient() + .then(c => c.get(key)) + .then(normalizeRedisString), + set: (key, value, opts?: RedisSetOptions) => + ensureClient() + .then(c => { + if (opts?.NX) { + return c.set(key, value, { + NX: true, + ...(opts.PX !== undefined ? { PX: opts.PX } : {}), + }); + } + if (opts?.PX !== undefined) { + return c.set(key, value, { PX: opts.PX }); + } + return c.set(key, value); + }) + .then(normalizeRedisString), + del: key => + ensureClient() + .then(c => c.del(key)) + .then(n => Number(n)), + eval: (script, options: RedisEvalOptions) => ensureClient().then(c => c.eval(script, options)), + scanIterator(options: RedisScanOptions): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + const c = await ensureClient(); + for await (const chunk of c.scanIterator(options)) { + if (Array.isArray(chunk)) { + yield chunk.map(normalizeScanKey); + continue; + } + yield normalizeScanKey(chunk); + } + }, + }; + }, + }; +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/lib/server.ts b/examples/typescript/fullstack/next-batch-settlement-redis/lib/server.ts new file mode 100644 index 0000000000..2e8ce234cc --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/lib/server.ts @@ -0,0 +1,56 @@ +import { HTTPFacilitatorClient, x402ResourceServer } from "@x402/core/server"; +import { BatchSettlementEvmScheme, RedisChannelStorage } from "@x402/evm/batch-settlement/server"; +import { privateKeyToAccount } from "viem/accounts"; + +import { createLazyRedisChannelStorageClient } from "./redisChannelClient"; + +export const NETWORK = "eip155:84532" as const; + +const facilitatorUrl = process.env.FACILITATOR_URL; +const evmAddress = process.env.EVM_ADDRESS as `0x${string}`; +const redisUrl = process.env.REDIS_URL; + +const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY as + | `0x${string}` + | undefined; + +const withdrawDelay = Number(process.env.DEFERRED_WITHDRAW_DELAY_SECONDS ?? "108000"); + +if (!facilitatorUrl) { + console.error("Missing required FACILITATOR_URL environment variable"); + process.exit(1); +} + +if (!evmAddress || !/^0x[0-9a-fA-F]{40}$/.test(evmAddress)) { + console.error("Missing or invalid EVM_ADDRESS (checksummed 20-byte hex, 0x-prefixed)"); + process.exit(1); +} + +if (!redisUrl) { + console.error("Missing required REDIS_URL environment variable"); + process.exit(1); +} + +const receiverAuthorizerSigner = receiverAuthorizerPrivateKey + ? privateKeyToAccount(receiverAuthorizerPrivateKey) + : undefined; + +export const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); + +const redisAdapter = createLazyRedisChannelStorageClient(redisUrl); + +export const batchedScheme = new BatchSettlementEvmScheme(evmAddress, { + ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), + withdrawDelay, + storage: new RedisChannelStorage({ client: redisAdapter }), +}); + +export const server = new x402ResourceServer(facilitatorClient).register(NETWORK, batchedScheme); +export const channelManager = batchedScheme.createChannelManager(facilitatorClient, NETWORK); + +/** Release the Redis connection (required for CLI cron scripts to exit). */ +export async function disconnectRedisChannelStorage(): Promise { + await redisAdapter.disconnect(); +} + +export { evmAddress }; diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/next-env.d.ts b/examples/typescript/fullstack/next-batch-settlement-redis/next-env.d.ts new file mode 100644 index 0000000000..9edff1c7ca --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/next.config.ts b/examples/typescript/fullstack/next-batch-settlement-redis/next.config.ts new file mode 100644 index 0000000000..cb651cdc00 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/package.json b/examples/typescript/fullstack/next-batch-settlement-redis/package.json new file mode 100644 index 0000000000..a774f6c05b --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/package.json @@ -0,0 +1,45 @@ +{ + "name": "@x402/next-batch-settlement-redis-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "cron:claim": "tsx scripts/cron.ts claim", + "cron:settle": "tsx scripts/cron.ts settle", + "cron:claim-and-settle": "tsx scripts/cron.ts claim-and-settle", + "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/extensions": "workspace:*", + "@x402/next": "workspace:*", + "next": "^16.0.10", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "redis": "^5.12.1", + "viem": "^2.39.3" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^22.13.4", + "@types/react": "^19", + "@types/react-dom": "^19", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "eslint": "^9.24.0", + "eslint-config-next": "16.0.6", + "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" + } +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/public/favicon.ico b/examples/typescript/fullstack/next-batch-settlement-redis/public/favicon.ico new file mode 100644 index 0000000000..ce91c36638 Binary files /dev/null and b/examples/typescript/fullstack/next-batch-settlement-redis/public/favicon.ico differ diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/public/site.webmanifest b/examples/typescript/fullstack/next-batch-settlement-redis/public/site.webmanifest new file mode 100644 index 0000000000..ee6a32d92d --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/public/site.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "x402 Next.js Demo", + "short_name": "x402 Demo", + "icons": [ + { + "src": "/x402-icon-black.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/x402-icon-black.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#000000", + "background_color": "#ffffff", + "display": "standalone" +} \ No newline at end of file diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/public/x402-icon-black.png b/examples/typescript/fullstack/next-batch-settlement-redis/public/x402-icon-black.png new file mode 100644 index 0000000000..ce91c36638 Binary files /dev/null and b/examples/typescript/fullstack/next-batch-settlement-redis/public/x402-icon-black.png differ diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/public/x402-icon-blue.png b/examples/typescript/fullstack/next-batch-settlement-redis/public/x402-icon-blue.png new file mode 100644 index 0000000000..88254d78cd Binary files /dev/null and b/examples/typescript/fullstack/next-batch-settlement-redis/public/x402-icon-blue.png differ diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/public/x402-logo-dark.png b/examples/typescript/fullstack/next-batch-settlement-redis/public/x402-logo-dark.png new file mode 100644 index 0000000000..f45c3c9522 Binary files /dev/null and b/examples/typescript/fullstack/next-batch-settlement-redis/public/x402-logo-dark.png differ diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/scripts/cron.ts b/examples/typescript/fullstack/next-batch-settlement-redis/scripts/cron.ts new file mode 100644 index 0000000000..63db3dbf12 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/scripts/cron.ts @@ -0,0 +1,42 @@ +import { existsSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +for (const file of [".env.local", ".env"]) { + const path = resolve(packageDir, file); + if (!existsSync(path)) continue; + + for (const line of readFileSync(path, "utf8").split("\n")) { + const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/); + if (!match || match[1].startsWith("#")) continue; + + const value = (match[2] ?? "").replace(/^['"]|['"]$/g, ""); + process.env[match[1]] ??= value; + } +} + +const command = process.argv[2]; +const [{ runClaimAndSettleCron, runClaimCron, runSettleCron }, { disconnectRedisChannelStorage }] = + await Promise.all([import("../lib/cron"), import("../lib/server")]); + +let summary; +switch (command) { + case "claim": + summary = await runClaimCron({ maxClaimsPerBatch: 100 }); + break; + case "settle": + summary = await runSettleCron(); + break; + case "claim-and-settle": + summary = await runClaimAndSettleCron({ maxClaimsPerBatch: 100 }); + break; +} + +if (!summary) { + console.error("Usage: tsx scripts/cron.ts "); + process.exit(1); +} + +console.log(JSON.stringify(summary, null, 2)); +await disconnectRedisChannelStorage(); diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/tsconfig.json b/examples/typescript/fullstack/next-batch-settlement-redis/tsconfig.json new file mode 100644 index 0000000000..2ee3a66608 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": [ + "types/**/*.ts", + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/turbo.json b/examples/typescript/fullstack/next-batch-settlement-redis/turbo.json new file mode 100644 index 0000000000..572dd61f42 --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turborepo.org/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": [".next/**", "!.next/cache/**"] + } + } +} diff --git a/examples/typescript/fullstack/next-batch-settlement-redis/vercel.json b/examples/typescript/fullstack/next-batch-settlement-redis/vercel.json new file mode 100644 index 0000000000..dbbd41c10b --- /dev/null +++ b/examples/typescript/fullstack/next-batch-settlement-redis/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/cron/claim-and-settle", + "schedule": "0 2 * * *" + } + ] +} diff --git a/examples/typescript/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index 9bf90d3795..1aafaabdcb 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -13,7 +13,7 @@ importers: version: 1.0.1 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.1) turbo: specifier: ^2.5.0 version: 2.5.6 @@ -315,7 +315,7 @@ importers: version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) fastify: specifier: ^5.0.0 - version: 5.8.4 + version: 5.8.2 prettier: specifier: 3.5.2 version: 3.5.2 @@ -437,7 +437,7 @@ importers: version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) hono: specifier: ^4.7.1 - version: 4.9.2 + version: 4.10.7 prettier: specifier: 3.5.2 version: 3.5.2 @@ -945,19 +945,19 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.1) typescript: specifier: ^5.7.3 version: 5.9.2 vite: specifier: ^6.2.6 - version: 6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1) + version: 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) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1)) + version: 5.1.4(typescript@5.9.2)(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)) vitest: specifier: ^3.0.5 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/stellar: dependencies: @@ -1203,6 +1203,107 @@ importers: specifier: ^5.3.0 version: 5.9.2 + clients/batch-settlement: + dependencies: + '@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.39.0 + version: 2.43.5(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@4.1.13) + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/node': + specifier: ^20.0.0 + version: 20.19.11 + '@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.2))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + 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.2))(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-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: ^4.7.0 + version: 4.20.4 + typescript: + specifier: ^5.3.0 + version: 5.9.2 + + clients/batch-settlement-streaming2: + 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.39.0 + version: 2.43.5(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@4.1.13) + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/node': + specifier: ^20.0.0 + version: 20.19.11 + '@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.2))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + 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.2))(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-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: ^4.7.0 + version: 4.21.0 + typescript: + specifier: ^5.3.0 + version: 5.9.2 + clients/custom: dependencies: '@scure/base': @@ -1390,7 +1491,7 @@ importers: version: 16.6.1 openai: specifier: ^4.77.3 - version: 4.104.0(encoding@0.1.13)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + version: 4.104.0(encoding@0.1.13)(ws@8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) viem: specifier: ^2.39.0 version: 2.43.5(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) @@ -1665,10 +1766,10 @@ importers: version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) eslint-plugin-prettier: specifier: ^5.2.6 - version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.6.2) prettier: specifier: ^3.3.3 - version: 3.5.2 + version: 3.6.2 tsx: specifier: ^4.19.2 version: 4.20.4 @@ -1702,6 +1803,61 @@ importers: viem: specifier: ^2.21.54 version: 2.43.5(bufferutil@4.0.9)(typescript@5.9.2)(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.10.1 + 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.2))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + eslint: + specifier: ^9.15.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.2))(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-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.6.2) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + tsx: + specifier: ^4.19.2 + version: 4.20.4 + typescript: + specifier: ^5.7.2 + version: 5.9.2 + + facilitator/batch-settlement: + dependencies: + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + express: + specifier: ^4.19.2 + version: 4.21.2 + viem: + specifier: ^2.21.54 + version: 2.43.5(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@4.1.13) devDependencies: '@eslint/js': specifier: ^9.24.0 @@ -1904,6 +2060,79 @@ importers: specifier: ^5 version: 5.9.2 + fullstack/next-batch-settlement-redis: + dependencies: + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + '@x402/extensions': + specifier: workspace:* + version: link:../../../../typescript/packages/extensions + '@x402/next': + specifier: workspace:* + version: link:../../../../typescript/packages/http/next + next: + specifier: ^16.0.10 + version: 16.0.10(@babel/core@7.28.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + redis: + specifier: ^5.12.1 + version: 5.12.1 + viem: + specifier: ^2.39.3 + version: 2.43.5(bufferutil@4.0.9)(typescript@5.9.2)(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 + '@types/react': + specifier: ^19 + version: 19.2.1 + '@types/react-dom': + specifier: ^19 + version: 19.2.1(@types/react@19.2.1) + '@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.2))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-config-next: + specifier: 16.0.6 + version: 16.0.6(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + 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.2))(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-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(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 + version: 5.9.2 + servers/advanced: dependencies: '@x402/avm': @@ -1974,6 +2203,125 @@ importers: specifier: ^5.3.0 version: 5.9.2 + servers/batch-settlement: + 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.39.3 + version: 2.43.5(bufferutil@4.0.9)(typescript@5.9.2)(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 + '@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.2))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + 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.2))(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-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsup: + specifier: ^7.2.0 + version: 7.3.0(postcss@8.5.6)(typescript@5.9.2) + tsx: + specifier: ^4.7.0 + version: 4.20.4 + typescript: + specifier: ^5.3.0 + version: 5.9.2 + + servers/batch-settlement-streaming2: + 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 + openai: + specifier: ^4.0.0 + version: 4.104.0(encoding@0.1.13)(ws@8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76) + viem: + specifier: ^2.39.3 + version: 2.43.5(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.25.76) + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/express': + specifier: ^5.0.1 + version: 5.0.3 + '@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.2))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) + 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.2))(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-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsup: + specifier: ^7.2.0 + version: 7.3.0(postcss@8.5.6)(typescript@5.9.2) + tsx: + specifier: ^4.7.0 + version: 4.21.0 + typescript: + specifier: ^5.3.0 + version: 5.9.2 + servers/bazaar: dependencies: '@x402/core': @@ -2169,7 +2517,7 @@ importers: version: 16.6.1 fastify: specifier: ^5.0.0 - version: 5.8.4 + version: 5.8.2 devDependencies: '@eslint/js': specifier: ^9.24.0 @@ -2209,7 +2557,7 @@ importers: dependencies: '@hono/node-server': specifier: ^1.14.0 - version: 1.19.0(hono@4.9.2) + version: 1.19.0(hono@4.10.7) '@x402/core': specifier: workspace:* version: link:../../../../typescript/packages/core @@ -2230,7 +2578,7 @@ importers: version: 16.6.1 hono: specifier: ^4.7.1 - version: 4.9.2 + version: 4.10.7 devDependencies: '@eslint/js': specifier: ^9.24.0 @@ -2865,6 +3213,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.19.12': resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} engines: {node: '>=12'} @@ -2877,6 +3231,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.19.12': resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} engines: {node: '>=12'} @@ -2889,6 +3249,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.19.12': resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} engines: {node: '>=12'} @@ -2901,6 +3267,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.19.12': resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} engines: {node: '>=12'} @@ -2913,6 +3285,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.19.12': resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} engines: {node: '>=12'} @@ -2925,6 +3303,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.19.12': resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} engines: {node: '>=12'} @@ -2937,6 +3321,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.19.12': resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} engines: {node: '>=12'} @@ -2949,6 +3339,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.19.12': resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} engines: {node: '>=12'} @@ -2961,6 +3357,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.19.12': resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} engines: {node: '>=12'} @@ -2973,6 +3375,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.19.12': resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} engines: {node: '>=12'} @@ -2985,6 +3393,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.19.12': resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} engines: {node: '>=12'} @@ -2997,6 +3411,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.19.12': resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} engines: {node: '>=12'} @@ -3009,6 +3429,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.19.12': resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} engines: {node: '>=12'} @@ -3021,6 +3447,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.19.12': resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} engines: {node: '>=12'} @@ -3033,6 +3465,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.19.12': resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} engines: {node: '>=12'} @@ -3045,6 +3483,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.19.12': resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} engines: {node: '>=12'} @@ -3057,12 +3501,24 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.9': resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.19.12': resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} engines: {node: '>=12'} @@ -3075,12 +3531,24 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.9': resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.19.12': resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} engines: {node: '>=12'} @@ -3093,12 +3561,24 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.9': resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.19.12': resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} engines: {node: '>=12'} @@ -3111,6 +3591,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.19.12': resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} engines: {node: '>=12'} @@ -3123,6 +3609,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.19.12': resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} engines: {node: '>=12'} @@ -3135,6 +3627,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.19.12': resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} engines: {node: '>=12'} @@ -3147,6 +3645,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.7.0': resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -3642,7 +4146,6 @@ packages: '@metamask/sdk-communication-layer@0.32.0': resolution: {integrity: sha512-dmj/KFjMi1fsdZGIOtbhxdg3amxhKL/A5BqSU4uh/SyDKPub/OT+x5pX8bGjpTL1WPWY/Q0OIlvFyX3VWnT06Q==} - deprecated: No longer maintained, superseded by https://docs.metamask.io/metamask-connect peerDependencies: cross-fetch: ^4.0.0 eciesjs: '*' @@ -3662,7 +4165,6 @@ packages: '@metamask/sdk-install-modal-web@0.32.0': resolution: {integrity: sha512-TFoktj0JgfWnQaL3yFkApqNwcaqJ+dw4xcnrJueMP3aXkSNev2Ido+WVNOg4IIMxnmOrfAC9t0UJ0u/dC9MjOQ==} - deprecated: No longer maintained, superseded by https://docs.metamask.io/metamask-connect '@metamask/sdk-install-modal-web@0.32.1': resolution: {integrity: sha512-MGmAo6qSjf1tuYXhCu2EZLftq+DSt5Z7fsIKr2P+lDgdTPWgLfZB1tJKzNcwKKOdf6q9Qmmxn7lJuI/gq5LrKw==} @@ -3670,7 +4172,6 @@ packages: '@metamask/sdk@0.32.0': resolution: {integrity: sha512-WmGAlP1oBuD9hk4CsdlG1WJFuPtYJY+dnTHJMeCyohTWD2GgkcLMUUuvu9lO1/NVzuOoSi1OrnjbuY1O/1NZ1g==} - deprecated: No longer maintained, superseded by https://docs.metamask.io/metamask-connect '@metamask/sdk@0.33.1': resolution: {integrity: sha512-1mcOQVGr9rSrVcbKPNVzbZ8eCl1K0FATsYH3WJ/MH4WcZDWGECWrXJPNMZoEAkLxWiMe8jOQBumg2pmcDa9zpQ==} @@ -4283,6 +4784,42 @@ packages: '@types/react': optional: true + '@redis/bloom@5.12.1': + resolution: {integrity: sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@redis/client': ^5.12.1 + + '@redis/client@5.12.1': + resolution: {integrity: sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + + '@redis/json@5.12.1': + resolution: {integrity: sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@redis/client': ^5.12.1 + + '@redis/search@5.12.1': + resolution: {integrity: sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@redis/client': ^5.12.1 + + '@redis/time-series@5.12.1': + resolution: {integrity: sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==} + engines: {node: '>= 18.19.0'} + peerDependencies: + '@redis/client': ^5.12.1 + '@reown/appkit-common@1.7.8': resolution: {integrity: sha512-ridIhc/x6JOp7KbDdwGKY4zwf8/iK8EYBl+HtWrruutSLwZyVi5P8WaZa+8iajL6LcDcDF7LoyLwMTym7SRuwQ==} @@ -6204,7 +6741,6 @@ packages: '@walletconnect/ethereum-provider@2.21.1': resolution: {integrity: sha512-SSlIG6QEVxClgl1s0LMk4xr2wg4eT3Zn/Hb81IocyqNSGfXpjtawWxKxiC5/9Z95f1INyBD6MctJbL/R1oBwIw==} - deprecated: 'Reliability and performance improvements. See: https://github.com/WalletConnect/walletconnect-monorepo/releases' '@walletconnect/events@1.0.1': resolution: {integrity: sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==} @@ -6569,8 +7105,8 @@ packages: axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} - axios@1.13.6: - resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + axios@1.13.4: + resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -6795,6 +7331,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -7214,6 +7754,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -7505,8 +8050,8 @@ packages: fastestsmallesttextencoderdecoder@1.0.22: resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} - fastify@5.8.4: - resolution: {integrity: sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==} + fastify@5.8.2: + resolution: {integrity: sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==} fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -7608,10 +8153,6 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} - engines: {node: '>= 6'} - formdata-node@4.4.1: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} @@ -7800,10 +8341,6 @@ packages: resolution: {integrity: sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw==} engines: {node: '>=16.9.0'} - hono@4.9.2: - resolution: {integrity: sha512-UG2jXGS/gkLH42l/1uROnwXpkjvvxkl3kpopL3LBo27NuaDPI6xHNfuUSilIHcrBkPfl4y0z6y2ByI455TjNRw==} - engines: {node: '>=16.9.0'} - html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -8377,9 +8914,6 @@ packages: lute-connect@1.7.0: resolution: {integrity: sha512-/eXb2/c/xltKyVEVWchd1QZB6F0fvgXwVIqXDQWeJ9unPo0kMMbtuLkeb1v4Kr1lffxX8uGnb+8kAMYjczUASg==} - magic-string@0.30.17: - resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -9063,6 +9597,11 @@ packages: engines: {node: '>=14'} hasBin: true + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -9279,6 +9818,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redis@5.12.1: + resolution: {integrity: sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==} + engines: {node: '>= 18.19.0'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -9768,10 +10311,6 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -9907,6 +10446,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + turbo-darwin-64@2.5.6: resolution: {integrity: sha512-3C1xEdo4aFwMJAPvtlPqz1Sw/+cddWIOmsalHFMrsqqydcptwBfu26WW2cDm3u93bUzMbBJ8k3zNKFqxJ9ei2A==} cpu: [x64] @@ -10331,7 +10875,6 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -10897,8 +11440,8 @@ snapshots: '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10) abitype: 1.0.6(typescript@5.9.2)(zod@3.25.76) - axios: 1.13.6 - axios-retry: 4.5.0(axios@1.13.6) + axios: 1.13.4 + axios-retry: 4.5.0(axios@1.13.4) jose: 6.0.12 md5: 2.3.0 uncrypto: 0.1.3 @@ -10920,8 +11463,8 @@ snapshots: '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.9.2)(utf-8-validate@5.0.10) abitype: 1.0.6(typescript@5.9.2)(zod@3.25.76) - axios: 1.13.6 - axios-retry: 4.5.0(axios@1.13.6) + axios: 1.13.4 + axios-retry: 4.5.0(axios@1.13.4) jose: 6.0.12 md5: 2.3.0 uncrypto: 0.1.3 @@ -11098,7 +11641,7 @@ snapshots: '@es-joy/jsdoccomment@0.50.2': dependencies: '@types/estree': 1.0.8 - '@typescript-eslint/types': 8.40.0 + '@typescript-eslint/types': 8.48.0 comment-parser: 1.4.1 esquery: 1.6.0 jsdoc-type-pratt-parser: 4.1.0 @@ -11109,147 +11652,225 @@ snapshots: '@esbuild/aix-ppc64@0.25.9': optional: true + '@esbuild/aix-ppc64@0.27.7': + optional: true + '@esbuild/android-arm64@0.19.12': optional: true '@esbuild/android-arm64@0.25.9': optional: true + '@esbuild/android-arm64@0.27.7': + optional: true + '@esbuild/android-arm@0.19.12': optional: true '@esbuild/android-arm@0.25.9': optional: true + '@esbuild/android-arm@0.27.7': + optional: true + '@esbuild/android-x64@0.19.12': optional: true '@esbuild/android-x64@0.25.9': optional: true + '@esbuild/android-x64@0.27.7': + optional: true + '@esbuild/darwin-arm64@0.19.12': optional: true '@esbuild/darwin-arm64@0.25.9': optional: true + '@esbuild/darwin-arm64@0.27.7': + optional: true + '@esbuild/darwin-x64@0.19.12': optional: true '@esbuild/darwin-x64@0.25.9': optional: true + '@esbuild/darwin-x64@0.27.7': + optional: true + '@esbuild/freebsd-arm64@0.19.12': optional: true '@esbuild/freebsd-arm64@0.25.9': optional: true + '@esbuild/freebsd-arm64@0.27.7': + optional: true + '@esbuild/freebsd-x64@0.19.12': optional: true '@esbuild/freebsd-x64@0.25.9': optional: true + '@esbuild/freebsd-x64@0.27.7': + optional: true + '@esbuild/linux-arm64@0.19.12': optional: true '@esbuild/linux-arm64@0.25.9': optional: true + '@esbuild/linux-arm64@0.27.7': + optional: true + '@esbuild/linux-arm@0.19.12': optional: true '@esbuild/linux-arm@0.25.9': optional: true + '@esbuild/linux-arm@0.27.7': + optional: true + '@esbuild/linux-ia32@0.19.12': optional: true '@esbuild/linux-ia32@0.25.9': optional: true + '@esbuild/linux-ia32@0.27.7': + optional: true + '@esbuild/linux-loong64@0.19.12': optional: true '@esbuild/linux-loong64@0.25.9': optional: true + '@esbuild/linux-loong64@0.27.7': + optional: true + '@esbuild/linux-mips64el@0.19.12': optional: true '@esbuild/linux-mips64el@0.25.9': optional: true + '@esbuild/linux-mips64el@0.27.7': + optional: true + '@esbuild/linux-ppc64@0.19.12': optional: true '@esbuild/linux-ppc64@0.25.9': optional: true + '@esbuild/linux-ppc64@0.27.7': + optional: true + '@esbuild/linux-riscv64@0.19.12': optional: true '@esbuild/linux-riscv64@0.25.9': optional: true + '@esbuild/linux-riscv64@0.27.7': + optional: true + '@esbuild/linux-s390x@0.19.12': optional: true '@esbuild/linux-s390x@0.25.9': optional: true + '@esbuild/linux-s390x@0.27.7': + optional: true + '@esbuild/linux-x64@0.19.12': optional: true '@esbuild/linux-x64@0.25.9': optional: true + '@esbuild/linux-x64@0.27.7': + optional: true + '@esbuild/netbsd-arm64@0.25.9': optional: true + '@esbuild/netbsd-arm64@0.27.7': + optional: true + '@esbuild/netbsd-x64@0.19.12': optional: true '@esbuild/netbsd-x64@0.25.9': optional: true + '@esbuild/netbsd-x64@0.27.7': + optional: true + '@esbuild/openbsd-arm64@0.25.9': optional: true + '@esbuild/openbsd-arm64@0.27.7': + optional: true + '@esbuild/openbsd-x64@0.19.12': optional: true '@esbuild/openbsd-x64@0.25.9': optional: true + '@esbuild/openbsd-x64@0.27.7': + optional: true + '@esbuild/openharmony-arm64@0.25.9': optional: true + '@esbuild/openharmony-arm64@0.27.7': + optional: true + '@esbuild/sunos-x64@0.19.12': optional: true '@esbuild/sunos-x64@0.25.9': optional: true + '@esbuild/sunos-x64@0.27.7': + optional: true + '@esbuild/win32-arm64@0.19.12': optional: true '@esbuild/win32-arm64@0.25.9': optional: true + '@esbuild/win32-arm64@0.27.7': + optional: true + '@esbuild/win32-ia32@0.19.12': optional: true '@esbuild/win32-ia32@0.25.9': optional: true + '@esbuild/win32-ia32@0.27.7': + optional: true + '@esbuild/win32-x64@0.19.12': optional: true '@esbuild/win32-x64@0.25.9': optional: true + '@esbuild/win32-x64@0.27.7': + optional: true + '@eslint-community/eslint-utils@4.7.0(eslint@9.33.0(jiti@2.6.1))': dependencies: eslint: 9.33.0(jiti@2.6.1) @@ -11663,9 +12284,9 @@ snapshots: - react-native - supports-color - '@hono/node-server@1.19.0(hono@4.9.2)': + '@hono/node-server@1.19.0(hono@4.10.7)': dependencies: - hono: 4.9.2 + hono: 4.10.7 '@humanfs/core@0.19.1': {} @@ -12645,6 +13266,26 @@ snapshots: optionalDependencies: '@types/react': 19.2.1 + '@redis/bloom@5.12.1(@redis/client@5.12.1)': + dependencies: + '@redis/client': 5.12.1 + + '@redis/client@5.12.1': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@5.12.1(@redis/client@5.12.1)': + dependencies: + '@redis/client': 5.12.1 + + '@redis/search@5.12.1(@redis/client@5.12.1)': + dependencies: + '@redis/client': 5.12.1 + + '@redis/time-series@5.12.1(@redis/client@5.12.1)': + dependencies: + '@redis/client': 5.12.1 + '@reown/appkit-common@1.7.8(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10)(zod@3.22.4)': dependencies: big.js: 6.2.2 @@ -13606,7 +14247,7 @@ snapshots: '@solana/errors@2.3.0(typescript@5.9.2)': dependencies: chalk: 5.6.2 - commander: 14.0.2 + commander: 14.0.3 typescript: 5.9.2 '@solana/errors@3.0.3(typescript@5.9.2)': @@ -15046,7 +15687,7 @@ snapshots: '@stellar/stellar-sdk@14.6.1': dependencies: '@stellar/stellar-base': 14.1.0 - axios: 1.13.6 + axios: 1.13.4 bignumber.js: 9.3.1 commander: 14.0.3 eventsource: 2.0.2 @@ -15457,7 +16098,7 @@ snapshots: fast-glob: 3.3.3 is-glob: 4.0.3 minimatch: 9.0.5 - semver: 7.7.3 + semver: 7.7.2 ts-api-utils: 2.1.0(typescript@5.9.2) typescript: 5.9.2 transitivePeerDependencies: @@ -15590,6 +16231,14 @@ snapshots: optionalDependencies: vite: 6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1) + '@vitest/mocker@3.2.4(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))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + 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) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -16265,7 +16914,7 @@ snapshots: '@noble/hashes': 1.7.0 '@walletconnect/safe-json': 1.0.2 '@walletconnect/time': 1.0.2 - uint8arrays: 3.1.1 + uint8arrays: 3.1.0 '@walletconnect/safe-json@1.0.0': {} @@ -17188,9 +17837,9 @@ snapshots: axe-core@4.10.3: {} - axios-retry@4.5.0(axios@1.13.6): + axios-retry@4.5.0(axios@1.13.4): dependencies: - axios: 1.13.6 + axios: 1.13.4 is-retry-allowed: 2.2.0 axios@1.13.2: @@ -17201,10 +17850,10 @@ snapshots: transitivePeerDependencies: - debug - axios@1.13.6: + axios@1.13.4: dependencies: follow-redirects: 1.15.11 - form-data: 4.0.5 + form-data: 4.0.4 proxy-from-env: 1.1.0 transitivePeerDependencies: - debug @@ -17462,6 +18111,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -17942,6 +18593,35 @@ snapshots: '@esbuild/win32-ia32': 0.25.9 '@esbuild/win32-x64': 0.25.9 + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -18107,7 +18787,7 @@ snapshots: espree: 10.4.0 esquery: 1.6.0 parse-imports-exports: 0.2.4 - semver: 7.7.2 + semver: 7.7.3 spdx-expression-parse: 4.0.0 transitivePeerDependencies: - supports-color @@ -18140,6 +18820,15 @@ snapshots: optionalDependencies: eslint-config-prettier: 10.1.8(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@9.33.0(jiti@2.6.1)))(eslint@9.33.0(jiti@2.6.1))(prettier@3.6.2): + dependencies: + eslint: 9.33.0(jiti@2.6.1) + prettier: 3.6.2 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.11 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-react-hooks@7.0.1(eslint@9.33.0(jiti@2.6.1)): dependencies: '@babel/core': 7.28.3 @@ -18460,7 +19149,7 @@ snapshots: fastestsmallesttextencoderdecoder@1.0.22: {} - fastify@5.8.4: + fastify@5.8.2: dependencies: '@fastify/ajv-compiler': 4.0.5 '@fastify/error': 4.2.0 @@ -18559,7 +19248,7 @@ snapshots: fix-dts-default-cjs-exports@1.0.1: dependencies: - magic-string: 0.30.17 + magic-string: 0.30.21 mlly: 1.7.4 rollup: 4.46.4 @@ -18595,14 +19284,6 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - form-data@4.0.5: - dependencies: - asynckit: 0.4.0 - combined-stream: 1.0.8 - es-set-tostringtag: 2.1.0 - hasown: 2.0.2 - mime-types: 2.1.35 - formdata-node@4.4.1: dependencies: node-domexception: 1.0.0 @@ -18808,8 +19489,6 @@ snapshots: hono@4.10.7: {} - hono@4.9.2: {} - html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -19368,10 +20047,6 @@ snapshots: lute-connect@1.7.0: {} - magic-string@0.30.17: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -19799,21 +20474,6 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@4.104.0(encoding@0.1.13)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76): - dependencies: - '@types/node': 18.19.123 - '@types/node-fetch': 2.6.13 - abort-controller: 3.0.0 - agentkeepalive: 4.6.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0(encoding@0.1.13) - optionalDependencies: - ws: 8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) - zod: 3.25.76 - transitivePeerDependencies: - - encoding - openai@4.104.0(encoding@0.1.13)(ws@8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@3.25.76): dependencies: '@types/node': 18.19.123 @@ -20273,6 +20933,15 @@ snapshots: tsx: 4.20.4 yaml: 2.8.1 + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.6.1 + postcss: 8.5.6 + tsx: 4.21.0 + yaml: 2.8.1 + postcss@8.4.31: dependencies: nanoid: 3.3.11 @@ -20297,6 +20966,8 @@ snapshots: prettier@3.5.2: {} + prettier@3.6.2: {} + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -20607,6 +21278,17 @@ snapshots: real-require@0.2.0: {} + redis@5.12.1: + dependencies: + '@redis/bloom': 5.12.1(@redis/client@5.12.1) + '@redis/client': 5.12.1 + '@redis/json': 5.12.1(@redis/client@5.12.1) + '@redis/search': 5.12.1(@redis/client@5.12.1) + '@redis/time-series': 5.12.1(@redis/client@5.12.1) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -21198,11 +21880,6 @@ snapshots: tinyexec@0.3.2: {} - tinyglobby@0.2.14: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -21318,7 +21995,35 @@ snapshots: source-map: 0.8.0-beta.0 sucrase: 3.35.0 tinyexec: 0.3.2 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 5.9.2 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsup@8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.1): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.9) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.1 + esbuild: 0.25.9 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1) + resolve-from: 5.0.0 + rollup: 4.46.4 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: postcss: 8.5.6 @@ -21336,6 +22041,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.10.1 + optionalDependencies: + fsevents: 2.3.3 + turbo-darwin-64@2.5.6: optional: true @@ -21739,6 +22451,27 @@ snapshots: - tsx - yaml + 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 + 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) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1)): dependencies: debug: 4.4.1 @@ -21750,6 +22483,17 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@5.1.4(typescript@5.9.2)(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)): + dependencies: + debug: 4.4.1 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.2) + optionalDependencies: + 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) + transitivePeerDependencies: + - supports-color + - typescript + vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1): dependencies: esbuild: 0.25.9 @@ -21757,7 +22501,7 @@ snapshots: picomatch: 4.0.3 postcss: 8.5.6 rollup: 4.46.4 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.17.2 fsevents: 2.3.3 @@ -21767,6 +22511,23 @@ snapshots: tsx: 4.20.4 yaml: 2.8.1 + 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): + dependencies: + esbuild: 0.25.9 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.46.4 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.17.2 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + terser: 5.46.2 + tsx: 4.21.0 + yaml: 2.8.1 + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.20.4)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 @@ -21810,6 +22571,49 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(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)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.1 + debug: 4.4.1 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + 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) + 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) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.17.2 + jsdom: 26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vlq@1.0.1: {} vlq@2.0.4: {} diff --git a/examples/typescript/servers/batch-settlement/.env-local b/examples/typescript/servers/batch-settlement/.env-local new file mode 100644 index 0000000000..75829ca394 --- /dev/null +++ b/examples/typescript/servers/batch-settlement/.env-local @@ -0,0 +1,4 @@ +EVM_ADDRESS= +EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= +FACILITATOR_URL=https://x402.org/facilitator +STORAGE_DIR= \ No newline at end of file diff --git a/examples/typescript/servers/batch-settlement/.prettierignore b/examples/typescript/servers/batch-settlement/.prettierignore new file mode 100644 index 0000000000..5bd240ba90 --- /dev/null +++ b/examples/typescript/servers/batch-settlement/.prettierignore @@ -0,0 +1,8 @@ +docs/ +dist/ +node_modules/ +coverage/ +.github/ +src/client +**/**/*.json +*.md \ No newline at end of file diff --git a/examples/typescript/servers/batch-settlement/.prettierrc b/examples/typescript/servers/batch-settlement/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/servers/batch-settlement/.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/batch-settlement/README.md b/examples/typescript/servers/batch-settlement/README.md new file mode 100644 index 0000000000..d754da5863 --- /dev/null +++ b/examples/typescript/servers/batch-settlement/README.md @@ -0,0 +1,103 @@ +# @x402/express Batch-Settlement Example Server + +Express server that protects a resource with the **batch-settlement** EVM scheme. Each request is paid by an off-chain voucher; the server batches voucher claims and onchain settlements via a `ChannelManager` running in the background. + +The route demonstrates **dynamic pricing**: the client authorizes up to `$0.01` per request, and the handler bills a random fraction of that via `setSettlementOverrides`. + +See the [scheme specification](../../../../specs/schemes/batch-settlement/scheme_batch_settlement_evm.md) and the [scheme README](../../../../typescript/packages/mechanisms/evm/src/batch-settlement/README.md) for protocol details. + +## Receiver Authorizer: Pick One + +Every channel commits to a `receiverAuthorizer` — the address whose EIP-712 signatures authorize `claimWithSignature` and `refundWithSignature`. This server lets you choose between two strategies: + +### 1. Self-managed (recommended) + +Set `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` to an EOA you own. The scheme uses it to sign claims/refunds locally; **any facilitator** can relay the resulting transactions. + +```typescript +const receiverAuthorizerSigner = privateKeyToAccount(process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY); +new BatchSettlementEvmScheme(evmAddress, { receiverAuthorizerSigner }); +``` + +Channels survive facilitator changes — you can switch facilitators (or add backups) without opening new channels. + +### 2. Facilitator-delegated + +Leave `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` unset. The scheme adopts the address advertised by the facilitator's `/supported`. + +```typescript +new BatchSettlementEvmScheme(evmAddress, { /* no receiverAuthorizerSigner */ }); +``` + +This is simpler operationally but binds each channel to the current facilitator authorizer. **Switching facilitators (or rotating their authorizer key) requires opening new channels.** Before swapping, claim outstanding vouchers and refund remaining balances on the old channels. + +## Settlement Policy + +Clients can call `initiateWithdraw` directly onchain at any time, **outside the request flow**. After the channel's `withdrawDelay` elapses, `finalizeWithdraw` drains the escrow and any unclaimed vouchers become unclaimable forever. + +This demo uses local-friendly timing: claim every 1 minute, settle every 2 minutes, and refund channels idle for 3 minutes. The default channel `withdrawDelay` is 1 day. + +For production, choose a `withdrawDelay` greater than your claim cadence plus an operational safety margin. A daily claim job pairs well with a `withdrawDelay` longer than one day; settle less frequently when gas savings matter more than receiver cash-flow latency. Idle refunds are usually best on a week-scale cadence unless your product needs faster channel cleanup. + +The `ChannelManager` runs the server-side lifecycle: claim vouchers from stored channels, settle claimed funds to `payTo`, and optionally refund idle channels. `start()` enables each job at the configured interval, while callbacks let you choose channels, gate settlement, and hook logging/metrics: + +```typescript +manager.start({ + claimIntervalSecs: 60, + settleIntervalSecs: 120, + refundIntervalSecs: 180, + maxClaimsPerBatch: 100, + selectClaimChannels: (channels, { now }) => + channels.filter( + channel => + channel.withdrawRequestedAt > 0 || + now - channel.lastRequestTimestamp >= 60_000, + ), + shouldSettle: ({ pendingSettle }) => pendingSettle, + selectRefundChannels: (channels, { now }) => + channels.filter(channel => now - channel.lastRequestTimestamp >= 180_000), + onClaim: result => console.log(`Claimed ${result.vouchers} vouchers`), + onSettle: result => console.log(`Settled ${result.transaction}`), + onRefund: result => console.log(`Refunded ${result.channel}`), + onError: error => console.error("Settlement error:", error), +}); +``` + +In this example, `selectClaimChannels` prioritizes channels with pending withdrawals and channels idle for at least 1 minute, so their vouchers are claimed before a withdrawal can finalize. The same selection callbacks can be reused with one-shot calls such as `claimAndSettle()` and `refundIdleChannels()` from a cron job or external worker. + +## Storage + +By default, channel sessions are in memory. Set `STORAGE_DIR` to persist them on disk for local restarts. + +For serverless deployments or multi-instance servers, configure the scheme with `RedisChannelStorage`; it stores channel sessions in Redis/Valkey so they survive cold starts and update atomically across processes. + +## Prerequisites + +- Node.js v20+, pnpm v10 +- A running [batch-settlement facilitator](../../facilitator/batch-settlement) (or a hosted one) +- An EVM `payTo` address (does **not** need ETH — it only receives funds via `settle`) + +## Setup + +```bash +cp .env-local .env +# fill EVM_ADDRESS, FACILITATOR_URL, optionally EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY + +cd ../../ +pnpm install && pnpm build +cd servers/batch-settlement + +pnpm dev +``` + +The server listens on `http://localhost:4021`. Hit it with the [client example](../../clients/batch-settlement). + +## Environment + +| Variable | Required | Description | +|----------|----------|-------------| +| `EVM_ADDRESS` | yes | `payTo` address (channel receiver) | +| `FACILITATOR_URL` | yes | Batch-settlement facilitator endpoint | +| `EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY` | no | Self-managed authorizer key (omit to delegate to facilitator) | +| `STORAGE_DIR` | no | Persist channel sessions on disk (defaults to in-memory) | +| `DEFERRED_WITHDRAW_DELAY_SECONDS` | no | Channel `withdrawDelay`; defaults to 86,400 (1 day) | diff --git a/examples/typescript/servers/batch-settlement/eslint.config.js b/examples/typescript/servers/batch-settlement/eslint.config.js new file mode 100644 index 0000000000..e2fde7b3b8 --- /dev/null +++ b/examples/typescript/servers/batch-settlement/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/batch-settlement/index.ts b/examples/typescript/servers/batch-settlement/index.ts new file mode 100644 index 0000000000..5669b60083 --- /dev/null +++ b/examples/typescript/servers/batch-settlement/index.ts @@ -0,0 +1,114 @@ +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { BatchSettlementEvmScheme, FileChannelStorage } from "@x402/evm/batch-settlement/server"; +import { paymentMiddleware, setSettlementOverrides, x402ResourceServer } from "@x402/express"; +import { config } from "dotenv"; +import express from "express"; +import { privateKeyToAccount } from "viem/accounts"; + +config(); + +const NETWORK = "eip155:84532" as const; + +const evmAddress = process.env.EVM_ADDRESS as `0x${string}`; +const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY as + | `0x${string}` + | undefined; +const storageDir = process.env.STORAGE_DIR; +const withdrawDelay = Number(process.env.DEFERRED_WITHDRAW_DELAY_SECONDS ?? "86400"); + +if (!evmAddress || !/^0x[0-9a-fA-F]{40}$/.test(evmAddress)) { + console.error("Missing or invalid EVM_ADDRESS (checksummed 20-byte hex, 0x-prefixed)"); + process.exit(1); +} + +const facilitatorUrl = process.env.FACILITATOR_URL; +if (!facilitatorUrl) { + console.error("Missing required FACILITATOR_URL environment variable"); + process.exit(1); +} + +const receiverAuthorizerSigner = receiverAuthorizerPrivateKey + ? privateKeyToAccount(receiverAuthorizerPrivateKey) + : undefined; + +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); + +const batchedScheme = new BatchSettlementEvmScheme(evmAddress, { + ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), + withdrawDelay, + ...(storageDir ? { storage: new FileChannelStorage({ directory: storageDir }) } : {}), +}); + +const resourceServer = new x402ResourceServer(facilitatorClient).register(NETWORK, batchedScheme); + +const channelManager = batchedScheme.createChannelManager(facilitatorClient, NETWORK); + +channelManager.start({ + claimIntervalSecs: 60, + settleIntervalSecs: 120, + refundIntervalSecs: 180, + maxClaimsPerBatch: 100, + selectRefundChannels: (channels, context) => + channels.filter(channel => { + if (BigInt(channel.balance) === 0n) return false; + if (channel.pendingRequest && channel.pendingRequest.expiresAt > context.now) return false; + return context.now - channel.lastRequestTimestamp >= 180_000; // Refund channels after 3 minutes of inactivity + }), + onClaim: (r: { vouchers: number; transaction: string }) => + console.log(`Claimed ${r.vouchers} vouchers (tx: ${r.transaction})`), + onSettle: (r: { transaction: string }) => + console.log(`Settled to ${evmAddress} (tx: ${r.transaction})`), + onRefund: r => console.log(`Refunded channel ${r.channel} (tx: ${r.transaction})`), + onError: (e: unknown) => console.error("Settlement error:", e), +}); + +process.on("SIGINT", async () => { + console.log("Shutting down — flushing pending claims…"); + await channelManager.stop({ flush: true }); + process.exit(0); +}); + +const app = express(); + +// Authorize up to this amount per request; optional usage-based override below bills actual usage. +const maxPrice = "$0.01"; + +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { + scheme: "batch-settlement", + price: maxPrice, + network: NETWORK, + payTo: evmAddress, + }, + description: "Weather data", + mimeType: "application/json", + }, + }, + resourceServer, + ), +); + +app.get("/weather", (req, res) => { + const chargedPercent = 1 + Math.floor(Math.random() * 100); + setSettlementOverrides(res, { amount: `${chargedPercent}%` }); + + res.send({ + report: { + weather: "sunny", + temperature: 70, + }, + }); +}); + +app.listen(4021, () => { + console.log("Batch-settlement server listening at http://localhost:4021"); + console.log(" GET /weather"); + if (receiverAuthorizerSigner) { + console.log(` Receiver authorizer: local signer ${receiverAuthorizerSigner.address}`); + } else { + console.log(" Receiver authorizer: facilitator"); + } +}); diff --git a/examples/typescript/servers/batch-settlement/package.json b/examples/typescript/servers/batch-settlement/package.json new file mode 100644 index 0000000000..d9bf217d75 --- /dev/null +++ b/examples/typescript/servers/batch-settlement/package.json @@ -0,0 +1,34 @@ +{ + "name": "@x402/batch-settlement-server-example", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx 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", + "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.39.3" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/express": "^5.0.1", + "@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", + "tsup": "^7.2.0", + "tsx": "^4.7.0", + "typescript": "^5.3.0" + } +} diff --git a/examples/typescript/servers/batch-settlement/tsconfig.json b/examples/typescript/servers/batch-settlement/tsconfig.json new file mode 100644 index 0000000000..78f9479b1b --- /dev/null +++ b/examples/typescript/servers/batch-settlement/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": ["index.ts"] +} diff --git a/go/http/avm_paywall_template.go b/go/http/avm_paywall_template.go index 65b8781cb2..9095c5b231 100644 --- a/go/http/avm_paywall_template.go +++ b/go/http/avm_paywall_template.go @@ -2,4 +2,4 @@ package http // AVMPaywallTemplate is the pre-built AVM paywall template with inlined CSS and JS -const AVMPaywallTemplate = "\n \n \n Payment Required\n \n
\n \n \n " +const AVMPaywallTemplate = "\n \n \n Payment Required\n \n
\n \n \n " diff --git a/go/http/evm_paywall_template.go b/go/http/evm_paywall_template.go index 7a94f887f7..23c263a7ac 100644 --- a/go/http/evm_paywall_template.go +++ b/go/http/evm_paywall_template.go @@ -2,4 +2,4 @@ package http // EVMPaywallTemplate is the pre-built EVM paywall template with inlined CSS and JS -const EVMPaywallTemplate = "\n \n \n Payment Required\n \n
\n \n \n " +const EVMPaywallTemplate = "\n \n \n Payment Required\n \n
\n \n \n " diff --git a/go/http/svm_paywall_template.go b/go/http/svm_paywall_template.go index 192299cc1b..c4dcfa55eb 100644 --- a/go/http/svm_paywall_template.go +++ b/go/http/svm_paywall_template.go @@ -2,4 +2,4 @@ package http // SVMPaywallTemplate is the pre-built SVM paywall template with inlined CSS and JS -const SVMPaywallTemplate = "\n \n \n Payment Required\n \n
\n \n \n " +const SVMPaywallTemplate = "\n \n \n Payment Required\n \n
\n \n \n " diff --git a/python/x402/http/paywall/avm_paywall_template.py b/python/x402/http/paywall/avm_paywall_template.py index ca9e02e954..cbb204357f 100644 --- a/python/x402/http/paywall/avm_paywall_template.py +++ b/python/x402/http/paywall/avm_paywall_template.py @@ -1,2 +1,2 @@ # THIS FILE IS AUTO-GENERATED - DO NOT EDIT -AVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' +AVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' diff --git a/python/x402/http/paywall/evm_paywall_template.py b/python/x402/http/paywall/evm_paywall_template.py index 46175c345a..d3f8611688 100644 --- a/python/x402/http/paywall/evm_paywall_template.py +++ b/python/x402/http/paywall/evm_paywall_template.py @@ -1,2 +1,2 @@ # THIS FILE IS AUTO-GENERATED - DO NOT EDIT -EVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' +EVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' diff --git a/python/x402/http/paywall/svm_paywall_template.py b/python/x402/http/paywall/svm_paywall_template.py index 4e93073da8..e2cf0579e9 100644 --- a/python/x402/http/paywall/svm_paywall_template.py +++ b/python/x402/http/paywall/svm_paywall_template.py @@ -1,2 +1,2 @@ # THIS FILE IS AUTO-GENERATED - DO NOT EDIT -SVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' +SVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' diff --git a/specs/x402-specification-v2.md b/specs/x402-specification-v2.md index b7eaea66f2..a7d2f452e0 100644 --- a/specs/x402-specification-v2.md +++ b/specs/x402-specification-v2.md @@ -253,6 +253,7 @@ The `VerifyResponse` schema contains the following fields: | `isValid` | `boolean` | Required | Indicates whether the payment authorization is valid | | `invalidReason` | `string` | Optional | Reason for invalidity (omitted if valid) | | `payer` | `string` | Optional | Address of the payer's wallet | +| `extra` | `object` | Optional | Scheme-specific additional data | **6. Payment Schemes (The Logic)** diff --git a/typescript/.changeset/chubby-cobras-obey.md b/typescript/.changeset/chubby-cobras-obey.md new file mode 100644 index 0000000000..69dea7b9a9 --- /dev/null +++ b/typescript/.changeset/chubby-cobras-obey.md @@ -0,0 +1,5 @@ +--- +'@x402/evm': minor +--- + +Implemented batch-settlement mechanism diff --git a/typescript/.changeset/chubby-lemons-cheer.md b/typescript/.changeset/chubby-lemons-cheer.md new file mode 100644 index 0000000000..7a61a86b62 --- /dev/null +++ b/typescript/.changeset/chubby-lemons-cheer.md @@ -0,0 +1,5 @@ +--- +'@x402/next': minor +--- + +Added setSettlementOverrides diff --git a/typescript/.changeset/long-melons-greet.md b/typescript/.changeset/long-melons-greet.md new file mode 100644 index 0000000000..eaf6ed72e2 --- /dev/null +++ b/typescript/.changeset/long-melons-greet.md @@ -0,0 +1,8 @@ +--- +'@x402/express': minor +'@x402/fastify': minor +'@x402/hono': minor +'@x402/next': minor +--- + +Added cancellationDispatcher for failed route handlers diff --git a/typescript/.changeset/stale-ties-look.md b/typescript/.changeset/stale-ties-look.md new file mode 100644 index 0000000000..0e4ded0b17 --- /dev/null +++ b/typescript/.changeset/stale-ties-look.md @@ -0,0 +1,8 @@ +--- +'@x402/core': minor +--- + +- Extended scheme surface with optional schemeHooks +- Added skip primitives to verify/route/settle for custom flows +- Added VerifyResponse / SettleResponse extra +- Added onPaymentResponse client hook and processPaymentResult utility \ No newline at end of file diff --git a/typescript/.changeset/violet-bottles-float.md b/typescript/.changeset/violet-bottles-float.md new file mode 100644 index 0000000000..fe81fb1ac3 --- /dev/null +++ b/typescript/.changeset/violet-bottles-float.md @@ -0,0 +1,6 @@ +--- +'@x402/axios': minor +'@x402/fetch': minor +--- + +Added processPaymentResult utility and recovery hook diff --git a/typescript/packages/core/src/client/x402Client.ts b/typescript/packages/core/src/client/x402Client.ts index 544aaa1c7c..bfb0c671ea 100644 --- a/typescript/packages/core/src/client/x402Client.ts +++ b/typescript/packages/core/src/client/x402Client.ts @@ -1,7 +1,7 @@ import { x402Version } from ".."; import { SchemeNetworkClient } from "../types/mechanisms"; import { PaymentPayload, PaymentRequirements } from "../types/payments"; -import { Network, PaymentRequired } from "../types"; +import { Network, PaymentRequired, SettleResponse } from "../types"; import { findByNetworkAndScheme, findSchemesByNetwork } from "../utils"; /** @@ -35,8 +35,42 @@ export type OnPaymentCreationFailureHook = ( context: PaymentCreationFailureContext, ) => Promise; +/** + * Context provided to payment response hooks after the paid request completes. + * + * Discriminate by what's present: + * - `settleResponse` with `success: true` → settle succeeded + * - `settleResponse` with `success: false` → settle failed + * - `paymentRequired` (no `settleResponse`) → verify failed + * - `error` → transport or parse error + */ +export interface PaymentResponseContext { + paymentPayload: PaymentPayload; + requirements: PaymentRequirements; + settleResponse?: SettleResponse; + paymentRequired?: PaymentRequired; + error?: Error; +} + +/** + * Hook fired after a paid request completes. + * Return `{ recovered: true }` to signal the transport should retry with a fresh payload. + */ +export type OnPaymentResponseHook = ( + ctx: PaymentResponseContext, +) => Promise; + export type SelectPaymentRequirements = (x402Version: number, paymentRequirements: PaymentRequirements[]) => PaymentRequirements; +type ClientHookAdapterHandles = { + beforePaymentCreation?: BeforePaymentCreationHook; + afterPaymentCreation?: AfterPaymentCreationHook; + onPaymentCreationFailure?: OnPaymentCreationFailureHook; + onPaymentResponse?: OnPaymentResponseHook; +}; + +type ClientHookPhase = keyof ClientHookAdapterHandles; + /** * Extension that can enrich payment payloads on the client side. * @@ -129,12 +163,14 @@ export interface x402ClientConfig { export class x402Client { private readonly paymentRequirementsSelector: SelectPaymentRequirements; private readonly registeredClientSchemes: Map>> = new Map(); + private readonly schemeClientHookAdapters: Map>> = new Map(); private readonly policies: PaymentPolicy[] = []; private readonly registeredExtensions: Map = new Map(); private beforePaymentCreationHooks: BeforePaymentCreationHook[] = []; private afterPaymentCreationHooks: AfterPaymentCreationHook[] = []; private onPaymentCreationFailureHooks: OnPaymentCreationFailureHook[] = []; + private paymentResponseHooks: OnPaymentResponseHook[] = []; /** * Creates a new x402Client instance. @@ -266,6 +302,41 @@ export class x402Client { return this; } + /** + * Register a hook to execute after a paid request completes. + * Can signal recovery by returning { recovered: true }, causing the transport to retry. + * + * @param hook - The hook function to register + * @returns The x402Client instance for chaining + */ + onPaymentResponse(hook: OnPaymentResponseHook): x402Client { + this.paymentResponseHooks.push(hook); + return this; + } + + /** + * Fires all registered payment response hooks in order. + * Returns `{ recovered: true }` if any hook signals recovery (first wins). + * + * @param ctx - The payment response context + * @returns Recovery signal or undefined + */ + async handlePaymentResponse( + ctx: PaymentResponseContext, + ): Promise<{ recovered: true } | undefined> { + for (const hook of this.getLabeledHooks( + "onPaymentResponse", + ctx.paymentPayload.x402Version, + ctx.requirements, + )) { + const result = await hook(ctx); + if (result && "recovered" in result && result.recovered) { + return { recovered: true }; + } + } + return undefined; + } + /** * Creates a payment payload based on a PaymentRequired response. * @@ -290,8 +361,11 @@ export class x402Client { selectedRequirements: requirements, }; - // Execute beforePaymentCreation hooks - for (const hook of this.beforePaymentCreationHooks) { + for (const hook of this.getLabeledHooks( + "beforePaymentCreation", + paymentRequired.x402Version, + requirements, + )) { const result = await hook(context); if (result && "abort" in result && result.abort) { throw new Error(`Payment creation aborted: ${result.reason}`); @@ -333,13 +407,16 @@ export class x402Client { // Enrich payload via registered client extensions (for non-scheme extensions) paymentPayload = await this.enrichPaymentPayloadWithExtensions(paymentPayload, paymentRequired); - // Execute afterPaymentCreation hooks const createdContext: PaymentCreatedContext = { ...context, paymentPayload, }; - for (const hook of this.afterPaymentCreationHooks) { + for (const hook of this.getLabeledHooks( + "afterPaymentCreation", + paymentRequired.x402Version, + requirements, + )) { await hook(createdContext); } @@ -350,8 +427,11 @@ export class x402Client { error: error as Error, }; - // Execute onPaymentCreationFailure hooks - for (const hook of this.onPaymentCreationFailureHooks) { + for (const hook of this.getLabeledHooks( + "onPaymentCreationFailure", + paymentRequired.x402Version, + requirements, + )) { const result = await hook(failureContext); if (result && "recovered" in result && result.recovered) { return result.payload; @@ -495,10 +575,90 @@ export class x402Client { } const clientByScheme = clientSchemesByNetwork.get(network)!; - if (!clientByScheme.has(client.scheme)) { - clientByScheme.set(client.scheme, client); + clientByScheme.set(client.scheme, client); + + if (!this.schemeClientHookAdapters.has(x402Version)) { + this.schemeClientHookAdapters.set(x402Version, new Map()); + } + const adaptersByNetwork = this.schemeClientHookAdapters.get(x402Version)!; + if (!adaptersByNetwork.has(network)) { + adaptersByNetwork.set(network, new Map()); + } + + const adaptersByScheme = adaptersByNetwork.get(network)!; + const hooks = client.schemeHooks; + if (!hooks) { + adaptersByScheme.delete(client.scheme); + return this; + } + + const handles: ClientHookAdapterHandles = {}; + if (hooks.onBeforePaymentCreation) { + handles.beforePaymentCreation = hooks.onBeforePaymentCreation; + } + if (hooks.onAfterPaymentCreation) { + handles.afterPaymentCreation = hooks.onAfterPaymentCreation; + } + if (hooks.onPaymentCreationFailure) { + handles.onPaymentCreationFailure = hooks.onPaymentCreationFailure; + } + if (hooks.onPaymentResponse) { + handles.onPaymentResponse = hooks.onPaymentResponse; + } + + if (Object.keys(handles).length > 0) { + adaptersByScheme.set(client.scheme, handles); + } else { + adaptersByScheme.delete(client.scheme); } return this; } + + /** + * Returns manual hooks followed by the hook for the selected scheme, if present. + * + * @param phase - Hook slot to collect + * @param x402Version - Protocol version for the selected requirement + * @param requirements - Selected payment requirement + * @returns Hooks in invocation order + */ + private getLabeledHooks

( + phase: P, + x402Version: number, + requirements: PaymentRequirements, + ): Array> { + let manual: Array>; + switch (phase) { + case "beforePaymentCreation": + manual = this.beforePaymentCreationHooks as Array< + NonNullable + >; + break; + case "afterPaymentCreation": + manual = this.afterPaymentCreationHooks as Array< + NonNullable + >; + break; + case "onPaymentCreationFailure": + manual = this.onPaymentCreationFailureHooks as Array< + NonNullable + >; + break; + case "onPaymentResponse": + manual = this.paymentResponseHooks as Array>; + break; + } + + const out: Array> = [...manual]; + const adaptersByNetwork = this.schemeClientHookAdapters.get(x402Version); + const schemeAdapter = adaptersByNetwork + ? findByNetworkAndScheme(adaptersByNetwork, requirements.scheme, requirements.network) + : undefined; + const hook = schemeAdapter?.[phase]; + if (hook !== undefined) { + out.push(hook); + } + return out; + } } diff --git a/typescript/packages/core/src/http/httpFacilitatorClient.ts b/typescript/packages/core/src/http/httpFacilitatorClient.ts index 37bd2a0674..c7675d7fa7 100644 --- a/typescript/packages/core/src/http/httpFacilitatorClient.ts +++ b/typescript/packages/core/src/http/httpFacilitatorClient.ts @@ -80,6 +80,10 @@ const verifyResponseSchema: z.ZodType = z .record(z.string(), z.unknown()) .nullish() .transform(v => v ?? undefined), + extra: z + .record(z.string(), z.unknown()) + .nullish() + .transform(v => v ?? undefined), }); const settleResponseSchema: z.ZodType = z.object({ @@ -98,10 +102,18 @@ const settleResponseSchema: z.ZodType = z .transform(v => v ?? undefined), transaction: z.string(), network: z.custom(value => typeof value === "string"), + amount: z + .string() + .nullish() + .transform(v => v ?? undefined), extensions: z .record(z.string(), z.unknown()) .nullish() .transform(v => v ?? undefined), + extra: z + .record(z.string(), z.unknown()) + .nullish() + .transform(v => v ?? undefined), }); const supportedKindSchema: z.ZodType = diff --git a/typescript/packages/core/src/http/x402HTTPClient.ts b/typescript/packages/core/src/http/x402HTTPClient.ts index 2a300ee179..78ed37f1bc 100644 --- a/typescript/packages/core/src/http/x402HTTPClient.ts +++ b/typescript/packages/core/src/http/x402HTTPClient.ts @@ -5,7 +5,7 @@ import { } from "."; import { SettleResponse } from "../types"; import { PaymentPayload, PaymentRequired } from "../types/payments"; -import { x402Client } from "../client/x402Client"; +import { x402Client, type PaymentResponseContext } from "../client/x402Client"; /** * Context provided to onPaymentRequired hooks. @@ -153,4 +153,104 @@ export class x402HTTPClient { async createPaymentPayload(paymentRequired: PaymentRequired): Promise { return this.client.createPaymentPayload(paymentRequired); } + + /** + * Parses response headers into protocol types, fires payment response hooks, + * and returns whether a hook signaled recovery. + * + * Called by transport wrappers (fetch, axios) after the paid request completes. + * + * @param paymentPayload - The payload that was sent with the request + * @param getHeader - Function to retrieve a response header by name + * @param status - The HTTP status code of the response + * @returns Whether a hook recovered and the parsed settle response (if any) + */ + async processPaymentResult( + paymentPayload: PaymentPayload, + getHeader: (name: string) => string | null | undefined, + status: number, + ): Promise<{ recovered: boolean; settleResponse?: SettleResponse }> { + const requirements = paymentPayload.accepted; + + let settleResponse: SettleResponse | undefined; + try { + settleResponse = this.getPaymentSettleResponse(getHeader); + } catch { + /* no header */ + } + + let paymentRequired: PaymentRequired | undefined; + if (!settleResponse && status === 402) { + try { + paymentRequired = this.getPaymentRequiredResponse(getHeader); + } catch { + /* no header */ + } + } + + const ctx: PaymentResponseContext = { + paymentPayload, + requirements: requirements!, + ...(settleResponse ? { settleResponse } : {}), + ...(paymentRequired ? { paymentRequired } : {}), + }; + + const result = await this.client.handlePaymentResponse(ctx); + return { recovered: result?.recovered === true, settleResponse }; + } + + /** + * Parses a fetch Response into a discriminated `x402PaymentResult` for app-level convenience. + * + * @param response - The fetch Response to process + * @returns A discriminated union describing the payment outcome + */ + async processResponse(response: Response): Promise { + const getHeader = (name: string) => response.headers.get(name); + + let settleResponse: SettleResponse | undefined; + try { + settleResponse = this.getPaymentSettleResponse(getHeader); + } catch { + /* no header */ + } + + const contentType = response.headers.get("content-type") ?? ""; + const body = contentType.includes("application/json") + ? await response.json() + : await response.text(); + + if (settleResponse && settleResponse.success) { + return { kind: "success", response, body, settleResponse }; + } + + if (settleResponse && !settleResponse.success) { + return { kind: "settle_failed", response, body, settleResponse }; + } + + if (response.status === 402) { + try { + const paymentRequired = this.getPaymentRequiredResponse(getHeader, body); + return { kind: "payment_required", response, paymentRequired }; + } catch { + /* no payment-required header */ + } + } + + if (response.ok) { + return { kind: "passthrough", response, body }; + } + + return { kind: "error", response, status: response.status, body }; + } } + +/** + * Discriminated union describing the outcome of a payment-enabled request. + */ +export type x402PaymentResult = + | { kind: "success"; response: Response; body: unknown; settleResponse: SettleResponse } + | { kind: "settle_failed"; response: Response; body: unknown; settleResponse: SettleResponse } + | { kind: "payment_required"; response: Response; paymentRequired: PaymentRequired } + | { kind: "error"; response: Response; status: number; body: unknown } + | { kind: "passthrough"; response: Response; body: unknown }; diff --git a/typescript/packages/core/src/http/x402HTTPResourceServer.ts b/typescript/packages/core/src/http/x402HTTPResourceServer.ts index f2d0ed572e..360f232f94 100644 --- a/typescript/packages/core/src/http/x402HTTPResourceServer.ts +++ b/typescript/packages/core/src/http/x402HTTPResourceServer.ts @@ -1,4 +1,9 @@ -import { x402ResourceServer, SettlementOverrides } from "../server"; +import { + x402ResourceServer, + SettlementOverrides, + SkipHandlerDirective, + PaymentCancellationDispatcher, +} from "../server"; import { decodePaymentSignatureHeader, encodePaymentRequiredHeader, @@ -257,6 +262,7 @@ export type HTTPProcessResult = | { type: "no-payment-required" } | { type: "payment-verified"; + cancellationDispatcher: PaymentCancellationDispatcher; paymentPayload: PaymentPayload; paymentRequirements: PaymentRequirements; declaredExtensions?: Record; @@ -557,6 +563,7 @@ export class x402HTTPResourceServer { verifyResult.invalidReason, extensions ?? {}, transportContext, + paymentPayload, ); return { type: "payment-error", @@ -564,9 +571,28 @@ export class x402HTTPResourceServer { }; } + // Bypass the resource handler + if (verifyResult.skipHandler) { + return await this.processSkipHandlerSettlement( + paymentPayload, + matchingRequirements, + extensions ?? {}, + transportContext, + verifyResult.skipHandler, + ); + } + + const cancellationDispatcher = this.ResourceServer.createPaymentCancellationDispatcher( + paymentPayload, + matchingRequirements, + extensions ?? {}, + transportContext, + ); + // Payment is valid, return data needed for settlement return { type: "payment-verified", + cancellationDispatcher, paymentPayload, paymentRequirements: matchingRequirements, declaredExtensions: extensions ?? {}, @@ -712,6 +738,56 @@ export class x402HTTPResourceServer { return this.getRouteConfig(context.path, method) !== undefined; } + /** + * Settle a verified payment that requested `skipHandler`, packaging the + * result as a `payment-error` HTTPProcessResult so framework adapters can + * write the response without invoking the route handler. + * + * - On success: status 200 + PAYMENT-RESPONSE header + configured body. + * - On failure: the standard 402 settlement-failure response. + * + * @param paymentPayload - Verified payment payload. + * @param requirements - Matched payment requirements. + * @param declaredExtensions - Optional declared extensions for the route. + * @param transportContext - Optional HTTP transport context. + * @param skipHandlerResponse - Optional content type + body to return on success. + * @returns A `payment-error` HTTPProcessResult carrying the final response. + */ + private async processSkipHandlerSettlement( + paymentPayload: PaymentPayload, + requirements: PaymentRequirements, + declaredExtensions: Record | undefined, + transportContext: HTTPTransportContext, + skipHandlerResponse: SkipHandlerDirective | undefined, + ): Promise { + const settleResult = await this.processSettlement( + paymentPayload, + requirements, + declaredExtensions, + transportContext, + ); + + if (!settleResult.success) { + return { type: "payment-error", response: settleResult.response }; + } + + const contentType = skipHandlerResponse?.contentType ?? "application/json"; + const body = skipHandlerResponse?.body ?? {}; + + return { + type: "payment-error", + response: { + status: 200, + headers: { + "Content-Type": contentType, + ...settleResult.headers, + }, + body, + isHtml: contentType.includes("text/html"), + }, + }; + } + /** * Build HTTPResponseInstructions for settlement failure. * Uses settlementFailedResponseBody hook if configured, otherwise defaults to empty body. diff --git a/typescript/packages/core/src/server/extensionResponsePolicy.ts b/typescript/packages/core/src/server/extensionResponsePolicy.ts deleted file mode 100644 index 1e66cbb3b0..0000000000 --- a/typescript/packages/core/src/server/extensionResponsePolicy.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { PaymentRequirements } from "../types/payments"; -import type { SettleResponse } from "../types/facilitator"; -import { deepEqual } from "../utils"; - -/** - * True when a string field is treated as unset and may be filled by `enrichPaymentRequiredResponse`. - * - * @param value - Candidate string from `PaymentRequirements` (e.g. `payTo`, `amount`, `asset`) - * @returns Whether the field counts as vacant (empty or whitespace-only) - */ -export function isVacantStringField(value: string): boolean { - return value.trim() === ""; -} - -/** - * Deep snapshot of `accepts` entries before any `enrichPaymentRequiredResponse` runs. - * - * @param requirements - Payment requirement rows to clone - * @returns Cloned requirements suitable as an immutable baseline for policy checks - */ -export function snapshotPaymentRequirementsList( - requirements: PaymentRequirements[], -): PaymentRequirements[] { - return requirements.map(req => ({ - ...req, - extra: structuredClone(req.extra), - })); -} - -/** - * After extension enrichment, each `accepts[i]` must still match the baseline except that - * **`payTo`**, **`amount`**, and **`asset`** may change only when the baseline value is vacant - * (whitespace-only string). **`scheme`**, **`network`**, and **`maxTimeoutSeconds`** are never - * writable by extensions. **`extra`** may gain new keys; values for keys present in the baseline - * must be unchanged (deep-equal). - * - * @param baseline - Snapshot taken before any enrich hooks for this response - * @param current - Live `accepts` entries after an extension enrich step - * @param extensionKey - Registered extension key (for error messages) - * @returns Nothing; throws if the policy is violated - */ -export function assertAcceptsAllowlistedAfterExtensionEnrich( - baseline: PaymentRequirements[], - current: PaymentRequirements[], - extensionKey: string, -): void { - if (baseline.length !== current.length) { - throw new Error( - `[x402] extension "${extensionKey}" violated accepts mutation policy: accepts length changed (${baseline.length} → ${current.length})`, - ); - } - - for (let i = 0; i < baseline.length; i++) { - const b = baseline[i]; - const c = current[i]; - - if (b.scheme !== c.scheme || b.network !== c.network) { - throw new Error( - `[x402] extension "${extensionKey}" violated accepts mutation policy: scheme/network are immutable (index ${i})`, - ); - } - if (b.maxTimeoutSeconds !== c.maxTimeoutSeconds) { - throw new Error( - `[x402] extension "${extensionKey}" violated accepts mutation policy: maxTimeoutSeconds is immutable (index ${i})`, - ); - } - - for (const field of ["payTo", "amount", "asset"] as const) { - const bv = b[field]; - const cv = c[field]; - if (!isVacantStringField(bv) && cv !== bv) { - throw new Error( - `[x402] extension "${extensionKey}" violated accepts mutation policy: "${field}" may only be set when the resource left it vacant (""); non-vacant values are immutable (index ${i})`, - ); - } - } - - for (const key of Object.keys(b.extra)) { - if (!Object.prototype.hasOwnProperty.call(c.extra, key)) { - throw new Error( - `[x402] extension "${extensionKey}" violated accepts mutation policy: extra["${key}"] was removed (index ${i})`, - ); - } - if (!deepEqual(c.extra[key], b.extra[key])) { - throw new Error( - `[x402] extension "${extensionKey}" violated accepts mutation policy: extra["${key}"] may not be changed (index ${i})`, - ); - } - } - } -} - -/** - * Immutable subset of {@link SettleResponse} compared across settlement extension enrich. - */ -export type SettleResponseCoreSnapshot = Pick< - SettleResponse, - "success" | "transaction" | "network" | "amount" | "payer" | "errorReason" | "errorMessage" ->; - -/** - * Captures facilitator-settled fields that extensions must not rewrite. - * - * @param result - Settlement response from the facilitator - * @returns Plain snapshot of core fields for later comparison - */ -export function snapshotSettleResponseCore(result: SettleResponse): SettleResponseCoreSnapshot { - return { - success: result.success, - transaction: result.transaction, - network: result.network, - amount: result.amount, - payer: result.payer, - errorReason: result.errorReason, - errorMessage: result.errorMessage, - }; -} - -/** - * Ensures `enrichSettlementResponse` did not rewrite facilitator outcome fields; only - * `extensions` may be populated via the merger (in addition to in-place adds on `extensions`). - * - * @param before - Snapshot taken before extension settlement enrich - * @param after - Live settlement result after an extension enrich step - * @param extensionKey - Registered extension key (for error messages) - * @returns Nothing; throws if a core field changed - */ -export function assertSettleResponseCoreUnchanged( - before: SettleResponseCoreSnapshot, - after: SettleResponse, - extensionKey: string, -): void { - const keys: (keyof SettleResponseCoreSnapshot)[] = [ - "success", - "transaction", - "network", - "amount", - "payer", - "errorReason", - "errorMessage", - ]; - for (const k of keys) { - if (!deepEqual(after[k], before[k])) { - throw new Error( - `[x402] extension "${extensionKey}" violated settlement mutation policy: field "${String(k)}" is immutable after facilitator settle`, - ); - } - } -} diff --git a/typescript/packages/core/src/server/hookPolicy.ts b/typescript/packages/core/src/server/hookPolicy.ts new file mode 100644 index 0000000000..ffe216d87e --- /dev/null +++ b/typescript/packages/core/src/server/hookPolicy.ts @@ -0,0 +1,326 @@ +import type { SettleResponse } from "../types/facilitator"; +import type { PaymentRequirements } from "../types/payments"; +import { deepEqual } from "../utils"; + +/** + * True when a string field is treated as unset and may be filled by `enrichPaymentRequiredResponse`. + * + * @param value - Candidate string from `PaymentRequirements` (e.g. `payTo`, `amount`, `asset`) + * @returns Whether the field counts as vacant (empty or whitespace-only) + */ +export function isVacantStringField(value: string): boolean { + return value.trim() === ""; +} + +/** + * Deep snapshot of `accepts` entries before any `enrichPaymentRequiredResponse` runs. + * + * @param requirements - Payment requirement rows to clone + * @returns Cloned requirements suitable as an immutable baseline for policy checks + */ +export function snapshotPaymentRequirementsList( + requirements: PaymentRequirements[], +): PaymentRequirements[] { + return requirements.map(req => ({ + ...req, + extra: structuredClone(req.extra), + })); +} + +/** + * After extension enrichment, each `accepts[i]` must still match the baseline except that + * **`payTo`**, **`amount`**, and **`asset`** may change only when the baseline value is vacant + * (whitespace-only string). **`scheme`**, **`network`**, and **`maxTimeoutSeconds`** are never + * writable by extensions. **`extra`** may gain new keys; values for keys present in the baseline + * must be unchanged (deep-equal). + * + * @param baseline - Snapshot taken before any enrich hooks for this response + * @param current - Live `accepts` entries after an extension enrich step + * @param extensionKey - Registered extension key (for error messages) + * @returns Nothing; throws if the policy is violated + */ +export function assertAcceptsAllowlistedAfterExtensionEnrich( + baseline: PaymentRequirements[], + current: PaymentRequirements[], + extensionKey: string, +): void { + if (baseline.length !== current.length) { + throw new Error( + `[x402] extension "${extensionKey}" violated accepts mutation policy: accepts length changed (${baseline.length} → ${current.length})`, + ); + } + + for (let i = 0; i < baseline.length; i++) { + const b = baseline[i]; + const c = current[i]; + + if (b.scheme !== c.scheme || b.network !== c.network) { + throw new Error( + `[x402] extension "${extensionKey}" violated accepts mutation policy: scheme/network are immutable (index ${i})`, + ); + } + if (b.maxTimeoutSeconds !== c.maxTimeoutSeconds) { + throw new Error( + `[x402] extension "${extensionKey}" violated accepts mutation policy: maxTimeoutSeconds is immutable (index ${i})`, + ); + } + + for (const field of ["payTo", "amount", "asset"] as const) { + const bv = b[field]; + const cv = c[field]; + if (!isVacantStringField(bv) && cv !== bv) { + throw new Error( + `[x402] extension "${extensionKey}" violated accepts mutation policy: "${field}" may only be set when the resource left it vacant (""); non-vacant values are immutable (index ${i})`, + ); + } + } + + for (const key of Object.keys(b.extra)) { + if (!Object.prototype.hasOwnProperty.call(c.extra, key)) { + throw new Error( + `[x402] extension "${extensionKey}" violated accepts mutation policy: extra["${key}"] was removed (index ${i})`, + ); + } + if (!deepEqual(c.extra[key], b.extra[key])) { + throw new Error( + `[x402] extension "${extensionKey}" violated accepts mutation policy: extra["${key}"] may not be changed (index ${i})`, + ); + } + } + } +} + +/** + * Ensures scheme 402 enrichment only adds `extra` keys to matching accepts. + * + * @param baseline - Snapshot before the scheme enrich step + * @param current - Live `accepts` entries after scheme enrichment + * @param scheme - Scheme whose hook was invoked + * @param network - Network whose hook was invoked + */ +export function assertAcceptsAdditiveExtraAfterSchemeEnrich( + baseline: PaymentRequirements[], + current: PaymentRequirements[], + scheme: string, + network: string, +): void { + if (baseline.length !== current.length) { + throw new Error( + `[x402] scheme "${scheme}" violated accepts mutation policy: accepts length changed (${baseline.length} → ${current.length})`, + ); + } + + for (let i = 0; i < baseline.length; i++) { + const b = baseline[i]; + const c = current[i]; + const isMatchingAccept = b.scheme === scheme && b.network === network; + + if (b.scheme !== c.scheme || b.network !== c.network) { + throw new Error( + `[x402] scheme "${scheme}" violated accepts mutation policy: scheme/network are immutable (index ${i})`, + ); + } + if ( + b.maxTimeoutSeconds !== c.maxTimeoutSeconds || + b.payTo !== c.payTo || + b.amount !== c.amount || + b.asset !== c.asset + ) { + throw new Error( + `[x402] scheme "${scheme}" violated accepts mutation policy: payment terms are immutable (index ${i})`, + ); + } + + for (const key of Object.keys(b.extra)) { + if (!Object.prototype.hasOwnProperty.call(c.extra, key)) { + throw new Error( + `[x402] scheme "${scheme}" violated accepts mutation policy: extra["${key}"] was removed (index ${i})`, + ); + } + if (!deepEqual(c.extra[key], b.extra[key])) { + throw new Error( + `[x402] scheme "${scheme}" violated accepts mutation policy: extra["${key}"] may not be changed (index ${i})`, + ); + } + } + + if (!isMatchingAccept && Object.keys(c.extra).length !== Object.keys(b.extra).length) { + throw new Error( + `[x402] scheme "${scheme}" violated accepts mutation policy: only matching accepts may receive new extra fields (index ${i})`, + ); + } + } +} + +/** + * Immutable subset of {@link SettleResponse} compared across settlement extension enrich. + */ +export type SettleResponseCoreSnapshot = Pick< + SettleResponse, + "success" | "transaction" | "network" | "amount" | "payer" | "errorReason" | "errorMessage" +>; + +/** + * Captures facilitator-settled fields that extensions must not rewrite. + * + * @param result - Settlement response from the facilitator + * @returns Plain snapshot of core fields for later comparison + */ +export function snapshotSettleResponseCore(result: SettleResponse): SettleResponseCoreSnapshot { + return { + success: result.success, + transaction: result.transaction, + network: result.network, + amount: result.amount, + payer: result.payer, + errorReason: result.errorReason, + errorMessage: result.errorMessage, + }; +} + +/** + * Ensures `enrichSettlementResponse` did not rewrite facilitator outcome fields; only + * `extensions` may be populated via the merger (in addition to in-place adds on `extensions`). + * + * @param before - Snapshot taken before extension settlement enrich + * @param after - Live settlement result after an extension enrich step + * @param extensionKey - Registered extension key (for error messages) + * @returns Nothing; throws if a core field changed + */ +export function assertSettleResponseCoreUnchanged( + before: SettleResponseCoreSnapshot, + after: SettleResponse, + extensionKey: string, +): void { + const keys: (keyof SettleResponseCoreSnapshot)[] = [ + "success", + "transaction", + "network", + "amount", + "payer", + "errorReason", + "errorMessage", + ]; + for (const k of keys) { + if (!deepEqual(after[k], before[k])) { + throw new Error( + `[x402] extension "${extensionKey}" violated settlement mutation policy: field "${String(k)}" is immutable after facilitator settle`, + ); + } + } +} + +/** + * Ensures scheme settlement-payload enrichment only adds server-owned fields. + * + * @param payload - Existing scheme payload before enrichment + * @param enrichment - Fields returned by the scheme enrichment hook + * @param callerLabel - Hook source label used in policy error messages + */ +export function assertAdditivePayloadEnrichment( + payload: Record, + enrichment: Record, + callerLabel: string, +): void { + for (const key of Object.keys(enrichment)) { + if (!Object.prototype.hasOwnProperty.call(payload, key)) continue; + + throw new Error( + `[x402] ${callerLabel} violated settlement payload enrichment policy: "${key}" already exists on the client payload`, + ); + } +} + +/** + * Whether `value` is a plain object record (not null or an array). + * + * @param value - Value to inspect + * @returns True when `value` is a non-null object and not an array + */ +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Ensures scheme response enrichment only adds new `extra` fields, including nested fields + * below existing objects. + * + * @param extra - Existing settlement extra fields + * @param enrichment - Fields returned by the scheme response enrichment hook + * @param callerLabel - Hook label used in policy error messages + */ +export function assertAdditiveSettlementExtra( + extra: Record, + enrichment: Record, + callerLabel: string, +): void { + assertAdditiveRecord(extra, enrichment, callerLabel, "extra"); +} + +/** + * Merges server-owned settlement response fields after additive policy validation. + * + * @param extra - Existing settlement extra fields + * @param enrichment - Additive fields returned by the scheme response enrichment hook + * @returns A merged extra object + */ +export function mergeAdditiveSettlementExtra( + extra: Record, + enrichment: Record, +): Record { + return mergeAdditiveRecord(extra, enrichment); +} + +/** + * Throws if enrichment would overwrite or collide with keys already present on `target`, + * recursively for nested plain objects. + * + * @param target - Existing record fields before enrichment + * @param enrichment - Fields proposed by the hook to merge into `target` + * @param callerLabel - Hook label used in policy error messages + * @param path - Dot-style path segment for nested keys (for error messages) + */ +function assertAdditiveRecord( + target: Record, + enrichment: Record, + callerLabel: string, + path: string, +): void { + for (const [key, enrichmentValue] of Object.entries(enrichment)) { + const nextPath = `${path}["${key}"]`; + if (!Object.prototype.hasOwnProperty.call(target, key)) continue; + + const targetValue = target[key]; + if (isPlainRecord(targetValue) && isPlainRecord(enrichmentValue)) { + assertAdditiveRecord(targetValue, enrichmentValue, callerLabel, nextPath); + continue; + } + + throw new Error( + `[x402] ${callerLabel} violated settlement response enrichment policy: ${nextPath} already exists on the settlement result`, + ); + } +} + +/** + * Deep-merges `enrichment` into `target`, recursively merging nested plain objects. + * + * @param target - Base record to merge into + * @param enrichment - Additive fields to merge (caller must enforce additive policy first) + * @returns Shallow copy of `target` with `enrichment` applied + */ +function mergeAdditiveRecord( + target: Record, + enrichment: Record, +): Record { + const merged = { ...target }; + for (const [key, enrichmentValue] of Object.entries(enrichment)) { + const targetValue = merged[key]; + if (isPlainRecord(targetValue) && isPlainRecord(enrichmentValue)) { + merged[key] = mergeAdditiveRecord(targetValue, enrichmentValue); + continue; + } + merged[key] = enrichmentValue; + } + return merged; +} diff --git a/typescript/packages/core/src/server/index.ts b/typescript/packages/core/src/server/index.ts index 9ca4de36f5..8b1a80d139 100644 --- a/typescript/packages/core/src/server/index.ts +++ b/typescript/packages/core/src/server/index.ts @@ -8,23 +8,39 @@ export type { SettleContext, SettleResultContext, SettleFailureContext, + VerifiedPaymentCanceledContext, + VerifiedPaymentCancellationReason, + VerifiedPaymentCancelOptions, + PaymentCancellationDispatcher, SettlementOverrides, + SkipHandlerDirective, + ResourceVerifyRespone, BeforeVerifyHook, AfterVerifyHook, OnVerifyFailureHook, BeforeSettleHook, AfterSettleHook, OnSettleFailureHook, + OnVerifiedPaymentCanceledHook, } from "./x402ResourceServer"; +export type { + SchemeEnrichPaymentRequiredResponseHook, + SchemePaymentRequiredContext, + SchemeEnrichSettlementPayloadHook, + SchemeEnrichSettlementResponseHook, +} from "../types/mechanisms"; export { + assertAdditivePayloadEnrichment, + assertAdditiveSettlementExtra, + assertAcceptsAdditiveExtraAfterSchemeEnrich, assertAcceptsAllowlistedAfterExtensionEnrich, assertSettleResponseCoreUnchanged, isVacantStringField, snapshotPaymentRequirementsList, snapshotSettleResponseCore, -} from "./extensionResponsePolicy"; -export type { SettleResponseCoreSnapshot } from "./extensionResponsePolicy"; +} from "./hookPolicy"; +export type { SettleResponseCoreSnapshot } from "./hookPolicy"; export { HTTPFacilitatorClient } from "../http/httpFacilitatorClient"; export type { FacilitatorClient, FacilitatorConfig } from "../http/httpFacilitatorClient"; diff --git a/typescript/packages/core/src/server/x402ResourceServer.ts b/typescript/packages/core/src/server/x402ResourceServer.ts index b20a5cf939..01965382a1 100644 --- a/typescript/packages/core/src/server/x402ResourceServer.ts +++ b/typescript/packages/core/src/server/x402ResourceServer.ts @@ -11,16 +11,20 @@ import { PaymentRequired, ResourceInfo, } from "../types/payments"; -import { SchemeNetworkServer } from "../types/mechanisms"; +import { SchemeNetworkServer, SchemePaymentRequiredContext } from "../types/mechanisms"; import { Price, Network, ResourceServerExtension, ResourceServerExtensionHooks } from "../types"; import type { DeepReadonly } from "../types/readonly"; import { deepEqual, findByNetworkAndScheme } from "../utils"; import { assertAcceptsAllowlistedAfterExtensionEnrich, + assertAcceptsAdditiveExtraAfterSchemeEnrich, + assertAdditivePayloadEnrichment, + assertAdditiveSettlementExtra, assertSettleResponseCoreUnchanged, + mergeAdditiveSettlementExtra, snapshotPaymentRequirementsList, snapshotSettleResponseCore, -} from "./extensionResponsePolicy"; +} from "./hookPolicy"; import { FacilitatorClient, HTTPFacilitatorClient } from "../http/httpFacilitatorClient"; import { x402Version } from ".."; @@ -72,6 +76,20 @@ export interface VerifyResultContext extends VerifyContext { result: DeepReadonly; } +/** + * Optional acknowledgement body returned to the caller when an `AfterVerifyHook` + * requests that the resource handler be skipped for a self-contained operation + * (e.g. cooperative refund). Travels in-process only — never on the facilitator wire. + */ +export interface SkipHandlerDirective { + contentType?: string; + body?: unknown; +} + +export type ResourceVerifyRespone = VerifyResponse & { + skipHandler?: SkipHandlerDirective; +}; + export interface VerifyFailureContext extends VerifyContext { error: Error; } @@ -91,11 +109,33 @@ export interface SettleFailureContext extends SettleContext { error: Error; } +export type VerifiedPaymentCancellationReason = "handler_threw" | "handler_failed"; + +export interface VerifiedPaymentCanceledContext extends SettleContext { + reason: VerifiedPaymentCancellationReason; + error?: unknown; + responseStatus?: number; +} + +export interface VerifiedPaymentCancelOptions { + reason: VerifiedPaymentCancellationReason; + error?: unknown; + responseStatus?: number; +} + +export interface PaymentCancellationDispatcher { + cancel(options: VerifiedPaymentCancelOptions): Promise; +} + export type BeforeVerifyHook = ( context: VerifyContext, -) => Promise; +) => Promise< + void | { abort: true; reason: string; message?: string } | { skip: true; result: VerifyResponse } +>; -export type AfterVerifyHook = (context: VerifyResultContext) => Promise; +export type AfterVerifyHook = ( + context: VerifyResultContext, +) => Promise; export type OnVerifyFailureHook = ( context: VerifyFailureContext, @@ -103,7 +143,9 @@ export type OnVerifyFailureHook = ( export type BeforeSettleHook = ( context: SettleContext, -) => Promise; +) => Promise< + void | { abort: true; reason: string; message?: string } | { skip: true; result: SettleResponse } +>; export type AfterSettleHook = (context: SettleResultContext) => Promise; @@ -111,6 +153,10 @@ export type OnSettleFailureHook = ( context: SettleFailureContext, ) => Promise; +export type OnVerifiedPaymentCanceledHook = ( + context: VerifiedPaymentCanceledContext, +) => Promise; + /** * Optional overrides for settlement parameters. * Used to support partial settlement (e.g., upto scheme billing by actual usage). @@ -179,17 +225,21 @@ export function resolveSettlementOverrideAmount( return rawAmount; } -type ExtensionAdapterHandles = { +type HookAdapterHandles = { beforeVerify?: BeforeVerifyHook; afterVerify?: AfterVerifyHook; onVerifyFailure?: OnVerifyFailureHook; beforeSettle?: BeforeSettleHook; afterSettle?: AfterSettleHook; onSettleFailure?: OnSettleFailureHook; + onVerifiedPaymentCanceled?: OnVerifiedPaymentCanceledHook; }; -/** Keys shared by {@link ExtensionAdapterHandles} and manual `*Hooks` arrays on the server. */ -type ResourceServerHookPhase = keyof ExtensionAdapterHandles; +type ExtensionAdapterHandles = HookAdapterHandles; +type SchemeAdapterHandles = HookAdapterHandles; + +/** Keys shared by adapter handles and manual `*Hooks` arrays on the server. */ +type ResourceServerHookPhase = keyof HookAdapterHandles; type ResourceServerManualHookArrayKey = `${ResourceServerHookPhase}Hooks`; @@ -200,6 +250,7 @@ type ResourceServerManualHookArrayKey = `${ResourceServerHookPhase}Hooks`; export class x402ResourceServer { private facilitatorClients: FacilitatorClient[]; private registeredServerSchemes: Map> = new Map(); + private schemeHookAdapters: Map> = new Map(); private supportedResponsesMap: Map>> = new Map(); private facilitatorClientsMap: Map>> = @@ -213,6 +264,7 @@ export class x402ResourceServer { private beforeSettleHooks: BeforeSettleHook[] = []; private afterSettleHooks: AfterSettleHook[] = []; private onSettleFailureHooks: OnSettleFailureHook[] = []; + private onVerifiedPaymentCanceledHooks: OnVerifiedPaymentCanceledHook[] = []; /** * Creates a new x402ResourceServer instance. @@ -247,8 +299,34 @@ export class x402ResourceServer { } const serverByScheme = this.registeredServerSchemes.get(network)!; - if (!serverByScheme.has(server.scheme)) { - serverByScheme.set(server.scheme, server); + serverByScheme.set(server.scheme, server); + + if (!this.schemeHookAdapters.has(network)) { + this.schemeHookAdapters.set(network, new Map()); + } + + const hooksByScheme = this.schemeHookAdapters.get(network)!; + const hooks = server.schemeHooks; + if (!hooks) { + hooksByScheme.delete(server.scheme); + return this; + } + + const handles: SchemeAdapterHandles = {}; + if (hooks.onBeforeVerify) handles.beforeVerify = hooks.onBeforeVerify; + if (hooks.onAfterVerify) handles.afterVerify = hooks.onAfterVerify; + if (hooks.onVerifyFailure) handles.onVerifyFailure = hooks.onVerifyFailure; + if (hooks.onBeforeSettle) handles.beforeSettle = hooks.onBeforeSettle; + if (hooks.onAfterSettle) handles.afterSettle = hooks.onAfterSettle; + if (hooks.onSettleFailure) handles.onSettleFailure = hooks.onSettleFailure; + if (hooks.onVerifiedPaymentCanceled) { + handles.onVerifiedPaymentCanceled = hooks.onVerifiedPaymentCanceled; + } + + if (Object.keys(handles).length > 0) { + hooksByScheme.set(server.scheme, handles); + } else { + hooksByScheme.delete(server.scheme); } return this; @@ -329,6 +407,7 @@ export class x402ResourceServer { bindExtensionHookAdapter("onBeforeSettle", "beforeSettle"); bindExtensionHookAdapter("onAfterSettle", "afterSettle"); bindExtensionHookAdapter("onSettleFailure", "onSettleFailure"); + bindExtensionHookAdapter("onVerifiedPaymentCanceled", "onVerifiedPaymentCanceled"); if (Object.keys(handles).length > 0) { this.extensionHookAdapters.set(extensionKey, handles); } else { @@ -457,6 +536,17 @@ export class x402ResourceServer { return this; } + /** + * Register a hook to execute when verified payment work is canceled before settlement. + * + * @param hook - The hook function to register + * @returns The x402ResourceServer instance for chaining + */ + onVerifiedPaymentCanceled(hook: OnVerifiedPaymentCanceledHook): x402ResourceServer { + this.onVerifiedPaymentCanceledHooks.push(hook); + return this; + } + /** * Initialize by fetching supported kinds from all facilitators * Creates mappings for supported responses and facilitator clients @@ -639,13 +729,9 @@ export class x402ResourceServer { }; // Delegate to the implementation for scheme-specific enhancements - // Note: enhancePaymentRequirements expects x402Version in the kind, so we add it back const requirement = await SchemeNetworkServer.enhancePaymentRequirements( baseRequirements, - { - ...supportedKind, - x402Version, - }, + supportedKind, facilitatorExtensions, ); @@ -706,6 +792,7 @@ export class x402ResourceServer { * @param error - Error message * @param extensions - Optional declared extensions (for per-key enrichment) * @param transportContext - Optional transport-specific context (e.g., HTTP request, MCP tool context) + * @param paymentPayload - Optional failed payment payload for response-time scheme enrichment * @returns Payment required response object */ async createPaymentRequiredResponse( @@ -714,19 +801,21 @@ export class x402ResourceServer { error?: string, extensions?: Record, transportContext?: unknown, + paymentPayload?: PaymentPayload, ): Promise { const acceptsClone = requirements.map(req => ({ ...req, extra: structuredClone(req.extra), })); - let baselineAccepts = snapshotPaymentRequirementsList(acceptsClone); + let workingAccepts = acceptsClone; + let baselineAccepts = snapshotPaymentRequirementsList(workingAccepts); // V2 response with resource at top level let response: PaymentRequired = { x402Version: 2, error, resource: resourceInfo, - accepts: acceptsClone, + accepts: workingAccepts, }; // Add extensions if provided @@ -734,6 +823,39 @@ export class x402ResourceServer { response.extensions = extensions; } + for (let i = 0; i < workingAccepts.length; i++) { + const accept = workingAccepts[i]; + const scheme = findByNetworkAndScheme( + this.registeredServerSchemes, + accept.scheme, + accept.network as Network, + ); + if (!scheme?.enrichPaymentRequiredResponse) { + continue; + } + + const context: SchemePaymentRequiredContext = { + requirements: workingAccepts, + paymentPayload, + resourceInfo, + error, + paymentRequiredResponse: response, + transportContext, + }; + const enrichedAccepts = await scheme.enrichPaymentRequiredResponse(context); + if (enrichedAccepts !== undefined) { + workingAccepts = enrichedAccepts; + response.accepts = workingAccepts; + } + assertAcceptsAdditiveExtraAfterSchemeEnrich( + baselineAccepts, + response.accepts, + accept.scheme, + accept.network, + ); + baselineAccepts = snapshotPaymentRequirementsList(response.accepts); + } + // Let declared extensions add data to PaymentRequired response if (extensions) { for (const [key, declaration] of Object.entries(extensions)) { @@ -741,7 +863,7 @@ export class x402ResourceServer { if (extension?.enrichPaymentRequiredResponse) { try { const context: PaymentRequiredContext = { - requirements: acceptsClone, + requirements: workingAccepts, resourceInfo, error, paymentRequiredResponse: response, @@ -760,8 +882,8 @@ export class x402ResourceServer { } catch (error) { this.warnExtensionHookFailure(key, "enrichPaymentRequiredResponse", error); } - assertAcceptsAllowlistedAfterExtensionEnrich(baselineAccepts, acceptsClone, key); - baselineAccepts = snapshotPaymentRequirementsList(acceptsClone); + assertAcceptsAllowlistedAfterExtensionEnrich(baselineAccepts, workingAccepts, key); + baselineAccepts = snapshotPaymentRequirementsList(workingAccepts); } } } @@ -776,16 +898,21 @@ export class x402ResourceServer { * @param requirements - Requirements matched to the payload * @param declaredExtensions - Optional per-extension declarations for the request * @param transportContext - Optional transport-specific context (e.g. HTTP, MCP) - * @returns Facilitator verify outcome, or abort/recovery as driven by hooks + * @returns Facilitator verify outcome (optionally carrying a `skipHandler` directive), + * or abort/recovery as driven by hooks */ async verifyPayment( paymentPayload: PaymentPayload, requirements: PaymentRequirements, declaredExtensions?: Record, transportContext?: unknown, - ): Promise { + ): Promise { const resolvedDeclaredExtensions = declaredExtensions ?? {}; const extensionKeysInUse = Object.keys(resolvedDeclaredExtensions); + const matchedScheme = { + network: requirements.network as Network, + scheme: requirements.scheme, + }; const context: VerifyContext = { paymentPayload, @@ -794,7 +921,11 @@ export class x402ResourceServer { transportContext, }; - for (const { label, hook } of this.getLabeledHooks("beforeVerify", extensionKeysInUse)) { + for (const { label, hook } of this.getLabeledHooks( + "beforeVerify", + extensionKeysInUse, + matchedScheme, + )) { try { const result = await hook(context); if (result && "abort" in result && result.abort) { @@ -804,6 +935,14 @@ export class x402ResourceServer { invalidMessage: result.message, }; } + if (result && "skip" in result && result.skip) { + return this.runAfterVerifyHooks( + result.result, + context, + extensionKeysInUse, + matchedScheme, + ); + } } catch (error) { this.warnResourceServerHookFailure("beforeVerify", label, error); } @@ -845,28 +984,18 @@ export class x402ResourceServer { verifyResult = await facilitatorClient.verify(paymentPayload, requirements); } - // Execute afterVerify hooks - const resultContext: VerifyResultContext = { - ...context, - result: verifyResult, - }; - - for (const { label, hook } of this.getLabeledHooks("afterVerify", extensionKeysInUse)) { - try { - await hook(resultContext); - } catch (error) { - this.warnResourceServerHookFailure("afterVerify", label, error); - } - } - - return verifyResult; + return this.runAfterVerifyHooks(verifyResult, context, extensionKeysInUse, matchedScheme); } catch (error) { const failureContext: VerifyFailureContext = { ...context, error: error as Error, }; - for (const { label, hook } of this.getLabeledHooks("onVerifyFailure", extensionKeysInUse)) { + for (const { label, hook } of this.getLabeledHooks( + "onVerifyFailure", + extensionKeysInUse, + matchedScheme, + )) { try { const result = await hook(failureContext); if (result && "recovered" in result && result.recovered) { @@ -881,6 +1010,40 @@ export class x402ResourceServer { } } + /** + * Create cancellation controls for a verified payment attempt. + * + * @param paymentPayload - Signed payment payload from the client + * @param requirements - Requirements matched to the payload + * @param declaredExtensions - Optional per-extension declarations for the request + * @param transportContext - Optional transport-specific context + * @returns Cancellation controls for the verified payment attempt + */ + createPaymentCancellationDispatcher( + paymentPayload: PaymentPayload, + requirements: PaymentRequirements, + declaredExtensions?: Record, + transportContext?: unknown, + ): PaymentCancellationDispatcher { + const resolvedDeclaredExtensions = declaredExtensions ?? {}; + let cancelPromise: Promise | undefined; + + return { + cancel: (options: VerifiedPaymentCancelOptions) => { + if (!cancelPromise) { + cancelPromise = this.dispatchVerifiedPaymentCanceled( + paymentPayload, + requirements, + resolvedDeclaredExtensions, + options, + transportContext, + ); + } + return cancelPromise; + }, + }; + } + /** * Settle a verified payment * @@ -923,8 +1086,16 @@ export class x402ResourceServer { declaredExtensions: resolvedDeclaredExtensions, transportContext, }; + const matchedScheme = { + network: effectiveRequirements.network as Network, + scheme: effectiveRequirements.scheme, + }; - for (const { label, hook } of this.getLabeledHooks("beforeSettle", extensionKeysInUse)) { + for (const { label, hook } of this.getLabeledHooks( + "beforeSettle", + extensionKeysInUse, + matchedScheme, + )) { try { const result = await hook(context); if (result && "abort" in result && result.abort) { @@ -936,6 +1107,32 @@ export class x402ResourceServer { network: requirements.network, }); } + if (result && "skip" in result && result.skip) { + const settleResult = result.result; + const skipResultContext: SettleResultContext = { + ...context, + result: settleResult, + transportContext, + }; + for (const { label, hook } of this.getLabeledHooks( + "afterSettle", + extensionKeysInUse, + matchedScheme, + )) { + try { + await hook(skipResultContext); + } catch (error) { + this.warnResourceServerHookFailure("afterSettle", label, error); + } + } + await this.enrichSettlementResponse( + settleResult, + skipResultContext, + resolvedDeclaredExtensions, + matchedScheme, + ); + return settleResult; + } } catch (error) { if (error instanceof SettleError) { throw error; @@ -945,6 +1142,21 @@ export class x402ResourceServer { } try { + const scheme = findByNetworkAndScheme( + this.registeredServerSchemes, + matchedScheme.scheme, + matchedScheme.network, + ); + const payloadEnrichmentHook = scheme?.enrichSettlementPayload; + if (payloadEnrichmentHook) { + const label = `scheme "${matchedScheme.scheme}" enrichSettlementPayload`; + const enrichment = await payloadEnrichmentHook(context); + if (enrichment !== undefined) { + assertAdditivePayloadEnrichment(paymentPayload.payload, enrichment, label); + paymentPayload.payload = { ...paymentPayload.payload, ...enrichment }; + } + } + // Find the facilitator that supports this payment type const facilitatorClient = this.getFacilitatorClient( paymentPayload.x402Version, @@ -986,7 +1198,11 @@ export class x402ResourceServer { result: settleResult, }; - for (const { label, hook } of this.getLabeledHooks("afterSettle", extensionKeysInUse)) { + for (const { label, hook } of this.getLabeledHooks( + "afterSettle", + extensionKeysInUse, + matchedScheme, + )) { try { await hook(resultContext); } catch (error) { @@ -994,30 +1210,12 @@ export class x402ResourceServer { } } - // Let declared extensions add data to settlement response - if (Object.keys(resolvedDeclaredExtensions).length > 0) { - const settleCoreSnapshot = snapshotSettleResponseCore(settleResult); - for (const [key, declaration] of Object.entries(resolvedDeclaredExtensions)) { - const extension = this.registeredExtensions.get(key); - if (extension?.enrichSettlementResponse) { - try { - const extensionData = await extension.enrichSettlementResponse( - declaration, - resultContext, - ); - if (extensionData !== undefined) { - if (!settleResult.extensions) { - settleResult.extensions = {}; - } - settleResult.extensions[key] = extensionData; - } - } catch (error) { - this.warnExtensionHookFailure(key, "enrichSettlementResponse", error); - } - assertSettleResponseCoreUnchanged(settleCoreSnapshot, settleResult, key); - } - } - } + await this.enrichSettlementResponse( + settleResult, + resultContext, + resolvedDeclaredExtensions, + matchedScheme, + ); return settleResult; } catch (error) { @@ -1026,7 +1224,11 @@ export class x402ResourceServer { error: error as Error, }; - for (const { label, hook } of this.getLabeledHooks("onSettleFailure", extensionKeysInUse)) { + for (const { label, hook } of this.getLabeledHooks( + "onSettleFailure", + extensionKeysInUse, + matchedScheme, + )) { try { const result = await hook(failureContext); if (result && "recovered" in result && result.recovered) { @@ -1072,92 +1274,6 @@ export class x402ResourceServer { } } - /** - * Process a payment request - * - * @param paymentPayload - Optional payment payload if provided - * @param resourceConfig - Configuration for the protected resource - * @param resourceInfo - Information about the resource being accessed - * @param extensions - Optional extensions to include in the response - * @param transportContext - Optional transport context for extension hooks and enrichment - * @returns Processing result - */ - async processPaymentRequest( - paymentPayload: PaymentPayload | null, - resourceConfig: ResourceConfig, - resourceInfo: ResourceInfo, - extensions?: Record, - transportContext?: unknown, - ): Promise<{ - success: boolean; - requiresPayment?: PaymentRequired; - verificationResult?: VerifyResponse; - settlementResult?: SettleResponse; - error?: string; - }> { - const requirements = await this.buildPaymentRequirements(resourceConfig); - const resolvedRouteExtensions = extensions ?? {}; - - if (!paymentPayload) { - return { - success: false, - requiresPayment: await this.createPaymentRequiredResponse( - requirements, - resourceInfo, - "Payment required", - extensions, - transportContext, - ), - }; - } - - // findMatching must use the same accepts the client saw (extensions may rewrite requirements). - const paymentRequired = await this.createPaymentRequiredResponse( - requirements, - resourceInfo, - undefined, - extensions, - transportContext, - ); - const matchingRequirements = this.findMatchingRequirements( - paymentRequired.accepts, - paymentPayload, - ); - if (!matchingRequirements) { - return { - success: false, - requiresPayment: await this.createPaymentRequiredResponse( - requirements, - resourceInfo, - "No matching payment requirements found", - extensions, - transportContext, - ), - }; - } - - // Verify payment - const verificationResult = await this.verifyPayment( - paymentPayload, - matchingRequirements, - resolvedRouteExtensions, - transportContext, - ); - if (!verificationResult.isValid) { - return { - success: false, - error: verificationResult.invalidReason, - verificationResult, - }; - } - - // Payment verified, ready for settlement - return { - success: true, - verificationResult, - }; - } - /** * Logs a warning when a manual or extension adapter lifecycle hook throws. * @@ -1165,11 +1281,7 @@ export class x402ResourceServer { * @param label - Hook source label from {@link getLabeledHooks} (manual index or extension key) * @param error - Thrown value or rejection reason */ - private warnResourceServerHookFailure( - phase: ResourceServerHookPhase, - label: string, - error: unknown, - ): void { + private warnResourceServerHookFailure(phase: string, label: string, error: unknown): void { const detail = error instanceof Error ? error.message : String(error); console.warn(`[x402] Resource server ${phase} hook threw (${label}): ${detail}`); } @@ -1187,16 +1299,162 @@ export class x402ResourceServer { } /** - * Manual hooks first, then extension adapters for keys in `extensionKeysInUse`. + * Executes after-verify hooks for facilitator and hook-provided verify results. + * + * @param verifyResult - Verify response passed to after-verify hooks. + * @param context - Verify context shared with before-verify hooks. + * @param extensionKeysInUse - Declared extension keys for this request. + * @param matchedScheme - Scheme/network selected for this payment. + * @param matchedScheme.network - Matched payment network. + * @param matchedScheme.scheme - Matched payment scheme. + * @returns Verify response with any in-process skip handler directive. + */ + private async runAfterVerifyHooks( + verifyResult: VerifyResponse, + context: VerifyContext, + extensionKeysInUse: readonly string[], + matchedScheme: { network: Network; scheme: string }, + ): Promise { + const resultContext: VerifyResultContext = { + ...context, + result: verifyResult, + }; + + let skipHandler: SkipHandlerDirective | undefined; + for (const { label, hook } of this.getLabeledHooks( + "afterVerify", + extensionKeysInUse, + matchedScheme, + )) { + try { + const directive = await hook(resultContext); + if (directive && "skipHandler" in directive && directive.skipHandler) { + skipHandler = directive.response ?? {}; + } + } catch (error) { + this.warnResourceServerHookFailure("afterVerify", label, error); + } + } + + return skipHandler ? { ...verifyResult, skipHandler } : verifyResult; + } + + /** + * Runs response enrichment after settlement lifecycle hooks complete. + * + * @param settleResult - Mutable settlement result being returned to the caller + * @param context - Read-only hook context for enrichment callbacks + * @param declaredExtensions - Extension declarations present on this payment + * @param matchedScheme - Scheme/network selected for this settlement + * @param matchedScheme.network - Matched payment network + * @param matchedScheme.scheme - Matched payment scheme + */ + private async enrichSettlementResponse( + settleResult: SettleResponse, + context: SettleResultContext, + declaredExtensions: Record, + matchedScheme: { network: Network; scheme: string }, + ): Promise { + if (Object.keys(declaredExtensions).length > 0) { + const settleCoreSnapshot = snapshotSettleResponseCore(settleResult); + for (const [key, declaration] of Object.entries(declaredExtensions)) { + const extension = this.registeredExtensions.get(key); + if (!extension?.enrichSettlementResponse) continue; + + try { + const extensionData = await extension.enrichSettlementResponse(declaration, context); + if (extensionData !== undefined) { + if (!settleResult.extensions) { + settleResult.extensions = {}; + } + settleResult.extensions[key] = extensionData; + } + } catch (error) { + this.warnExtensionHookFailure(key, "enrichSettlementResponse", error); + } + assertSettleResponseCoreUnchanged(settleCoreSnapshot, settleResult, key); + } + } + + const scheme = findByNetworkAndScheme( + this.registeredServerSchemes, + matchedScheme.scheme, + matchedScheme.network, + ); + const hook = scheme?.enrichSettlementResponse; + if (!hook) return; + + const label = `scheme "${matchedScheme.scheme}" enrichSettlementResponse`; + try { + const enrichment = await hook(context); + if (enrichment === undefined) return; + + assertAdditiveSettlementExtra(settleResult.extra ?? {}, enrichment, label); + settleResult.extra = mergeAdditiveSettlementExtra(settleResult.extra ?? {}, enrichment); + } catch (error) { + this.warnResourceServerHookFailure("enrichSettlementResponse", label, error); + } + } + + /** + * Notify hooks that verified work ended before settlement. + * + * @param paymentPayload - Signed payment payload from the client + * @param requirements - Requirements matched to the payload + * @param declaredExtensions - Optional per-extension declarations for the request + * @param options - Cancellation reason and optional diagnostics + * @param fallbackTransportContext - Optional transport-specific context + */ + private async dispatchVerifiedPaymentCanceled( + paymentPayload: PaymentPayload, + requirements: PaymentRequirements, + declaredExtensions: Record, + options: VerifiedPaymentCancelOptions, + fallbackTransportContext?: unknown, + ): Promise { + const extensionKeysInUse = Object.keys(declaredExtensions); + const matchedScheme = { + network: requirements.network as Network, + scheme: requirements.scheme, + }; + const context: VerifiedPaymentCanceledContext = { + paymentPayload, + requirements, + declaredExtensions, + transportContext: fallbackTransportContext, + reason: options.reason, + error: options.error, + responseStatus: options.responseStatus, + }; + + for (const { label, hook } of this.getLabeledHooks( + "onVerifiedPaymentCanceled", + extensionKeysInUse, + matchedScheme, + )) { + try { + await hook(context); + } catch (error) { + this.warnResourceServerHookFailure("onVerifiedPaymentCanceled", label, error); + } + } + } + + /** + * Manual hooks first, then the matched scheme adapter, then extension adapters for keys in use. * Each entry carries a stable label for logging when a hook throws. * * @param phase - Hook slot (e.g. `beforeVerify`) * @param extensionKeysInUse - Declared extension keys for this request + * @param matchedScheme - Scheme/network selected for this payment + * @param matchedScheme.network - Matched payment network + * @param matchedScheme.scheme - Matched payment scheme * @returns Hooks in invocation order with source labels */ private getLabeledHooks

( phase: P, extensionKeysInUse: readonly string[], + matchedScheme?: { network: Network; scheme: string }, ): Array<{ label: string; hook: NonNullable; @@ -1210,6 +1468,21 @@ export class x402ResourceServer { out.push({ label: `manual ${phase} hook #${index}`, hook }); }); + if (matchedScheme) { + const schemeHandles = findByNetworkAndScheme( + this.schemeHookAdapters, + matchedScheme.scheme, + matchedScheme.network, + ); + const hook = schemeHandles?.[phase]; + if (hook !== undefined) { + out.push({ + label: `scheme "${matchedScheme.scheme}" ${phase}`, + hook, + }); + } + } + const inUse = new Set(extensionKeysInUse); for (const [extensionKey, adapterHandles] of this.extensionHookAdapters.entries()) { if (!inUse.has(extensionKey)) continue; diff --git a/typescript/packages/core/src/types/extensions.ts b/typescript/packages/core/src/types/extensions.ts index e188f1fbcc..006bdef9a3 100644 --- a/typescript/packages/core/src/types/extensions.ts +++ b/typescript/packages/core/src/types/extensions.ts @@ -7,6 +7,7 @@ import type { VerifyFailureContext, SettleContext, SettleFailureContext, + VerifiedPaymentCanceledContext, } from "../server/x402ResourceServer"; export type { @@ -17,6 +18,7 @@ export type { VerifyFailureContext, SettleContext, SettleFailureContext, + VerifiedPaymentCanceledContext, }; export interface FacilitatorExtension { @@ -31,7 +33,11 @@ export interface ResourceServerExtensionHooks { onBeforeVerify?: ( declaration: unknown, context: VerifyContext, - ) => Promise; + ) => Promise< + | void + | { abort: true; reason: string; message?: string } + | { skip: true; result: VerifyResponse } + >; onAfterVerify?: (declaration: unknown, context: VerifyResultContext) => Promise; onVerifyFailure?: ( declaration: unknown, @@ -40,12 +46,20 @@ export interface ResourceServerExtensionHooks { onBeforeSettle?: ( declaration: unknown, context: SettleContext, - ) => Promise; + ) => Promise< + | void + | { abort: true; reason: string; message?: string } + | { skip: true; result: SettleResponse } + >; onAfterSettle?: (declaration: unknown, context: SettleResultContext) => Promise; onSettleFailure?: ( declaration: unknown, context: SettleFailureContext, ) => Promise; + onVerifiedPaymentCanceled?: ( + declaration: unknown, + context: VerifiedPaymentCanceledContext, + ) => Promise; } export interface ResourceServerExtension { diff --git a/typescript/packages/core/src/types/facilitator.ts b/typescript/packages/core/src/types/facilitator.ts index e330967f03..f1d937051e 100644 --- a/typescript/packages/core/src/types/facilitator.ts +++ b/typescript/packages/core/src/types/facilitator.ts @@ -13,6 +13,7 @@ export type VerifyResponse = { invalidMessage?: string; payer?: string; extensions?: Record; + extra?: Record; }; export type SettleRequest = { @@ -31,6 +32,7 @@ export type SettleResponse = { /** Actual amount settled in atomic token units. Present for schemes like `upto` where settlement amount may differ from the authorized maximum. */ amount?: string; extensions?: Record; + extra?: Record; }; export type SupportedKind = { @@ -59,7 +61,7 @@ export class VerifyError extends Error { * Creates a VerifyError from a failed verification response. * * @param statusCode - HTTP status code from the facilitator - * @param response - The verify response containing error details + * @param response - The verify response containing failure details */ constructor(statusCode: number, response: VerifyResponse) { const reason = response.invalidReason || "unknown reason"; diff --git a/typescript/packages/core/src/types/index.ts b/typescript/packages/core/src/types/index.ts index 2469e7eee6..87d7949533 100644 --- a/typescript/packages/core/src/types/index.ts +++ b/typescript/packages/core/src/types/index.ts @@ -4,6 +4,7 @@ export type { SettleRequest, SettleResponse, SupportedResponse, + SupportedKind, } from "./facilitator"; export { VerifyError, @@ -19,12 +20,16 @@ export type { } from "./payments"; export type { SchemeNetworkClient, + SchemeClientHooks, SchemeNetworkFacilitator, SchemeNetworkServer, + SchemeServerHooks, MoneyParser, PaymentPayloadResult, PaymentPayloadContext, FacilitatorContext, + SchemePaymentRequiredContext, + SchemeEnrichPaymentRequiredResponseHook, } from "./mechanisms"; export type { PaymentRequirementsV1, PaymentRequiredV1, PaymentPayloadV1 } from "./v1"; export type { @@ -38,6 +43,7 @@ export type { VerifyFailureContext, SettleContext, SettleFailureContext, + VerifiedPaymentCanceledContext, } from "./extensions"; export type { DeepReadonly } from "./readonly"; diff --git a/typescript/packages/core/src/types/mechanisms.ts b/typescript/packages/core/src/types/mechanisms.ts index fb3fa46a92..2a0cc691b5 100644 --- a/typescript/packages/core/src/types/mechanisms.ts +++ b/typescript/packages/core/src/types/mechanisms.ts @@ -1,8 +1,25 @@ -import { SettleResponse, VerifyResponse } from "./facilitator"; -import { PaymentRequirements } from "./payments"; -import { PaymentPayload } from "./payments"; +import { SettleResponse, SupportedKind, VerifyResponse } from "./facilitator"; +import { PaymentPayload, PaymentRequired, PaymentRequirements, ResourceInfo } from "./payments"; import { Price, Network, AssetAmount } from "."; import { FacilitatorExtension } from "./extensions"; +import type { DeepReadonly } from "./readonly"; +import type { + BeforeVerifyHook, + AfterVerifyHook, + BeforeSettleHook, + AfterSettleHook, + OnVerifyFailureHook, + OnSettleFailureHook, + OnVerifiedPaymentCanceledHook, + SettleContext, + SettleResultContext, +} from "../server/x402ResourceServer"; +import type { + BeforePaymentCreationHook, + AfterPaymentCreationHook, + OnPaymentCreationFailureHook, + OnPaymentResponseHook, +} from "../client/x402Client"; /** * Money parser function that converts a numeric amount to an AssetAmount @@ -35,8 +52,16 @@ export interface PaymentPayloadContext { extensions?: Record; } +export interface SchemeClientHooks { + onBeforePaymentCreation?: BeforePaymentCreationHook; + onAfterPaymentCreation?: AfterPaymentCreationHook; + onPaymentCreationFailure?: OnPaymentCreationFailureHook; + onPaymentResponse?: OnPaymentResponseHook; +} + export interface SchemeNetworkClient { readonly scheme: string; + readonly schemeHooks?: SchemeClientHooks; createPaymentPayload( x402Version: number, @@ -128,8 +153,43 @@ export interface SchemeNetworkFacilitator { ): Promise; } +export interface SchemeServerHooks { + onBeforeVerify?: BeforeVerifyHook; + onAfterVerify?: AfterVerifyHook; + onBeforeSettle?: BeforeSettleHook; + onAfterSettle?: AfterSettleHook; + onVerifyFailure?: OnVerifyFailureHook; + onSettleFailure?: OnSettleFailureHook; + onVerifiedPaymentCanceled?: OnVerifiedPaymentCanceledHook; +} + +export type SchemeEnrichSettlementPayloadHook = ( + ctx: SettleContext, +) => Promise | void>; + +export type SchemeEnrichSettlementResponseHook = ( + ctx: SettleResultContext, +) => Promise | void>; + +export interface SchemePaymentRequiredContext { + requirements: PaymentRequirements[]; + paymentPayload?: DeepReadonly; + resourceInfo: ResourceInfo; + error?: string; + paymentRequiredResponse: PaymentRequired; + transportContext?: unknown; +} + +export type SchemeEnrichPaymentRequiredResponseHook = ( + ctx: SchemePaymentRequiredContext, +) => Promise; + export interface SchemeNetworkServer { readonly scheme: string; + readonly schemeHooks?: SchemeServerHooks; + enrichPaymentRequiredResponse?: SchemeEnrichPaymentRequiredResponseHook; + enrichSettlementPayload?: SchemeEnrichSettlementPayloadHook; + enrichSettlementResponse?: SchemeEnrichSettlementResponseHook; /** * Convert a user-friendly price to the scheme's specific amount and asset format @@ -173,12 +233,7 @@ export interface SchemeNetworkServer { */ enhancePaymentRequirements( paymentRequirements: PaymentRequirements, - supportedKind: { - x402Version: number; - scheme: string; - network: Network; - extra?: Record; - }, + supportedKind: SupportedKind, facilitatorExtensions: string[], ): Promise; } diff --git a/typescript/packages/core/test/mocks/generic/MockSchemeClient.ts b/typescript/packages/core/test/mocks/generic/MockSchemeClient.ts index 96c59889ba..e1e8619c28 100644 --- a/typescript/packages/core/test/mocks/generic/MockSchemeClient.ts +++ b/typescript/packages/core/test/mocks/generic/MockSchemeClient.ts @@ -1,4 +1,4 @@ -import { SchemeNetworkClient } from "../../../src/types/mechanisms"; +import { SchemeClientHooks, SchemeNetworkClient } from "../../../src/types/mechanisms"; import { PaymentPayload, PaymentRequirements } from "../../../src/types/payments"; /** @@ -6,6 +6,7 @@ import { PaymentPayload, PaymentRequirements } from "../../../src/types/payments */ export class MockSchemeNetworkClient implements SchemeNetworkClient { public readonly scheme: string; + public readonly schemeHooks?: SchemeClientHooks; private payloadResult: Pick | Error; // Call tracking @@ -22,12 +23,14 @@ export class MockSchemeNetworkClient implements SchemeNetworkClient { constructor( scheme: string, payloadResult?: Pick | Error, + schemeHooks?: SchemeClientHooks, ) { this.scheme = scheme; this.payloadResult = payloadResult || { x402Version: 2, payload: { signature: "mock_signature", from: "mock_address" }, }; + this.schemeHooks = schemeHooks; } /** diff --git a/typescript/packages/core/test/mocks/generic/MockSchemeServer.ts b/typescript/packages/core/test/mocks/generic/MockSchemeServer.ts index 803124b1d4..ddbce1c93a 100644 --- a/typescript/packages/core/test/mocks/generic/MockSchemeServer.ts +++ b/typescript/packages/core/test/mocks/generic/MockSchemeServer.ts @@ -1,19 +1,22 @@ -import { SchemeNetworkServer } from "../../../src/types/mechanisms"; +import { SchemeNetworkServer, SchemeServerHooks } from "../../../src/types/mechanisms"; import { AssetAmount, Network, Price } from "../../../src/types"; import { PaymentRequirements } from "../../../src/types/payments"; +import type { SupportedKind } from "../../../src/types/facilitator"; /** * Mock scheme network server for testing. */ export class MockSchemeNetworkServer implements SchemeNetworkServer { public readonly scheme: string; + public readonly schemeHooks?: SchemeServerHooks; private parsePriceResult: AssetAmount | Error; private enhanceResult: PaymentRequirements | Error | null = null; private assetDecimalsResult: number | null = null; // Call tracking public parsePriceCalls: Array<{ price: Price; network: Network }> = []; - public enhanceCalls: Array<{ requirements: PaymentRequirements }> = []; + public enhanceCalls: Array<{ requirements: PaymentRequirements; supportedKind: SupportedKind }> = + []; /** * @@ -23,9 +26,11 @@ export class MockSchemeNetworkServer implements SchemeNetworkServer { constructor( scheme: string, parsePriceResult: AssetAmount = { amount: "1000000", asset: "USDC", extra: {} }, + schemeHooks?: SchemeServerHooks, ) { this.scheme = scheme; this.parsePriceResult = parsePriceResult; + this.schemeHooks = schemeHooks; } /** @@ -54,15 +59,10 @@ export class MockSchemeNetworkServer implements SchemeNetworkServer { */ async enhancePaymentRequirements( paymentRequirements: PaymentRequirements, - _supportedKind: { - x402Version: number; - scheme: string; - network: Network; - extra?: Record; - }, + _supportedKind: SupportedKind, _facilitatorExtensions: string[], ): Promise { - this.enhanceCalls.push({ requirements: paymentRequirements }); + this.enhanceCalls.push({ requirements: paymentRequirements, supportedKind: _supportedKind }); if (this.enhanceResult instanceof Error) { throw this.enhanceResult; diff --git a/typescript/packages/core/test/unit/client/x402Client.test.ts b/typescript/packages/core/test/unit/client/x402Client.test.ts index b9401ca689..b6e9dcd458 100644 --- a/typescript/packages/core/test/unit/client/x402Client.test.ts +++ b/typescript/packages/core/test/unit/client/x402Client.test.ts @@ -213,6 +213,69 @@ describe("x402Client", () => { expect(evmClient.createPaymentPayloadCalls.length).toBe(1); }); + + it("runs scheme hooks only for the selected network pattern and scheme", async () => { + const client = new x402Client(); + const order: string[] = []; + + client.onBeforePaymentCreation(async () => { + order.push("manual"); + }); + client.register( + "eip155:*" as Network, + new MockSchemeNetworkClient("exact", undefined, { + onBeforePaymentCreation: async () => { + order.push("scheme"); + }, + }), + ); + client.register( + "eip155:*" as Network, + new MockSchemeNetworkClient("other", undefined, { + onBeforePaymentCreation: async () => { + order.push("other-scheme"); + }, + }), + ); + client.register( + "solana:*" as Network, + new MockSchemeNetworkClient("exact", undefined, { + onBeforePaymentCreation: async () => { + order.push("other-network"); + }, + }), + ); + + await client.createPaymentPayload( + buildPaymentRequired({ + accepts: [ + buildPaymentRequirements({ scheme: "exact", network: "eip155:8453" as Network }), + ], + }), + ); + + expect(order).toEqual(["manual", "scheme"]); + }); + + it("removes scheme client hook adapters when a scheme is re-registered without hooks", async () => { + const client = new x402Client(); + let calls = 0; + + client.register( + "test:network" as Network, + new MockSchemeNetworkClient("test-scheme", undefined, { + onBeforePaymentCreation: async () => { + calls++; + }, + }), + ); + await client.createPaymentPayload(buildPaymentRequired()); + expect(calls).toBe(1); + + client.register("test:network" as Network, new MockSchemeNetworkClient("test-scheme")); + await client.createPaymentPayload(buildPaymentRequired()); + expect(calls).toBe(1); + }); }); describe("registerV1", () => { diff --git a/typescript/packages/core/test/unit/http/x402HTTPResourceServer.hooks.test.ts b/typescript/packages/core/test/unit/http/x402HTTPResourceServer.hooks.test.ts index 6d20c9b0ce..5382aca8f2 100644 --- a/typescript/packages/core/test/unit/http/x402HTTPResourceServer.hooks.test.ts +++ b/typescript/packages/core/test/unit/http/x402HTTPResourceServer.hooks.test.ts @@ -329,6 +329,60 @@ describe("x402HTTPResourceServer Hooks", () => { expect(receivedContext?.transportContext).toEqual(transportContext); }); + it("should merge nested scheme settlement response enrichment", async () => { + const schemeWithEnrichment = extensionMockScheme as MockSchemeNetworkServer & { + enrichSettlementResponse: () => Promise>; + }; + schemeWithEnrichment.enrichSettlementResponse = async () => ({ + chargedAmount: "1000", + channelState: { + chargedCumulativeAmount: "1000", + }, + }); + + const routes = { + "/api/test": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$1.00" as Price, + network: "eip155:8453" as Network, + }, + }, + }; + const httpServer = new x402HTTPResourceServer(extensionResourceServer, routes); + const payload = buildPaymentPayload(); + const requirements = buildPaymentRequirements({ + scheme: "exact", + network: "eip155:8453" as Network, + }); + + extensionMockFacilitator.setSettleResponse( + buildSettleResponse({ + success: true, + network: "eip155:8453" as Network, + extra: { + channelState: { + channelId: "0xchannel", + balance: "10000", + }, + }, + }), + ); + + const result = await httpServer.processSettlement(payload, requirements); + + expect(result.success).toBe(true); + expect(result.extra).toEqual({ + chargedAmount: "1000", + channelState: { + channelId: "0xchannel", + balance: "10000", + chargedCumulativeAmount: "1000", + }, + }); + }); + it("should have undefined transportContext when not provided", async () => { let receivedContext: SettleResultContext | undefined; diff --git a/typescript/packages/core/test/unit/http/x402HTTPResourceService.test.ts b/typescript/packages/core/test/unit/http/x402HTTPResourceService.test.ts index 2dda52f143..15754a7127 100644 --- a/typescript/packages/core/test/unit/http/x402HTTPResourceService.test.ts +++ b/typescript/packages/core/test/unit/http/x402HTTPResourceService.test.ts @@ -197,7 +197,7 @@ describe("x402HTTPResourceServer", () => { const result = await httpServer.processHTTPRequest(context); expect(contextReceived).toBeDefined(); - expect(contextReceived?.path).toBe("/api/dynamic"); + expect((contextReceived as HTTPRequestContext | null)?.path).toBe("/api/dynamic"); expect(result.type).toBe("payment-error"); // No payment provided }); @@ -289,7 +289,7 @@ describe("x402HTTPResourceServer", () => { await httpServer.processHTTPRequest(context); expect(contextReceived).toBeDefined(); - expect(contextReceived?.path).toBe("/api/dynamic"); + expect((contextReceived as HTTPRequestContext | null)?.path).toBe("/api/dynamic"); }); it("should use static payTo if not a function", async () => { @@ -726,6 +726,68 @@ describe("x402HTTPResourceServer", () => { } }); + it("threads the failed payment payload into 402 response enrichment", async () => { + mockFacilitator.setVerifyResponse( + buildVerifyResponse({ isValid: false, invalidReason: "stale_state" }), + ); + const scheme = mockScheme as MockSchemeNetworkServer & { + enrichPaymentRequiredResponse: NonNullable< + import("../../../src/types").SchemeNetworkServer["enrichPaymentRequiredResponse"] + >; + }; + let sawFailedPayload = false; + scheme.enrichPaymentRequiredResponse = async ctx => { + if (ctx.error !== "stale_state") { + return; + } + sawFailedPayload = ctx.paymentPayload?.payload.signature === "test_signature"; + ctx.requirements[0].extra.ChannelState = { channelId: "0x123" }; + }; + + const routes = { + "/api/test": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$1.00" as Price, + network: "eip155:8453" as Network, + }, + }, + }; + const httpServer = new x402HTTPResourceServer(ResourceServer, routes); + const matchingRequirements = buildPaymentRequirements({ + scheme: "exact", + network: "eip155:8453" as Network, + payTo: "0xabc", + amount: "1000000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + maxTimeoutSeconds: 300, + extra: {}, + }); + const payload = buildPaymentPayload({ accepted: matchingRequirements }); + const { decodePaymentRequiredHeader, encodePaymentSignatureHeader } = await import( + "../../../src/http" + ); + const adapter = new MockHTTPAdapter({ + "payment-signature": encodePaymentSignatureHeader(payload), + }); + + const result = await httpServer.processHTTPRequest({ + adapter, + path: "/api/test", + method: "GET", + }); + + expect(result.type).toBe("payment-error"); + if (result.type === "payment-error") { + const paymentRequired = decodePaymentRequiredHeader( + result.response.headers["PAYMENT-REQUIRED"], + ); + expect(sawFailedPayload).toBe(true); + expect(paymentRequired.accepts[0].extra.ChannelState).toEqual({ channelId: "0x123" }); + } + }); + it("should delegate verification to resource service", async () => { const routes = { "/api/test": { @@ -1022,6 +1084,66 @@ describe("x402HTTPResourceServer", () => { } }); + it("should bypass the resource handler when an AfterVerifyHook returns skipHandler", async () => { + mockFacilitator.setVerifyResponse({ + isValid: true, + payer: "0xpayer", + }); + + ResourceServer.onAfterVerify(async () => ({ + skipHandler: true, + response: { + contentType: "application/json", + body: { message: "Refund acknowledged" }, + }, + })); + + const routes = { + "/api/refund": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$1.00" as Price, + network: "eip155:8453" as Network, + }, + }, + }; + + const httpServer = new x402HTTPResourceServer(ResourceServer, routes); + + const matchingRequirements = buildPaymentRequirements({ + scheme: "exact", + network: "eip155:8453" as Network, + payTo: "0xabc", + amount: "1000000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + maxTimeoutSeconds: 300, + extra: {}, + }); + const payload = buildPaymentPayload({ accepted: matchingRequirements }); + const { encodePaymentSignatureHeader } = await import("../../../src/http"); + const paymentHeader = encodePaymentSignatureHeader(payload); + + const adapter = new MockHTTPAdapter({ "payment-signature": paymentHeader }); + const context: HTTPRequestContext = { + adapter, + path: "/api/refund", + method: "GET", + }; + + const result = await httpServer.processHTTPRequest(context); + + expect(mockFacilitator.verifyCalls.length).toBe(1); + expect(mockFacilitator.settleCalls.length).toBe(1); + + expect(result.type).toBe("payment-error"); + if (result.type === "payment-error") { + expect(result.response.status).toBe(200); + expect(result.response.headers["PAYMENT-RESPONSE"]).toBeDefined(); + expect(result.response.body).toEqual({ message: "Refund acknowledged" }); + } + }); + it("should not treat API clients as browsers", async () => { const routes = { "/api/test": { diff --git a/typescript/packages/core/test/unit/server/extensionResponsePolicy.test.ts b/typescript/packages/core/test/unit/server/hookPolicy.test.ts similarity index 62% rename from typescript/packages/core/test/unit/server/extensionResponsePolicy.test.ts rename to typescript/packages/core/test/unit/server/hookPolicy.test.ts index 798725db21..d9d43b05cb 100644 --- a/typescript/packages/core/test/unit/server/extensionResponsePolicy.test.ts +++ b/typescript/packages/core/test/unit/server/hookPolicy.test.ts @@ -1,15 +1,18 @@ import { describe, it, expect } from "vitest"; import { assertAcceptsAllowlistedAfterExtensionEnrich, + assertAdditivePayloadEnrichment, + assertAdditiveSettlementExtra, assertSettleResponseCoreUnchanged, isVacantStringField, + mergeAdditiveSettlementExtra, snapshotPaymentRequirementsList, snapshotSettleResponseCore, -} from "../../../src/server/extensionResponsePolicy"; +} from "../../../src/server/hookPolicy"; import { buildPaymentRequirements, buildSettleResponse } from "../../mocks"; import type { Network } from "../../../src/types"; -describe("extensionResponsePolicy", () => { +describe("hookPolicy", () => { describe("isVacantStringField", () => { it("treats empty and whitespace-only strings as vacant", () => { expect(isVacantStringField("")).toBe(true); @@ -124,4 +127,113 @@ describe("extensionResponsePolicy", () => { expect(() => assertSettleResponseCoreUnchanged(snap, base, "ext")).toThrow(/transaction/); }); }); + + describe("assertAdditivePayloadEnrichment", () => { + it("allows adding new payload fields", () => { + expect(() => + assertAdditivePayloadEnrichment( + { clientField: "client" }, + { serverField: "server" }, + "scheme test", + ), + ).not.toThrow(); + }); + + it("rejects overwriting payload fields", () => { + expect(() => + assertAdditivePayloadEnrichment( + { clientField: "client" }, + { clientField: "server" }, + "scheme test", + ), + ).toThrow(/clientField/); + }); + }); + + describe("assertAdditiveSettlementExtra", () => { + it("allows adding new settlement extra fields", () => { + expect(() => + assertAdditiveSettlementExtra( + { facilitatorField: "facilitator" }, + { schemeField: "scheme" }, + "scheme test", + ), + ).not.toThrow(); + }); + + it("allows adding nested settlement extra fields", () => { + expect(() => + assertAdditiveSettlementExtra( + { + channelState: { + channelId: "0xchannel", + balance: "1000", + }, + }, + { + channelState: { + chargedCumulativeAmount: "200", + }, + }, + "scheme test", + ), + ).not.toThrow(); + }); + + it("rejects overwriting settlement extra fields", () => { + expect(() => + assertAdditiveSettlementExtra( + { facilitatorField: "facilitator" }, + { facilitatorField: "scheme" }, + "scheme test", + ), + ).toThrow(/facilitatorField/); + }); + + it("rejects overwriting nested settlement extra fields", () => { + expect(() => + assertAdditiveSettlementExtra( + { + channelState: { + balance: "1000", + }, + }, + { + channelState: { + balance: "2000", + }, + }, + "scheme test", + ), + ).toThrow(/channelState.*balance/); + }); + }); + + describe("mergeAdditiveSettlementExtra", () => { + it("merges nested settlement extra fields", () => { + expect( + mergeAdditiveSettlementExtra( + { + channelState: { + channelId: "0xchannel", + balance: "1000", + }, + }, + { + chargedAmount: "100", + channelState: { + chargedCumulativeAmount: "200", + }, + }, + ), + ).toEqual({ + chargedAmount: "100", + channelState: { + channelId: "0xchannel", + balance: "1000", + chargedCumulativeAmount: "200", + }, + }); + }); + }); }); diff --git a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts index dc0f6ed004..24a1e9c60e 100644 --- a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts +++ b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts @@ -100,6 +100,83 @@ describe("x402ResourceServer", () => { // This is verified implicitly - both registrations succeed without error expect(server).toBeDefined(); }); + + it("runs scheme hooks only for the matched network pattern and scheme", async () => { + const mockClient = new MockFacilitatorClient( + buildSupportedResponse(), + buildVerifyResponse({ isValid: true }), + ); + const server = new x402ResourceServer(mockClient); + const order: string[] = []; + + server.onBeforeVerify(async () => { + order.push("manual"); + }); + server.register( + "eip155:*" as Network, + new MockSchemeNetworkServer("batch", undefined, { + onBeforeVerify: async () => { + order.push("scheme"); + }, + }), + ); + server.register( + "eip155:*" as Network, + new MockSchemeNetworkServer("other", undefined, { + onBeforeVerify: async () => { + order.push("other-scheme"); + }, + }), + ); + server.register( + "solana:*" as Network, + new MockSchemeNetworkServer("batch", undefined, { + onBeforeVerify: async () => { + order.push("other-network"); + }, + }), + ); + server.registerExtension({ + key: "ext", + hooks: { + onBeforeVerify: async () => { + order.push("extension"); + }, + }, + }); + + await server.verifyPayment( + buildPaymentPayload(), + buildPaymentRequirements({ scheme: "batch", network: "eip155:8453" as Network }), + { ext: {} }, + ); + + expect(order).toEqual(["manual", "scheme", "extension"]); + }); + + it("overwrites scheme hook adapters when a scheme is re-registered", async () => { + const mockClient = new MockFacilitatorClient( + buildSupportedResponse(), + buildVerifyResponse({ isValid: true }), + ); + const server = new x402ResourceServer(mockClient); + let calls = 0; + + server.register( + "test:network" as Network, + new MockSchemeNetworkServer("test-scheme", undefined, { + onBeforeVerify: async () => { + calls++; + }, + }), + ); + await server.verifyPayment(buildPaymentPayload(), buildPaymentRequirements()); + expect(calls).toBe(1); + + server.register("test:network" as Network, new MockSchemeNetworkServer("test-scheme")); + await server.verifyPayment(buildPaymentPayload(), buildPaymentRequirements()); + expect(calls).toBe(1); + }); }); describe("initialize", () => { @@ -317,6 +394,11 @@ describe("x402ResourceServer", () => { }); expect(mockScheme.enhanceCalls.length).toBe(1); + expect(mockScheme.enhanceCalls[0].supportedKind).toEqual({ + x402Version: 2, + scheme: "test-scheme", + network: "test:network", + }); }); it("should use default maxTimeoutSeconds of 300", async () => { @@ -452,6 +534,72 @@ describe("x402ResourceServer", () => { expect(mockClient.verifyCalls.length).toBe(0); // Facilitator not called }); + it("should abort verification with the hook reason", async () => { + server.onBeforeVerify(async () => { + return { + abort: true, + reason: "stale_state", + }; + }); + + const result = await server.verifyPayment( + buildPaymentPayload(), + buildPaymentRequirements(), + ); + + expect(result).toMatchObject({ + isValid: false, + invalidReason: "stale_state", + }); + }); + + it("should skip facilitator verification when a beforeVerify hook returns a result", async () => { + server.onBeforeVerify(async () => { + return { + skip: true, + result: buildVerifyResponse({ + isValid: true, + payer: "0xlocal", + extra: { source: "local" }, + }), + }; + }); + + const result = await server.verifyPayment( + buildPaymentPayload(), + buildPaymentRequirements(), + ); + + expect(mockClient.verifyCalls.length).toBe(0); + expect(result).toMatchObject({ + isValid: true, + payer: "0xlocal", + extra: { source: "local" }, + }); + }); + + it("should run afterVerify hooks when beforeVerify skips facilitator verification", async () => { + const executionOrder: string[] = []; + + server + .onBeforeVerify(async () => { + executionOrder.push("before"); + return { + skip: true, + result: buildVerifyResponse({ isValid: true, payer: "0xlocal" }), + }; + }) + .onAfterVerify(async context => { + executionOrder.push("after"); + expect(context.result.payer).toBe("0xlocal"); + }); + + await server.verifyPayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(mockClient.verifyCalls.length).toBe(0); + expect(executionOrder).toEqual(["before", "after"]); + }); + it("should execute multiple hooks in order", async () => { const executionOrder: number[] = []; @@ -843,6 +991,50 @@ describe("x402ResourceServer", () => { expect(result.transaction).toBe("0xRecoveredTx"); }); }); + + describe("onVerifiedPaymentCanceled", () => { + it("executes manual, scheme, and extension hooks once", async () => { + const server = new x402ResourceServer(mockClient); + const calls: string[] = []; + + server.onVerifiedPaymentCanceled(async context => { + calls.push(`manual:${context.reason}:${context.responseStatus}`); + }); + server.register( + "eip155:*" as Network, + new MockSchemeNetworkServer("exact", undefined, { + onVerifiedPaymentCanceled: async context => { + calls.push(`scheme:${context.reason}`); + }, + }), + ); + server.registerExtension({ + key: "ext", + hooks: { + onVerifiedPaymentCanceled: async (_declaration, context) => { + calls.push(`extension:${context.reason}`); + }, + }, + }); + + const transportContext = { requestId: "req-1" }; + const cancellation = server.createPaymentCancellationDispatcher( + buildPaymentPayload(), + buildPaymentRequirements({ scheme: "exact", network: "eip155:8453" as Network }), + { ext: {} }, + transportContext, + ); + + await cancellation.cancel({ reason: "handler_failed", responseStatus: 500 }); + await cancellation.cancel({ reason: "handler_failed", responseStatus: 500 }); + + expect(calls).toEqual([ + "manual:handler_failed:500", + "scheme:handler_failed", + "extension:handler_failed", + ]); + }); + }); }); describe("verifyPayment", () => { @@ -1147,6 +1339,175 @@ describe("x402ResourceServer", () => { expect(hookAmount).toBe("300000"); }); + it("runs labeled afterSettle hooks when beforeSettle returns a skip result", async () => { + const mockClient = new MockFacilitatorClient( + buildSupportedResponse(), + buildVerifyResponse({ isValid: true }), + buildSettleResponse({ success: true }), + ); + const server = new x402ResourceServer(mockClient); + const order: string[] = []; + + server.onBeforeSettle(async () => ({ + skip: true, + result: buildSettleResponse({ success: true }), + })); + server.onAfterSettle(async () => { + order.push("manual"); + }); + server.register( + "test:network" as Network, + new MockSchemeNetworkServer("test-scheme", undefined, { + onAfterSettle: async () => { + order.push("scheme"); + }, + }), + ); + server.registerExtension({ + key: "ext", + hooks: { + onAfterSettle: async () => { + order.push("extension"); + }, + }, + }); + + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements(), { + ext: {}, + }); + + expect(result.success).toBe(true); + expect(mockClient.settleCalls.length).toBe(0); + expect(order).toEqual(["manual", "scheme", "extension"]); + }); + + it("applies scheme payload enrichment before facilitator settlement", async () => { + const mockClient = new MockFacilitatorClient( + buildSupportedResponse(), + buildVerifyResponse({ isValid: true }), + buildSettleResponse({ success: true }), + ); + const server = new x402ResourceServer(mockClient); + const order: string[] = []; + + server.register( + "test:network", + Object.assign(new MockSchemeNetworkServer("test-scheme"), { + enrichSettlementPayload: async () => { + order.push("payload"); + return { serverField: "server" }; + }, + }), + ); + + await server.settlePayment( + buildPaymentPayload({ payload: { clientField: "client" } }), + buildPaymentRequirements(), + ); + + expect(order).toEqual(["payload"]); + expect(mockClient.settleCalls[0].payload.payload).toEqual({ + clientField: "client", + serverField: "server", + }); + }); + + it("rejects payload enrichment that overwrites client payload fields", async () => { + const mockClient = new MockFacilitatorClient( + buildSupportedResponse(), + buildVerifyResponse({ isValid: true }), + buildSettleResponse({ success: true }), + ); + const server = new x402ResourceServer(mockClient); + + server.register( + "test:network", + Object.assign(new MockSchemeNetworkServer("test-scheme"), { + enrichSettlementPayload: async () => ({ clientField: "server" }), + }), + ); + + await expect( + server.settlePayment( + buildPaymentPayload({ payload: { clientField: "client" } }), + buildPaymentRequirements(), + ), + ).rejects.toThrow(/clientField/); + expect(mockClient.settleCalls.length).toBe(0); + }); + + it("runs settlement response enrichment after afterSettle and extension enrichment", async () => { + const mockClient = new MockFacilitatorClient( + buildSupportedResponse(), + buildVerifyResponse({ isValid: true }), + buildSettleResponse({ success: true, extra: { facilitatorField: "facilitator" } }), + ); + const server = new x402ResourceServer(mockClient); + const order: string[] = []; + + server.onAfterSettle(async () => { + order.push("afterSettle"); + }); + server.registerExtension({ + key: "ext", + enrichSettlementResponse: async () => { + order.push("extension"); + return { extensionField: "extension" }; + }, + }); + server.register( + "test:network", + Object.assign(new MockSchemeNetworkServer("test-scheme"), { + enrichSettlementResponse: async () => { + order.push("scheme"); + return { schemeField: "scheme" }; + }, + }), + ); + + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements(), { + ext: {}, + }); + + expect(order).toEqual(["afterSettle", "extension", "scheme"]); + expect(result.extensions).toEqual({ ext: { extensionField: "extension" } }); + expect(result.extra).toEqual({ + facilitatorField: "facilitator", + schemeField: "scheme", + }); + }); + + it("skips payload enrichment and still runs response enrichment for skip results", async () => { + const mockClient = new MockFacilitatorClient( + buildSupportedResponse(), + buildVerifyResponse({ isValid: true }), + buildSettleResponse({ success: true }), + ); + const server = new x402ResourceServer(mockClient); + const enrichSettlementPayload = vi.fn(async () => ({ serverField: "server" })); + + server.onBeforeSettle(async () => ({ + skip: true, + result: buildSettleResponse({ success: true, extra: { skipField: "skip" } }), + })); + server.register( + "test:network", + Object.assign(new MockSchemeNetworkServer("test-scheme"), { + enrichSettlementPayload, + enrichSettlementResponse: async () => ({ schemeField: "scheme" }), + }), + ); + + const result = await server.settlePayment(buildPaymentPayload(), buildPaymentRequirements()); + + expect(enrichSettlementPayload).not.toHaveBeenCalled(); + expect(mockClient.settleCalls.length).toBe(0); + expect(result.extra).toEqual({ + skipField: "skip", + schemeField: "scheme", + }); + }); + it("rejects enrichSettlementResponse that mutates facilitator core fields", async () => { const mockClient = new MockFacilitatorClient( buildSupportedResponse(), @@ -1363,6 +1724,77 @@ describe("x402ResourceServer", () => { expect((result.extensions as Record).mut).toEqual({ ok: true }); }); + it("serializes accepts mutations made by enrichPaymentRequiredResponse on the cloned list", async () => { + const server = new x402ResourceServer(); + server.registerExtension({ + key: "mut", + enrichPaymentRequiredResponse: async (_d, ctx) => { + ctx.paymentRequiredResponse.accepts[0]!.extra.corrective = "x"; + return undefined; + }, + }); + const requirements = [buildPaymentRequirements({ extra: {} })]; + + const result = await server.createPaymentRequiredResponse( + requirements, + { url: "https://example.com", description: "", mimeType: "" }, + undefined, + { mut: {} }, + ); + + expect(result.accepts[0].extra.corrective).toBe("x"); + expect(requirements[0].extra.corrective).toBeUndefined(); + }); + + it("lets a scheme enrich matching accepts with additive extra fields", async () => { + const server = new x402ResourceServer(); + const scheme = new MockSchemeNetworkServer("test-scheme") as MockSchemeNetworkServer & { + enrichPaymentRequiredResponse: NonNullable< + import("../../../src/types").SchemeNetworkServer["enrichPaymentRequiredResponse"] + >; + }; + const paymentPayload = buildPaymentPayload(); + const enrich = vi.fn(async ctx => { + expect(ctx.paymentPayload).toBe(paymentPayload); + ctx.requirements[0].extra.ChannelState = { channelId: "0x123" }; + }); + scheme.enrichPaymentRequiredResponse = enrich; + server.register("test:network" as Network, scheme); + + const result = await server.createPaymentRequiredResponse( + [buildPaymentRequirements()], + { url: "https://example.com", description: "", mimeType: "" }, + "stale_state", + undefined, + undefined, + paymentPayload, + ); + + expect(enrich).toHaveBeenCalledTimes(1); + expect(result.accepts[0].extra.ChannelState).toEqual({ channelId: "0x123" }); + }); + + it("rejects scheme response enrichment that overwrites baseline terms", async () => { + const server = new x402ResourceServer(); + const scheme = new MockSchemeNetworkServer("test-scheme") as MockSchemeNetworkServer & { + enrichPaymentRequiredResponse: NonNullable< + import("../../../src/types").SchemeNetworkServer["enrichPaymentRequiredResponse"] + >; + }; + scheme.enrichPaymentRequiredResponse = async ctx => { + ctx.requirements[0].extra = { ChannelState: { channelId: "0x123" } }; + }; + server.register("test:network" as Network, scheme); + + await expect( + server.createPaymentRequiredResponse( + [buildPaymentRequirements({ extra: { name: "USDC" } })], + { url: "https://example.com", description: "", mimeType: "" }, + "stale_state", + ), + ).rejects.toThrow(/extra\["name"\] was removed/); + }); + it("rejects enrichPaymentRequiredResponse that overwrites a non-vacant payTo", async () => { const server = new x402ResourceServer(); server.registerExtension({ @@ -1683,44 +2115,6 @@ describe("x402ResourceServer", () => { }); }); - describe("processPaymentRequest with extension-mutated accepts", () => { - it("matches client accepted against enriched accepts", async () => { - const mockClient = new MockFacilitatorClient( - buildSupportedResponse(), - buildVerifyResponse({ isValid: true }), - ); - const server = new x402ResourceServer(mockClient); - await server.initialize(); - server.register("test:network" as Network, new MockSchemeNetworkServer("test-scheme")); - server.registerExtension({ - key: "stealth", - enrichPaymentRequiredResponse: async (_d, ctx) => { - ctx.paymentRequiredResponse.accepts[0]!.payTo = "0x_stealth_payto"; - return undefined; - }, - }); - - const resourceConfig = { - scheme: "test-scheme", - payTo: "", - price: 1.0 as const, - network: "test:network" as Network, - }; - const built = await server.buildPaymentRequirements(resourceConfig); - const accepted = { ...built[0], payTo: "0x_stealth_payto" }; - const payload = buildPaymentPayload({ accepted }); - - const result = await server.processPaymentRequest( - payload, - resourceConfig, - { url: "https://example.com/r", description: "", mimeType: "" }, - { stealth: {} }, - ); - - expect(result.success).toBe(true); - }); - }); - describe("getSupportedKind and getFacilitatorExtensions", () => { it("should return supported kind after initialization", async () => { const mockClient = new MockFacilitatorClient( diff --git a/typescript/packages/http/axios/src/index.test.ts b/typescript/packages/http/axios/src/index.test.ts index d69f66f52f..e587e95a60 100644 --- a/typescript/packages/http/axios/src/index.test.ts +++ b/typescript/packages/http/axios/src/index.test.ts @@ -16,6 +16,7 @@ vi.mock("@x402/core/client", () => { MockX402HTTPClient.prototype.getPaymentRequiredResponse = vi.fn(); MockX402HTTPClient.prototype.encodePaymentSignatureHeader = vi.fn(); MockX402HTTPClient.prototype.handlePaymentRequired = vi.fn(); + MockX402HTTPClient.prototype.processPaymentResult = vi.fn(); const MockX402Client = vi.fn() as ReturnType & { fromConfig: ReturnType; @@ -90,6 +91,19 @@ describe("wrapAxiosWithPayment()", () => { ); }; + const createAxiosResponse = ( + status: number, + data?: unknown, + headers?: Record, + ): AxiosResponse => + ({ + status, + statusText: status === 402 ? "Payment Required" : "OK", + data, + headers: headers || {}, + config: createErrorConfig(), + }) as AxiosResponse; + beforeEach(async () => { vi.resetAllMocks(); @@ -126,6 +140,9 @@ describe("wrapAxiosWithPayment()", () => { ( MockX402HTTPClient.prototype.handlePaymentRequired as ReturnType ).mockResolvedValue(null); + ( + MockX402HTTPClient.prototype.processPaymentResult as ReturnType + ).mockResolvedValue({ recovered: false }); // Set up the interceptor wrapAxiosWithPayment(mockAxiosClient, mockClient); @@ -310,6 +327,119 @@ describe("wrapAxiosWithPayment()", () => { "PAYMENT-RESPONSE,X-PAYMENT-RESPONSE", ); }); + + it("should recover from a corrective 402 paid retry with one fresh payload retry", async () => { + const { x402HTTPClient: MockX402HTTPClient } = await import("@x402/core/client"); + const correctiveResponse = createAxiosResponse(402, validPaymentRequired, { + "PAYMENT-REQUIRED": "corrective-payment-required", + }); + const successResponse = createAxiosResponse( + 200, + { data: "success" }, + { + "PAYMENT-RESPONSE": "settled", + }, + ); + const freshPaymentPayload: PaymentPayload = { + ...validPaymentPayload, + payload: { signature: "0xfreshsignature" }, + }; + + (mockClient.createPaymentPayload as ReturnType) + .mockResolvedValueOnce(validPaymentPayload) + .mockResolvedValueOnce(freshPaymentPayload); + (MockX402HTTPClient.prototype.processPaymentResult as ReturnType) + .mockResolvedValueOnce({ recovered: true }) + .mockResolvedValueOnce({ recovered: false }); + (mockAxiosClient.request as ReturnType) + .mockResolvedValueOnce(correctiveResponse) + .mockResolvedValueOnce(successResponse); + + const error = createAxiosError(402, createErrorConfig(), validPaymentRequired); + const result = await interceptor(error); + + expect(result).toBe(successResponse); + expect(mockAxiosClient.request).toHaveBeenCalledTimes(2); + expect(mockClient.createPaymentPayload).toHaveBeenCalledTimes(2); + expect(MockX402HTTPClient.prototype.processPaymentResult).toHaveBeenCalledTimes(2); + expect(MockX402HTTPClient.prototype.processPaymentResult).toHaveBeenNthCalledWith( + 1, + validPaymentPayload, + expect.any(Function), + 402, + ); + expect(MockX402HTTPClient.prototype.processPaymentResult).toHaveBeenNthCalledWith( + 2, + freshPaymentPayload, + expect.any(Function), + 200, + ); + }); + + it("should return a corrective 402 paid retry when recovery does not run", async () => { + const { x402HTTPClient: MockX402HTTPClient } = await import("@x402/core/client"); + const correctiveResponse = createAxiosResponse(402, validPaymentRequired, { + "PAYMENT-REQUIRED": "corrective-payment-required", + }); + + (mockAxiosClient.request as ReturnType).mockResolvedValue(correctiveResponse); + + const error = createAxiosError(402, createErrorConfig(), validPaymentRequired); + const result = await interceptor(error); + + expect(result).toBe(correctiveResponse); + expect(mockAxiosClient.request).toHaveBeenCalledTimes(1); + expect(mockClient.createPaymentPayload).toHaveBeenCalledTimes(1); + expect(MockX402HTTPClient.prototype.processPaymentResult).toHaveBeenCalledTimes(1); + expect(MockX402HTTPClient.prototype.processPaymentResult).toHaveBeenCalledWith( + validPaymentPayload, + expect.any(Function), + 402, + ); + }); + + it("should preserve caller validateStatus for non-402 retry statuses", async () => { + const successResponse = createAxiosResponse(200, { data: "success" }); + const config = createErrorConfig(); + config.validateStatus = status => status === 409; + (mockAxiosClient.request as ReturnType).mockResolvedValue(successResponse); + + const error = createAxiosError(402, config, validPaymentRequired); + await interceptor(error); + + const retryConfig = (mockAxiosClient.request as ReturnType).mock.calls[0][0]; + expect(retryConfig.validateStatus(402)).toBe(true); + expect(retryConfig.validateStatus(409)).toBe(true); + expect(retryConfig.validateStatus(200)).toBe(false); + expect(retryConfig.validateStatus(500)).toBe(false); + }); + + it("should fall through to paid retry when hook retry returns 402", async () => { + const { x402HTTPClient: MockX402HTTPClient } = await import("@x402/core/client"); + const hookResponse = createAxiosResponse(402, validPaymentRequired, { + "PAYMENT-REQUIRED": "hook-payment-required", + }); + const successResponse = createAxiosResponse(200, { data: "success" }); + + ( + MockX402HTTPClient.prototype.handlePaymentRequired as ReturnType + ).mockResolvedValue({ "X-HOOK": "handled" }); + (mockAxiosClient.request as ReturnType) + .mockResolvedValueOnce(hookResponse) + .mockResolvedValueOnce(successResponse); + + const error = createAxiosError(402, createErrorConfig(), validPaymentRequired); + const result = await interceptor(error); + + expect(result).toBe(successResponse); + expect(mockAxiosClient.request).toHaveBeenCalledTimes(2); + const hookConfig = (mockAxiosClient.request as ReturnType).mock.calls[0][0]; + const paidConfig = (mockAxiosClient.request as ReturnType).mock.calls[1][0]; + expect(hookConfig.validateStatus(402)).toBe(true); + expect(hookConfig.headers.get("X-HOOK")).toBe("handled"); + expect(paidConfig.headers.get("PAYMENT-SIGNATURE")).toBe("encoded-payment-header"); + expect(mockClient.createPaymentPayload).toHaveBeenCalledWith(validPaymentRequired); + }); }); describe("wrapAxiosWithPaymentFromConfig()", () => { diff --git a/typescript/packages/http/axios/src/index.ts b/typescript/packages/http/axios/src/index.ts index 041ef2893c..628d6b221c 100644 --- a/typescript/packages/http/axios/src/index.ts +++ b/typescript/packages/http/axios/src/index.ts @@ -1,6 +1,38 @@ import { x402Client, x402ClientConfig, x402HTTPClient } from "@x402/core/client"; import { type PaymentRequired } from "@x402/core/types"; -import type { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from "axios"; +import { + AxiosHeaders, + type AxiosInstance, + type AxiosError, + type InternalAxiosRequestConfig, +} from "axios"; + +type X402RetryConfig = InternalAxiosRequestConfig & { __is402Retry?: boolean }; + +/** + * Clones an Axios internal request config so a retry can treat HTTP 402 as a successful + * response status for validation (so the interceptor can handle payment flow). + * + * @param config - Original Axios request configuration for the outgoing request. + * @returns Request config with copied headers and validateStatus that returns true for 402. + */ +function createX402RetryConfig(config: InternalAxiosRequestConfig): X402RetryConfig { + const originalValidateStatus = config.validateStatus; + + return { + ...config, + headers: AxiosHeaders.from(config.headers), + validateStatus: status => { + if (status === 402) { + return true; + } + + return originalValidateStatus + ? originalValidateStatus(status) + : status >= 200 && status < 300; + }, + }; +} /** * Wraps an Axios instance with x402 payment handling. @@ -57,9 +89,7 @@ export function wrapAxiosWithPayment( } // Check if this is already a retry to prevent infinite loops - if ( - (originalConfig as InternalAxiosRequestConfig & { __is402Retry?: boolean }).__is402Retry - ) { + if ((originalConfig as X402RetryConfig).__is402Retry) { return Promise.reject(error); } @@ -90,8 +120,7 @@ export function wrapAxiosWithPayment( // Run payment required hooks const hookHeaders = await httpClient.handlePaymentRequired(paymentRequired); if (hookHeaders) { - const hookConfig = { ...originalConfig }; - hookConfig.headers = { ...originalConfig.headers } as typeof originalConfig.headers; + const hookConfig = createX402RetryConfig(originalConfig); Object.entries(hookHeaders).forEach(([key, value]) => { hookConfig.headers.set(key, value); }); @@ -117,23 +146,56 @@ export function wrapAxiosWithPayment( // Encode payment header const paymentHeaders = httpClient.encodePaymentSignatureHeader(paymentPayload); - // Mark this as a retry - (originalConfig as InternalAxiosRequestConfig & { __is402Retry?: boolean }).__is402Retry = - true; + const paidConfig = createX402RetryConfig(originalConfig); + paidConfig.__is402Retry = true; // Add payment headers to the request Object.entries(paymentHeaders).forEach(([key, value]) => { - originalConfig.headers.set(key, value); + paidConfig.headers.set(key, value); }); // Add CORS header to expose payment response - originalConfig.headers.set( + paidConfig.headers.set( "Access-Control-Expose-Headers", "PAYMENT-RESPONSE,X-PAYMENT-RESPONSE", ); // Retry the request with payment - const secondResponse = await axiosInstance.request(originalConfig); + const secondResponse = await axiosInstance.request(paidConfig); + + // Fire payment response hooks and handle recovery + const getResponseHeader = (name: string) => { + const value = secondResponse.headers[name] ?? secondResponse.headers[name.toLowerCase()]; + return typeof value === "string" ? value : undefined; + }; + const result = await httpClient.processPaymentResult( + paymentPayload, + getResponseHeader, + secondResponse.status, + ); + + if (result.recovered) { + // Retry once with a fresh payload after recovery. + const freshPayload = await client.createPaymentPayload(paymentRequired); + const retryHeaders = httpClient.encodePaymentSignatureHeader(freshPayload); + const retryConfig = createX402RetryConfig(originalConfig); + Object.entries(retryHeaders).forEach(([key, value]) => { + retryConfig.headers.set(key, value); + }); + retryConfig.headers.set( + "Access-Control-Expose-Headers", + "PAYMENT-RESPONSE,X-PAYMENT-RESPONSE", + ); + const retryResponse = await axiosInstance.request(retryConfig); + // Process the final retry result without another recovery attempt. + const getRetryHeader = (name: string) => { + const value = retryResponse.headers[name] ?? retryResponse.headers[name.toLowerCase()]; + return typeof value === "string" ? value : undefined; + }; + await httpClient.processPaymentResult(freshPayload, getRetryHeader, retryResponse.status); + return retryResponse; + } + return secondResponse; } catch (retryError) { return Promise.reject(retryError); diff --git a/typescript/packages/http/express/src/index.test.ts b/typescript/packages/http/express/src/index.test.ts index c6792dec3e..1af84211de 100644 --- a/typescript/packages/http/express/src/index.test.ts +++ b/typescript/packages/http/express/src/index.test.ts @@ -40,6 +40,24 @@ let mockProcessSettlement: ReturnType; let mockRegisterPaywallProvider: ReturnType; let mockRequiresPayment: ReturnType; +type PaymentVerifiedResult = Extract; +type MockHTTPProcessResult = + | Exclude + | (Omit & { + cancellationDispatcher?: PaymentVerifiedResult["cancellationDispatcher"]; + }); + +/** + * Creates a mock payment cancellation dispatcher. + * + * @returns Mock payment cancellation dispatcher. + */ +function createMockPaymentCancellationDispatcher(): PaymentVerifiedResult["cancellationDispatcher"] { + return { + cancel: vi.fn().mockResolvedValue(undefined), + } as unknown as PaymentVerifiedResult["cancellationDispatcher"]; +} + vi.mock("@x402/core/server", () => ({ SETTLEMENT_OVERRIDES_HEADER: "Settlement-Overrides", FacilitatorResponseError: class FacilitatorResponseError extends Error { @@ -92,7 +110,7 @@ vi.mock("@x402/core/server", () => ({ * @param settlementResult - Result to return from processSettlement. */ function setupMockHttpServer( - processResult: HTTPProcessResult, + processResult: MockHTTPProcessResult, settlementResult: | { success: true; headers: Record } | { @@ -105,7 +123,15 @@ function setupMockHttpServer( headers: {}, }, ): void { - mockProcessHTTPRequest.mockResolvedValue(processResult); + const normalizedResult = + processResult.type === "payment-verified" + ? { + ...processResult, + cancellationDispatcher: + processResult.cancellationDispatcher ?? createMockPaymentCancellationDispatcher(), + } + : processResult; + mockProcessHTTPRequest.mockResolvedValue(normalizedResult); mockProcessSettlement.mockResolvedValue(settlementResult); } @@ -398,6 +424,53 @@ describe("paymentMiddleware", () => { expect(next).toHaveBeenCalled(); expect(mockProcessSettlement).not.toHaveBeenCalled(); + const cancellationDispatcher = (await mockProcessHTTPRequest.mock.results[0].value) + .cancellationDispatcher; + expect(cancellationDispatcher.cancel).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "handler_failed", + responseStatus: 500, + }), + ); + }); + + it("cancels payment when handler throws", async () => { + const cancellationDispatcher = createMockPaymentCancellationDispatcher(); + setupMockHttpServer( + { + type: "payment-verified", + paymentPayload: mockPaymentPayload, + paymentRequirements: mockPaymentRequirements, + cancellationDispatcher, + }, + { success: true, headers: { "PAYMENT-RESPONSE": "settled" } }, + ); + + const middleware = paymentMiddleware( + mockRoutes, + {} as unknown as x402ResourceServer, + undefined, + undefined, + false, + ); + const req = createMockRequest(); + const res = createMockResponse(); + const handlerError = new Error("Handler failed"); + const next = vi.fn((error?: unknown) => { + if (error) { + return; + } + throw handlerError; + }); + + await middleware(req, res, next); + + expect(cancellationDispatcher.cancel).toHaveBeenCalledWith({ + reason: "handler_threw", + error: handlerError, + }); + expect(mockProcessSettlement).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(handlerError); }); it("returns 402 when settlement throws error", async () => { diff --git a/typescript/packages/http/express/src/index.ts b/typescript/packages/http/express/src/index.ts index ce13ea1de6..8a979b1490 100644 --- a/typescript/packages/http/express/src/index.ts +++ b/typescript/packages/http/express/src/index.ts @@ -195,7 +195,8 @@ export function paymentMiddlewareFromHTTPServer( case "payment-verified": // Payment is valid, need to wrap response for settlement - const { paymentPayload, paymentRequirements, declaredExtensions } = result; + const { cancellationDispatcher, paymentPayload, paymentRequirements, declaredExtensions } = + result; // Intercept and buffer all core methods that can commit response to client const originalWriteHead = res.writeHead.bind(res); @@ -211,6 +212,14 @@ export function paymentMiddlewareFromHTTPServer( let bufferedCalls: BufferedCall[] = []; let settled = false; + const restoreResponseMethods = () => { + settled = true; + res.writeHead = originalWriteHead; + res.write = originalWrite; + res.end = originalEnd; + res.flushHeaders = originalFlushHeaders; + }; + // Create a promise that resolves when the handler finishes and calls res.end() let endCalled: () => void; const endPromise = new Promise(resolve => { @@ -252,18 +261,28 @@ export function paymentMiddlewareFromHTTPServer( }; // Proceed to the next middleware or route handler - next(); + try { + await Promise.resolve(next()); + } catch (error) { + await cancellationDispatcher.cancel({ + reason: "handler_threw", + error, + }); + bufferedCalls = []; + restoreResponseMethods(); + return next(error); + } // Wait for the handler to actually call res.end() before checking status await endPromise; // If the response from the protected route is >= 400, do not settle payment if (res.statusCode >= 400) { - settled = true; - res.writeHead = originalWriteHead; - res.write = originalWrite; - res.end = originalEnd; - res.flushHeaders = originalFlushHeaders; + await cancellationDispatcher.cancel({ + reason: "handler_failed", + responseStatus: res.statusCode, + }); + restoreResponseMethods(); // Replay all buffered calls in order for (const [method, args] of bufferedCalls) { if (method === "writeHead") @@ -330,11 +349,7 @@ export function paymentMiddlewareFromHTTPServer( res.status(402).json({}); return; } finally { - settled = true; - res.writeHead = originalWriteHead; - res.write = originalWrite; - res.end = originalEnd; - res.flushHeaders = originalFlushHeaders; + restoreResponseMethods(); // Replay all buffered calls in order for (const [method, args] of bufferedCalls) { diff --git a/typescript/packages/http/fastify/src/index.test.ts b/typescript/packages/http/fastify/src/index.test.ts index bb6cae8ec5..17c0e43013 100644 --- a/typescript/packages/http/fastify/src/index.test.ts +++ b/typescript/packages/http/fastify/src/index.test.ts @@ -39,6 +39,24 @@ let mockProcessSettlement: ReturnType; let mockRegisterPaywallProvider: ReturnType; let mockRequiresPayment: ReturnType; +type PaymentVerifiedResult = Extract; +type MockHTTPProcessResult = + | Exclude + | (Omit & { + cancellationDispatcher?: PaymentVerifiedResult["cancellationDispatcher"]; + }); + +/** + * Creates a mock payment cancellation dispatcher. + * + * @returns Mock payment cancellation dispatcher. + */ +function createMockPaymentCancellationDispatcher(): PaymentVerifiedResult["cancellationDispatcher"] { + return { + cancel: vi.fn().mockResolvedValue(undefined), + } as unknown as PaymentVerifiedResult["cancellationDispatcher"]; +} + vi.mock("@x402/core/server", () => ({ SETTLEMENT_OVERRIDES_HEADER: "Settlement-Overrides", FacilitatorResponseError: class FacilitatorResponseError extends Error { @@ -92,6 +110,7 @@ type HookHandler = (...args: unknown[]) => Promise; interface CapturedHooks { onRequest: HookHandler[]; onSend: HookHandler[]; + onError: HookHandler[]; } /** @@ -100,12 +119,13 @@ interface CapturedHooks { * @returns Object containing the mock app and captured hooks. */ function createMockApp(): { app: FastifyInstance; hooks: CapturedHooks } { - const hooks: CapturedHooks = { onRequest: [], onSend: [] }; + const hooks: CapturedHooks = { onRequest: [], onSend: [], onError: [] }; const app = { addHook: vi.fn((name: string, handler: HookHandler) => { if (name === "onRequest") hooks.onRequest.push(handler); if (name === "onSend") hooks.onSend.push(handler); + if (name === "onError") hooks.onError.push(handler); }), decorateRequest: vi.fn(), } as unknown as FastifyInstance; @@ -120,7 +140,7 @@ function createMockApp(): { app: FastifyInstance; hooks: CapturedHooks } { * @param settlementResult - Result to return from processSettlement. */ function setupMockHttpServer( - processResult: HTTPProcessResult, + processResult: MockHTTPProcessResult, settlementResult: | { success: true; headers: Record } | { @@ -138,7 +158,15 @@ function setupMockHttpServer( headers: {}, }, ): void { - mockProcessHTTPRequest.mockResolvedValue(processResult); + const normalizedResult = + processResult.type === "payment-verified" + ? { + ...processResult, + cancellationDispatcher: + processResult.cancellationDispatcher ?? createMockPaymentCancellationDispatcher(), + } + : processResult; + mockProcessHTTPRequest.mockResolvedValue(normalizedResult); mockProcessSettlement.mockResolvedValue(settlementResult); } @@ -221,7 +249,12 @@ function createMockReply(): FastifyReply & { }), }; - return reply as unknown as typeof reply; + return reply as unknown as FastifyReply & { + _status: number; + _headers: Record; + _body: unknown; + _type: string | undefined; + }; } // --- Tests --- @@ -524,6 +557,12 @@ describe("paymentMiddleware", () => { const result = await hooks.onSend[0](request, reply, payload); expect(mockProcessSettlement).not.toHaveBeenCalled(); + expect(request.x402Context?.cancellationDispatcher.cancel).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "handler_failed", + responseStatus: 500, + }), + ); expect(result).toBe(payload); }); diff --git a/typescript/packages/http/fastify/src/index.ts b/typescript/packages/http/fastify/src/index.ts index d1590052f0..0a649abd17 100644 --- a/typescript/packages/http/fastify/src/index.ts +++ b/typescript/packages/http/fastify/src/index.ts @@ -12,6 +12,7 @@ import { SETTLEMENT_OVERRIDES_HEADER, SettlementOverrides, checkIfBazaarNeeded, + PaymentCancellationDispatcher, } from "@x402/core/server"; import { SchemeNetworkServer, @@ -34,6 +35,7 @@ export function setSettlementOverrides(reply: FastifyReply, overrides: Settlemen } interface X402PaymentContext { + cancellationDispatcher: PaymentCancellationDispatcher; paymentPayload: PaymentPayload; paymentRequirements: PaymentRequirements; declaredExtensions?: Record; @@ -361,6 +363,7 @@ export function paymentMiddlewareFromHTTPServer( case "payment-verified": { request.x402Context = { + cancellationDispatcher: result.cancellationDispatcher, paymentPayload: result.paymentPayload, paymentRequirements: result.paymentRequirements, declaredExtensions: result.declaredExtensions, @@ -411,6 +414,10 @@ export function paymentMiddlewareFromHTTPServer( } if (reply.statusCode >= 400) { + await x402Context.cancellationDispatcher.cancel({ + reason: "handler_failed", + responseStatus: reply.statusCode, + }); return effectivePayload; } @@ -460,6 +467,17 @@ export function paymentMiddlewareFromHTTPServer( return JSON.stringify({}); } }); + + app.addHook("onError", async (request: FastifyRequest, _reply: FastifyReply, error: Error) => { + const x402Context = request.x402Context; + if (!x402Context) { + return; + } + await x402Context.cancellationDispatcher.cancel({ + reason: "handler_threw", + error, + }); + }); } /** diff --git a/typescript/packages/http/fetch/src/index.test.ts b/typescript/packages/http/fetch/src/index.test.ts index f59139c531..4959f309c3 100644 --- a/typescript/packages/http/fetch/src/index.test.ts +++ b/typescript/packages/http/fetch/src/index.test.ts @@ -9,6 +9,7 @@ vi.mock("@x402/core/client", () => { MockX402HTTPClient.prototype.getPaymentRequiredResponse = vi.fn(); MockX402HTTPClient.prototype.encodePaymentSignatureHeader = vi.fn(); MockX402HTTPClient.prototype.handlePaymentRequired = vi.fn(); + MockX402HTTPClient.prototype.processPaymentResult = vi.fn(); const MockX402Client = vi.fn() as ReturnType & { fromConfig: ReturnType; @@ -94,6 +95,9 @@ describe("wrapFetchWithPayment()", () => { ( MockX402HTTPClient.prototype.handlePaymentRequired as ReturnType ).mockResolvedValue(null); + ( + MockX402HTTPClient.prototype.processPaymentResult as ReturnType + ).mockResolvedValue({ recovered: false }); wrappedFetch = wrapFetchWithPayment(mockFetch, mockClient); }); @@ -451,6 +455,9 @@ describe("wrapFetchWithPaymentFromConfig()", () => { ( MockX402HTTPClient.prototype.handlePaymentRequired as ReturnType ).mockResolvedValue(null); + ( + MockX402HTTPClient.prototype.processPaymentResult as ReturnType + ).mockResolvedValue({ recovered: false }); }); it("should create client from config and wrap fetch", async () => { diff --git a/typescript/packages/http/fetch/src/index.ts b/typescript/packages/http/fetch/src/index.ts index 63b29cb027..01cb77afb1 100644 --- a/typescript/packages/http/fetch/src/index.ts +++ b/typescript/packages/http/fetch/src/index.ts @@ -120,6 +120,36 @@ export function wrapFetchWithPayment( // Retry the request with payment const secondResponse = await fetch(clonedRequest); + + // Fire payment response hooks and handle recovery + const result = await httpClient.processPaymentResult( + paymentPayload, + name => secondResponse.headers.get(name), + secondResponse.status, + ); + + if (result.recovered) { + // Hook fixed state — retry with fresh payload (bounded to one recovery) + const freshPayload = await client.createPaymentPayload(paymentRequired); + const retryHeaders = httpClient.encodePaymentSignatureHeader(freshPayload); + const retryRequest = new Request(input, init); + for (const [k, v] of Object.entries(retryHeaders)) { + retryRequest.headers.set(k, v); + } + retryRequest.headers.set( + "Access-Control-Expose-Headers", + "PAYMENT-RESPONSE,X-PAYMENT-RESPONSE", + ); + const retryResponse = await fetch(retryRequest); + // Fire hooks on retry response — no further recovery to prevent loops + await httpClient.processPaymentResult( + freshPayload, + name => retryResponse.headers.get(name), + retryResponse.status, + ); + return retryResponse; + } + return secondResponse; }; } @@ -141,6 +171,7 @@ export function wrapFetchWithPaymentFromConfig( // Re-export types and utilities for convenience export { x402Client, x402HTTPClient } from "@x402/core/client"; +export type { x402PaymentResult } from "@x402/core/client"; export type { PaymentPolicy, SchemeRegistration, diff --git a/typescript/packages/http/hono/src/index.test.ts b/typescript/packages/http/hono/src/index.test.ts index 05d3f858ad..2ef5ac0612 100644 --- a/typescript/packages/http/hono/src/index.test.ts +++ b/typescript/packages/http/hono/src/index.test.ts @@ -40,6 +40,24 @@ let mockProcessSettlement: ReturnType; let mockRegisterPaywallProvider: ReturnType; let mockRequiresPayment: ReturnType; +type PaymentVerifiedResult = Extract; +type MockHTTPProcessResult = + | Exclude + | (Omit & { + cancellationDispatcher?: PaymentVerifiedResult["cancellationDispatcher"]; + }); + +/** + * Creates a mock payment cancellation dispatcher. + * + * @returns Mock payment cancellation dispatcher. + */ +function createMockPaymentCancellationDispatcher(): PaymentVerifiedResult["cancellationDispatcher"] { + return { + cancel: vi.fn().mockResolvedValue(undefined), + } as unknown as PaymentVerifiedResult["cancellationDispatcher"]; +} + vi.mock("@x402/core/server", () => ({ SETTLEMENT_OVERRIDES_HEADER: "Settlement-Overrides", FacilitatorResponseError: class FacilitatorResponseError extends Error { @@ -92,7 +110,7 @@ vi.mock("@x402/core/server", () => ({ * @param settlementResult - Result to return from processSettlement. */ function setupMockHttpServer( - processResult: HTTPProcessResult, + processResult: MockHTTPProcessResult, settlementResult: | { success: true; headers: Record } | { @@ -105,7 +123,15 @@ function setupMockHttpServer( headers: {}, }, ): void { - mockProcessHTTPRequest.mockResolvedValue(processResult); + const normalizedResult = + processResult.type === "payment-verified" + ? { + ...processResult, + cancellationDispatcher: + processResult.cancellationDispatcher ?? createMockPaymentCancellationDispatcher(), + } + : processResult; + mockProcessHTTPRequest.mockResolvedValue(normalizedResult); mockProcessSettlement.mockResolvedValue(settlementResult); } diff --git a/typescript/packages/http/hono/src/index.ts b/typescript/packages/http/hono/src/index.ts index 5179197e01..9f097d0dad 100644 --- a/typescript/packages/http/hono/src/index.ts +++ b/typescript/packages/http/hono/src/index.ts @@ -192,16 +192,29 @@ export function paymentMiddlewareFromHTTPServer( case "payment-verified": // Payment is valid, need to wrap response for settlement - const { paymentPayload, paymentRequirements, declaredExtensions } = result; + const { cancellationDispatcher, paymentPayload, paymentRequirements, declaredExtensions } = + result; // Proceed to the next middleware or route handler - await next(); + try { + await next(); + } catch (error) { + await cancellationDispatcher.cancel({ + reason: "handler_threw", + error, + }); + throw error; + } // Get the current response let res = c.res; // If the response from the protected route is >= 400, do not settle payment if (res.status >= 400) { + await cancellationDispatcher.cancel({ + reason: "handler_failed", + responseStatus: res.status, + }); return; } diff --git a/typescript/packages/http/next/src/index.test.ts b/typescript/packages/http/next/src/index.test.ts index ab7ca0bf9f..55e3296ed4 100644 --- a/typescript/packages/http/next/src/index.test.ts +++ b/typescript/packages/http/next/src/index.test.ts @@ -1,11 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest, NextResponse } from "next/server"; -import type { - HTTPProcessResult, - x402HTTPResourceServer, - PaywallProvider, - FacilitatorClient, -} from "@x402/core/server"; +import type { HTTPProcessResult, PaywallProvider, FacilitatorClient } from "@x402/core/server"; import { x402ResourceServer, x402HTTPResourceServer } from "@x402/core/server"; import type { PaymentPayload, PaymentRequirements, SchemeNetworkServer } from "@x402/core/types"; import { paymentProxy, paymentProxyFromConfig, withX402, type SchemeRegistration } from "./index"; @@ -97,6 +92,24 @@ const mockPaymentRequirements = { payTo: "0x123", } as unknown as PaymentRequirements; +type PaymentVerifiedResult = Extract; +type MockHTTPProcessResult = + | Exclude + | (Omit & { + cancellationDispatcher?: PaymentVerifiedResult["cancellationDispatcher"]; + }); + +/** + * Creates a mock payment cancellation dispatcher. + * + * @returns Mock payment cancellation dispatcher. + */ +function createMockPaymentCancellationDispatcher(): PaymentVerifiedResult["cancellationDispatcher"] { + return { + cancel: vi.fn().mockResolvedValue(undefined), + } as unknown as PaymentVerifiedResult["cancellationDispatcher"]; +} + // --- Mock Factories --- /** * Creates a mock HTTP server for testing. @@ -106,7 +119,7 @@ const mockPaymentRequirements = { * @returns A mock x402HTTPResourceServer. */ function createMockHttpServer( - processResult: HTTPProcessResult, + processResult: MockHTTPProcessResult, settlementResult: | { success: true; headers: Record } | { @@ -119,8 +132,16 @@ function createMockHttpServer( headers: {}, }, ): x402HTTPResourceServer { + const normalizedResult = + processResult.type === "payment-verified" + ? { + ...processResult, + cancellationDispatcher: + processResult.cancellationDispatcher ?? createMockPaymentCancellationDispatcher(), + } + : processResult; return { - processHTTPRequest: vi.fn().mockResolvedValue(processResult), + processHTTPRequest: vi.fn().mockResolvedValue(normalizedResult), processSettlement: vi.fn().mockResolvedValue(settlementResult), registerPaywallProvider: vi.fn(), initialize: vi.fn().mockResolvedValue(undefined), @@ -242,6 +263,7 @@ describe("paymentProxy", () => { type: "payment-verified", paymentPayload: mockPaymentPayload, paymentRequirements: mockPaymentRequirements, + declaredExtensions: {}, }, { success: true, headers: { "X-Settlement": "complete" } }, ); @@ -255,7 +277,7 @@ describe("paymentProxy", () => { expect(mockServer.processSettlement).toHaveBeenCalledWith( mockPaymentPayload, mockPaymentRequirements, - undefined, + {}, expect.objectContaining({ request: expect.objectContaining({ path: "/api/test", @@ -413,6 +435,15 @@ describe("withX402", () => { expect(handler).toHaveBeenCalled(); expect(response.status).toBe(400); expect(mockServer.processSettlement).not.toHaveBeenCalled(); + const cancellationDispatcher = ( + await vi.mocked(mockServer.processHTTPRequest).mock.results[0].value + ).cancellationDispatcher; + expect(cancellationDispatcher.cancel).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "handler_failed", + responseStatus: 400, + }), + ); }); it("returns 402 when settlement throws error, not the handler response", async () => { diff --git a/typescript/packages/http/next/src/index.ts b/typescript/packages/http/next/src/index.ts index 71073b0477..93752ece08 100644 --- a/typescript/packages/http/next/src/index.ts +++ b/typescript/packages/http/next/src/index.ts @@ -2,11 +2,14 @@ import { PaywallConfig, PaywallProvider, x402ResourceServer, + x402HTTPResourceServer, RoutesConfig, RouteConfig, FacilitatorClient, FacilitatorResponseError, checkIfBazaarNeeded, + SETTLEMENT_OVERRIDES_HEADER, + SettlementOverrides, } from "@x402/core/server"; import { SchemeNetworkServer, Network } from "@x402/core/types"; import { NextRequest, NextResponse } from "next/server"; @@ -18,7 +21,17 @@ import { createFacilitatorErrorResponse, getFacilitatorResponseError, } from "./utils"; -import { x402HTTPResourceServer } from "@x402/core/server"; + +/** + * Set settlement overrides on the response for partial settlement. + * `withX402` reads this header before settlement and strips it from the client response. + * + * @param res - Next.js `NextResponse` from your route handler + * @param overrides - Settlement overrides (for example `{ amount: "50%" }` for partial settlement) + */ +export function setSettlementOverrides(res: NextResponse, overrides: SettlementOverrides): void { + res.headers.set(SETTLEMENT_OVERRIDES_HEADER, JSON.stringify(overrides)); +} /** * Configuration for registering a payment scheme with a specific network @@ -127,16 +140,15 @@ export function paymentProxyFromHTTPServer( case "payment-verified": { // Payment is valid, need to wrap response for settlement - const { paymentPayload, paymentRequirements, declaredExtensions } = result; - // Proceed to the next proxy or route handler const nextResponse = NextResponse.next(); return handleSettlement( httpServer, nextResponse, - paymentPayload, - paymentRequirements, - declaredExtensions, + result.paymentPayload, + result.paymentRequirements, + result.declaredExtensions, + result.cancellationDispatcher, context, ); } @@ -306,14 +318,23 @@ export function withX402FromHTTPServer( case "payment-verified": { // Payment is valid, need to wrap response for settlement - const { paymentPayload, paymentRequirements, declaredExtensions } = result; - const handlerResponse = await routeHandler(request); + let handlerResponse: NextResponse; + try { + handlerResponse = await routeHandler(request); + } catch (error) { + await result.cancellationDispatcher.cancel({ + reason: "handler_threw", + error, + }); + throw error; + } return handleSettlement( httpServer, handlerResponse, - paymentPayload, - paymentRequirements, - declaredExtensions, + result.paymentPayload, + result.paymentRequirements, + result.declaredExtensions, + result.cancellationDispatcher, context, ) as Promise>; } @@ -392,7 +413,12 @@ export type { SchemeNetworkServer, } from "@x402/core/types"; -export type { PaywallProvider, PaywallConfig, RouteConfig } from "@x402/core/server"; +export type { + PaywallProvider, + PaywallConfig, + RouteConfig, + SettlementOverrides, +} from "@x402/core/server"; export { x402ResourceServer, diff --git a/typescript/packages/http/next/src/utils.test.ts b/typescript/packages/http/next/src/utils.test.ts index 26a849aada..ba9d2acd82 100644 --- a/typescript/packages/http/next/src/utils.test.ts +++ b/typescript/packages/http/next/src/utils.test.ts @@ -4,6 +4,7 @@ import type { x402HTTPResourceServer, x402ResourceServer, PaywallProvider, + PaymentCancellationDispatcher, } from "@x402/core/server"; import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; import { @@ -250,8 +251,13 @@ describe("handleSettlement", () => { scheme: "exact", network: "eip155:84532", } as unknown as PaymentRequirements; + const mockDeclaredExtensions = {}; + let mockPaymentCancellationDispatcher: PaymentCancellationDispatcher; beforeEach(() => { + mockPaymentCancellationDispatcher = { + cancel: vi.fn().mockResolvedValue(undefined), + } as unknown as PaymentCancellationDispatcher; mockHttpServer = { processSettlement: vi .fn() @@ -267,10 +273,18 @@ describe("handleSettlement", () => { response, mockPaymentPayload, mockRequirements, + mockDeclaredExtensions, + mockPaymentCancellationDispatcher, ); expect(result.status).toBe(500); expect(mockHttpServer.processSettlement).not.toHaveBeenCalled(); + expect(mockPaymentCancellationDispatcher.cancel).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "handler_failed", + responseStatus: 500, + }), + ); }); it("returns original response when status is exactly 400", async () => { @@ -281,6 +295,8 @@ describe("handleSettlement", () => { response, mockPaymentPayload, mockRequirements, + mockDeclaredExtensions, + mockPaymentCancellationDispatcher, ); expect(result.status).toBe(400); @@ -295,6 +311,8 @@ describe("handleSettlement", () => { response, mockPaymentPayload, mockRequirements, + mockDeclaredExtensions, + mockPaymentCancellationDispatcher, ); expect(result.status).toBe(200); @@ -302,11 +320,8 @@ describe("handleSettlement", () => { expect(mockHttpServer.processSettlement).toHaveBeenCalledWith( mockPaymentPayload, mockRequirements, + mockDeclaredExtensions, undefined, - expect.objectContaining({ - request: undefined, - responseBody: expect.any(Buffer), - }), ); }); @@ -333,6 +348,8 @@ describe("handleSettlement", () => { response, mockPaymentPayload, mockRequirements, + mockDeclaredExtensions, + mockPaymentCancellationDispatcher, ); expect(result.status).toBe(402); @@ -350,6 +367,8 @@ describe("handleSettlement", () => { response, mockPaymentPayload, mockRequirements, + mockDeclaredExtensions, + mockPaymentCancellationDispatcher, ); expect(result.status).toBe(402); diff --git a/typescript/packages/http/next/src/utils.ts b/typescript/packages/http/next/src/utils.ts index 291a357da1..f058fd1303 100644 --- a/typescript/packages/http/next/src/utils.ts +++ b/typescript/packages/http/next/src/utils.ts @@ -8,8 +8,9 @@ import { RoutesConfig, FacilitatorResponseError, getFacilitatorResponseError as getCoreFacilitatorResponseError, + PaymentCancellationDispatcher, } from "@x402/core/server"; -import { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; import { NextAdapter } from "./adapter"; /** @@ -152,6 +153,7 @@ export function handlePaymentError(response: HTTPResponseInstructions): NextResp * @param paymentPayload - The payment payload from the client * @param paymentRequirements - The payment requirements for the route * @param declaredExtensions - Optional declared extensions (for per-key enrichment) + * @param cancellationDispatcher - Cancels verified payments that should not settle * @param httpContext - Optional HTTP request context for extensions * @returns The response with settlement headers or an error response if settlement fails */ @@ -160,11 +162,16 @@ export async function handleSettlement( response: NextResponse, paymentPayload: PaymentPayload, paymentRequirements: PaymentRequirements, - declaredExtensions?: Record, + declaredExtensions: Record | undefined, + cancellationDispatcher: PaymentCancellationDispatcher, httpContext?: HTTPRequestContext, ): Promise { // If the response from the protected route is >= 400, do not settle payment if (response.status >= 400) { + await cancellationDispatcher.cancel({ + reason: "handler_failed", + responseStatus: response.status, + }); return response; } @@ -176,7 +183,7 @@ export async function handleSettlement( paymentPayload, paymentRequirements, declaredExtensions, - { request: httpContext, responseBody }, + httpContext ? { request: httpContext, responseBody } : undefined, ); if (!result.success) { diff --git a/typescript/packages/http/paywall/src/avm/gen/template.ts b/typescript/packages/http/paywall/src/avm/gen/template.ts index c3e6fa9db7..2ddf8778db 100644 --- a/typescript/packages/http/paywall/src/avm/gen/template.ts +++ b/typescript/packages/http/paywall/src/avm/gen/template.ts @@ -3,4 +3,4 @@ * The pre-built AVM paywall template with inlined CSS and JS */ export const AVM_PAYWALL_TEMPLATE = - '\n \n \n Payment Required\n \n

\n \n \n '; + '\n \n \n Payment Required\n \n
\n \n \n '; diff --git a/typescript/packages/http/paywall/src/evm/gen/template.ts b/typescript/packages/http/paywall/src/evm/gen/template.ts index 3ad82e0a87..ec469c9d56 100644 --- a/typescript/packages/http/paywall/src/evm/gen/template.ts +++ b/typescript/packages/http/paywall/src/evm/gen/template.ts @@ -3,4 +3,4 @@ * The pre-built EVM paywall template with inlined CSS and JS */ export const EVM_PAYWALL_TEMPLATE = - '\n \n \n Payment Required\n \n
\n \n \n '; + '\n \n \n Payment Required\n \n
\n \n \n '; diff --git a/typescript/packages/http/paywall/src/svm/gen/template.ts b/typescript/packages/http/paywall/src/svm/gen/template.ts index 872d8ba4bd..e02c594385 100644 --- a/typescript/packages/http/paywall/src/svm/gen/template.ts +++ b/typescript/packages/http/paywall/src/svm/gen/template.ts @@ -3,4 +3,4 @@ * The pre-built SVM paywall template with inlined CSS and JS */ export const SVM_PAYWALL_TEMPLATE = - '\n \n \n Payment Required\n \n
\n \n \n '; + '\n \n \n Payment Required\n \n
\n \n \n '; diff --git a/typescript/packages/mechanisms/evm/package.json b/typescript/packages/mechanisms/evm/package.json index b5051b2adc..e7717c0940 100644 --- a/typescript/packages/mechanisms/evm/package.json +++ b/typescript/packages/mechanisms/evm/package.json @@ -149,6 +149,36 @@ "types": "./dist/cjs/upto/facilitator/index.d.ts", "default": "./dist/cjs/upto/facilitator/index.js" } + }, + "./batch-settlement/client": { + "import": { + "types": "./dist/esm/batch-settlement/client/index.d.mts", + "default": "./dist/esm/batch-settlement/client/index.mjs" + }, + "require": { + "types": "./dist/cjs/batch-settlement/client/index.d.ts", + "default": "./dist/cjs/batch-settlement/client/index.js" + } + }, + "./batch-settlement/server": { + "import": { + "types": "./dist/esm/batch-settlement/server/index.d.mts", + "default": "./dist/esm/batch-settlement/server/index.mjs" + }, + "require": { + "types": "./dist/cjs/batch-settlement/server/index.d.ts", + "default": "./dist/cjs/batch-settlement/server/index.js" + } + }, + "./batch-settlement/facilitator": { + "import": { + "types": "./dist/esm/batch-settlement/facilitator/index.d.mts", + "default": "./dist/esm/batch-settlement/facilitator/index.mjs" + }, + "require": { + "types": "./dist/cjs/batch-settlement/facilitator/index.d.ts", + "default": "./dist/cjs/batch-settlement/facilitator/index.js" + } } }, "files": [ diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/README.md b/typescript/packages/mechanisms/evm/src/batch-settlement/README.md new file mode 100644 index 0000000000..1ae4de5949 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/README.md @@ -0,0 +1,223 @@ +# Batch-Settlement EVM Scheme (`@x402/evm/batch-settlement`) + +The **batch-settlement** scheme enables high-throughput, low-cost EVM payments via **stateless unidirectional payment channels**. Clients deposit funds into an onchain escrow once, then sign off-chain **cumulative vouchers** per request. Servers verify vouchers with a fast signature check and claim them onchain in batches. + +A single claim transaction can cover many channels at once, and claimed funds are swept to the receiver in a separate `settle` step. The scheme also supports **dynamic pricing**: the client authorizes a max per-request and the server charges only what was actually used. + +See the [scheme specification](https://github.com/x402-foundation/x402/blob/main/specs/schemes/batch-settlement/scheme_batch_settlement_evm.md) for full protocol details. + +## Import Paths + +| Role | Import | +|------|--------| +| Client | `@x402/evm/batch-settlement/client` | +| Server | `@x402/evm/batch-settlement/server` | +| Facilitator | `@x402/evm/batch-settlement/facilitator` | + +## Client Usage + +Register `BatchSettlementEvmScheme` with an `x402Client`. The client handles deposits, voucher signing, channel-state recovery, and corrective 402 resync. + +```typescript +import { x402Client } from "@x402/core/client"; +import { toClientEvmSigner } from "@x402/evm"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; +import { privateKeyToAccount } from "viem/accounts"; +import { createPublicClient, http } from "viem"; +import { baseSepolia } from "viem/chains"; + +const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`); +const publicClient = createPublicClient({ chain: baseSepolia, transport: http() }); +const signer = toClientEvmSigner(account, publicClient); + +const scheme = new BatchSettlementEvmScheme(signer, { + depositPolicy: { depositMultiplier: 5 }, +}); + +const client = new x402Client(); +client.register("eip155:*", scheme); +``` + +### Deposit Policy + +Controls how much the client deposits when the channel needs funding or top-up: + +| Field | Description | +|-------|-------------| +| `depositMultiplier` | Per-request `amount × multiplier` is deposited (default 5, minimum 3) | + +Use `depositStrategy` for app-specific deposit decisions. The strategy can: + +- Return `undefined` to use the SDK default deposit amount. +- Return `false` to skip this deposit attempt. +- Return a base-unit string or bigint to choose a custom amount. The amount must cover the next voucher. + +```typescript +const maxDeposit = 1_000_000n; + +const scheme = new BatchSettlementEvmScheme(signer, { + depositPolicy: { depositMultiplier: 5 }, + depositStrategy: ({ depositAmount }) => { + const amount = BigInt(depositAmount); + return amount > maxDeposit ? maxDeposit : undefined; + }, +}); +``` + +### Voucher Signer Delegation + +By default, vouchers are signed by the same key as the payer. For better performance — especially when the payer is a **smart wallet** (EIP-1271) — delegate voucher signing to a dedicated EOA. The scheme commits this address as the channel's `payerAuthorizer`, so the facilitator can verify vouchers via fast ECDSA recovery instead of an onchain `isValidSignature` RPC. + +```typescript +const voucherSigner = toClientEvmSigner(privateKeyToAccount(VOUCHER_KEY)); +const scheme = new BatchSettlementEvmScheme(signer, { voucherSigner }); +``` + +### Cooperative Refund + +Trigger a cooperative refund request: + +```typescript +// Full refund: refunds the remaining channel balance. +const settle = await scheme.refund("https://api.example.com/any-protected-route"); + +// Partial refund: +await scheme.refund(url, { amount: "1000000" }); +``` + +The server claims any outstanding vouchers and then executes `refundWithSignature` to return `balance - totalClaimed` or `amount` to the payer. + +### Persistence + +By default, channel state is stored in memory. For long-lived clients, use `FileClientChannelStorage`: + +```typescript +import { FileClientChannelStorage } from "@x402/evm/batch-settlement/client"; + +const scheme = new BatchSettlementEvmScheme(signer, { + storage: new FileClientChannelStorage({ directory: "./channels" }), +}); +``` + +If state is lost, the client recovers from onchain `channels(channelId)` plus corrective 402s — see the spec's *Recovery After State Loss* section. + +## Server Usage + +Register the scheme with an `x402ResourceServer` and pair it with a `ChannelManager` to handle batched claims, settlements, and refunds. + +```typescript +import { x402ResourceServer } from "@x402/core/server"; +import { + BatchSettlementEvmScheme, + FileChannelStorage, + RedisChannelStorage, +} from "@x402/evm/batch-settlement/server"; + +const scheme = new BatchSettlementEvmScheme(receiverAddress, { + receiverAuthorizerSigner, // optional: self-managed authorizer (recommended) + withdrawDelay: 900, // 15 min – 30 days + storage: new FileChannelStorage({ directory: "./channels" }), +}); + +const server = new x402ResourceServer(facilitatorClient).register("eip155:84532", scheme); + +const manager = scheme.createChannelManager(facilitatorClient, "eip155:84532"); +manager.start({ + claimIntervalSecs: 60, + settleIntervalSecs: 300, + refundIntervalSecs: 3600, + selectClaimChannels: channels => channels, + selectRefundChannels: channels => + channels.filter(channel => Date.now() - channel.lastRequestTimestamp >= 3_600_000), +}); +``` + +For serverless deployments or multi-instance servers, use Redis/Valkey-backed storage so channel updates survive cold starts and are atomic across processes: + +```typescript +const scheme = new BatchSettlementEvmScheme(receiverAddress, { + storage: new RedisChannelStorage({ client: redisClient }), +}); +``` + +Use the same `selectClaimChannels` policy with one-shot cron jobs when you need to claim a specific channel subset: + +```typescript +const selectedChannelIds = new Set(["0x..."]); + +await manager.claimAndSettle({ + maxClaimsPerBatch: 100, + selectClaimChannels: channels => + channels.filter(channel => selectedChannelIds.has(channel.channelId.toLowerCase())), +}); +``` + +### Receiver Authorizer + +The `receiverAuthorizer` signs `ClaimBatch` and `Refund` EIP-712 messages and is committed into the channel's identity at deposit time: + +- **Self-managed** (recommended): pass a `receiverAuthorizerSigner` (an EOA you control). Channels survive facilitator changes — any facilitator can relay your signed claims and refunds. +- **Facilitator-delegated**: omit `receiverAuthorizerSigner`. The scheme picks up `extra.receiverAuthorizer` advertised by the facilitator's `/supported`. Switching facilitators requires opening **new channels**, so claim and refund existing channels first with. + +### Pricing + +Set the route `price` to the per-request maximum. To bill less than the max, override at handler time: + +```typescript +import { setSettlementOverrides } from "@x402/express"; + +app.get("/api/generate", (req, res) => { + const actualUsage = computeCost(); + setSettlementOverrides(res, { amount: String(actualUsage) }); + res.json({ result: "..." }); +}); +``` + +`amount` accepts raw atomic units, percentages (`"50%"`), or dollar prices (`"$0.001"`). + +## Facilitator Usage + +```typescript +import { x402Facilitator } from "@x402/core/facilitator"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/facilitator"; + +const facilitator = new x402Facilitator().register( + "eip155:84532", + new BatchSettlementEvmScheme(evmSigner, authorizerSigner), +); +``` + +The `authorizerSigner` produces the EIP-712 signatures advertised in `/supported.kinds[].extra.receiverAuthorizer`. Servers may delegate to it (see above) or supply their own. The `evmSigner` (the wallet account) submits transactions for `deposit`, `claimWithSignature`, `settle`, and `refundWithSignature` — anyone can submit a valid claim/refund tx, but only the configured signer here will be used by this facilitator. + +## Supported Networks + +| Network | CAIP-2 ID | +|---------|-----------| +| Base Mainnet | `eip155:8453` | +| Base Sepolia | `eip155:84532` | + +Requires the x402 batch-settlement contract deployed on the target network. + +## Asset Transfer Methods + +Deposits use one of two onchain transfer methods, controlled by `extra.assetTransferMethod`: + +| Method | Description | +|--------|-------------| +| `eip3009` | `receiveWithAuthorization` — for tokens that support EIP-3009 (e.g. USDC). Default. | +| `permit2` | Universal fallback for any ERC-20 via Uniswap Permit2. | + +Deposits are sponsored by the facilitator (gasless for the client). + +## Examples + +- [Server example](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/batch-settlement) +- [Client example](https://github.com/x402-foundation/x402/tree/main/examples/typescript/clients/batch-settlement) +- [Facilitator example](https://github.com/x402-foundation/x402/tree/main/examples/typescript/facilitator/batch-settlement) +- [Streaming server (SSE, mid-stream voucher renewal)](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/batch-settlement-streaming) + +## See Also + +- [Exact EVM Scheme](../exact/README.md) — fixed-price, no escrow +- [Upto EVM Scheme](../upto/README.md) — usage-based, single-shot +- [Batch-Settlement EVM Scheme Specification](https://github.com/x402-foundation/x402/blob/main/specs/schemes/batch-settlement/scheme_batch_settlement_evm.md) diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/abi.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/abi.ts new file mode 100644 index 0000000000..ee939c5fd7 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/abi.ts @@ -0,0 +1,217 @@ +export const channelConfigComponents = [ + { name: "payer", type: "address" }, + { name: "payerAuthorizer", type: "address" }, + { name: "receiver", type: "address" }, + { name: "receiverAuthorizer", type: "address" }, + { name: "token", type: "address" }, + { name: "withdrawDelay", type: "uint40" }, + { name: "salt", type: "bytes32" }, +] as const; + +const voucherClaimComponents = [ + { + name: "voucher", + type: "tuple", + components: [ + { + name: "channel", + type: "tuple", + components: channelConfigComponents, + }, + { name: "maxClaimableAmount", type: "uint128" }, + ], + }, + { name: "signature", type: "bytes" }, + { name: "totalClaimed", type: "uint128" }, +] as const; + +export const batchSettlementABI = [ + { + type: "function", + name: "multicall", + inputs: [{ name: "data", type: "bytes[]" }], + outputs: [{ name: "results", type: "bytes[]" }], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "deposit", + inputs: [ + { name: "config", type: "tuple", components: channelConfigComponents }, + { name: "amount", type: "uint128" }, + { name: "collector", type: "address" }, + { name: "collectorData", type: "bytes" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "claim", + inputs: [{ name: "voucherClaims", type: "tuple[]", components: voucherClaimComponents }], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "claimWithSignature", + inputs: [ + { name: "voucherClaims", type: "tuple[]", components: voucherClaimComponents }, + { name: "authorizerSignature", type: "bytes" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "settle", + inputs: [ + { name: "receiver", type: "address" }, + { name: "token", type: "address" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "initiateWithdraw", + inputs: [ + { name: "config", type: "tuple", components: channelConfigComponents }, + { name: "amount", type: "uint128" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "finalizeWithdraw", + inputs: [{ name: "config", type: "tuple", components: channelConfigComponents }], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "refund", + inputs: [ + { name: "config", type: "tuple", components: channelConfigComponents }, + { name: "amount", type: "uint128" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "refundWithSignature", + inputs: [ + { name: "config", type: "tuple", components: channelConfigComponents }, + { name: "amount", type: "uint128" }, + { name: "nonce", type: "uint256" }, + { name: "receiverAuthorizerSignature", type: "bytes" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "getChannelId", + inputs: [{ name: "config", type: "tuple", components: channelConfigComponents }], + outputs: [{ name: "", type: "bytes32" }], + stateMutability: "view", + }, + { + type: "function", + name: "CHANNEL_CONFIG_TYPEHASH", + inputs: [], + outputs: [{ name: "", type: "bytes32" }], + stateMutability: "view", + }, + { + type: "function", + name: "channels", + inputs: [{ name: "channelId", type: "bytes32" }], + outputs: [ + { name: "balance", type: "uint128" }, + { name: "totalClaimed", type: "uint128" }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "pendingWithdrawals", + inputs: [{ name: "channelId", type: "bytes32" }], + outputs: [ + { name: "amount", type: "uint128" }, + { name: "initiatedAt", type: "uint40" }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "receivers", + inputs: [ + { name: "receiver", type: "address" }, + { name: "token", type: "address" }, + ], + outputs: [ + { name: "totalClaimed", type: "uint128" }, + { name: "totalSettled", type: "uint128" }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "getVoucherDigest", + inputs: [ + { name: "channelId", type: "bytes32" }, + { name: "maxClaimableAmount", type: "uint128" }, + ], + outputs: [{ name: "", type: "bytes32" }], + stateMutability: "view", + }, + { + type: "function", + name: "getRefundDigest", + inputs: [ + { name: "channelId", type: "bytes32" }, + { name: "nonce", type: "uint256" }, + { name: "amount", type: "uint128" }, + ], + outputs: [{ name: "", type: "bytes32" }], + stateMutability: "view", + }, + { + type: "function", + name: "refundNonce", + inputs: [{ name: "channelId", type: "bytes32" }], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "getClaimBatchDigest", + inputs: [{ name: "voucherClaims", type: "tuple[]", components: voucherClaimComponents }], + outputs: [{ name: "", type: "bytes32" }], + stateMutability: "view", + }, + { + type: "event", + name: "Settled", + inputs: [ + { name: "receiver", type: "address", indexed: true }, + { name: "token", type: "address", indexed: true }, + { name: "sender", type: "address", indexed: true }, + { name: "amount", type: "uint128", indexed: false }, + ], + anonymous: false, + }, +] as const; + +export const erc20BalanceOfABI = [ + { + type: "function", + name: "balanceOf", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, +] as const; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/authorizerSigner.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/authorizerSigner.ts new file mode 100644 index 0000000000..9c65ea8f9e --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/authorizerSigner.ts @@ -0,0 +1,66 @@ +import type { AuthorizerSigner, BatchSettlementVoucherClaim } from "./types"; +import { claimBatchTypes, refundTypes } from "./constants"; +import { computeChannelId, getBatchSettlementEip712Domain } from "./utils"; +import { getEvmChainId } from "../utils"; + +/** + * Signs a `ClaimBatch` EIP-712 digest for `claimWithSignature()`. + * + * @param signer - Authorizer signer holding the `receiverAuthorizer` key. + * @param claims - Voucher claims to include in the batch. + * @param network - CAIP-2 network identifier (e.g. `"eip155:84532"`). + * @returns EIP-712 signature over `ClaimBatch(ClaimEntry[] claims)`. + */ +export async function signClaimBatch( + signer: AuthorizerSigner, + claims: BatchSettlementVoucherClaim[], + network: string, +): Promise<`0x${string}`> { + const chainId = getEvmChainId(network); + + const claimEntries = claims.map(c => ({ + channelId: computeChannelId(c.voucher.channel, chainId), + maxClaimableAmount: BigInt(c.voucher.maxClaimableAmount), + totalClaimed: BigInt(c.totalClaimed), + })); + + return signer.signTypedData({ + domain: getBatchSettlementEip712Domain(chainId), + types: claimBatchTypes, + primaryType: "ClaimBatch", + message: { + claims: claimEntries, + }, + }); +} + +/** + * Signs a `Refund` EIP-712 digest for `refundWithSignature()`. + * + * @param signer - Authorizer signer holding the `receiverAuthorizer` key. + * @param channelId - Channel to authorize refund for. + * @param amount - Refund amount (capped to unclaimed escrow onchain). + * @param nonce - Must match onchain `refundNonce(channelId)`. + * @param network - CAIP-2 network identifier (e.g. `"eip155:84532"`). + * @returns EIP-712 signature over `Refund(channelId, nonce, amount)`. + */ +export async function signRefund( + signer: AuthorizerSigner, + channelId: `0x${string}`, + amount: string, + nonce: string, + network: string, +): Promise<`0x${string}`> { + const chainId = getEvmChainId(network); + + return signer.signTypedData({ + domain: getBatchSettlementEip712Domain(chainId), + types: refundTypes, + primaryType: "Refund", + message: { + channelId, + nonce: BigInt(nonce), + amount: BigInt(amount), + }, + }); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/channel.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/channel.ts new file mode 100644 index 0000000000..a5693a9911 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/channel.ts @@ -0,0 +1,259 @@ +import { decodePaymentResponseHeader } from "@x402/core/http"; +import type { PaymentRequirements, SettleResponse } from "@x402/core/types"; +import { getAddress } from "viem"; +import type { ClientEvmSigner } from "../../signer"; +import { batchSettlementABI } from "../abi"; +import { BATCH_SETTLEMENT_ADDRESS, MIN_WITHDRAW_DELAY } from "../constants"; +import type { + BatchSettlementPaymentRequirementsExtra, + BatchSettlementPaymentResponseExtra, + ChannelConfig, +} from "../types"; +import { computeChannelId } from "../utils"; +import type { BatchSettlementClientContext, ClientChannelStorage } from "./storage"; + +type ResponseChannelState = NonNullable; + +/** + * Reads the nested channel state from a settlement response extra object. + * + * @param extra - Settlement response extra fields. + * @returns Channel state fields, or undefined when absent. + */ +function readResponseChannelState( + extra: Record, +): ResponseChannelState | undefined { + const channelState = extra.channelState; + if (typeof channelState !== "object" || channelState === null) { + return undefined; + } + return channelState as ResponseChannelState; +} + +/** + * Runtime dependency bag shared by every storage-bound client helper (channel, + * recovery, refund) and the {@link BatchSettlementEvmScheme} class. + */ +export interface BatchSettlementClientDeps { + signer: ClientEvmSigner; + storage: ClientChannelStorage; + salt: `0x${string}`; + payerAuthorizer?: `0x${string}`; + voucherSigner?: ClientEvmSigner; +} + +/** + * Constructs the immutable {@link ChannelConfig} from payment requirements and + * a client deps bag (signer, salt, optional payerAuthorizer / voucherSigner). + * + * @param deps - Client identity inputs. + * @param paymentRequirements - Server payment requirements providing receiver, asset, and extra fields. + * @returns The ChannelConfig that uniquely identifies this payment channel. + */ +export function buildChannelConfig( + deps: BatchSettlementClientDeps, + paymentRequirements: PaymentRequirements, +): ChannelConfig { + const extra = paymentRequirements.extra as + | Partial + | undefined; + const receiverAuthorizer = extra?.receiverAuthorizer; + if ( + !receiverAuthorizer || + getAddress(receiverAuthorizer) === "0x0000000000000000000000000000000000000000" + ) { + throw new Error("Payment requirements must include a non-zero extra.receiverAuthorizer"); + } + + return { + payer: deps.signer.address, + payerAuthorizer: getAddress( + deps.payerAuthorizer ?? deps.voucherSigner?.address ?? deps.signer.address, + ), + receiver: paymentRequirements.payTo as `0x${string}`, + receiverAuthorizer: getAddress(receiverAuthorizer), + token: paymentRequirements.asset as `0x${string}`, + withdrawDelay: + typeof extra?.withdrawDelay === "number" ? extra.withdrawDelay : MIN_WITHDRAW_DELAY, + salt: deps.salt, + }; +} + +/** + * Updates local channel state from a parsed `SettleResponse`. + * + * @param storage - Client channel storage. + * @param settle - The parsed settle response. + */ +export async function processSettleResponse( + storage: ClientChannelStorage, + settle: SettleResponse, +): Promise { + const extra = settle.extra ?? {}; + const channelState = readResponseChannelState(extra); + if (!channelState) return; + + const channelId = channelState.channelId; + const key = channelId.toLowerCase(); + + const prev = await storage.get(key); + const next: BatchSettlementClientContext = { ...(prev ?? {}) }; + + if (channelState.chargedCumulativeAmount !== undefined) { + next.chargedCumulativeAmount = String(channelState.chargedCumulativeAmount); + } + if (channelState.balance !== undefined) { + next.balance = String(channelState.balance); + } + if (channelState.totalClaimed !== undefined) { + next.totalClaimed = String(channelState.totalClaimed); + } + + await storage.set(key, next); +} + +/** + * Reconciles local channel state with the outcome of a cooperative refund. + * + * Deletes the channel record when the post-refund balance is zero (full refund), + * otherwise updates local state from the server snapshot. + * + * @param storage - Client channel storage. + * @param channelKey - Lowercased channel id used as the storage key. + * @param settleExtra - The `extra` block from the refund settle response. + */ +export async function updateChannelAfterRefund( + storage: ClientChannelStorage, + channelKey: string, + settleExtra: Record, +): Promise { + const channelState = readResponseChannelState(settleExtra); + if (!channelState) { + await storage.delete(channelKey); + return; + } + + const balanceAfter = + channelState.balance !== undefined ? BigInt(String(channelState.balance)) : undefined; + + if (balanceAfter === undefined || balanceAfter <= 0n) { + await storage.delete(channelKey); + return; + } + + const prev = await storage.get(channelKey); + const next: BatchSettlementClientContext = { ...(prev ?? {}) }; + next.balance = balanceAfter.toString(); + if (channelState.chargedCumulativeAmount !== undefined) { + next.chargedCumulativeAmount = String(channelState.chargedCumulativeAmount); + } + if (channelState.totalClaimed !== undefined) { + next.totalClaimed = String(channelState.totalClaimed); + } + await storage.set(channelKey, next); +} + +/** + * Processes the `PAYMENT-RESPONSE` header after a successful request. + * + * Decodes the header into a `SettleResponse` and delegates to + * {@link processSettleResponse}. + * + * @param storage - Client channel storage. + * @param getHeader - Function to retrieve a response header by name. + */ +export async function processPaymentResponse( + storage: ClientChannelStorage, + getHeader: (name: string) => string | null | undefined, +): Promise { + const raw = getHeader("PAYMENT-RESPONSE"); + if (!raw) return; + + const settle = decodePaymentResponseHeader(raw); + await processSettleResponse(storage, settle); +} + +/** + * Recovers a channel record from onchain state (useful after a cold start or + * channel record loss). + * + * @param deps - Signer + storage + identity inputs. + * @param paymentRequirements - Server payment requirements used to derive the ChannelConfig. + * @returns The recovered client context. + */ +export async function recoverChannel( + deps: BatchSettlementClientDeps, + paymentRequirements: PaymentRequirements, +): Promise { + if (!deps.signer.readContract) { + throw new Error("recoverChannel requires ClientEvmSigner.readContract"); + } + + const config = buildChannelConfig(deps, paymentRequirements); + const channelId = computeChannelId(config, paymentRequirements.network); + + const [chBalance, chTotalClaimed] = await readChannelBalanceAndTotalClaimed( + deps.signer, + channelId, + ); + + const ctx: BatchSettlementClientContext = { + chargedCumulativeAmount: chTotalClaimed.toString(), + balance: chBalance.toString(), + totalClaimed: chTotalClaimed.toString(), + }; + + await deps.storage.set(channelId.toLowerCase(), ctx); + return ctx; +} + +/** + * Reads `channels(channelId)` returning `[balance, totalClaimed]`. + * + * @param signer - Signer providing `readContract`. + * @param channelId - The `bytes32` channel id to query. + * @returns Tuple of `[balance, totalClaimed]` as bigints. + */ +export async function readChannelBalanceAndTotalClaimed( + signer: ClientEvmSigner, + channelId: `0x${string}`, +): Promise<[bigint, bigint]> { + if (!signer.readContract) { + throw new Error("readChannelBalanceAndTotalClaimed requires ClientEvmSigner.readContract"); + } + return (await signer.readContract({ + address: BATCH_SETTLEMENT_ADDRESS, + abi: batchSettlementABI, + functionName: "channels", + args: [channelId], + })) as [bigint, bigint]; +} + +/** + * Returns whether a local channel record exists for the given channel. + * + * @param storage - Client channel storage. + * @param channelId - The channel identifier to check. + * @returns `true` when a channel record is stored. + */ +export async function hasChannel( + storage: ClientChannelStorage, + channelId: string, +): Promise { + const channel = await storage.get(channelId.toLowerCase()); + return channel !== undefined; +} + +/** + * Returns the local channel context for a channel, if present. + * + * @param storage - Client channel storage. + * @param channelId - The channel identifier. + * @returns Stored context or `undefined`. + */ +export async function getChannel( + storage: ClientChannelStorage, + channelId: string, +): Promise { + return storage.get(channelId.toLowerCase()); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/config.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/config.ts new file mode 100644 index 0000000000..59b8c86116 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/config.ts @@ -0,0 +1,155 @@ +import type { PaymentRequirements } from "@x402/core/types"; +import type { ClientEvmSigner } from "../../signer"; +import type { EvmSchemeOptions } from "../../shared/rpc"; +import type { ChannelConfig } from "../types"; +import { type ClientChannelStorage, InMemoryClientChannelStorage } from "./storage"; +import type { BatchSettlementClientContext } from "./storage"; + +const DEFAULT_SALT = + "0x0000000000000000000000000000000000000000000000000000000000000000" as `0x${string}`; + +/** + * Caller-tunable policy controlling how the client sizes channel deposits. + */ +export interface BatchSettlementDepositPolicy { + depositMultiplier?: number; +} + +/** + * Return shape for custom deposit sizing. + */ +export type BatchSettlementDepositStrategyResult = string | bigint | false | undefined; + +/** + * Information supplied before the client signs a deposit authorization. + */ +export interface BatchSettlementDepositStrategyContext { + paymentRequirements: PaymentRequirements; + channelConfig: ChannelConfig; + channelId: `0x${string}`; + clientContext: BatchSettlementClientContext; + requestAmount: string; + maxClaimableAmount: string; + currentBalance: string; + minimumDepositAmount: string; + depositAmount: string; +} + +/** + * Custom deposit sizing callback for initial deposits and top-ups. + */ +export type BatchSettlementDepositStrategy = ( + context: BatchSettlementDepositStrategyContext, +) => BatchSettlementDepositStrategyResult | Promise; + +/** + * Full options object accepted by `BatchSettlementEvmScheme`. Either this or a + * bare {@link BatchSettlementDepositPolicy} can be passed as the second + * constructor argument. + */ +export interface BatchSettlementEvmSchemeOptions { + depositPolicy?: BatchSettlementDepositPolicy; + /** Optional callback for app-specific deposit sizing or skipping. */ + depositStrategy?: BatchSettlementDepositStrategy; + storage?: ClientChannelStorage; + salt?: `0x${string}`; + payerAuthorizer?: `0x${string}`; + rpcUrl?: string; + /** When set, EIP-712 vouchers are signed with this key; deposits still use the main `signer`. */ + voucherSigner?: ClientEvmSigner; +} + +/** + * Resolved options after merging defaults — used internally by the scheme, + * recovery, and refund modules. + */ +export interface ResolvedClientOptions { + depositPolicy?: BatchSettlementDepositPolicy; + depositStrategy?: BatchSettlementDepositStrategy; + storage: ClientChannelStorage; + salt: `0x${string}`; + payerAuthorizer?: `0x${string}`; + voucherSigner?: ClientEvmSigner; + extensionRpcOptions?: EvmSchemeOptions; +} + +/** + * Discriminates a full options object from a bare deposit-policy object. + * + * @param o - Constructor argument that may be options, deposit policy only, or undefined. + * @returns `true` when `o` is a {@link BatchSettlementEvmSchemeOptions} object. + */ +export function isBatchSettlementEvmSchemeOptions( + o: BatchSettlementEvmSchemeOptions | BatchSettlementDepositPolicy | undefined, +): o is BatchSettlementEvmSchemeOptions { + return ( + o !== undefined && + typeof o === "object" && + ("storage" in o || + "depositPolicy" in o || + "depositStrategy" in o || + "salt" in o || + "payerAuthorizer" in o || + "rpcUrl" in o || + "voucherSigner" in o) + ); +} + +/** + * Normalises the constructor's second argument into a uniform options shape. + * + * @param second - Optional second constructor argument (options or deposit policy). + * @returns Resolved storage, salt, deposit policy, and optional payer authorizer. + */ +export function resolveClientOptions( + second?: BatchSettlementEvmSchemeOptions | BatchSettlementDepositPolicy, +): ResolvedClientOptions { + if (second === undefined) { + return { storage: new InMemoryClientChannelStorage(), salt: DEFAULT_SALT }; + } + if (isBatchSettlementEvmSchemeOptions(second)) { + return { + storage: second.storage ?? new InMemoryClientChannelStorage(), + depositPolicy: second.depositPolicy, + depositStrategy: second.depositStrategy, + salt: second.salt ?? DEFAULT_SALT, + payerAuthorizer: second.payerAuthorizer, + voucherSigner: second.voucherSigner, + extensionRpcOptions: second.rpcUrl ? { rpcUrl: second.rpcUrl } : undefined, + }; + } + return { + storage: new InMemoryClientChannelStorage(), + depositPolicy: second, + salt: DEFAULT_SALT, + }; +} + +/** + * Validates a {@link BatchSettlementDepositPolicy}, throwing on invalid fields. + * + * @param policy - The policy to validate (no-op when undefined). + */ +export function validateDepositPolicy(policy: BatchSettlementDepositPolicy | undefined): void { + if (!policy) return; + + const m = policy.depositMultiplier; + if (m !== undefined && (!Number.isInteger(m) || m < 3)) { + throw new Error("depositMultiplier must be an integer >= 3"); + } +} + +/** + * Computes the deposit amount based on the deposit multiplier. + * + * @param policy - Deposit policy controlling multiplier (may be undefined). + * @param requestAmount - Amount requested for this operation, in token base units. + * @returns Deposit amount string in token base units. + */ +export function depositAmountForRequest( + policy: BatchSettlementDepositPolicy | undefined, + requestAmount: bigint, +): string { + const mult = BigInt(policy?.depositMultiplier ?? 5); + return (mult * requestAmount).toString(); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/eip3009.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/eip3009.ts new file mode 100644 index 0000000000..6d53b41b80 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/eip3009.ts @@ -0,0 +1,100 @@ +import { PaymentRequirements, PaymentPayloadResult } from "@x402/core/types"; +import { getAddress } from "viem"; +import { ClientEvmSigner } from "../../signer"; +import { ChannelConfig, BatchSettlementDepositPayload } from "../types"; +import { ERC3009_DEPOSIT_COLLECTOR_ADDRESS, receiveAuthorizationTypes } from "../constants"; +import { createNonce, getEvmChainId } from "../../utils"; +import { signVoucher } from "./voucher"; +import { computeChannelId } from "../utils"; +import { buildErc3009DepositNonce } from "../encoding"; + +/** + * Creates a deposit payload that bundles an ERC-3009 `receiveWithAuthorization` approval + * together with a cumulative voucher signature. + * + * When the facilitator submits this payload onchain, the contract atomically transfers + * tokens from the payer into the channel and records the initial voucher. + * + * @param signer - Client wallet used to sign the ERC-3009 authorization (`from` = payer). + * @param x402Version - Protocol version to embed in the payload envelope. + * @param paymentRequirements - Server-provided payment requirements (asset, network, amount, etc.). + * @param channelConfig - Immutable channel configuration (payer, receiver, token, …). + * @param depositAmount - Number of tokens (decimal string) to deposit into the channel. + * @param maxClaimableAmount - Cumulative ceiling for the accompanying voucher. + * @param voucherSigner - Optional key that signs the voucher; defaults to `signer` (same as payer). + * @returns A {@link PaymentPayloadResult} containing the signed deposit + voucher payload. + */ +export async function createBatchSettlementEIP3009DepositPayload( + signer: ClientEvmSigner, + x402Version: number, + paymentRequirements: PaymentRequirements, + channelConfig: ChannelConfig, + depositAmount: string, + maxClaimableAmount: string, + voucherSigner?: ClientEvmSigner, +): Promise { + const salt = createNonce(); + const now = Math.floor(Date.now() / 1000); + const chainId = getEvmChainId(paymentRequirements.network); + + if (!paymentRequirements.extra?.name || !paymentRequirements.extra?.version) { + throw new Error( + `EIP-712 domain parameters (name, version) are required in payment requirements for asset ${paymentRequirements.asset}`, + ); + } + + const { name, version } = paymentRequirements.extra; + + const channelId = computeChannelId(channelConfig, paymentRequirements.network); + + const erc3009Nonce = buildErc3009DepositNonce(channelId, salt); + + const signature = await signer.signTypedData({ + domain: { + name, + version, + chainId, + verifyingContract: getAddress(paymentRequirements.asset), + }, + types: receiveAuthorizationTypes, + primaryType: "ReceiveWithAuthorization", + message: { + from: getAddress(signer.address), + to: getAddress(ERC3009_DEPOSIT_COLLECTOR_ADDRESS), + value: BigInt(depositAmount), + validAfter: BigInt(now - 600), + validBefore: BigInt(now + paymentRequirements.maxTimeoutSeconds), + nonce: erc3009Nonce, + }, + }); + + const vSigner = voucherSigner ?? signer; + const voucher = await signVoucher( + vSigner, + channelId, + maxClaimableAmount, + paymentRequirements.network, + ); + + const payload: BatchSettlementDepositPayload = { + type: "deposit", + channelConfig, + voucher, + deposit: { + amount: depositAmount, + authorization: { + erc3009Authorization: { + validAfter: (now - 600).toString(), + validBefore: (now + paymentRequirements.maxTimeoutSeconds).toString(), + salt, + signature, + }, + }, + }, + }; + + return { + x402Version, + payload, + }; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/fileStorage.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/fileStorage.ts new file mode 100644 index 0000000000..16e6921c5c --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/fileStorage.ts @@ -0,0 +1,68 @@ +import { unlink } from "node:fs/promises"; +import { join } from "node:path"; + +import { isNodeEnoent, readJsonFile, writeJsonAtomic } from "../storage-utils"; +import type { FileChannelStorageOptions } from "../types"; +import type { ClientChannelStorage, BatchSettlementClientContext } from "./storage"; + +/** + * Node.js file-backed {@link ClientChannelStorage} for the batched client scheme. + * Each channel's context is persisted as `{root}/client/{channelId}.json` so that channel + * records survive process restarts. + */ +export class FileClientChannelStorage implements ClientChannelStorage { + private readonly root: string; + + /** + * Creates file-backed client channel storage under the given root directory. + * + * @param options - Configuration including the storage root directory. + */ + constructor(options: FileChannelStorageOptions) { + this.root = options.directory; + } + + /** + * Loads the stored client context for a channel, if present. + * + * @param key - Channel storage key (typically a lowercased channelId). + * @returns Parsed context or `undefined` when the file is missing. + */ + async get(key: string): Promise { + return readJsonFile(this.filePath(key)); + } + + /** + * Persists the client context for a channel. + * + * @param key - Channel storage key. + * @param context - Context record to write. + */ + async set(key: string, context: BatchSettlementClientContext): Promise { + await writeJsonAtomic(this.filePath(key), context); + } + + /** + * Removes the persisted context file for a channel, if it exists. + * + * @param key - Channel storage key. + */ + async delete(key: string): Promise { + try { + await unlink(this.filePath(key)); + } catch (err: unknown) { + if (isNodeEnoent(err)) return; + throw err; + } + } + + /** + * Absolute path to the JSON file for a channel. + * + * @param key - Channel storage key. + * @returns Filesystem path under `{root}/client/...`. + */ + private filePath(key: string): string { + return join(this.root, "client", `${key.toLowerCase()}.json`); + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/hooks.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/hooks.ts new file mode 100644 index 0000000000..917d99abc6 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/hooks.ts @@ -0,0 +1,55 @@ +import type { PaymentResponseContext } from "@x402/core/client"; +import type { SchemeClientHooks } from "@x402/core/types"; +import { isBatchSettlementRefundPayload } from "../types"; +import type { BatchSettlementClientDeps } from "./channel"; +import { processSettleResponse, updateChannelAfterRefund } from "./channel"; +import { processCorrectivePaymentRequired } from "./recovery"; + +/** + * Creates storage-aware client hooks for batch-settlement payment responses. + * + * @param deps - Client identity and storage inputs. + * @returns Scheme hooks for response reconciliation and corrective recovery. + */ +export function createBatchSettlementClientHooks( + deps: BatchSettlementClientDeps, +): SchemeClientHooks { + return { + onPaymentResponse: ctx => handleBatchSettlementPaymentResponse(deps, ctx), + }; +} + +/** + * Reconciles batch-settlement client state after a paid request or refund attempt. + * + * @param deps - Client identity and storage inputs. + * @param ctx - Core payment response context. + * @returns A recovery signal when corrective recovery succeeds. + */ +export async function handleBatchSettlementPaymentResponse( + deps: BatchSettlementClientDeps, + ctx: PaymentResponseContext, +): Promise { + if (ctx.settleResponse) { + if (isBatchSettlementRefundPayload(ctx.paymentPayload.payload)) { + const extra = ctx.settleResponse.extra ?? {}; + const channelState = extra.channelState; + const channelId = + typeof channelState === "object" && channelState !== null && "channelId" in channelState + ? channelState.channelId + : undefined; + if (typeof channelId === "string" && channelId) { + await updateChannelAfterRefund(deps.storage, channelId.toLowerCase(), extra); + } + return; + } + + await processSettleResponse(deps.storage, ctx.settleResponse); + return; + } + + if (ctx.paymentRequired) { + const recovered = await processCorrectivePaymentRequired(deps, ctx.paymentRequired); + return recovered ? { recovered: true } : undefined; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/index.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/index.ts new file mode 100644 index 0000000000..8f2e3abce4 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/index.ts @@ -0,0 +1,44 @@ +export { BatchSettlementEvmScheme } from "./scheme"; +export type { + BatchSettlementClientContext, + BatchSettlementDepositPolicy, + BatchSettlementDepositStrategy, + BatchSettlementDepositStrategyContext, + BatchSettlementDepositStrategyResult, + BatchSettlementEvmSchemeOptions, +} from "./scheme"; +export type { ClientChannelStorage } from "./storage"; +export { InMemoryClientChannelStorage } from "./storage"; +export { FileClientChannelStorage } from "./fileStorage"; +export { createBatchSettlementEIP3009DepositPayload } from "./eip3009"; +export { signVoucher } from "./voucher"; +export { refundChannel } from "./refund"; +export type { RefundOptions } from "./refund"; +export { createBatchSettlementClientHooks, handleBatchSettlementPaymentResponse } from "./hooks"; +export { computeChannelId } from "../utils"; + +export { + depositAmountForRequest, + isBatchSettlementEvmSchemeOptions, + resolveClientOptions, + validateDepositPolicy, +} from "./config"; +export type { ResolvedClientOptions } from "./config"; + +export { + buildChannelConfig, + getChannel, + hasChannel, + processPaymentResponse, + processSettleResponse, + readChannelBalanceAndTotalClaimed, + recoverChannel, + updateChannelAfterRefund, +} from "./channel"; +export type { BatchSettlementClientDeps } from "./channel"; + +export { + processCorrectivePaymentRequired, + recoverFromOnChainState, + recoverFromSignature, +} from "./recovery"; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/permit2.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/permit2.ts new file mode 100644 index 0000000000..cce19d36d9 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/permit2.ts @@ -0,0 +1,92 @@ +import { PaymentRequirements, PaymentPayloadResult } from "@x402/core/types"; +import { getAddress } from "viem"; +import { PERMIT2_ADDRESS } from "../../constants"; +import { ClientEvmSigner } from "../../signer"; +import { createPermit2Nonce, getEvmChainId } from "../../utils"; +import { PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, batchPermit2WitnessTypes } from "../constants"; +import { ChannelConfig, BatchSettlementDepositPayload } from "../types"; +import { computeChannelId } from "../utils"; +import { signVoucher } from "./voucher"; + +/** + * Builds a batch deposit payload using a channel-bound Permit2 witness transfer. + * + * @param signer - Payer signer for the Permit2 authorization. + * @param x402Version - Protocol version for the payment envelope. + * @param paymentRequirements - Server-provided payment requirements. + * @param channelConfig - Channel configuration bound into the voucher and witness. + * @param depositAmount - Token amount deposited into the channel. + * @param maxClaimableAmount - Cumulative amount signed in the voucher. + * @param voucherSigner - Optional signer for the voucher. + * @returns Signed deposit payload and voucher. + */ +export async function createBatchSettlementPermit2DepositPayload( + signer: ClientEvmSigner, + x402Version: number, + paymentRequirements: PaymentRequirements, + channelConfig: ChannelConfig, + depositAmount: string, + maxClaimableAmount: string, + voucherSigner?: ClientEvmSigner, +): Promise { + const chainId = getEvmChainId(paymentRequirements.network); + const nonce = createPermit2Nonce(); + const deadline = Math.floor(Date.now() / 1000 + paymentRequirements.maxTimeoutSeconds).toString(); + const channelId = computeChannelId(channelConfig, paymentRequirements.network); + + const permit2Authorization = { + from: signer.address, + permitted: { + token: getAddress(paymentRequirements.asset), + amount: depositAmount, + }, + spender: getAddress(PERMIT2_DEPOSIT_COLLECTOR_ADDRESS), + nonce, + deadline, + witness: { + channelId, + }, + }; + + const signature = await signer.signTypedData({ + domain: { name: "Permit2", chainId, verifyingContract: PERMIT2_ADDRESS }, + types: batchPermit2WitnessTypes, + primaryType: "PermitWitnessTransferFrom", + message: { + permitted: { + token: permit2Authorization.permitted.token, + amount: BigInt(permit2Authorization.permitted.amount), + }, + spender: permit2Authorization.spender, + nonce: BigInt(permit2Authorization.nonce), + deadline: BigInt(permit2Authorization.deadline), + witness: { + channelId, + }, + }, + }); + + const voucher = await signVoucher( + voucherSigner ?? signer, + channelId, + maxClaimableAmount, + paymentRequirements.network, + ); + + const payload: BatchSettlementDepositPayload = { + type: "deposit", + channelConfig, + voucher, + deposit: { + amount: depositAmount, + authorization: { + permit2Authorization: { + ...permit2Authorization, + signature, + }, + }, + }, + }; + + return { x402Version, payload }; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/recovery.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/recovery.ts new file mode 100644 index 0000000000..d64b7ba322 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/recovery.ts @@ -0,0 +1,167 @@ +import type { PaymentRequired, PaymentRequirements } from "@x402/core/types"; +import { getAddress, recoverTypedDataAddress } from "viem"; +import { BATCH_SETTLEMENT_SCHEME, voucherTypes } from "../constants"; +import type { BatchSettlementClientContext } from "./storage"; +import { computeChannelId, getBatchSettlementEip712Domain } from "../utils"; +import { getEvmChainId } from "../../utils"; +import { + type BatchSettlementClientDeps, + buildChannelConfig, + readChannelBalanceAndTotalClaimed, +} from "./channel"; +import * as Errors from "../errors"; +import type { BatchSettlementChannelStateExtra, BatchSettlementVoucherStateExtra } from "../types"; + +/** + * Handles a corrective 402 response from the server when the client's + * cumulative base is out of sync. + * + * Validates the server-provided state (chargedCumulativeAmount, + * signedMaxClaimable, signature) against onchain data and the client's own + * signing key, then updates the local channel state if everything checks out. + * + * @param deps - Signer + storage + identity inputs. + * @param paymentRequired - The decoded 402 response body. + * @returns `true` if the channel state was successfully resynced and the request can be retried. + */ +export async function processCorrectivePaymentRequired( + deps: BatchSettlementClientDeps, + paymentRequired: PaymentRequired, +): Promise { + if ( + paymentRequired.error !== Errors.ErrCumulativeAmountMismatch && + paymentRequired.error !== Errors.ErrCumulativeAmountBelowClaimed + ) { + return false; + } + + const accept = paymentRequired.accepts.find(a => a.scheme === BATCH_SETTLEMENT_SCHEME); + if (!accept) { + return false; + } + + const channelState = accept.extra.channelState as BatchSettlementChannelStateExtra | undefined; + const voucherState = accept.extra.voucherState as BatchSettlementVoucherStateExtra | undefined; + const hasSig = + channelState?.chargedCumulativeAmount !== undefined && + voucherState?.signedMaxClaimable !== undefined && + voucherState.signature !== undefined; + + if (!hasSig) { + return recoverFromOnChainState(deps, accept); + } + + return recoverFromSignature(deps, accept, channelState, voucherState); +} + +/** + * Recovers channel state from a corrective 402 that includes a server-provided + * voucher signature. Verifies the signature matches the client's own signing + * key before accepting. + * + * @param deps - Signer + storage + identity inputs. + * @param accept - Batch settlement payment requirements from the corrective 402. + * @param channelState - Server channel snapshot from `accept.extra.channelState`. + * @param voucherState - Latest signed voucher proof from `accept.extra.voucherState`. + * @returns `true` when local channel state was updated successfully. + */ +export async function recoverFromSignature( + deps: BatchSettlementClientDeps, + accept: PaymentRequirements, + channelState: BatchSettlementChannelStateExtra, + voucherState: BatchSettlementVoucherStateExtra, +): Promise { + const chargedRaw = channelState.chargedCumulativeAmount; + const signedRaw = voucherState.signedMaxClaimable; + const sig = voucherState.signature as `0x${string}`; + + const charged = BigInt(String(chargedRaw)); + const signed = BigInt(String(signedRaw)); + + if (charged > signed) { + return false; + } + + const config = buildChannelConfig(deps, accept); + const channelId = computeChannelId(config, accept.network); + + if (!deps.signer.readContract) { + return false; + } + + const [chBalance, chTotalClaimed] = await readChannelBalanceAndTotalClaimed( + deps.signer, + channelId, + ); + + if (charged < chTotalClaimed) { + return false; + } + + const chainId = getEvmChainId(accept.network); + const recovered = await recoverTypedDataAddress({ + domain: getBatchSettlementEip712Domain(chainId), + types: voucherTypes, + primaryType: "Voucher", + message: { + channelId, + maxClaimableAmount: signed, + }, + signature: sig, + }); + + const expectedSigner = getAddress( + deps.payerAuthorizer ?? deps.voucherSigner?.address ?? deps.signer.address, + ); + if (recovered.toLowerCase() !== expectedSigner.toLowerCase()) { + return false; + } + + const ctx: BatchSettlementClientContext = { + chargedCumulativeAmount: charged.toString(), + signedMaxClaimable: signed.toString(), + signature: sig, + balance: chBalance.toString(), + totalClaimed: chTotalClaimed.toString(), + }; + + await deps.storage.set(channelId.toLowerCase(), ctx); + return true; +} + +/** + * Recovers channel state purely from onchain state when the server has no stored + * voucher (e.g. after a cooperative refund deleted the channel record). The onchain + * `totalClaimed` becomes the new baseline — no signature verification is + * needed because the contract is the source of truth when no outstanding + * voucher exists. + * + * @param deps - Signer + storage + identity inputs. + * @param accept - Batch settlement payment requirements from the corrective 402. + * @returns `true` when local channel state was updated from onchain data. + */ +export async function recoverFromOnChainState( + deps: BatchSettlementClientDeps, + accept: PaymentRequirements, +): Promise { + if (!deps.signer.readContract) { + return false; + } + + const config = buildChannelConfig(deps, accept); + const channelId = computeChannelId(config, accept.network); + + const [chBalance, chTotalClaimed] = await readChannelBalanceAndTotalClaimed( + deps.signer, + channelId, + ); + + const ctx: BatchSettlementClientContext = { + chargedCumulativeAmount: chTotalClaimed.toString(), + balance: chBalance.toString(), + totalClaimed: chTotalClaimed.toString(), + }; + + await deps.storage.set(channelId.toLowerCase(), ctx); + return true; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/refund.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/refund.ts new file mode 100644 index 0000000000..0e07d2e5dd --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/refund.ts @@ -0,0 +1,322 @@ +import { decodePaymentRequiredHeader, decodePaymentResponseHeader } from "@x402/core/http"; +import { x402Client, x402HTTPClient } from "@x402/core/client"; +import type { + PaymentPayload, + PaymentRequired, + PaymentRequirements, + SettleResponse, +} from "@x402/core/types"; +import { BATCH_SETTLEMENT_SCHEME } from "../constants"; +import * as Errors from "../errors"; +import type { + BatchSettlementPaymentRequirementsExtra, + BatchSettlementRefundPayload, +} from "../types"; +import { computeChannelId } from "../utils"; +import { type BatchSettlementClientDeps, buildChannelConfig, recoverChannel } from "./channel"; +import { createBatchSettlementClientHooks } from "./hooks"; +import { signVoucher } from "./voucher"; + +/** + * Refund-specific server errors that the client cannot recover from automatically. + * Seeing any of these means the user should adjust their request (or accept that the + * channel has nothing left to refund) — retrying will not help. + */ +const NON_RECOVERABLE_REFUND_ERRORS: ReadonlySet = new Set([ + Errors.ErrRefundNoBalance, + Errors.ErrRefundAmountInvalid, +]); + +interface RefundRequirementsProbe { + paymentRequired: PaymentRequired; + requirements: PaymentRequirements; +} + +/** + * Caller-facing options for {@link refundChannel}. + */ +export interface RefundOptions { + /** Token base units to refund; omit for a full refund (drains remaining balance). */ + amount?: string; + /** Custom fetch implementation (defaults to `globalThis.fetch`). */ + fetch?: typeof fetch; +} + +/** + * Sends a cooperative refund request to the channel that backs `url`. + * + * Flow: + * 1. Probe the URL with `GET` (no payment) to obtain the route's payment requirements. + * 2. Build the `ChannelConfig` and resolve the local session (or recover it). + * 3. Sign a zero-charge refund voucher (`maxClaimableAmount = chargedCumulativeAmount`). + * 4. Send the voucher via `PAYMENT-SIGNATURE`. On a corrective 402, run the + * standard recovery path and retry once. + * 5. Return the parsed `SettleResponse` from the server. + * + * @param ctx - Identity inputs (storage, signers, salt, payerAuthorizer). + * @param url - Any protected route on the channel to refund (the resource handler is bypassed). + * @param options - Optional `amount` (partial refund) and `fetch` override. + * @returns The settle response describing the refund outcome. + * @throws When the probe fails, the receiver lacks an authorizer, or recovery fails. + */ +export async function refundChannel( + ctx: BatchSettlementClientDeps, + url: string, + options?: RefundOptions, +): Promise { + const fetchImpl = options?.fetch ?? globalThis.fetch; + if (!fetchImpl) { + throw new Error("refund requires a fetch implementation (globalThis.fetch unavailable)"); + } + + const refundAmount = normalizeRefundAmount(options?.amount); + const probe = await probeRefundRequirements(url, fetchImpl); + return executeRefund(ctx, url, probe, refundAmount, fetchImpl); +} + +/** + * Probes a URL with an unauthenticated GET to retrieve batch-settlement payment + * requirements via the 402 PAYMENT-REQUIRED header. + * + * @param url - The protected URL to probe. + * @param fetchImpl - Fetch implementation used for the probe. + * @returns Matching batch-settlement payment requirements for the route. + */ +async function probeRefundRequirements( + url: string, + fetchImpl: typeof fetch, +): Promise { + const probe = await fetchImpl(url, { method: "GET" }); + if (probe.status !== 402) { + throw new Error(`Refund probe expected 402, got ${probe.status}`); + } + + const header = probe.headers.get("PAYMENT-REQUIRED"); + if (!header) { + throw new Error("Refund probe response missing PAYMENT-REQUIRED header"); + } + + const paymentRequired = decodePaymentRequiredHeader(header); + const requirements = paymentRequired.accepts.find(a => a.scheme === BATCH_SETTLEMENT_SCHEME); + if (!requirements) { + throw new Error(`No ${BATCH_SETTLEMENT_SCHEME} payment option at ${url}`); + } + + const extra = requirements.extra as Partial | undefined; + if (!extra?.receiverAuthorizer) { + throw new Error("Refund requires a configured receiverAuthorizer on the receiver"); + } + + return { paymentRequired, requirements }; +} + +/** + * Builds and submits the refund voucher, retrying once after a corrective 402. + * + * @param ctx - Identity inputs (storage, signers, salt, payerAuthorizer). + * @param url - The protected URL to send the refund voucher to. + * @param probe - Resolved payment requirements and probe metadata for this channel. + * @param refundAmount - Optional partial refund amount in token base units. + * @param fetchImpl - Fetch implementation used for the request. + * @returns The parsed settle response. + */ +async function executeRefund( + ctx: BatchSettlementClientDeps, + url: string, + probe: RefundRequirementsProbe, + refundAmount: string | undefined, + fetchImpl: typeof fetch, +): Promise { + const maxAttempts = 2; + const { paymentRequired, requirements } = probe; + const httpClient = createRefundHttpClient(ctx, requirements); + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const paymentPayload = await buildRefundPaymentPayload( + ctx, + paymentRequired, + requirements, + refundAmount, + ); + const headers = httpClient.encodePaymentSignatureHeader(paymentPayload); + + const response = await fetchImpl(url, { method: "GET", headers }); + + if (response.status === 402) { + const nonRecoverable = getNonRecoverableRefundFailure(response); + if (nonRecoverable) { + throw new Error(nonRecoverable); + } + } + + const result = await httpClient.processPaymentResult( + paymentPayload, + name => response.headers.get(name), + response.status, + ); + + if (response.status === 402) { + if (result.recovered && attempt < maxAttempts) { + continue; + } + if (result.recovered) { + throw new Error(`Refund failed: server returned 402 after ${attempt} attempt(s)`); + } + + const corrective = getRefundPaymentRequired(response); + throw new Error(`Refund failed: ${corrective.error ?? "unknown"}`); + } + + if (!result.settleResponse) { + throw new Error( + `Refund response missing PAYMENT-RESPONSE header (status ${response.status})`, + ); + } + + return result.settleResponse; + } + + throw new Error("Refund failed: retry budget exhausted"); +} + +/** + * Builds the refund payload with a zero-charge `maxClaimableAmount`. + * + * @param ctx - Identity inputs (storage, signers, salt, payerAuthorizer). + * @param paymentRequired - Decoded 402 body from the probe (resource, extensions, etc.). + * @param requirements - Resolved payment requirements for the channel. + * @param refundAmount - Optional partial refund amount in token base units. + * @returns A full payment payload wrapping the signed refund request. + */ +async function buildRefundPaymentPayload( + ctx: BatchSettlementClientDeps, + paymentRequired: PaymentRequired, + requirements: PaymentRequirements, + refundAmount: string | undefined, +): Promise { + const config = buildChannelConfig(ctx, requirements); + const channelId = computeChannelId(config, requirements.network); + const key = channelId.toLowerCase(); + + let channel = await ctx.storage.get(key); + if (channel === undefined && ctx.signer.readContract) { + channel = await recoverChannel(ctx, requirements); + } + if (channel === undefined) { + throw new Error( + "Refund requires an existing channel record; deposit first or call from a context with an EVM RPC", + ); + } + + // Avoid a refund request when local state shows the channel has no refundable balance. + const charged = channel.chargedCumulativeAmount ?? "0"; + if (channel.balance !== undefined && BigInt(channel.balance) <= BigInt(charged)) { + throw new Error( + `Refund failed: channel has no remaining balance (balance=${channel.balance}, chargedCumulativeAmount=${charged})`, + ); + } + + const voucherSigner = ctx.voucherSigner ?? ctx.signer; + const voucher = await signVoucher(voucherSigner, channelId, charged, requirements.network); + + const payload: BatchSettlementRefundPayload = { + type: "refund", + channelConfig: config, + voucher, + ...(refundAmount !== undefined ? { amount: refundAmount } : {}), + }; + + return { + x402Version: 2, + accepted: requirements, + payload: payload as unknown as Record, + ...(paymentRequired.resource ? { resource: paymentRequired.resource } : {}), + ...(paymentRequired.extensions ? { extensions: paymentRequired.extensions } : {}), + }; +} + +/** + * Creates an x402 HTTP client for batch settlement with hooks; refund payloads are supplied + * by {@link refundChannel} instead of the default payment builder. + * + * @param ctx - Identity inputs (storage, signers, salt, payerAuthorizer). + * @param requirements - Resolved payment requirements for the channel network. + * @returns An `x402HTTPClient` wired for batch-settlement scheme hooks. + */ +function createRefundHttpClient( + ctx: BatchSettlementClientDeps, + requirements: PaymentRequirements, +): x402HTTPClient { + const client = new x402Client().register(requirements.network, { + scheme: BATCH_SETTLEMENT_SCHEME, + schemeHooks: createBatchSettlementClientHooks(ctx), + createPaymentPayload: async () => { + throw new Error("Refund payloads are built by refundChannel"); + }, + }); + return new x402HTTPClient(client); +} + +/** + * If the refund HTTP response cannot be recovered by retrying, returns a user-facing message; + * otherwise returns `undefined`. + * + * @param response - The refund request response (402 with headers or settle failure). + * @returns A formatted failure string, or `undefined` when retry may succeed. + */ +function getNonRecoverableRefundFailure(response: Response): string | undefined { + const settleHeader = response.headers.get("PAYMENT-RESPONSE"); + if (settleHeader) { + return formatRefundFailure(decodePaymentResponseHeader(settleHeader)); + } + + const paymentRequired = getRefundPaymentRequired(response); + const errorCode = paymentRequired.error; + if (errorCode && NON_RECOVERABLE_REFUND_ERRORS.has(errorCode)) { + return `Refund failed: ${errorCode}`; + } +} + +/** + * Reads and decodes the `PAYMENT-REQUIRED` header from a refund-related 402 response. + * + * @param response - HTTP response that must include `PAYMENT-REQUIRED`. + * @returns The decoded {@link PaymentRequired} payload. + * @throws When the header is missing. + */ +function getRefundPaymentRequired(response: Response): PaymentRequired { + const requiredHeader = response.headers.get("PAYMENT-REQUIRED"); + if (!requiredHeader) { + throw new Error("Refund 402 missing PAYMENT-REQUIRED header"); + } + return decodePaymentRequiredHeader(requiredHeader); +} + +/** + * Builds a human-readable error message from a settle failure response. + * + * @param settle - The decoded SettleResponse from the server's 402 reply. + * @returns A formatted error string suitable for `throw new Error(...)`. + */ +function formatRefundFailure(settle: SettleResponse): string { + const reason = settle.errorReason ?? "unknown_settlement_error"; + const message = settle.errorMessage; + if (message && message !== reason) { + return `Refund failed: ${reason}: ${message}`; + } + return `Refund failed: ${reason}`; +} + +/** + * Validates and normalises the optional `refundAmount` argument. + * + * @param amount - Raw amount from caller (string of base units). + * @returns The same string when valid, or `undefined` when omitted. + */ +function normalizeRefundAmount(amount: string | undefined): string | undefined { + if (amount === undefined) return undefined; + if (!/^\d+$/.test(amount) || amount === "0") { + throw new Error(`Invalid refund amount "${amount}": must be a positive integer string`); + } + return amount; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/scheme.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/scheme.ts new file mode 100644 index 0000000000..caebb71b78 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/scheme.ts @@ -0,0 +1,376 @@ +import { + SchemeNetworkClient, + SchemeClientHooks, + PaymentRequired, + PaymentRequirements, + PaymentPayloadResult, + PaymentPayloadContext, + SettleResponse, +} from "@x402/core/types"; +import { getAddress } from "viem"; +import { ClientEvmSigner } from "../../signer"; +import { BATCH_SETTLEMENT_SCHEME } from "../constants"; +import { + BatchSettlementAssetTransferMethod, + BatchSettlementVoucherPayload, + ChannelConfig, +} from "../types"; +import { computeChannelId } from "../utils"; +import { + trySignEip2612PermitExtension, + trySignErc20ApprovalExtension, +} from "../../shared/extensions"; +import type { EvmSchemeOptions } from "../../shared/rpc"; +import { createBatchSettlementEIP3009DepositPayload } from "./eip3009"; +import { createBatchSettlementPermit2DepositPayload } from "./permit2"; +import { + type BatchSettlementDepositStrategy, + type BatchSettlementDepositStrategyContext, + type BatchSettlementDepositPolicy, + type BatchSettlementEvmSchemeOptions, + depositAmountForRequest, + resolveClientOptions, + validateDepositPolicy, +} from "./config"; +import { refundChannel, type RefundOptions } from "./refund"; +import { + type BatchSettlementClientDeps, + buildChannelConfig, + processSettleResponse, + recoverChannel, +} from "./channel"; +import { createBatchSettlementClientHooks } from "./hooks"; +import { processCorrectivePaymentRequired } from "./recovery"; +import type { ClientChannelStorage } from "./storage"; +import { signVoucher } from "./voucher"; + +export type { BatchSettlementClientContext } from "./storage"; +export type { + BatchSettlementDepositPolicy, + BatchSettlementDepositStrategy, + BatchSettlementDepositStrategyContext, + BatchSettlementDepositStrategyResult, + BatchSettlementEvmSchemeOptions, +} from "./config"; +export type { RefundOptions } from "./refund"; + +/** + * Client-side implementation of the `batch-settlement` scheme for EVM networks. + * + * Builds payment payloads (deposit + voucher or voucher-only), processes server + * responses to update local session state via {@link processSettleResponse}, + * handles corrective 402 resynchronisation via + * {@link processCorrectivePaymentRequired}, and supports on-demand cooperative + * refund requests via {@link refundChannel}. + */ +export class BatchSettlementEvmScheme implements SchemeNetworkClient { + readonly scheme = BATCH_SETTLEMENT_SCHEME; + + readonly schemeHooks: SchemeClientHooks; + + private readonly storage: ClientChannelStorage; + private readonly depositPolicy: BatchSettlementDepositPolicy | undefined; + private readonly depositStrategy: BatchSettlementDepositStrategy | undefined; + private readonly salt: `0x${string}`; + private readonly payerAuthorizer: `0x${string}` | undefined; + private readonly voucherSigner: ClientEvmSigner | undefined; + private readonly extensionRpcOptions: EvmSchemeOptions | undefined; + + /** + * Constructs a batched client scheme. + * + * @param signer - Client EVM wallet used for signing vouchers and ERC-3009 authorizations. + * @param optionsOrPolicy - Either a full options object or a bare deposit-policy. + */ + constructor( + private readonly signer: ClientEvmSigner, + optionsOrPolicy?: BatchSettlementEvmSchemeOptions | BatchSettlementDepositPolicy, + ) { + const { + storage, + depositPolicy, + depositStrategy, + salt, + payerAuthorizer, + voucherSigner, + extensionRpcOptions, + } = resolveClientOptions(optionsOrPolicy); + this.storage = storage; + this.depositPolicy = depositPolicy; + this.depositStrategy = depositStrategy; + this.salt = salt; + this.payerAuthorizer = payerAuthorizer; + this.voucherSigner = voucherSigner; + this.extensionRpcOptions = extensionRpcOptions; + + if ( + payerAuthorizer !== undefined && + voucherSigner !== undefined && + getAddress(payerAuthorizer) !== getAddress(voucherSigner.address) + ) { + throw new Error("payerAuthorizer address must match voucherSigner.address"); + } + + validateDepositPolicy(depositPolicy); + this.schemeHooks = createBatchSettlementClientHooks(this.deps()); + } + + /** + * Creates the payment payload for a batched request. + * + * If the channel has no onchain deposit (or needs a top-up), builds an + * ERC-3009 deposit payload bundled with a voucher. Otherwise, signs and + * returns a voucher-only payload. + * + * @param x402Version - Protocol version for the payload envelope. + * @param paymentRequirements - Server payment requirements (scheme, network, asset, amount). + * @param context - Optional payment payload context with extension hints. + * @returns A {@link PaymentPayloadResult} ready to be sent as the `X-PAYMENT` header. + */ + async createPaymentPayload( + x402Version: number, + paymentRequirements: PaymentRequirements, + context?: PaymentPayloadContext, + ): Promise { + const deps = this.deps(); + const config = buildChannelConfig(deps, paymentRequirements); + const channelId = computeChannelId(config, paymentRequirements.network); + const key = channelId.toLowerCase(); + + let batchedCtx = await this.storage.get(key); + if (batchedCtx === undefined && this.signer.readContract) { + batchedCtx = await recoverChannel(deps, paymentRequirements); + } + batchedCtx = batchedCtx ?? {}; + + const needsInitialDeposit = !batchedCtx.balance || batchedCtx.balance === "0"; + + const baseCumulative = BigInt(batchedCtx.chargedCumulativeAmount ?? "0"); + const requestAmount = BigInt(paymentRequirements.amount); + const maxClaimableAmount = (baseCumulative + requestAmount).toString(); + + const currentBalance = BigInt(batchedCtx.balance ?? "0"); + const needsTopUp = !needsInitialDeposit && BigInt(maxClaimableAmount) > currentBalance; + + if (needsInitialDeposit || needsTopUp) { + const computedDeposit = depositAmountForRequest(this.depositPolicy, requestAmount); + const minimumDepositAmount = BigInt(maxClaimableAmount) - currentBalance; + const depositAmount = await this.resolveDepositAmount({ + paymentRequirements, + channelConfig: config, + channelId, + clientContext: batchedCtx, + requestAmount: requestAmount.toString(), + maxClaimableAmount, + currentBalance: currentBalance.toString(), + minimumDepositAmount: minimumDepositAmount.toString(), + depositAmount: computedDeposit, + }); + if (depositAmount === false) { + return this.createVoucherPayload( + x402Version, + channelId, + maxClaimableAmount, + paymentRequirements.network, + config, + ); + } + + const assetTransferMethod = + (paymentRequirements.extra?.assetTransferMethod as BatchSettlementAssetTransferMethod) ?? + "eip3009"; + + if (assetTransferMethod === "eip3009") { + return createBatchSettlementEIP3009DepositPayload( + this.signer, + x402Version, + paymentRequirements, + config, + depositAmount, + maxClaimableAmount, + this.voucherSigner, + ); + } + + if (assetTransferMethod !== "permit2") { + throw new Error(`unsupported batch-settlement assetTransferMethod: ${assetTransferMethod}`); + } + + const result = await createBatchSettlementPermit2DepositPayload( + this.signer, + x402Version, + paymentRequirements, + config, + depositAmount, + maxClaimableAmount, + this.voucherSigner, + ); + + const eip2612Extensions = await trySignEip2612PermitExtension( + this.signer, + this.extensionRpcOptions, + paymentRequirements, + result, + context, + depositAmount, + ); + if (eip2612Extensions) { + return { ...result, extensions: eip2612Extensions }; + } + + const erc20Extensions = await trySignErc20ApprovalExtension( + this.signer, + this.extensionRpcOptions, + paymentRequirements, + context, + depositAmount, + ); + if (erc20Extensions) { + return { ...result, extensions: erc20Extensions }; + } + + return result; + } + + return this.createVoucherPayload( + x402Version, + channelId, + maxClaimableAmount, + paymentRequirements.network, + config, + ); + } + + /** + * Sends a cooperative refund request. + * + * @param url - The route URL backing the channel to refund. + * @param options - Optional `amount` (partial refund) and `fetch` override. + * @returns The settle response describing the refund outcome. + */ + async refund(url: string, options?: RefundOptions): Promise { + return refundChannel(this.deps(), url, options); + } + + /** + * Updates local channel state from a settle response. + * + * @param settle - The parsed settle response from the server. + * @returns Resolves when local channel state has been updated. + */ + async processSettleResponse(settle: SettleResponse): Promise { + return processSettleResponse(this.storage, settle); + } + + /** + * Resyncs local channel state from a corrective 402 response. + * + * @param paymentRequired - The decoded 402 response body. + * @returns `true` if local state was successfully resynced and a retry is warranted. + */ + async processCorrectivePaymentRequired(paymentRequired: PaymentRequired): Promise { + return processCorrectivePaymentRequired(this.deps(), paymentRequired); + } + + /** + * Builds the immutable {@link ChannelConfig} for a given set of payment + * requirements, using the scheme's own signer and salt. + * + * @param paymentRequirements - Server payment requirements for the channel. + * @returns The channel config that uniquely identifies the payment channel. + */ + buildChannelConfig(paymentRequirements: PaymentRequirements): ChannelConfig { + return buildChannelConfig(this.deps(), paymentRequirements); + } + + /** + * Resolves the deposit amount after applying the optional custom strategy. + * + * @param context - Deposit attempt context exposed to the strategy. + * @returns The deposit amount to sign, or `false` to skip this deposit attempt. + */ + private async resolveDepositAmount( + context: BatchSettlementDepositStrategyContext, + ): Promise { + const strategyResult = await this.depositStrategy?.(context); + if (strategyResult === false) return false; + if (strategyResult === undefined) return context.depositAmount; + + const depositAmount = this.normalizeStrategyDepositAmount(strategyResult); + if (BigInt(depositAmount) < BigInt(context.minimumDepositAmount)) { + throw new Error( + `depositStrategy returned ${depositAmount}, below required top-up ${context.minimumDepositAmount}`, + ); + } + return depositAmount; + } + + /** + * Normalizes and validates a strategy-provided base-unit deposit amount. + * + * @param value - Strategy-provided string or bigint amount. + * @returns Normalized decimal string. + */ + private normalizeStrategyDepositAmount(value: string | bigint): string { + if (typeof value === "bigint") { + if (value <= 0n) { + throw new Error("depositStrategy must return a positive integer deposit amount"); + } + return value.toString(); + } + + if (/^\d+$/.test(value) && BigInt(value) > 0n) { + return BigInt(value).toString(); + } + + throw new Error("depositStrategy must return a positive integer deposit amount"); + } + + /** + * Signs a voucher-only payment payload for the current channel. + * + * @param x402Version - Protocol version for the payload envelope. + * @param channelId - Channel identifier for the voucher. + * @param maxClaimableAmount - Cumulative ceiling for the voucher. + * @param network - CAIP-2 network identifier. + * @param config - Immutable channel configuration. + * @returns Voucher-only payment payload. + */ + private async createVoucherPayload( + x402Version: number, + channelId: `0x${string}`, + maxClaimableAmount: string, + network: string, + config: ChannelConfig, + ): Promise { + const voucherSigner = this.voucherSigner ?? this.signer; + const voucher = await signVoucher(voucherSigner, channelId, maxClaimableAmount, network); + + const payload: BatchSettlementVoucherPayload = { + type: "voucher", + channelConfig: config, + voucher, + }; + + return { + x402Version, + payload, + }; + } + + /** + * Bundles the class state into the {@link BatchSettlementClientDeps} shape + * consumed by the `channel`, `recovery`, and `refund` modules. + * + * @returns Client deps wrapping the scheme's own signer and storage. + */ + private deps(): BatchSettlementClientDeps { + return { + signer: this.signer, + storage: this.storage, + salt: this.salt, + payerAuthorizer: this.payerAuthorizer, + voucherSigner: this.voucherSigner, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/storage.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/storage.ts new file mode 100644 index 0000000000..70b9654d8d --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/storage.ts @@ -0,0 +1,59 @@ +/** + * Client-side channel fields mirrored from PAYMENT-RESPONSE / recovery flows. + */ +export interface BatchSettlementClientContext { + /** Current cumulative amount charged by the server for this channel */ + chargedCumulativeAmount?: string; + /** Current onchain channel balance */ + balance?: string; + /** Total claimed onchain */ + totalClaimed?: string; + /** Latest client-signed maxClaimableAmount cap (after corrective recovery, optional) */ + signedMaxClaimable?: string; + /** Client voucher signature for {@link signedMaxClaimable} (optional) */ + signature?: `0x${string}`; +} + +export interface ClientChannelStorage { + get(key: string): Promise; + set(key: string, context: BatchSettlementClientContext): Promise; + delete(key: string): Promise; +} + +/** + * Default in-memory {@link ClientChannelStorage} (channel records do not survive process restart). + */ +export class InMemoryClientChannelStorage implements ClientChannelStorage { + private readonly channels = new Map(); + + /** + * Returns the channel record for `key` if present. + * + * @param key - Channel storage key (channelId). + * @returns Persisted context or undefined. + */ + async get(key: string): Promise { + return this.channels.get(key); + } + + /** + * Stores or replaces the channel record for `key`. + * + * @param key - Channel storage key. + * @param context - Channel fields to persist. + * @returns Resolves when stored. + */ + async set(key: string, context: BatchSettlementClientContext): Promise { + this.channels.set(key, context); + } + + /** + * Removes the channel record for `key` if it exists. + * + * @param key - Channel storage key. + * @returns Resolves when removed. + */ + async delete(key: string): Promise { + this.channels.delete(key); + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/voucher.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/voucher.ts new file mode 100644 index 0000000000..9fb49e09d4 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/voucher.ts @@ -0,0 +1,43 @@ +import { ClientEvmSigner } from "../../signer"; +import { voucherTypes } from "../constants"; +import { BatchSettlementVoucherFields } from "../types"; +import { getEvmChainId } from "../../utils"; +import { getBatchSettlementEip712Domain } from "../utils"; + +/** + * Signs a cumulative voucher using the client's wallet. + * + * The voucher authorises the receiver to claim up to `maxClaimableAmount` from the + * channel identified by `channelId`. The signature covers the EIP-712 `Voucher` struct + * under the batched domain. + * + * @param signer - Client wallet used to produce the EIP-712 signature. + * @param channelId - Identifier of the payment channel (`keccak256(abi.encode(ChannelConfig))`). + * @param maxClaimableAmount - Cumulative ceiling the receiver may claim (decimal string in token units). + * @param network - CAIP-2 network identifier (e.g. `eip155:84532`). + * @returns Signed voucher fields ready to be included in a payment payload. + */ +export async function signVoucher( + signer: ClientEvmSigner, + channelId: `0x${string}`, + maxClaimableAmount: string, + network: string, +): Promise { + const chainId = getEvmChainId(network); + + const signature = await signer.signTypedData({ + domain: getBatchSettlementEip712Domain(chainId), + types: voucherTypes, + primaryType: "Voucher", + message: { + channelId, + maxClaimableAmount: BigInt(maxClaimableAmount), + }, + }); + + return { + channelId, + maxClaimableAmount, + signature, + }; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/constants.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/constants.ts new file mode 100644 index 0000000000..7b01805563 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/constants.ts @@ -0,0 +1,102 @@ +import { keccak256, toBytes } from "viem"; + +/** Scheme identifier for the batch-settlement payment scheme. */ +export const BATCH_SETTLEMENT_SCHEME = "batch-settlement" as const; + +/** Deployed address of the x402BatchSettlement contract. */ +export const BATCH_SETTLEMENT_ADDRESS = "0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003" as const; + +/** Deployed address of the ERC3009DepositCollector contract. */ +export const ERC3009_DEPOSIT_COLLECTOR_ADDRESS = + "0x4020806089470a89826cB9fB1f4059150b550004" as const; + +/** Deployed address of the Permit2DepositCollector contract. */ +export const PERMIT2_DEPOSIT_COLLECTOR_ADDRESS = + "0x4020425FAf3B746C082C2f942b4E5159887B0005" as const; + +/** Minimum withdraw delay in seconds (15 minutes), matching the onchain constant. */ +export const MIN_WITHDRAW_DELAY = 900; + +/** Maximum withdraw delay in seconds (30 days), matching the onchain constant. */ +export const MAX_WITHDRAW_DELAY = 2_592_000; + +/** EIP-712 domain fields shared across all batch-settlement typed-data signatures. */ +export const BATCH_SETTLEMENT_DOMAIN = { + name: "x402 Batch Settlement", + version: "1", +} as const; + +/** EIP-712 type hash for channel identity. */ +export const CHANNEL_CONFIG_TYPEHASH = keccak256( + toBytes( + "ChannelConfig(address payer,address payerAuthorizer,address receiver,address receiverAuthorizer,address token,uint40 withdrawDelay,bytes32 salt)", + ), +); + +/** EIP-712 type definition for a channel configuration. */ +export const channelConfigTypes = { + ChannelConfig: [ + { name: "payer", type: "address" }, + { name: "payerAuthorizer", type: "address" }, + { name: "receiver", type: "address" }, + { name: "receiverAuthorizer", type: "address" }, + { name: "token", type: "address" }, + { name: "withdrawDelay", type: "uint40" }, + { name: "salt", type: "bytes32" }, + ], +} as const; + +/** EIP-712 type definition for a cumulative voucher: `Voucher(bytes32 channelId, uint128 maxClaimableAmount)`. */ +export const voucherTypes = { + Voucher: [ + { name: "channelId", type: "bytes32" }, + { name: "maxClaimableAmount", type: "uint128" }, + ], +} as const; + +/** EIP-712 type definition for cooperative refund: `Refund(bytes32 channelId, uint256 nonce, uint128 amount)`. */ +export const refundTypes = { + Refund: [ + { name: "channelId", type: "bytes32" }, + { name: "nonce", type: "uint256" }, + { name: "amount", type: "uint128" }, + ], +} as const; + +/** EIP-712 type definitions for a receiver-authorizer claim batch (nested ClaimEntry). */ +export const claimBatchTypes = { + ClaimBatch: [{ name: "claims", type: "ClaimEntry[]" }], + ClaimEntry: [ + { name: "channelId", type: "bytes32" }, + { name: "maxClaimableAmount", type: "uint128" }, + { name: "totalClaimed", type: "uint128" }, + ], +} as const; + +/** EIP-712 type definition for ERC-3009 `ReceiveWithAuthorization` (used for gasless deposits). */ +export const receiveAuthorizationTypes = { + ReceiveWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], +} as const; + +/** Permit2 typed data for channel-bound batch deposits. */ +export const batchPermit2WitnessTypes = { + PermitWitnessTransferFrom: [ + { name: "permitted", type: "TokenPermissions" }, + { name: "spender", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "witness", type: "DepositWitness" }, + ], + TokenPermissions: [ + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + ], + DepositWitness: [{ name: "channelId", type: "bytes32" }], +} as const; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/encoding.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/encoding.ts new file mode 100644 index 0000000000..5723d9a4a5 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/encoding.ts @@ -0,0 +1,94 @@ +/** + * @file Encoding helpers for batch-settlement deposit collectors. + */ +import { encodeAbiParameters, keccak256 } from "viem"; + +/** + * Computes the ERC-3009 nonce used by the deposit collector: + * `keccak256(abi.encode(channelId, salt))`. + * + * @param channelId - The `bytes32` channel id binding the authorization to a channel. + * @param salt - Random salt provided by the client to make the nonce unique per deposit. + * @returns The `bytes32` ERC-3009 nonce. + */ +export function buildErc3009DepositNonce( + channelId: `0x${string}`, + salt: `0x${string}`, +): `0x${string}` { + return keccak256( + encodeAbiParameters([{ type: "bytes32" }, { type: "uint256" }], [channelId, BigInt(salt)]), + ); +} + +/** + * Encodes the `collectorData` payload for `ERC3009DepositCollector.collect()`: + * `abi.encode(validAfter, validBefore, salt, signature)`. + * + * @param validAfter - Earliest unix timestamp the authorization is valid (decimal string). + * @param validBefore - Latest unix timestamp the authorization is valid (decimal string). + * @param salt - Random salt provided by the client (hex string). + * @param signature - ERC-3009 `ReceiveWithAuthorization` signature. + * @returns ABI-encoded collector data passed to `deposit(..., collector, collectorData)`. + */ +export function buildErc3009CollectorData( + validAfter: string, + validBefore: string, + salt: `0x${string}`, + signature: `0x${string}`, +): `0x${string}` { + return encodeAbiParameters( + [{ type: "uint256" }, { type: "uint256" }, { type: "uint256" }, { type: "bytes" }], + [BigInt(validAfter), BigInt(validBefore), BigInt(salt), signature], + ); +} + +/** + * Encodes optional EIP-2612 permit data consumed by `Permit2DepositCollector`. + * + * @param params - Permit amount, deadline, and split signature fields. + * @param params.value - Approved Permit2 allowance value. + * @param params.deadline - EIP-2612 permit deadline. + * @param params.v - Signature recovery id. + * @param params.r - Signature `r` value. + * @param params.s - Signature `s` value. + * @returns ABI-encoded permit segment. + */ +export function buildEip2612PermitData(params: { + value: string; + deadline: string; + v: number; + r: `0x${string}`; + s: `0x${string}`; +}): `0x${string}` { + return encodeAbiParameters( + [ + { type: "uint256" }, + { type: "uint256" }, + { type: "uint8" }, + { type: "bytes32" }, + { type: "bytes32" }, + ], + [BigInt(params.value), BigInt(params.deadline), params.v, params.r, params.s], + ); +} + +/** + * Encodes the `collectorData` payload for `Permit2DepositCollector.collect()`. + * + * @param nonce - Permit2 transfer nonce. + * @param deadline - Permit2 transfer deadline. + * @param permit2Signature - Signature over the channel-bound Permit2 authorization. + * @param eip2612PermitData - Optional encoded EIP-2612 permit segment. + * @returns ABI-encoded collector data passed to `deposit`. + */ +export function buildPermit2CollectorData( + nonce: string, + deadline: string, + permit2Signature: `0x${string}`, + eip2612PermitData: `0x${string}` = "0x", +): `0x${string}` { + return encodeAbiParameters( + [{ type: "uint256" }, { type: "uint256" }, { type: "bytes" }, { type: "bytes" }], + [BigInt(nonce), BigInt(deadline), permit2Signature, eip2612PermitData], + ); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts new file mode 100644 index 0000000000..279f8a67f2 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts @@ -0,0 +1,67 @@ +/** Error codes for the batch-settlement EVM scheme (see scheme_batch_settlement_evm2.md). */ + +export const ErrChannelNotFound = "invalid_batch_settlement_evm_channel_not_found"; +export const ErrTokenMismatch = "invalid_batch_settlement_evm_token_mismatch"; +export const ErrInvalidVoucherSignature = "invalid_batch_settlement_evm_voucher_signature"; +export const ErrCumulativeExceedsBalance = + "invalid_batch_settlement_evm_cumulative_exceeds_balance"; +export const ErrCumulativeAmountBelowClaimed = + "invalid_batch_settlement_evm_cumulative_below_claimed"; +export const ErrInsufficientBalance = "invalid_batch_settlement_evm_insufficient_balance"; +export const ErrDepositTransactionFailed = + "invalid_batch_settlement_evm_deposit_transaction_failed"; +export const ErrClaimTransactionFailed = "invalid_batch_settlement_evm_claim_transaction_failed"; +export const ErrSettleTransactionFailed = "invalid_batch_settlement_evm_settle_transaction_failed"; +export const ErrInvalidScheme = "invalid_batch_settlement_evm_scheme"; +export const ErrNetworkMismatch = "invalid_batch_settlement_evm_network_mismatch"; +export const ErrMissingEip712Domain = "invalid_batch_settlement_evm_missing_eip712_domain"; +export const ErrValidBeforeExpired = + "invalid_batch_settlement_evm_payload_authorization_valid_before"; +export const ErrValidAfterInFuture = + "invalid_batch_settlement_evm_payload_authorization_valid_after"; +export const ErrInvalidReceiveAuthorizationSignature = + "invalid_batch_settlement_evm_receive_authorization_signature"; +export const ErrErc3009AuthorizationRequired = + "invalid_batch_settlement_evm_erc3009_authorization_required"; +export const ErrRefundTransactionFailed = "invalid_batch_settlement_evm_refund_transaction_failed"; +export const ErrInvalidPayloadType = "invalid_batch_settlement_evm_payload_type"; +export const ErrWithdrawDelayOutOfRange = + "invalid_batch_settlement_evm_withdraw_delay_out_of_range"; +export const ErrChannelIdMismatch = "invalid_batch_settlement_evm_channel_id_mismatch"; +export const ErrReceiverMismatch = "invalid_batch_settlement_evm_receiver_mismatch"; +export const ErrReceiverAuthorizerMismatch = + "invalid_batch_settlement_evm_receiver_authorizer_mismatch"; +export const ErrWithdrawDelayMismatch = "invalid_batch_settlement_evm_withdraw_delay_mismatch"; +export const ErrAuthorizerAddressMismatch = + "invalid_batch_settlement_evm_authorizer_address_mismatch"; +export const ErrDepositSimulationFailed = "invalid_batch_settlement_evm_deposit_simulation_failed"; +export const ErrClaimSimulationFailed = "invalid_batch_settlement_evm_claim_simulation_failed"; +export const ErrSettleSimulationFailed = "invalid_batch_settlement_evm_settle_simulation_failed"; +export const ErrRefundPayload = "invalid_batch_settlement_evm_refund_payload"; +export const ErrRefundSimulationFailed = "invalid_batch_settlement_evm_refund_simulation_failed"; +export const ErrRpcReadFailed = "invalid_batch_settlement_evm_rpc_read_failed"; +export const ErrPermit2AuthorizationRequired = + "invalid_batch_settlement_evm_permit2_authorization_required"; +export const ErrPermit2InvalidSpender = "invalid_batch_settlement_evm_permit2_invalid_spender"; +export const ErrPermit2AmountMismatch = "invalid_batch_settlement_evm_permit2_amount_mismatch"; +export const ErrPermit2DeadlineExpired = "invalid_batch_settlement_evm_permit2_deadline_expired"; +export const ErrPermit2InvalidSignature = "invalid_batch_settlement_evm_permit2_invalid_signature"; +export const ErrPermit2AllowanceRequired = + "invalid_batch_settlement_evm_permit2_allowance_required"; +export const ErrEip2612AmountMismatch = "invalid_batch_settlement_evm_eip2612_amount_mismatch"; +export const ErrEip2612OwnerMismatch = "invalid_batch_settlement_evm_eip2612_owner_mismatch"; +export const ErrEip2612AssetMismatch = "invalid_batch_settlement_evm_eip2612_asset_mismatch"; +export const ErrEip2612SpenderMismatch = "invalid_batch_settlement_evm_eip2612_spender_mismatch"; +export const ErrEip2612DeadlineExpired = "invalid_batch_settlement_evm_eip2612_deadline_expired"; +export const ErrErc20ApprovalUnavailable = + "invalid_batch_settlement_evm_erc20_approval_unavailable"; + +/** Resource server: 402 `error` and lifecycle `reason` (same strings as the spec). */ +export const ErrCumulativeAmountMismatch = + "invalid_batch_settlement_evm_cumulative_amount_mismatch"; +export const ErrChannelBusy = "invalid_batch_settlement_evm_channel_busy"; +export const ErrChargeExceedsSignedCumulative = + "invalid_batch_settlement_evm_charge_exceeds_signed_cumulative"; +export const ErrMissingChannel = "invalid_batch_settlement_evm_missing_channel"; +export const ErrRefundNoBalance = "invalid_batch_settlement_evm_refund_no_balance"; +export const ErrRefundAmountInvalid = "invalid_batch_settlement_evm_refund_amount_invalid"; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts new file mode 100644 index 0000000000..37054d9807 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts @@ -0,0 +1,123 @@ +import { SettleResponse, PaymentRequirements } from "@x402/core/types"; +import { getAddress } from "viem"; +import { FacilitatorEvmSigner } from "../../signer"; +import type { AuthorizerSigner, BatchSettlementClaimPayload } from "../types"; +import { batchSettlementABI } from "../abi"; +import { BATCH_SETTLEMENT_ADDRESS } from "../constants"; +import { signClaimBatch } from "../authorizerSigner"; +import * as Errors from "../errors"; +import { toContractChannelConfig } from "./utils"; + +/** + * Converts an array of {@link BatchSettlementVoucherClaim} into the onchain tuple format + * expected by the contract's `claimWithSignature()` function. + * + * @param claims - Typed voucher claims with channel config, amounts, and signatures. + * @returns Contract-ready VoucherClaim argument array. + */ +export function buildVoucherClaimArgs(claims: BatchSettlementClaimPayload["claims"]) { + return claims.map(c => ({ + voucher: { + channel: toContractChannelConfig(c.voucher.channel), + maxClaimableAmount: BigInt(c.voucher.maxClaimableAmount), + }, + signature: c.signature, + totalClaimed: BigInt(c.totalClaimed), + })); +} + +/** + * Submits a batch claim via `claimWithSignature()`. + * + * When `claimAuthorizerSignature` is present in the payload it is used directly. + * When absent the facilitator signs the `ClaimBatch` EIP-712 digest using + * `authorizerSigner`, after verifying that every claim's `receiverAuthorizer` + * matches `authorizerSigner.address`. + * + * @param signer - Facilitator signer used to submit the claim transaction. + * @param payload - Claim payload containing voucher claims and optional authorizer signature. + * @param requirements - Payment requirements for network identification. + * @param authorizerSigner - Dedicated key for producing `ClaimBatch` EIP-712 signatures. + * @returns A {@link SettleResponse} with the transaction hash on success. + */ +export async function executeClaimWithSignature( + signer: FacilitatorEvmSigner, + payload: BatchSettlementClaimPayload, + requirements: PaymentRequirements, + authorizerSigner: AuthorizerSigner, +): Promise { + const network = requirements.network; + const claimArgs = buildVoucherClaimArgs(payload.claims); + + let sig = payload.claimAuthorizerSignature; + + if (!sig) { + for (const claim of payload.claims) { + if ( + getAddress(claim.voucher.channel.receiverAuthorizer) !== + getAddress(authorizerSigner.address) + ) { + return { + success: false, + errorReason: Errors.ErrAuthorizerAddressMismatch, + transaction: "", + network, + }; + } + } + sig = await signClaimBatch(authorizerSigner, payload.claims, network); + } + + try { + await signer.readContract({ + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "claimWithSignature", + args: [claimArgs, sig], + }); + } catch (e) { + return { + success: false, + errorReason: Errors.ErrClaimSimulationFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } + + try { + const tx = await signer.writeContract({ + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "claimWithSignature", + args: [claimArgs, sig], + }); + + const receipt = await signer.waitForTransactionReceipt({ hash: tx }); + + if (receipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrClaimTransactionFailed, + errorMessage: `transaction reverted (receipt status ${receipt.status})`, + transaction: tx, + network, + }; + } + + return { + success: true, + transaction: tx, + network, + amount: "", + }; + } catch (e) { + return { + success: false, + errorReason: Errors.ErrClaimTransactionFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts new file mode 100644 index 0000000000..9d4fd9fd05 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts @@ -0,0 +1,148 @@ +import { PaymentRequirements, VerifyResponse } from "@x402/core/types"; +import { getAddress } from "viem"; +import { FacilitatorEvmSigner } from "../../signer"; +import { BatchSettlementDepositPayload } from "../types"; +import { ERC3009_DEPOSIT_COLLECTOR_ADDRESS, receiveAuthorizationTypes } from "../constants"; +import { buildErc3009CollectorData, buildErc3009DepositNonce } from "../encoding"; +import * as Errors from "../errors"; +import { erc3009AuthorizationTimeInvalidReason } from "./utils"; + +/** + * Returns the collector contract used for EIP-3009 deposits. + * + * @returns ERC-3009 deposit collector address. + */ +export function getEip3009DepositCollectorAddress(): `0x${string}` { + return getAddress(ERC3009_DEPOSIT_COLLECTOR_ADDRESS); +} + +/** + * Encodes collector data for an EIP-3009 deposit payload. + * + * @param payload - Deposit payload containing the ERC-3009 authorization. + * @returns ABI-encoded collector data. + */ +export function buildEip3009DepositCollectorData( + payload: BatchSettlementDepositPayload, +): `0x${string}` { + const auth = payload.deposit.authorization.erc3009Authorization; + if (!auth) { + throw new Error(Errors.ErrErc3009AuthorizationRequired); + } + + return buildErc3009CollectorData(auth.validAfter, auth.validBefore, auth.salt, auth.signature); +} + +/** + * Verifies the ERC-3009 authorization fields and typed-data signature. + * + * @param signer - Facilitator signer for typed-data verification. + * @param payload - Deposit payload to verify. + * @param requirements - Payment requirements containing token domain metadata. + * @param chainId - EVM chain id. + * @returns A failure response, or `null` when valid. + */ +export async function verifyEip3009DepositAuthorization( + signer: FacilitatorEvmSigner, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + chainId: number, +): Promise { + const { deposit, voucher } = payload; + const payer = payload.channelConfig.payer; + const auth = deposit.authorization.erc3009Authorization; + + if (!auth) { + return { isValid: false, invalidReason: Errors.ErrErc3009AuthorizationRequired, payer }; + } + + const extra = requirements.extra as { name?: string; version?: string } | undefined; + if (!extra?.name || !extra?.version) { + return { isValid: false, invalidReason: Errors.ErrMissingEip712Domain, payer }; + } + + const validAfter = BigInt(auth.validAfter); + const validBefore = BigInt(auth.validBefore); + const timeInvalid = erc3009AuthorizationTimeInvalidReason(validAfter, validBefore); + if (timeInvalid) { + return { isValid: false, invalidReason: timeInvalid, payer }; + } + + const erc3009Nonce = buildErc3009DepositNonce(voucher.channelId, auth.salt); + const receiveAuthOk = await verifyReceiveAuth(signer, { + payer, + asset: requirements.asset, + name: extra.name, + version: extra.version, + chainId, + amount: deposit.amount, + validAfter, + validBefore, + nonce: erc3009Nonce, + signature: auth.signature, + }); + + if (!receiveAuthOk) { + return { isValid: false, invalidReason: Errors.ErrInvalidReceiveAuthorizationSignature, payer }; + } + + return null; +} + +/** + * Verifies a `ReceiveWithAuthorization` signature. + * + * @param signer - Facilitator signer used for typed-data verification. + * @param params - Authorization fields and signature. + * @param params.payer - Expected authorization signer. + * @param params.asset - ERC-20 verifying contract. + * @param params.name - ERC-20 EIP-712 domain name. + * @param params.version - ERC-20 EIP-712 domain version. + * @param params.chainId - EVM chain id. + * @param params.amount - Authorized token amount. + * @param params.validAfter - Earliest valid timestamp. + * @param params.validBefore - Expiration timestamp. + * @param params.nonce - ERC-3009 nonce. + * @param params.signature - Receive authorization signature. + * @returns True when the signature matches the expected payer. + */ +async function verifyReceiveAuth( + signer: FacilitatorEvmSigner, + params: { + payer: `0x${string}`; + asset: string; + name: string; + version: string; + chainId: number; + amount: string; + validAfter: bigint; + validBefore: bigint; + nonce: `0x${string}`; + signature: `0x${string}`; + }, +): Promise { + try { + return await signer.verifyTypedData({ + address: getAddress(params.payer), + domain: { + name: params.name, + version: params.version, + chainId: params.chainId, + verifyingContract: getAddress(params.asset), + }, + types: receiveAuthorizationTypes, + primaryType: "ReceiveWithAuthorization", + message: { + from: getAddress(params.payer), + to: getAddress(ERC3009_DEPOSIT_COLLECTOR_ADDRESS), + value: BigInt(params.amount), + validAfter: params.validAfter, + validBefore: params.validBefore, + nonce: params.nonce, + }, + signature: params.signature, + }); + } catch { + return false; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts new file mode 100644 index 0000000000..989c6c4e94 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts @@ -0,0 +1,360 @@ +import { + FacilitatorContext, + PaymentPayload, + PaymentRequirements, + VerifyResponse, +} from "@x402/core/types"; +import { encodeFunctionData, getAddress } from "viem"; +import { + extractEip2612GasSponsoringInfo, + extractErc20ApprovalGasSponsoringInfo, + ERC20_APPROVAL_GAS_SPONSORING_KEY, + resolveErc20ApprovalExtensionSigner, + type Eip2612GasSponsoringInfo, + type Erc20ApprovalGasSponsoringFacilitatorExtension, + type Erc20ApprovalGasSponsoringSigner, +} from "../../exact/extensions"; +import { validateErc20ApprovalForPayment } from "../../shared/erc20approval"; +import { validateEip2612PermitForPayment, splitEip2612Signature } from "../../shared/permit2"; +import { PERMIT2_ADDRESS, erc20AllowanceAbi } from "../../constants"; +import { FacilitatorEvmSigner } from "../../signer"; +import { batchSettlementABI } from "../abi"; +import { + BATCH_SETTLEMENT_ADDRESS, + PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, + batchPermit2WitnessTypes, +} from "../constants"; +import { buildEip2612PermitData, buildPermit2CollectorData } from "../encoding"; +import { BatchSettlementDepositPayload } from "../types"; +import { toContractChannelConfig } from "./utils"; +import * as Errors from "../errors"; + +export type Permit2DepositBranch = + | { + kind: "standard"; + collectorData: `0x${string}`; + } + | { + kind: "eip2612"; + collectorData: `0x${string}`; + } + | { + kind: "erc20Approval"; + collectorData: `0x${string}`; + signedTransaction: `0x${string}`; + extensionSigner: Erc20ApprovalGasSponsoringSigner; + }; + +/** + * Returns the collector contract used for Permit2 deposits. + * + * @returns Permit2 deposit collector address. + */ +export function getPermit2DepositCollectorAddress(): `0x${string}` { + return getAddress(PERMIT2_DEPOSIT_COLLECTOR_ADDRESS); +} + +/** + * Encodes collector data for a Permit2 deposit payload. + * + * @param payload - Deposit payload containing the Permit2 authorization. + * @param eip2612PermitData - Optional encoded EIP-2612 permit segment. + * @returns ABI-encoded collector data. + */ +export function buildPermit2DepositCollectorData( + payload: BatchSettlementDepositPayload, + eip2612PermitData: `0x${string}` = "0x", +): `0x${string}` { + const auth = payload.deposit.authorization.permit2Authorization; + if (!auth) { + throw new Error(Errors.ErrPermit2AuthorizationRequired); + } + + return buildPermit2CollectorData(auth.nonce, auth.deadline, auth.signature, eip2612PermitData); +} + +/** + * Verifies Permit2 authorization fields, setup branch, and approval-bundle simulation. + * + * @param signer - Facilitator signer for reads and signature verification. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for the request. + * @param chainId - EVM chain id. + * @param context - Optional facilitator extension context. + * @returns A failure response, or `null` when valid. + */ +export async function verifyPermit2DepositAuthorization( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + chainId: number, + context?: FacilitatorContext, +): Promise { + const authResult = await verifyPermit2TypedData(signer, payload, requirements, chainId); + if (authResult) { + return authResult; + } + + const branchResult = await resolvePermit2DepositBranch( + signer, + payment, + payload, + requirements, + context, + ); + if ("isValid" in branchResult) { + return branchResult; + } + + if (branchResult.kind !== "erc20Approval" || !branchResult.extensionSigner.simulateTransactions) { + return null; + } + + const ok = await branchResult.extensionSigner.simulateTransactions([ + branchResult.signedTransaction, + buildDepositTransaction(payload, branchResult.collectorData), + ]); + if (!ok) { + return { + isValid: false, + invalidReason: Errors.ErrDepositSimulationFailed, + payer: payload.channelConfig.payer, + }; + } + + return null; +} + +/** + * Resolves the Permit2 setup branch and collector data for verification or settlement. + * + * @param signer - Facilitator signer for allowance reads. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for the request. + * @param context - Optional facilitator extension context. + * @returns Resolved branch, or a verification failure response. + */ +export async function resolvePermit2DepositBranch( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, +): Promise { + const payer = payload.channelConfig.payer; + const tokenAddress = getAddress(requirements.asset); + const eip2612Info = extractEip2612GasSponsoringInfo(payment); + if (eip2612Info) { + const result = validateBatchEip2612Permit( + eip2612Info, + payer, + tokenAddress, + payload.deposit.amount, + ); + if (!result.isValid) { + return { isValid: false, invalidReason: result.invalidReason, payer }; + } + + const { v, r, s } = splitEip2612Signature(eip2612Info.signature); + return { + kind: "eip2612", + collectorData: buildPermit2DepositCollectorData( + payload, + buildEip2612PermitData({ + value: eip2612Info.amount, + deadline: eip2612Info.deadline, + v, + r, + s, + }), + ), + }; + } + + const erc20Info = extractErc20ApprovalGasSponsoringInfo(payment); + if (erc20Info) { + const extensionSigner = resolveErc20ApprovalExtensionSigner( + context?.getExtension( + ERC20_APPROVAL_GAS_SPONSORING_KEY, + ), + requirements.network, + ); + if (!extensionSigner) { + return { isValid: false, invalidReason: Errors.ErrErc20ApprovalUnavailable, payer }; + } + + const result = await validateErc20ApprovalForPayment(erc20Info, payer, tokenAddress); + if (!result.isValid) { + return { + isValid: false, + invalidReason: result.invalidReason, + invalidMessage: result.invalidMessage, + payer, + }; + } + + return { + kind: "erc20Approval", + collectorData: buildPermit2DepositCollectorData(payload), + signedTransaction: erc20Info.signedTransaction, + extensionSigner, + }; + } + + try { + const allowance = (await signer.readContract({ + address: tokenAddress, + abi: erc20AllowanceAbi, + functionName: "allowance", + args: [payer, PERMIT2_ADDRESS], + })) as bigint; + + if (allowance < BigInt(payload.deposit.amount)) { + return { isValid: false, invalidReason: Errors.ErrPermit2AllowanceRequired, payer }; + } + } catch { + return { isValid: false, invalidReason: Errors.ErrPermit2AllowanceRequired, payer }; + } + + return { + kind: "standard", + collectorData: buildPermit2DepositCollectorData(payload), + }; +} + +/** + * Builds the unsigned batch `deposit` transaction used after a sponsored approval. + * + * @param payload - Batch deposit payload. + * @param collectorData - Encoded Permit2 collector data. + * @returns Transaction request for the extension signer. + */ +export function buildDepositTransaction( + payload: BatchSettlementDepositPayload, + collectorData: `0x${string}`, +): { to: `0x${string}`; data: `0x${string}`; gas: bigint } { + return { + to: getAddress(BATCH_SETTLEMENT_ADDRESS), + data: encodeFunctionData({ + abi: batchSettlementABI, + functionName: "deposit", + args: [ + toContractChannelConfig(payload.channelConfig), + BigInt(payload.deposit.amount), + getPermit2DepositCollectorAddress(), + collectorData, + ], + }), + gas: 300_000n, + }; +} + +/** + * Verifies the channel-bound Permit2 typed-data authorization. + * + * @param signer - Facilitator signer for typed-data verification. + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for token matching. + * @param chainId - EVM chain id. + * @returns A failure response, or `null` when valid. + */ +async function verifyPermit2TypedData( + signer: FacilitatorEvmSigner, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + chainId: number, +): Promise { + const auth = payload.deposit.authorization.permit2Authorization; + const payer = payload.channelConfig.payer; + + if (!auth) { + return { isValid: false, invalidReason: Errors.ErrPermit2AuthorizationRequired, payer }; + } + + if (getAddress(auth.from) !== getAddress(payer)) { + return { isValid: false, invalidReason: Errors.ErrPermit2InvalidSignature, payer }; + } + + if (getAddress(auth.spender) !== getPermit2DepositCollectorAddress()) { + return { isValid: false, invalidReason: Errors.ErrPermit2InvalidSpender, payer }; + } + + if (getAddress(auth.permitted.token) !== getAddress(requirements.asset)) { + return { isValid: false, invalidReason: Errors.ErrTokenMismatch, payer }; + } + + if (BigInt(auth.permitted.amount) !== BigInt(payload.deposit.amount)) { + return { isValid: false, invalidReason: Errors.ErrPermit2AmountMismatch, payer }; + } + + if (auth.witness.channelId !== payload.voucher.channelId) { + return { isValid: false, invalidReason: Errors.ErrChannelIdMismatch, payer }; + } + + const now = Math.floor(Date.now() / 1000); + if (BigInt(auth.deadline) < BigInt(now + 6)) { + return { isValid: false, invalidReason: Errors.ErrPermit2DeadlineExpired, payer }; + } + + try { + const ok = await signer.verifyTypedData({ + address: getAddress(auth.from), + domain: { name: "Permit2", chainId, verifyingContract: PERMIT2_ADDRESS }, + types: batchPermit2WitnessTypes, + primaryType: "PermitWitnessTransferFrom", + message: { + permitted: { + token: getAddress(auth.permitted.token), + amount: BigInt(auth.permitted.amount), + }, + spender: getAddress(auth.spender), + nonce: BigInt(auth.nonce), + deadline: BigInt(auth.deadline), + witness: { + channelId: auth.witness.channelId, + }, + }, + signature: auth.signature, + }); + if (!ok) { + return { isValid: false, invalidReason: Errors.ErrPermit2InvalidSignature, payer }; + } + } catch { + return { isValid: false, invalidReason: Errors.ErrPermit2InvalidSignature, payer }; + } + + return null; +} + +/** + * Applies batch-specific EIP-2612 validation on top of the shared Permit2 checks. + * + * @param info - EIP-2612 sponsoring info from the payment envelope. + * @param payer - Expected token owner. + * @param tokenAddress - Expected token contract. + * @param depositAmount - Required approval amount. + * @returns Validation result. + */ +function validateBatchEip2612Permit( + info: Eip2612GasSponsoringInfo, + payer: `0x${string}`, + tokenAddress: `0x${string}`, + depositAmount: string, +): { isValid: true } | { isValid: false; invalidReason: string } { + const baseline = validateEip2612PermitForPayment(info, payer, tokenAddress); + if (!baseline.isValid) { + return { + isValid: false, + invalidReason: baseline.invalidReason ?? Errors.ErrInvalidPayloadType, + }; + } + + if (BigInt(info.amount) !== BigInt(depositAmount)) { + return { isValid: false, invalidReason: Errors.ErrEip2612AmountMismatch }; + } + + return { isValid: true }; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts new file mode 100644 index 0000000000..36c9412532 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts @@ -0,0 +1,505 @@ +import { + FacilitatorContext, + PaymentPayload, + PaymentRequirements, + VerifyResponse, + SettleResponse, +} from "@x402/core/types"; +import { getAddress } from "viem"; +import { FacilitatorEvmSigner } from "../../signer"; +import type { TransactionRequest } from "../../exact/extensions"; +import { BatchSettlementAssetTransferMethod, BatchSettlementDepositPayload } from "../types"; +import { batchSettlementABI, erc20BalanceOfABI } from "../abi"; +import { BATCH_SETTLEMENT_ADDRESS } from "../constants"; +import { getEvmChainId } from "../../utils"; +import { multicall } from "../../multicall"; +import * as Errors from "../errors"; +import { + readChannelState, + toContractChannelConfig, + validateChannelConfig, + verifyBatchSettlementVoucherTypedData, +} from "./utils"; +import { + buildEip3009DepositCollectorData, + getEip3009DepositCollectorAddress, + verifyEip3009DepositAuthorization, +} from "./deposit-eip3009"; +import { + buildDepositTransaction, + getPermit2DepositCollectorAddress, + resolvePermit2DepositBranch, + verifyPermit2DepositAuthorization, +} from "./deposit-permit2"; + +/** + * Verifies a deposit payload (authorization + voucher) without executing any + * onchain transaction. + * + * Performs the following validations: + * - Token in channelConfig matches the payment requirements asset. + * - Deposit authorization is valid for the selected transfer method. + * - Accompanying voucher signature is valid (ECDSA or ERC-1271). + * - Payer has sufficient token balance for the deposit. + * - Resulting `maxClaimableAmount` does not exceed effective balance (existing + deposit). + * + * @param signer - Facilitator signer for onchain reads and signature verification. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - The full deposit payload including channelConfig, amount, authorization, and voucher. + * @param requirements - Server payment requirements (asset, EIP-712 domain info, timeout, etc.). + * @param context - Optional facilitator extension context. + * @returns A {@link VerifyResponse} with channel state in `extra` on success. + */ +export async function verifyDeposit( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, +): Promise { + const payer = payload.channelConfig.payer; + const chainId = getEvmChainId(requirements.network); + const configErr = validateChannelConfig( + payload.channelConfig, + payload.voucher.channelId, + requirements, + ); + if (configErr) { + return { isValid: false, invalidReason: configErr, payer }; + } + + const transferMethod = resolveDepositTransferMethod(payload, requirements); + if (transferMethod === "permit2" && !payload.deposit.authorization.permit2Authorization) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadType, payer }; + } + + const methodErr = + transferMethod === "permit2" + ? await verifyPermit2DepositAuthorization( + signer, + payment, + payload, + requirements, + chainId, + context, + ) + : await verifyEip3009DepositAuthorization(signer, payload, requirements, chainId); + + if (methodErr) { + return methodErr; + } + + const shared = await verifySharedDepositState(signer, payload, requirements); + if (!shared.ok) { + return shared.response; + } + + const { depositAmount, chBalance, chTotalClaimed, wdInitiatedAt, refundNonceVal } = shared; + + const execution = await resolveDepositExecution(signer, payment, payload, requirements, context); + if ("isValid" in execution) { + return execution; + } + + if (!execution.skipDirectSimulation) { + try { + await signer.readContract({ + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "deposit", + args: [ + toContractChannelConfig(payload.channelConfig), + depositAmount, + execution.collector, + execution.collectorData, + ], + }); + } catch (e) { + return { + isValid: false, + invalidReason: Errors.ErrDepositSimulationFailed, + invalidMessage: e instanceof Error ? e.message : String(e), + payer, + }; + } + } + + return { + isValid: true, + payer, + extra: { + channelId: payload.voucher.channelId, + balance: chBalance.toString(), + totalClaimed: chTotalClaimed.toString(), + withdrawRequestedAt: Number(wdInitiatedAt), + refundNonce: refundNonceVal.toString(), + }, + }; +} + +/** + * Verifies channel, voucher, balance, and cumulative amount invariants. + * + * @param signer - Facilitator signer for reads and voucher verification. + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for the request. + * @returns Shared channel state on success, or a verification failure. + */ +async function verifySharedDepositState( + signer: FacilitatorEvmSigner, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, +): Promise< + | { + ok: true; + chainId: number; + depositAmount: bigint; + payer: `0x${string}`; + chBalance: bigint; + chTotalClaimed: bigint; + wdInitiatedAt: bigint; + refundNonceVal: bigint; + } + | { ok: false; response: VerifyResponse } +> { + const { deposit, voucher } = payload; + const config = payload.channelConfig; + const payer = config.payer; + const chainId = getEvmChainId(requirements.network); + + const configErr = validateChannelConfig(config, voucher.channelId, requirements); + if (configErr) { + return { ok: false, response: { isValid: false, invalidReason: configErr, payer } }; + } + + const voucherOk = await verifyBatchSettlementVoucherTypedData( + signer, + { + channelId: voucher.channelId, + maxClaimableAmount: voucher.maxClaimableAmount, + payerAuthorizer: config.payerAuthorizer, + payer: config.payer, + signature: voucher.signature, + }, + chainId, + ); + if (!voucherOk) { + return { + ok: false, + response: { isValid: false, invalidReason: Errors.ErrInvalidVoucherSignature, payer }, + }; + } + + const mcResults = await multicall(signer.readContract.bind(signer), [ + { + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "channels", + args: [voucher.channelId], + }, + { + address: getAddress(requirements.asset), + abi: erc20BalanceOfABI, + functionName: "balanceOf", + args: [getAddress(payer)], + }, + { + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "pendingWithdrawals", + args: [voucher.channelId], + }, + { + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "refundNonce", + args: [voucher.channelId], + }, + ]); + + const [chRes, balRes, wdRes, rnRes] = mcResults; + if ( + chRes.status === "failure" || + balRes.status === "failure" || + wdRes.status === "failure" || + rnRes.status === "failure" + ) { + return { + ok: false, + response: { isValid: false, invalidReason: Errors.ErrRpcReadFailed, payer }, + }; + } + + const [chBalance, chTotalClaimed] = chRes.result as [bigint, bigint]; + const payerBalance = balRes.result as bigint; + const [, wdInitiatedAt] = wdRes.result as [bigint, bigint]; + const refundNonceVal = rnRes.result as bigint; + const depositAmount = BigInt(deposit.amount); + + if (payerBalance < depositAmount) { + return { + ok: false, + response: { isValid: false, invalidReason: Errors.ErrInsufficientBalance, payer }, + }; + } + + const effectiveBalance = chBalance + depositAmount; + const maxClaimableAmount = BigInt(voucher.maxClaimableAmount); + + if (maxClaimableAmount > effectiveBalance) { + return { + ok: false, + response: { isValid: false, invalidReason: Errors.ErrCumulativeExceedsBalance, payer }, + }; + } + + if (maxClaimableAmount <= chTotalClaimed) { + return { + ok: false, + response: { isValid: false, invalidReason: Errors.ErrCumulativeAmountBelowClaimed, payer }, + }; + } + + return { + ok: true, + chainId, + depositAmount, + payer, + chBalance, + chTotalClaimed, + wdInitiatedAt, + refundNonceVal, + }; +} + +/** + * Executes a deposit onchain through the collector for the selected transfer method. + * + * The deposit is first verified via {@link verifyDeposit}; if invalid the returned + * {@link SettleResponse} will have `success: false` with the verification reason. + * + * @param signer - Facilitator signer used to submit the onchain transaction. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - The deposit payload (channelConfig, amount, authorization, voucher). + * @param requirements - Server payment requirements. + * @param context - Optional facilitator extension context. + * @returns A {@link SettleResponse} with the transaction hash and updated channel state in `extra`. + */ +export async function settleDeposit( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, +): Promise { + const { deposit, voucher } = payload; + const config = payload.channelConfig; + const payer = config.payer; + + const verified = await verifyDeposit(signer, payment, payload, requirements, context); + if (!verified.isValid) { + const reason = verified.invalidReason ?? Errors.ErrInvalidPayloadType; + return { + success: false, + errorReason: reason, + errorMessage: verified.invalidMessage ?? reason, + transaction: "", + network: requirements.network, + payer: verified.payer, + }; + } + + try { + const execution = await resolveDepositExecution( + signer, + payment, + payload, + requirements, + context, + ); + if ("isValid" in execution) { + const reason = execution.invalidReason ?? Errors.ErrInvalidPayloadType; + return { + success: false, + errorReason: reason, + errorMessage: execution.invalidMessage ?? reason, + transaction: "", + network: requirements.network, + payer: execution.payer, + }; + } + + const depositTx = buildDepositTransaction(payload, execution.collectorData); + const tx = + execution.kind === "erc20Approval" + ? ( + await execution.extensionSigner.sendTransactions([ + execution.signedTransaction, + depositTx, + ]) + )[1] + : await signer.writeContract({ + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "deposit", + args: [ + toContractChannelConfig(config), + BigInt(deposit.amount), + execution.collector, + execution.collectorData, + ], + }); + + const receipt = await signer.waitForTransactionReceipt({ hash: tx }); + + if (receipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrDepositTransactionFailed, + errorMessage: `transaction reverted (receipt status ${receipt.status})`, + transaction: tx, + network: requirements.network, + payer, + }; + } + + const optimisticExtra = { + channelState: { + channelId: voucher.channelId, + balance: ( + BigInt(String(verified.extra?.balance ?? "0")) + BigInt(deposit.amount) + ).toString(), + totalClaimed: String(verified.extra?.totalClaimed ?? "0"), + withdrawRequestedAt: Number(verified.extra?.withdrawRequestedAt ?? 0), + refundNonce: String(verified.extra?.refundNonce ?? "0"), + }, + }; + + // Poll the RPC until it reflects the just-confirmed deposit, so subsequent verify reads are guaranteed to see this balance + const expectedMinBalance = BigInt(optimisticExtra.channelState.balance); + const rpcDeadline = Date.now() + 2_000; + let postState = await readChannelState(signer, voucher.channelId); + while (postState.balance < expectedMinBalance && Date.now() < rpcDeadline) { + await new Promise(resolve => setTimeout(resolve, 150)); + postState = await readChannelState(signer, voucher.channelId); + } + + const rpcCaughtUp = postState.balance >= expectedMinBalance; + + return { + success: true, + transaction: tx, + network: requirements.network, + payer, + amount: deposit.amount, + extra: rpcCaughtUp + ? { + ...optimisticExtra, + channelState: { + channelId: voucher.channelId, + balance: postState.balance.toString(), + totalClaimed: postState.totalClaimed.toString(), + withdrawRequestedAt: postState.withdrawRequestedAt, + refundNonce: postState.refundNonce.toString(), + }, + } + : optimisticExtra, + }; + } catch (e) { + return { + success: false, + errorReason: Errors.ErrDepositTransactionFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network: requirements.network, + payer, + }; + } +} + +type DepositExecution = + | { + kind: "direct"; + collector: `0x${string}`; + collectorData: `0x${string}`; + skipDirectSimulation?: false; + } + | { + kind: "erc20Approval"; + collector: `0x${string}`; + collectorData: `0x${string}`; + signedTransaction: `0x${string}`; + extensionSigner: { + sendTransactions(transactions: TransactionRequest[]): Promise<`0x${string}`[]>; + }; + skipDirectSimulation: true; + }; + +/** + * Resolves the collector address and collector data for a deposit payload. + * + * @param signer - Facilitator signer for Permit2 allowance reads. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for the request. + * @param context - Optional facilitator extension context. + * @returns Execution details, or a verification failure response. + */ +async function resolveDepositExecution( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, +): Promise { + const transferMethod = resolveDepositTransferMethod(payload, requirements); + if (transferMethod === "eip3009") { + return { + kind: "direct", + collector: getEip3009DepositCollectorAddress(), + collectorData: buildEip3009DepositCollectorData(payload), + }; + } + + const branch = await resolvePermit2DepositBranch(signer, payment, payload, requirements, context); + if ("isValid" in branch) { + return branch; + } + + if (branch.kind === "erc20Approval") { + return { + kind: "erc20Approval", + collector: getPermit2DepositCollectorAddress(), + collectorData: branch.collectorData, + signedTransaction: branch.signedTransaction, + extensionSigner: branch.extensionSigner, + skipDirectSimulation: true, + }; + } + + return { + kind: "direct", + collector: getPermit2DepositCollectorAddress(), + collectorData: branch.collectorData, + }; +} + +/** + * Selects the transfer method from requirements, falling back to payload shape. + * + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for the request. + * @returns Selected batch-settlement transfer method. + */ +function resolveDepositTransferMethod( + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, +): BatchSettlementAssetTransferMethod { + const hinted = ( + requirements.extra as { assetTransferMethod?: BatchSettlementAssetTransferMethod } + )?.assetTransferMethod; + if (hinted) { + return hinted; + } + return payload.deposit.authorization.permit2Authorization ? "permit2" : "eip3009"; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/index.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/index.ts new file mode 100644 index 0000000000..a1bca12efd --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/index.ts @@ -0,0 +1 @@ +export { BatchSettlementEvmScheme } from "./scheme"; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts new file mode 100644 index 0000000000..c0b5429141 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts @@ -0,0 +1,305 @@ +import { SettleResponse, PaymentRequirements } from "@x402/core/types"; +import { encodeFunctionData, getAddress } from "viem"; +import { FacilitatorEvmSigner } from "../../signer"; +import type { + AuthorizerSigner, + BatchSettlementEnrichedRefundPayload, + ChannelState, +} from "../types"; +import { batchSettlementABI } from "../abi"; +import { BATCH_SETTLEMENT_ADDRESS } from "../constants"; +import { computeChannelId } from "../utils"; +import { signClaimBatch, signRefund } from "../authorizerSigner"; +import * as Errors from "../errors"; +import { buildVoucherClaimArgs } from "./claim"; +import { readChannelState, toContractChannelConfig } from "./utils"; + +type RefundSettlementExtra = { + channelState: { + channelId: `0x${string}`; + balance: string; + totalClaimed: string; + withdrawRequestedAt: number; + refundNonce: string; + }; +}; + +type RefundSettlementDetails = { + amount: string; + extra: RefundSettlementExtra; +}; + +const REFUND_STATE_POLL_MS = 2_000; +const REFUND_STATE_POLL_INTERVAL_MS = 150; + +/** + * Builds facilitator-owned response details for a refund settlement after applying the refund amount. + * + * @param payload - Refund payload containing claims and amount. + * @param channelId - Canonical channel id for the refund. + * @param preState - Onchain channel state before this refund, or null if unknown. + * @returns Actual refund amount and extra fields for the settlement response. + */ +function buildRefundExtra( + payload: BatchSettlementEnrichedRefundPayload, + channelId: `0x${string}`, + preState: ChannelState | null, +): RefundSettlementDetails { + const preTotalClaimed = preState?.totalClaimed ?? 0n; + const preBalance = preState?.balance ?? 0n; + + const lastClaimTotal = + payload.claims.length > 0 + ? BigInt(payload.claims[payload.claims.length - 1].totalClaimed) + : preTotalClaimed; + const postClaimTotalClaimed = lastClaimTotal > preTotalClaimed ? lastClaimTotal : preTotalClaimed; + + const available = preBalance - postClaimTotalClaimed; + const requestedAmount = BigInt(payload.amount); + const actualRefund = requestedAmount > available ? available : requestedAmount; + + return { + amount: actualRefund.toString(), + extra: { + channelState: { + channelId, + balance: (preBalance - actualRefund).toString(), + totalClaimed: postClaimTotalClaimed.toString(), + withdrawRequestedAt: 0, + refundNonce: String((preState?.refundNonce ?? 0n) + 1n), + }, + }, + }; +} + +/** + * Reads the post-refund state when pending withdrawal state can be affected. + * + * @param signer - Facilitator signer used for onchain reads. + * @param channelId - Channel that was refunded. + * @param submittedNonce - Nonce used for this refund transaction. + * @returns Fresh channel state once the nonce advances, or `null` if RPC reads lag. + */ +async function readPostRefundState( + signer: FacilitatorEvmSigner, + channelId: `0x${string}`, + submittedNonce: string, +): Promise { + const expectedNonce = BigInt(submittedNonce) + 1n; + const deadline = Date.now() + REFUND_STATE_POLL_MS; + + do { + let state: ChannelState; + try { + state = await readChannelState(signer, channelId); + } catch { + return null; + } + if (state.refundNonce >= expectedNonce) { + return state; + } + await new Promise(resolve => setTimeout(resolve, REFUND_STATE_POLL_INTERVAL_MS)); + } while (Date.now() < deadline); + + return null; +} + +/** + * Builds refund response details from confirmed post-transaction state. + * + * @param channelId - Canonical channel id for the refund. + * @param preState - Onchain state read before the transaction. + * @param postState - Onchain state after the transaction. + * @returns Actual refund amount and extra fields for the settlement response. + */ +function buildRefundExtraFromPostState( + channelId: `0x${string}`, + preState: ChannelState, + postState: ChannelState, +): RefundSettlementDetails { + const actualRefund = + preState.balance > postState.balance ? preState.balance - postState.balance : 0n; + + return { + amount: actualRefund.toString(), + extra: { + channelState: { + channelId, + balance: postState.balance.toString(), + totalClaimed: postState.totalClaimed.toString(), + withdrawRequestedAt: postState.withdrawRequestedAt, + refundNonce: postState.refundNonce.toString(), + }, + }, + }; +} + +/** + * Executes a cooperative refund via `refundWithSignature`. + * + * When `refundAuthorizerSignature` / `claimAuthorizerSignature` are present they are used + * directly. When absent the facilitator signs the missing digests using + * `authorizerSigner`, after verifying that `config.receiverAuthorizer` matches + * `authorizerSigner.address`. + * + * If `payload.claims` is non-empty, the claim and refund are batched atomically via + * the contract's `multicall`. + * + * @param signer - Facilitator signer used to submit the onchain transactions. + * @param payload - Refund payload with optional signatures, amount, and nonce. + * @param requirements - Payment requirements for network identification. + * @param authorizerSigner - Dedicated key for producing EIP-712 signatures. + * @returns A {@link SettleResponse} with the transaction hash on success. + */ +export async function executeRefundWithSignature( + signer: FacilitatorEvmSigner, + payload: BatchSettlementEnrichedRefundPayload, + requirements: PaymentRequirements, + authorizerSigner: AuthorizerSigner, +): Promise { + const network = requirements.network; + + try { + const channelId = computeChannelId(payload.channelConfig, network); + const preState = await readChannelState(signer, channelId); + const contractAddr = getAddress(BATCH_SETTLEMENT_ADDRESS); + + const hasClientSig = payload.refundAuthorizerSignature !== undefined; + const authorizerMismatch = + getAddress(payload.channelConfig.receiverAuthorizer) !== getAddress(authorizerSigner.address); + + if (!hasClientSig && authorizerMismatch) { + return { + success: false, + errorReason: Errors.ErrAuthorizerAddressMismatch, + transaction: "", + network, + }; + } + + const refundSig = + payload.refundAuthorizerSignature ?? + (await signRefund(authorizerSigner, channelId, payload.amount, payload.refundNonce, network)); + + const refundCalldata = encodeFunctionData({ + abi: batchSettlementABI, + functionName: "refundWithSignature", + args: [ + toContractChannelConfig(payload.channelConfig), + BigInt(payload.amount), + BigInt(payload.refundNonce), + refundSig, + ], + }); + + let tx: `0x${string}`; + + if (payload.claims.length > 0) { + let claimSig = payload.claimAuthorizerSignature; + if (!claimSig) { + claimSig = await signClaimBatch(authorizerSigner, payload.claims, network); + } + + const claimCalldata = encodeFunctionData({ + abi: batchSettlementABI, + functionName: "claimWithSignature", + args: [buildVoucherClaimArgs(payload.claims), claimSig], + }); + + try { + await signer.readContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "multicall", + args: [[claimCalldata, refundCalldata]], + }); + } catch (e) { + return { + success: false, + errorReason: Errors.ErrRefundSimulationFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } + + tx = await signer.writeContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "multicall", + args: [[claimCalldata, refundCalldata]], + }); + } else { + try { + await signer.readContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "refundWithSignature", + args: [ + toContractChannelConfig(payload.channelConfig), + BigInt(payload.amount), + BigInt(payload.refundNonce), + refundSig, + ], + }); + } catch (e) { + return { + success: false, + errorReason: Errors.ErrRefundSimulationFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } + + tx = await signer.writeContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "refundWithSignature", + args: [ + toContractChannelConfig(payload.channelConfig), + BigInt(payload.amount), + BigInt(payload.refundNonce), + refundSig, + ], + }); + } + + const receipt = await signer.waitForTransactionReceipt({ hash: tx }); + if (receipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrRefundTransactionFailed, + errorMessage: `transaction reverted (receipt status ${receipt.status})`, + transaction: tx, + network, + }; + } + + const postState = + preState && preState.withdrawRequestedAt !== 0 + ? await readPostRefundState(signer, channelId, payload.refundNonce) + : null; + const refundDetails = + preState && postState + ? buildRefundExtraFromPostState(channelId, preState, postState) + : buildRefundExtra(payload, channelId, preState); + + return { + success: true, + transaction: tx, + network, + payer: payload.channelConfig.payer, + amount: refundDetails.amount, + extra: refundDetails.extra, + }; + } catch (e) { + return { + success: false, + errorReason: Errors.ErrRefundTransactionFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts new file mode 100644 index 0000000000..521417ff33 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts @@ -0,0 +1,168 @@ +import { + PaymentPayload, + PaymentRequirements, + SchemeNetworkFacilitator, + FacilitatorContext, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import { FacilitatorEvmSigner } from "../../signer"; +import { BATCH_SETTLEMENT_SCHEME } from "../constants"; +import { + isBatchSettlementDepositPayload, + isBatchSettlementVoucherPayload, + isBatchSettlementClaimPayload, + isBatchSettlementSettlePayload, + isBatchSettlementRefundPayload, + isBatchSettlementEnrichedRefundPayload, +} from "../types"; +import type { AuthorizerSigner } from "../types"; +import { verifyDeposit, settleDeposit } from "./deposit"; +import { verifyVoucher } from "./voucher"; +import { executeClaimWithSignature } from "./claim"; +import { executeSettle } from "./settle"; +import { executeRefundWithSignature } from "./refund"; +import * as Errors from "../errors"; + +/** + * Facilitator-side implementation of the `batch-settlement` scheme for EVM networks. + * + * Routes incoming verify/settle requests to the appropriate handler based on payload + * type (deposit, voucher, claim, settle, refund). + */ +export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { + readonly scheme = BATCH_SETTLEMENT_SCHEME; + readonly caipFamily = "eip155:*"; + + /** + * Creates a facilitator scheme for verifying and settling batch-settlement payments. + * + * @param signer - Facilitator EVM signer(s) used for tx submission and onchain reads. + * @param authorizerSigner - Dedicated key that provides EIP-712 signatures for + * `claimWithSignature` / `refundWithSignature`. The facilitator will sign missing + * authorizer signatures using this key when the server omits them. + */ + constructor( + private readonly signer: FacilitatorEvmSigner, + private readonly authorizerSigner: AuthorizerSigner, + ) {} + + /** + * Returns facilitator-specific extra fields to be merged into payment requirements. + * + * Exposes the configured `receiverAuthorizer` address so the server and client can + * embed it in `ChannelConfig`. + * + * @param _ - Network identifier (unused). + * @returns Extra fields containing `receiverAuthorizer`. + */ + getExtra(_: string): { receiverAuthorizer: `0x${string}` } | undefined { + return { receiverAuthorizer: this.authorizerSigner.address }; + } + + /** + * Returns all facilitator signer addresses available for the given network. + * + * @param _ - Network identifier (unused). + * @returns Array of hex addresses. + */ + getSigners(_: string): `0x${string}`[] { + return [...this.signer.getAddresses()]; + } + + /** + * Verifies a payment payload (deposit or voucher) without executing settlement. + * + * @param payload - The x402 payment payload envelope. + * @param requirements - Server payment requirements (scheme, network, asset, amount). + * @param context - Optional facilitator extension context. + * @returns A {@link VerifyResponse} indicating validity with payer and channel state in `extra`. + */ + async verify( + payload: PaymentPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + ): Promise { + const rawPayload = payload.payload; + + if ( + payload.accepted.scheme !== BATCH_SETTLEMENT_SCHEME || + requirements.scheme !== BATCH_SETTLEMENT_SCHEME + ) { + return { isValid: false, invalidReason: Errors.ErrInvalidScheme }; + } + + if (payload.accepted.network !== requirements.network) { + return { isValid: false, invalidReason: Errors.ErrNetworkMismatch }; + } + + if (isBatchSettlementDepositPayload(rawPayload)) { + return verifyDeposit(this.signer, payload, rawPayload, requirements, context); + } + + if (isBatchSettlementVoucherPayload(rawPayload)) { + return verifyVoucher(this.signer, rawPayload, requirements, rawPayload.channelConfig); + } + + if (isBatchSettlementRefundPayload(rawPayload)) { + return verifyVoucher(this.signer, rawPayload, requirements, rawPayload.channelConfig); + } + + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadType }; + } + + /** + * Executes settlement for a payment payload. + * + * Dispatches to the correct handler based on payload settle action: + * - `deposit` → onchain `deposit(config, amount, collector, collectorData)` + * - `claim` → onchain `claimWithSignature(VoucherClaim[], bytes)` + * - `settle` → onchain `settle(receiver, token)` + * - `refund` → optional claim + onchain `refundWithSignature(config, amount, nonce, sig)` + * + * @param payload - The x402 payment payload envelope. + * @param requirements - Server payment requirements. + * @param context - Optional facilitator extension context. + * @returns A {@link SettleResponse} with the transaction hash on success. + */ + async settle( + payload: PaymentPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + ): Promise { + const rawPayload = payload.payload; + + if (isBatchSettlementDepositPayload(rawPayload)) { + return settleDeposit(this.signer, payload, rawPayload, requirements, context); + } + + if (isBatchSettlementClaimPayload(rawPayload)) { + return executeClaimWithSignature( + this.signer, + rawPayload, + requirements, + this.authorizerSigner, + ); + } + + if (isBatchSettlementEnrichedRefundPayload(rawPayload)) { + return executeRefundWithSignature( + this.signer, + rawPayload, + requirements, + this.authorizerSigner, + ); + } + + if (isBatchSettlementSettlePayload(rawPayload)) { + return executeSettle(this.signer, rawPayload, requirements); + } + + return { + success: false, + errorReason: Errors.ErrInvalidPayloadType, + transaction: "", + network: requirements.network, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/settle.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/settle.ts new file mode 100644 index 0000000000..cc5adc4c1c --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/settle.ts @@ -0,0 +1,95 @@ +import { SettleResponse, PaymentRequirements } from "@x402/core/types"; +import { getAddress, isAddressEqual, parseEventLogs } from "viem"; +import { FacilitatorEvmSigner } from "../../signer"; +import { BatchSettlementSettlePayload } from "../types"; +import { batchSettlementABI } from "../abi"; +import { BATCH_SETTLEMENT_ADDRESS } from "../constants"; +import * as Errors from "../errors"; + +/** + * Transfers claimed funds from the contract. + * + * This should be called after one or more `claim()` transactions have updated the + * receiver's `totalClaimed` accounting onchain. + * + * @param signer - Facilitator signer used to submit the settlement transaction. + * @param payload - Settle payload containing the receiver address and token address. + * @param requirements - Payment requirements for network identification. + * @returns A {@link SettleResponse} with the transaction hash on success. + */ +export async function executeSettle( + signer: FacilitatorEvmSigner, + payload: BatchSettlementSettlePayload, + requirements: PaymentRequirements, +): Promise { + const network = requirements.network; + const contractAddr = getAddress(BATCH_SETTLEMENT_ADDRESS); + const receiver = getAddress(payload.receiver); + const token = getAddress(payload.token); + + try { + await signer.readContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "settle", + args: [receiver, token], + }); + } catch (e) { + return { + success: false, + errorReason: Errors.ErrSettleSimulationFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } + + try { + const tx = await signer.writeContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "settle", + args: [receiver, token], + }); + + const receipt = await signer.waitForTransactionReceipt({ hash: tx }); + + if (receipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrSettleTransactionFailed, + errorMessage: `transaction reverted (receipt status ${receipt.status})`, + transaction: tx, + network, + }; + } + + let amount = ""; + if (receipt.logs) { + const logs = parseEventLogs({ + abi: batchSettlementABI, + eventName: "Settled", + logs: receipt.logs.filter(log => isAddressEqual(log.address, contractAddr)), + }); + const settledLog = logs.find( + log => isAddressEqual(log.args.receiver, receiver) && isAddressEqual(log.args.token, token), + ); + amount = settledLog?.args.amount.toString() ?? "0"; + } + + return { + success: true, + transaction: tx, + network, + amount, + }; + } catch (e) { + return { + success: false, + errorReason: Errors.ErrSettleTransactionFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/utils.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/utils.ts new file mode 100644 index 0000000000..8b7a2835e8 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/utils.ts @@ -0,0 +1,217 @@ +import { getAddress, verifyTypedData as viemVerifyTypedData } from "viem"; +import type { PaymentRequirements } from "@x402/core/types"; +import { FacilitatorEvmSigner } from "../../signer"; +import { multicall } from "../../multicall"; +import { + BATCH_SETTLEMENT_ADDRESS, + MIN_WITHDRAW_DELAY, + MAX_WITHDRAW_DELAY, + voucherTypes, +} from "../constants"; +import { batchSettlementABI } from "../abi"; +import type { + BatchSettlementPaymentRequirementsExtra, + ChannelConfig, + ChannelState, +} from "../types"; +import { computeChannelId, getBatchSettlementEip712Domain } from "../utils"; +import * as Errors from "../errors"; + +/** + * Normalises a {@link ChannelConfig} into the checksummed-address tuple expected by the + * batch-settlement contract's `deposit` / `refundWithSignature` / `claimWithSignature` calls. + * + * @param config - In-memory channel configuration. + * @returns Channel config tuple with all address fields checksummed via `getAddress`. + */ +export function toContractChannelConfig(config: ChannelConfig) { + return { + payer: getAddress(config.payer), + payerAuthorizer: getAddress(config.payerAuthorizer), + receiver: getAddress(config.receiver), + receiverAuthorizer: getAddress(config.receiverAuthorizer), + token: getAddress(config.token), + withdrawDelay: config.withdrawDelay, + salt: config.salt, + }; +} + +/** + * Case-insensitive comparison of two channel id hex strings. + * + * @param a - First channel id. + * @param b - Second channel id (may be any unknown value). + * @returns `true` when both ids refer to the same channel. + */ +export function channelIdsEqual(a: `0x${string}`, b: unknown): boolean { + if (typeof b !== "string" || b.length === 0) return false; + const norm = (x: string) => { + let s = x.toLowerCase(); + if (s.startsWith("0x")) s = s.slice(2); + return `0x${s}`; + }; + return norm(a) === norm(b); +} + +/** + * Validates the time window of an ERC-3009 `ReceiveWithAuthorization`. + * + * @param validAfter - Earliest unix timestamp the authorization is valid (in seconds). + * @param validBefore - Latest unix timestamp before which the authorization is valid. + * @returns An error code string if the time window is invalid, otherwise `undefined`. + */ +export function erc3009AuthorizationTimeInvalidReason( + validAfter: bigint, + validBefore: bigint, +): string | undefined { + const now = Math.floor(Date.now() / 1000); + if (validBefore < BigInt(now + 6)) return Errors.ErrValidBeforeExpired; + if (validAfter > BigInt(now)) return Errors.ErrValidAfterInFuture; + return undefined; +} + +/** + * Dual-path voucher signature verification. + * + * When `payerAuthorizer` is a non-zero address, the signature is verified off-chain via + * ECDSA recovery against that address (no RPC call). When `payerAuthorizer` is `address(0)`, + * verification falls back to an ERC-1271 `isValidSignature` call against the payer contract + * (smart-wallet path). + * + * @param signer - Facilitator signer providing `verifyTypedData` (may issue RPC for ERC-1271). + * @param params - Voucher fields and authorizer addresses needed for verification. + * @param params.channelId - EIP-712 voucher channel id (`bytes32` hex). + * @param params.maxClaimableAmount - Max cumulative claimable amount as a decimal string. + * @param params.payerAuthorizer - Address that signed the voucher; zero address selects ERC-1271 verification. + * @param params.payer - Payer contract address (used for ERC-1271). + * @param params.signature - EIP-712 signature bytes over the voucher. + * @param chainId - Numeric EVM chain id for the EIP-712 domain. + * @returns `true` when the voucher signature is valid. + */ +export async function verifyBatchSettlementVoucherTypedData( + signer: FacilitatorEvmSigner, + params: { + channelId: `0x${string}`; + maxClaimableAmount: string; + payerAuthorizer: `0x${string}`; + payer: `0x${string}`; + signature: `0x${string}`; + }, + chainId: number, +): Promise { + const domain = getBatchSettlementEip712Domain(chainId); + const message = { + channelId: params.channelId, + maxClaimableAmount: BigInt(params.maxClaimableAmount), + }; + + const zeroAddress = "0x0000000000000000000000000000000000000000"; + + if (params.payerAuthorizer !== zeroAddress) { + return viemVerifyTypedData({ + address: getAddress(params.payerAuthorizer), + domain, + types: voucherTypes, + primaryType: "Voucher", + message, + signature: params.signature, + }); + } + + return signer.verifyTypedData({ + address: getAddress(params.payer), + domain, + types: voucherTypes, + primaryType: "Voucher", + message, + signature: params.signature, + }); +} + +/** + * Validates that a {@link ChannelConfig} is consistent with the claimed `channelId` and + * the server's {@link PaymentRequirements}. + * + * @param config - The channel configuration from the payload. + * @param channelId - The `channelId` claimed in the payload. + * @param requirements - Server payment requirements to cross-check against. + * @returns An error code string if validation fails, otherwise `undefined`. + */ +export function validateChannelConfig( + config: ChannelConfig, + channelId: `0x${string}`, + requirements: PaymentRequirements, +): string | undefined { + const computedId = computeChannelId(config, requirements.network); + if (computedId.toLowerCase() !== channelId.toLowerCase()) { + return Errors.ErrChannelIdMismatch; + } + + if (getAddress(config.receiver) !== getAddress(requirements.payTo)) { + return Errors.ErrReceiverMismatch; + } + + const extra = requirements.extra as Partial | undefined; + const requiredReceiverAuthorizer = extra?.receiverAuthorizer; + + if ( + !requiredReceiverAuthorizer || + getAddress(requiredReceiverAuthorizer) === "0x0000000000000000000000000000000000000000" || + getAddress(config.receiverAuthorizer) !== getAddress(requiredReceiverAuthorizer) + ) { + return Errors.ErrReceiverAuthorizerMismatch; + } + + if (getAddress(config.token) !== getAddress(requirements.asset)) { + return Errors.ErrTokenMismatch; + } + + if (extra?.withdrawDelay !== undefined && config.withdrawDelay !== Number(extra.withdrawDelay)) { + return Errors.ErrWithdrawDelayMismatch; + } + + if (config.withdrawDelay < MIN_WITHDRAW_DELAY || config.withdrawDelay > MAX_WITHDRAW_DELAY) { + return Errors.ErrWithdrawDelayOutOfRange; + } + + return undefined; +} + +/** + * Reads onchain channel state via a 3-call multicall: + * `channels(channelId)`, `pendingWithdrawals(channelId)`, `refundNonce(channelId)`. + * + * Throws when any sub-call fails so callers can distinguish RPC failures + * from missing channels (which return zero balance/totalClaimed/refundNonce). + * + * @param signer - Facilitator signer for onchain reads. + * @param channelId - The `bytes32` channel id. + * @returns Fresh {@link ChannelState}. + */ +export async function readChannelState( + signer: FacilitatorEvmSigner, + channelId: `0x${string}`, +): Promise { + const target = getAddress(BATCH_SETTLEMENT_ADDRESS); + const mcResults = await multicall(signer.readContract.bind(signer), [ + { address: target, abi: batchSettlementABI, functionName: "channels", args: [channelId] }, + { + address: target, + abi: batchSettlementABI, + functionName: "pendingWithdrawals", + args: [channelId], + }, + { address: target, abi: batchSettlementABI, functionName: "refundNonce", args: [channelId] }, + ]); + + const [chRes, wdRes, rnRes] = mcResults; + if (chRes.status === "failure" || wdRes.status === "failure" || rnRes.status === "failure") { + throw new Error(`${Errors.ErrRpcReadFailed}: multicall returned failure for ${channelId}`); + } + + const [balance, totalClaimed] = chRes.result as [bigint, bigint]; + const [, wdInitiatedAt] = wdRes.result as [bigint, bigint]; + const refundNonce = rnRes.result as bigint; + + return { balance, totalClaimed, withdrawRequestedAt: Number(wdInitiatedAt), refundNonce }; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/voucher.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/voucher.ts new file mode 100644 index 0000000000..abee9f40e6 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/voucher.ts @@ -0,0 +1,98 @@ +import { PaymentRequirements, VerifyResponse } from "@x402/core/types"; +import { FacilitatorEvmSigner } from "../../signer"; +import { + BatchSettlementRefundPayload, + BatchSettlementVoucherPayload, + ChannelConfig, +} from "../types"; +import { getEvmChainId } from "../../utils"; +import * as Errors from "../errors"; +import { + validateChannelConfig, + verifyBatchSettlementVoucherTypedData, + readChannelState, +} from "./utils"; + +/** + * Verifies a cumulative voucher payload against onchain channel state. + * + * @param signer - Facilitator signer used for onchain reads and signature verification. + * @param payload - Voucher or refund payload with signed voucher fields. + * @param requirements - Server payment requirements (asset, network, amount). + * @param channelConfig - Reconstructed channel configuration for the payer/receiver pair. + * @returns A {@link VerifyResponse} indicating validity and returning channel state in `extra`. + */ +export async function verifyVoucher( + signer: FacilitatorEvmSigner, + payload: BatchSettlementVoucherPayload | BatchSettlementRefundPayload, + requirements: PaymentRequirements, + channelConfig: ChannelConfig, +): Promise { + const { voucher } = payload; + const channelId = voucher.channelId; + const chainId = getEvmChainId(requirements.network); + + const configErr = validateChannelConfig(channelConfig, channelId, requirements); + if (configErr) { + return { isValid: false, invalidReason: configErr, payer: channelConfig.payer }; + } + + const voucherOk = await verifyBatchSettlementVoucherTypedData( + signer, + { + channelId, + maxClaimableAmount: voucher.maxClaimableAmount, + payerAuthorizer: channelConfig.payerAuthorizer, + payer: channelConfig.payer, + signature: voucher.signature, + }, + chainId, + ); + if (!voucherOk) { + return { + isValid: false, + invalidReason: Errors.ErrInvalidVoucherSignature, + payer: channelConfig.payer, + }; + } + + const state = await readChannelState(signer, channelId); + + if (state.balance === 0n) { + return { isValid: false, invalidReason: Errors.ErrChannelNotFound, payer: channelConfig.payer }; + } + + const maxClaimableAmount = BigInt(voucher.maxClaimableAmount); + + if (maxClaimableAmount > state.balance) { + return { + isValid: false, + invalidReason: Errors.ErrCumulativeExceedsBalance, + payer: channelConfig.payer, + }; + } + + const belowClaimed = + payload.type === "refund" + ? maxClaimableAmount < state.totalClaimed + : maxClaimableAmount <= state.totalClaimed; + if (belowClaimed) { + return { + isValid: false, + invalidReason: Errors.ErrCumulativeAmountBelowClaimed, + payer: channelConfig.payer, + }; + } + + return { + isValid: true, + payer: channelConfig.payer, + extra: { + channelId, + balance: state.balance.toString(), + totalClaimed: state.totalClaimed.toString(), + withdrawRequestedAt: state.withdrawRequestedAt, + refundNonce: state.refundNonce.toString(), + }, + }; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/index.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/index.ts new file mode 100644 index 0000000000..541e7478f1 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/index.ts @@ -0,0 +1,2 @@ +export { BatchSettlementEvmScheme } from "./client/scheme"; +export { computeChannelId } from "./utils"; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/channelManager.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/channelManager.ts new file mode 100644 index 0000000000..c8ca1a33b2 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/channelManager.ts @@ -0,0 +1,791 @@ +import type { + Network, + PaymentPayload, + PaymentRequirements, + SettleResponse, +} from "@x402/core/types"; +import type { FacilitatorClient } from "@x402/core/server"; +import type { BatchSettlementVoucherClaim } from "../types"; +import type { BatchSettlementEvmScheme } from "./scheme"; +import { computeChannelId } from "../utils"; +import { BATCH_SETTLEMENT_SCHEME } from "../constants"; +import { signClaimBatch, signRefund } from "../authorizerSigner"; +import type { Channel } from "./storage"; + +export interface ChannelManagerConfig { + scheme: BatchSettlementEvmScheme; + facilitator: FacilitatorClient; + receiver: `0x${string}`; + token: `0x${string}`; + network: Network; +} + +export type ClaimChannelSelector = ( + channels: Channel[], + context: AutoSettlementContext, +) => Channel[] | Promise; + +export interface ClaimOptions { + maxClaimsPerBatch?: number; + idleSecs?: number; + selectClaimChannels?: ClaimChannelSelector; +} + +export interface AutoSettlementConfig { + claimIntervalSecs?: number; + settleIntervalSecs?: number; + refundIntervalSecs?: number; + maxClaimsPerBatch?: number; + selectClaimChannels?: ClaimChannelSelector; + shouldSettle?: (context: AutoSettlementContext) => boolean | Promise; + selectRefundChannels?: ( + channels: Channel[], + context: AutoSettlementContext, + ) => Channel[] | Promise; + onClaim?: (result: ClaimResult) => void; + onSettle?: (result: SettleResult) => void; + onRefund?: (result: RefundResult) => void; + onError?: (error: unknown) => void; +} + +export interface AutoSettlementContext { + now: number; + lastClaimTime: number; + lastSettleTime: number; + pendingSettle: boolean; +} + +export interface ClaimResult { + vouchers: number; + transaction: string; +} + +export interface SettleResult { + transaction: string; +} + +export interface RefundResult { + channel: string; + transaction: string; +} + +type AutoJob = "claim" | "settle" | "refund"; + +const AUTO_JOB_PRIORITY: AutoJob[] = ["claim", "settle", "refund"]; + +/** + * Formats a `Facilitator.settle()` failure into a human-readable error message. + * + * @param operation - Operation label (e.g. `"Claim"`, `"Settle"`, `"Refund"`). + * @param response - The failed settle response. + * @returns Error message including reason and (when available) facilitator-provided detail. + */ +function formatFacilitatorFailure(operation: string, response: SettleResponse): string { + return `${operation} failed: ${response.errorReason ?? "unknown"} — ${response.errorMessage ?? ""}`; +} + +/** + * Checks whether a channel has a non-expired payer request reservation. + * + * @param channel - Channel state to inspect. + * @param now - Current wall-clock time in milliseconds. + * @returns Whether the channel is busy with a live pending request. + */ +function hasLivePendingRequest(channel: Channel, now = Date.now()): boolean { + return channel.pendingRequest !== undefined && channel.pendingRequest.expiresAt > now; +} + +/** + * Manages the server-side channel lifecycle for the `batch-settlement` scheme: + * batch claiming of vouchers, settlement of claimed funds, and cooperative refund. + * + * Provides one-shot operations (`claim()`, `settle()`, `claimAndSettle()`, + * `refundIdleChannels()`) and an optional interval runner. + */ +export class BatchSettlementChannelManager { + private readonly scheme: BatchSettlementEvmScheme; + private readonly facilitator: FacilitatorClient; + private readonly receiver: `0x${string}`; + private readonly token: `0x${string}`; + private readonly network: Network; + + private timers: Partial>> = {}; + private lastClaimTime = 0; + private lastSettleTime = 0; + private pendingSettle = false; + private running = false; + private pendingJobs = new Set(); + private drainingJobs = false; + private autoSettleConfig: AutoSettlementConfig = {}; + + /** + * Creates a new channel manager. + * + * @param config - Manager configuration: scheme, facilitator, receiver, token, network. + */ + constructor(config: ChannelManagerConfig) { + this.scheme = config.scheme; + this.facilitator = config.facilitator; + this.receiver = config.receiver; + this.token = config.token; + this.network = config.network; + } + + /** + * Collects claimable vouchers and submits them in batches to the facilitator via `claim()`. + * + * @param opts - Optional claim execution and target selection options. + * @param opts.maxClaimsPerBatch - Max vouchers per facilitator `claim` batch. + * @param opts.idleSecs - When set, only include channels idle for at least this many seconds. + * @param opts.selectClaimChannels - Optional selector for choosing channels before claimability checks. + * @returns Array of claim results (one per batch). + */ + async claim(opts?: ClaimOptions): Promise { + const channels = await this.selectClaimTargets(opts); + return this.claimFromChannels(channels, { + maxClaimsPerBatch: opts?.maxClaimsPerBatch ?? 100, + ...(opts?.idleSecs !== undefined ? { idleSecs: opts.idleSecs } : {}), + }); + } + + /** + * Transfers claimed (but unsettled) funds to the receiver by calling `settle(receiver, token)`. + * + * @returns Settle result with the transaction hash. + */ + async settle(): Promise { + const paymentPayload = this.buildSettlePaymentPayload(); + const requirements = this.buildPaymentRequirements(); + + const response = await this.facilitator.settle(paymentPayload, requirements); + if (!response.success) { + throw new Error(formatFacilitatorFailure("Settle", response)); + } + + this.pendingSettle = false; + return { transaction: response.transaction }; + } + + /** + * Convenience: claims all eligible vouchers then settles in one call. + * + * @param opts - Optional claim execution and target selection options. + * @param opts.maxClaimsPerBatch - Max vouchers per claim batch before settling. + * @param opts.idleSecs - When set, only include channels idle for at least this many seconds. + * @param opts.selectClaimChannels - Optional selector for choosing channels before claimability checks. + * @returns Combined claim and settle results. + */ + async claimAndSettle( + opts?: ClaimOptions, + ): Promise<{ claims: ClaimResult[]; settle?: SettleResult }> { + const claims = await this.claim(opts); + let settleResult: SettleResult | undefined; + if (claims.length > 0) { + settleResult = await this.settle(); + } + return { claims, settle: settleResult }; + } + + /** + * Initiates cooperative refunds for one or more channels. + * + * @param channelIds - Specific channels to refund; defaults to all sessions. + * @returns One result per successfully refunded channel. + */ + async refund(channelIds?: string[]): Promise { + const storage = this.scheme.getStorage(); + const channels = await storage.list(); + + const now = Date.now(); + const targets = ( + channelIds + ? channels.filter(s => + channelIds.some(id => id.toLowerCase() === s.channelId.toLowerCase()), + ) + : channels + ).filter(channel => !hasLivePendingRequest(channel, now)); + + if (targets.length === 0) { + return []; + } + + return this.refundChannels(targets); + } + + /** + * Refunds idle channels with non-zero balances. + * + * @param opts - Idle refund options. + * @param opts.idleSecs - Minimum seconds since the last request. + * @returns One result per successfully refunded channel. + */ + async refundIdleChannels(opts: { idleSecs: number }): Promise { + const channels = await this.getIdleChannelsForRefund(opts.idleSecs); + return this.refundChannels(channels); + } + + /** + * Collects vouchers that are eligible for onchain claiming. + * + * A voucher is claimable when its `chargedCumulativeAmount` exceeds what has already + * been claimed onchain. An optional idle filter skips sessions that received a + * request within the last `idleSecs` seconds. + * + * @param opts - Optional filtering: `idleSecs` to only return idle channels. + * @param opts.idleSecs - Minimum seconds since last request for a channel to be included. + * @returns Array of {@link BatchSettlementVoucherClaim} entries for batch submission. + */ + async getClaimableVouchers(opts?: { idleSecs?: number }): Promise { + const channels = await this.scheme.getStorage().list(); + return this.getClaimableVouchersFromChannels(channels, opts); + } + + /** + * Returns channels that have a pending payer-initiated withdrawal. + * + * @returns All stored channel records with `withdrawRequestedAt` set. + */ + async getWithdrawalPendingSessions(): Promise { + const channels = await this.scheme.getStorage().list(); + return channels.filter(s => s.withdrawRequestedAt > 0); + } + + /** + * Starts auto-settlement jobs for configured claim, settle, and refund intervals. + * + * @param config - Auto-settlement policy configuration. + */ + start(config: AutoSettlementConfig = {}): void { + if (this.running) { + return; + } + + const now = Date.now(); + this.lastClaimTime = now; + this.lastSettleTime = now; + this.running = true; + this.autoSettleConfig = config; + + this.startAutoTimer("claim", config.claimIntervalSecs); + this.startAutoTimer("settle", config.settleIntervalSecs); + this.startAutoTimer("refund", config.refundIntervalSecs); + } + + /** + * Stops the auto-settlement loop. + * + * @param opts - Stop options. + * @param opts.flush - When true, run `claimAndSettle` before stopping. + * @returns Resolves when the loop is stopped (and flush work completes, if requested). + */ + async stop(opts?: { flush?: boolean }): Promise { + this.running = false; + for (const timer of Object.values(this.timers)) { + clearInterval(timer); + } + this.timers = {}; + this.pendingJobs.clear(); + + if (opts?.flush) { + await this.claimAndSettle({ + maxClaimsPerBatch: this.autoSettleConfig.maxClaimsPerBatch, + selectClaimChannels: this.autoSettleConfig.selectClaimChannels, + }); + } + } + + /** + * Refunds a single channel and removes it from storage after success. + * + * @param target - Channel to refund. + * @returns Successful refund transaction. + */ + private async refundChannel(target: Channel): Promise { + const authorizerSigner = this.scheme.getReceiverAuthorizerSigner(); + const claims = this.buildRefundClaims(target); + + const refundAmount = ( + BigInt(target.balance) - BigInt(target.chargedCumulativeAmount) + ).toString(); + + const nonce = String(target.refundNonce ?? 0); + + const refundAuthorizerSignature = authorizerSigner + ? await signRefund( + authorizerSigner, + target.channelId as `0x${string}`, + refundAmount, + nonce, + this.network, + ) + : undefined; + + const claimAuthorizerSignature = + authorizerSigner && claims.length > 0 + ? await signClaimBatch(authorizerSigner, claims, this.network) + : undefined; + + const paymentPayload: PaymentPayload = { + x402Version: 2, + accepted: this.buildPaymentRequirements(), + payload: { + type: "refund", + channelConfig: target.channelConfig, + voucher: { + channelId: target.channelId as `0x${string}`, + maxClaimableAmount: target.signedMaxClaimable, + signature: target.signature as `0x${string}`, + }, + amount: refundAmount, + refundNonce: nonce, + claims, + ...(refundAuthorizerSignature ? { refundAuthorizerSignature } : {}), + ...(claimAuthorizerSignature ? { claimAuthorizerSignature } : {}), + }, + }; + + const response = await this.facilitator.settle(paymentPayload, this.buildPaymentRequirements()); + if (!response.success) { + throw new Error(formatFacilitatorFailure("Refund", response)); + } + + await this.scheme + .getStorage() + .updateChannel(target.channelId, current => + current && !hasLivePendingRequest(current) ? undefined : current, + ); + + return { + channel: target.channelId, + transaction: response.transaction, + }; + } + + /** + * Starts a recurring timer for one auto job. + * + * @param job - Job to enqueue when the interval fires. + * @param intervalSecs - Timer interval in seconds. + */ + private startAutoTimer(job: AutoJob, intervalSecs?: number): void { + if (intervalSecs === undefined) { + return; + } + + this.timers[job] = setInterval(() => { + this.enqueueJob(job); + }, intervalSecs * 1000); + } + + /** + * Adds an auto job to the coalescing queue. + * + * @param job - Job to run. + */ + private enqueueJob(job: AutoJob): void { + if (!this.running) { + return; + } + + this.pendingJobs.add(job); + if (!this.drainingJobs) { + void this.drainJobs(); + } + } + + /** + * Drains queued auto jobs in priority order. + */ + private async drainJobs(): Promise { + if (this.drainingJobs) { + return; + } + + this.drainingJobs = true; + try { + while (this.running && this.pendingJobs.size > 0) { + const job = this.nextPendingJob(); + if (!job) { + return; + } + this.pendingJobs.delete(job); + await this.runAutoJob(job); + } + } finally { + this.drainingJobs = false; + } + } + + /** + * Returns the highest-priority queued auto job. + * + * @returns Next job to run. + */ + private nextPendingJob(): AutoJob | undefined { + return AUTO_JOB_PRIORITY.find(job => this.pendingJobs.has(job)); + } + + /** + * Runs one auto job. + * + * @param job - Job to run. + */ + private async runAutoJob(job: AutoJob): Promise { + switch (job) { + case "claim": + await this.runClaimJob(); + return; + case "settle": + await this.runSettleJob(); + return; + case "refund": + await this.runRefundJob(); + return; + } + } + + /** + * Runs the claim auto job. + */ + private async runClaimJob(): Promise { + const cfg = this.autoSettleConfig; + try { + const targets = await this.selectClaimTargets({ + selectClaimChannels: cfg.selectClaimChannels, + }); + const results = await this.claimFromChannels(targets, { + maxClaimsPerBatch: cfg.maxClaimsPerBatch ?? 100, + }); + + this.lastClaimTime = Date.now(); + for (const result of results) { + cfg.onClaim?.(result); + } + } catch (err) { + cfg.onError?.(err); + } + } + + /** + * Runs the settlement auto job. + */ + private async runSettleJob(): Promise { + const cfg = this.autoSettleConfig; + const context = this.buildAutoSettlementContext(Date.now()); + if (!context.pendingSettle) { + return; + } + + try { + if (cfg.shouldSettle && !(await cfg.shouldSettle(context))) { + return; + } + + const result = await this.settle(); + this.lastSettleTime = Date.now(); + cfg.onSettle?.(result); + } catch (err) { + cfg.onError?.(err); + } + } + + /** + * Runs the refund auto job. + */ + private async runRefundJob(): Promise { + const cfg = this.autoSettleConfig; + if (!cfg.selectRefundChannels) { + return; + } + + try { + const context = this.buildAutoSettlementContext(Date.now()); + const channels = await this.scheme.getStorage().list(); + const targets = await cfg.selectRefundChannels(channels, context); + for (const result of await this.refundChannels(targets)) { + cfg.onRefund?.(result); + } + } catch (err) { + cfg.onError?.(err); + } + } + + /** + * Claims vouchers from a provided channel snapshot. + * + * @param channels - Channels to inspect for claimable vouchers. + * @param opts - Claim batching and filtering options. + * @param opts.maxClaimsPerBatch - Max vouchers per facilitator claim transaction. + * @param opts.idleSecs - Optional idle filter. + * @returns Claim results, one per submitted batch. + */ + private async claimFromChannels( + channels: Channel[], + opts: { + maxClaimsPerBatch: number; + idleSecs?: number; + }, + ): Promise { + const allClaims = this.getClaimableVouchersFromChannels( + channels, + opts.idleSecs !== undefined ? { idleSecs: opts.idleSecs } : undefined, + ); + + if (allClaims.length === 0) { + return []; + } + + const results: ClaimResult[] = []; + for (let i = 0; i < allClaims.length; i += opts.maxClaimsPerBatch) { + const batch = allClaims.slice(i, i + opts.maxClaimsPerBatch); + const result = await this.submitClaim(batch); + results.push(result); + await this.updateClaimedSessions(batch); + } + + if (results.length > 0) { + this.pendingSettle = true; + } + + return results; + } + + /** + * Loads stored channels and applies the configured claim selector, if any. + * + * @param opts - Claim options containing an optional target selector. + * @returns The channel snapshot that should be inspected for claimable vouchers. + */ + private async selectClaimTargets( + opts?: Pick, + ): Promise { + const channels = await this.scheme.getStorage().list(); + if (!opts?.selectClaimChannels) { + return channels; + } + + const context = this.buildAutoSettlementContext(Date.now()); + return opts.selectClaimChannels(channels, context); + } + + /** + * Refunds each eligible channel independently. + * + * @param channels - Channels to refund. + * @returns Successful refund results. + */ + private async refundChannels(channels: Channel[]): Promise { + const results: RefundResult[] = []; + for (const channel of channels) { + if (hasLivePendingRequest(channel)) { + continue; + } + results.push(await this.refundChannel(channel)); + } + return results; + } + + /** + * Builds an outstanding voucher claim for a refund payload. + * + * @param channel - Channel being refunded. + * @returns Claim payloads needed before refunding unclaimed balance. + */ + private buildRefundClaims(channel: Channel): BatchSettlementVoucherClaim[] { + if (BigInt(channel.chargedCumulativeAmount) <= BigInt(channel.totalClaimed)) { + return []; + } + + return [ + { + voucher: { + channel: channel.channelConfig, + maxClaimableAmount: channel.signedMaxClaimable, + }, + signature: channel.signature as `0x${string}`, + totalClaimed: channel.chargedCumulativeAmount, + }, + ]; + } + + /** + * Builds the policy context passed to interval hooks. + * + * @param now - Current wall-clock time in milliseconds. + * @returns Auto-settlement policy context. + */ + private buildAutoSettlementContext(now: number): AutoSettlementContext { + return { + now, + lastClaimTime: this.lastClaimTime, + lastSettleTime: this.lastSettleTime, + pendingSettle: this.pendingSettle, + }; + } + + /** + * Collects claimable vouchers from a provided channel snapshot. + * + * @param channels - Channels to inspect. + * @param opts - Optional idle filter. + * @param opts.idleSecs - Minimum seconds since last request. + * @returns Claimable voucher payloads. + */ + private getClaimableVouchersFromChannels( + channels: Channel[], + opts?: { idleSecs?: number }, + ): BatchSettlementVoucherClaim[] { + const now = Date.now(); + const claims: BatchSettlementVoucherClaim[] = []; + + for (const c of channels) { + if (BigInt(c.chargedCumulativeAmount) <= BigInt(c.totalClaimed)) { + continue; + } + if (opts?.idleSecs !== undefined) { + const idleMs = now - c.lastRequestTimestamp; + if (idleMs < opts.idleSecs * 1000) { + continue; + } + } + claims.push({ + voucher: { + channel: c.channelConfig, + maxClaimableAmount: c.signedMaxClaimable, + }, + signature: c.signature as `0x${string}`, + totalClaimed: c.chargedCumulativeAmount, + }); + } + + return claims; + } + + /** + * Filters idle channels that can be cooperatively refunded. + * + * @param channels - Channels to inspect. + * @param idleSecs - Minimum seconds since the last request. + * @returns Idle refundable channels. + */ + private getIdleChannelsForRefundFromChannels(channels: Channel[], idleSecs: number): Channel[] { + const now = Date.now(); + const idleMs = idleSecs * 1000; + return channels.filter(c => { + if (BigInt(c.balance) === 0n) return false; + if (hasLivePendingRequest(c, now)) return false; + return now - c.lastRequestTimestamp >= idleMs; + }); + } + + /** + * Returns channels that have been idle longer than `idleSecs` and still have + * a non-zero balance (candidates for cooperative refund). + * + * @param idleSecs - Minimum seconds since last request for a session to count as idle. + * @returns Channels meeting the idle and balance criteria. + */ + private async getIdleChannelsForRefund(idleSecs: number): Promise { + const channels = await this.scheme.getStorage().list(); + return this.getIdleChannelsForRefundFromChannels(channels, idleSecs); + } + + /** + * Submits a batch of voucher claims to the facilitator. + * + * @param claims - Voucher claims to send in one `type: "claim"` payload. + * @returns Per-batch claim summary (count and transaction hash). + */ + private async submitClaim(claims: BatchSettlementVoucherClaim[]): Promise { + const authorizerSigner = this.scheme.getReceiverAuthorizerSigner(); + + const claimAuthorizerSignature = authorizerSigner + ? await signClaimBatch(authorizerSigner, claims, this.network) + : undefined; + + const paymentPayload: PaymentPayload = { + x402Version: 2, + accepted: this.buildPaymentRequirements(), + payload: { + type: "claim", + claims, + ...(claimAuthorizerSignature ? { claimAuthorizerSignature } : {}), + }, + }; + + const response: SettleResponse = await this.facilitator.settle( + paymentPayload, + this.buildPaymentRequirements(), + ); + + if (!response.success) { + throw new Error(formatFacilitatorFailure("Claim", response)); + } + + return { vouchers: claims.length, transaction: response.transaction }; + } + + /** + * Builds a settlement payment payload for `settle(receiver, token)`. + * + * @returns Payload with `type: "settle"` and receiver/token fields. + */ + private buildSettlePaymentPayload(): PaymentPayload { + return { + x402Version: 2, + accepted: this.buildPaymentRequirements(), + payload: { + type: "settle", + receiver: this.receiver, + token: this.token, + }, + }; + } + + /** + * Builds a minimal {@link PaymentRequirements} for channel manager operations. + * + * @returns Requirements describing batched operations for this manager. + */ + private buildPaymentRequirements(): PaymentRequirements { + return { + scheme: BATCH_SETTLEMENT_SCHEME, + network: this.network, + asset: this.token, + amount: "0", + payTo: this.receiver, + maxTimeoutSeconds: 0, + extra: {}, + }; + } + + /** + * Updates session records after a successful claim submission so that + * `getClaimableVouchers` no longer returns already-claimed vouchers. + * + * @param claims - Voucher claims that were included in the submitted settlement transaction. + */ + private async updateClaimedSessions(claims: BatchSettlementVoucherClaim[]): Promise { + const storage = this.scheme.getStorage(); + for (const claim of claims) { + const channelId = computeChannelId(claim.voucher.channel, this.network); + const channel = await storage.get(channelId); + if (!channel) { + continue; + } + const claimedAmount = BigInt(claim.totalClaimed); + if (claimedAmount <= BigInt(channel.totalClaimed)) { + continue; + } + await storage.updateChannel(channelId, current => { + if (!current || claimedAmount <= BigInt(current.totalClaimed)) { + return current; + } + return { + ...current, + totalClaimed: claimedAmount.toString(), + }; + }); + } + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/fileStorage.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/fileStorage.ts new file mode 100644 index 0000000000..d3038b62ab --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/fileStorage.ts @@ -0,0 +1,143 @@ +import { mkdir, open, readdir, readFile, unlink } from "node:fs/promises"; +import { constants } from "node:fs"; +import { dirname, join } from "node:path"; + +import { isNodeEnoent, readJsonFile, writeJsonAtomic } from "../storage-utils"; +import type { FileChannelStorageOptions } from "../types"; +import type { ChannelStorage, Channel, ChannelUpdateResult } from "./storage"; + +export type { FileChannelStorageOptions }; + +/** + * Node.js file-backed {@link ChannelStorage} for the batched server scheme. + */ +export class FileChannelStorage implements ChannelStorage { + private readonly root: string; + + /** + * Creates file-backed server channel storage under the given root directory. + * + * @param options - Configuration including the storage root directory. + */ + constructor(options: FileChannelStorageOptions) { + this.root = options.directory; + } + + /** + * Loads a persisted channel record, if present. + * + * @param channelId - The channel identifier (path segment is lowercased). + * @returns Parsed channel record or `undefined` when the file is missing. + */ + async get(channelId: string): Promise { + return readJsonFile(this.filePath(channelId)); + } + + /** + * Lists all stored channel records by reading the server directory. + * + * @returns Channel records sorted by channelId; empty array if the directory is missing. + */ + async list(): Promise { + const dir = join(this.root, "server"); + let names: string[]; + try { + names = await readdir(dir); + } catch (err: unknown) { + if (isNodeEnoent(err)) return []; + throw err; + } + + const channels: Channel[] = []; + for (const name of names) { + if (!name.endsWith(".json")) continue; + const path = join(dir, name); + try { + const raw = await readFile(path, "utf8"); + channels.push(JSON.parse(raw) as Channel); + } catch (err: unknown) { + // Skip files that disappeared between readdir and readFile (e.g. concurrent delete). + // Rethrow other failures (corrupt JSON, permission denied) so callers see them. + if (isNodeEnoent(err)) continue; + throw err; + } + } + return channels.sort((a, b) => a.channelId.localeCompare(b.channelId)); + } + + /** + * Atomically inspects and mutates a channel record under a cross-process file lock. + * + * @param channelId - The channel identifier. + * @param update - Mutation callback. Return `undefined` to delete, or `current` to leave unchanged. + * @returns The final stored channel and whether storage updated, stayed unchanged, or deleted. + */ + async updateChannel( + channelId: string, + update: (current: Channel | undefined) => Channel | undefined, + ): Promise { + const lockPath = this.filePath(channelId) + ".lock"; + await mkdir(dirname(lockPath), { recursive: true }); + const lockHandle = await this.acquireLock(lockPath); + + try { + const path = this.filePath(channelId); + let current: Channel | undefined; + try { + const raw = await readFile(path, "utf8"); + current = JSON.parse(raw) as Channel; + } catch (err: unknown) { + if (!isNodeEnoent(err)) throw err; + } + + const next = update(current); + if (next === current) { + return { channel: current, status: "unchanged" }; + } + + if (!next) { + try { + await unlink(path); + } catch (err: unknown) { + if (!isNodeEnoent(err)) throw err; + } + return { channel: undefined, status: current ? "deleted" : "unchanged" }; + } + + await writeJsonAtomic(path, next); + return { channel: next, status: "updated" }; + } finally { + await lockHandle.close(); + await unlink(lockPath).catch(() => {}); + } + } + + /** + * Absolute path to the JSON file for a channel. + * + * @param channelId - The channel identifier. + * @returns Filesystem path under `{root}/server/...`. + */ + private filePath(channelId: string): string { + return join(this.root, "server", `${channelId.toLowerCase()}.json`); + } + + /** + * Creates an exclusive lock file, polling until no other process holds it. + * + * @param lockPath - Absolute path for the lock file (created with `O_EXCL`). + * @returns Writable file handle for the lock file; caller must close it to release. + */ + private async acquireLock(lockPath: string) { + while (true) { + try { + return await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") { + throw err; + } + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/index.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/index.ts new file mode 100644 index 0000000000..eb4f408fbe --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/index.ts @@ -0,0 +1,26 @@ +export { BatchSettlementEvmScheme } from "./scheme"; +export type { BatchSettlementEvmSchemeServerConfig, BatchSettlementRequestContext } from "./scheme"; +export type { AuthorizerSigner } from "../types"; +export { InMemoryChannelStorage } from "./storage"; +export type { Channel, ChannelStorage, ChannelUpdateResult, PendingRequest } from "./storage"; +export type { FileChannelStorageOptions } from "./fileStorage"; +export { FileChannelStorage } from "./fileStorage"; +export { RedisChannelStorage } from "./redisStorage"; +export type { + RedisChannelStorageClient, + RedisChannelStorageOptions, + RedisEvalOptions, + RedisScanOptions, + RedisSetOptions, +} from "./redisStorage"; +export { BatchSettlementChannelManager } from "./channelManager"; +export type { + ChannelManagerConfig, + AutoSettlementConfig, + AutoSettlementContext, + ClaimChannelSelector, + ClaimOptions, + ClaimResult, + SettleResult, + RefundResult, +} from "./channelManager"; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/redisStorage.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/redisStorage.ts new file mode 100644 index 0000000000..c07b39288b --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/redisStorage.ts @@ -0,0 +1,235 @@ +import type { Channel, ChannelStorage, ChannelUpdateResult } from "./storage"; + +const DEFAULT_KEY_PREFIX = "x402:batch-settlement"; +const DEFAULT_LOCK_RETRY_INTERVAL_MS = 10; +const DEFAULT_SCAN_COUNT = 100; + +const UPDATE_CHANNEL_SCRIPT = ` +local current = redis.call("GET", KEYS[1]) +local expectedExists = ARGV[1] +local expected = ARGV[2] +local operation = ARGV[3] +local nextValue = ARGV[4] + +if expectedExists == "0" then + if current ~= false then + return {0, current} + end +elseif current ~= expected then + return {0, current or false} +end + +if operation == "delete" then + redis.call("DEL", KEYS[1]) + return {1, false} +end + +if operation == "set" then + redis.call("SET", KEYS[1], nextValue) + return {1, nextValue} +end + +return {1, current or false} +`; + +export type RedisEvalOptions = { + keys: string[]; + arguments: string[]; +}; + +export type RedisSetOptions = { + NX?: true; + PX?: number; +}; + +export type RedisScanOptions = { + MATCH?: string; + COUNT?: number; +}; + +export type RedisChannelStorageClient = { + get(key: string): Promise; + set(key: string, value: string, options?: RedisSetOptions): Promise; + del(key: string): Promise; + eval(script: string, options: RedisEvalOptions): Promise; + scanIterator(options: RedisScanOptions): AsyncIterable; +}; + +export type RedisChannelStorageOptions = { + client: RedisChannelStorageClient; + keyPrefix?: string; + lockTtlMs?: number; + lockRetryIntervalMs?: number; + lockRenewalIntervalMs?: number; + scanCount?: number; +}; + +type RedisUpdateOperation = "delete" | "keep" | "set"; + +type ParsedRedisUpdateResult = { + applied: boolean; +}; + +/** + * Redis-backed {@link ChannelStorage} with optimistic atomic updates. + */ +export class RedisChannelStorage implements ChannelStorage { + private readonly client: RedisChannelStorageClient; + private readonly keyPrefix: string; + private readonly channelKeyPrefix: string; + private readonly lockRetryIntervalMs: number; + private readonly scanCount: number; + + /** + * Creates Redis-backed server channel storage. + * + * @param options - Redis client and optional key/retry configuration. + */ + constructor(options: RedisChannelStorageOptions) { + this.client = options.client; + this.keyPrefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX; + this.channelKeyPrefix = `${this.keyPrefix}:server:channel`; + this.lockRetryIntervalMs = options.lockRetryIntervalMs ?? DEFAULT_LOCK_RETRY_INTERVAL_MS; + this.scanCount = options.scanCount ?? DEFAULT_SCAN_COUNT; + } + + /** + * Loads a persisted channel record, if present. + * + * @param channelId - The channel identifier. + * @returns Parsed channel record or `undefined` when the key is missing. + */ + async get(channelId: string): Promise { + const raw = await this.client.get(this.channelKey(channelId)); + if (!raw) return undefined; + return JSON.parse(raw) as Channel; + } + + /** + * Lists all stored channel records by scanning Redis keys. + * + * @returns Channel records sorted by channelId. + */ + async list(): Promise { + const channels: Channel[] = []; + for await (const keyOrKeys of this.client.scanIterator({ + MATCH: `${this.channelKeyPrefix}:*`, + COUNT: this.scanCount, + })) { + const keys = Array.isArray(keyOrKeys) ? keyOrKeys : [keyOrKeys]; + for (const key of keys) { + if (key.endsWith(":lock")) continue; + const raw = await this.client.get(key); + if (!raw) continue; + channels.push(JSON.parse(raw) as Channel); + } + } + return channels.sort((a, b) => a.channelId.localeCompare(b.channelId)); + } + + /** + * Atomically inspects and mutates a channel record with Redis compare-and-write retries. + * + * @param channelId - The channel identifier. + * @param update - Mutation callback. Return `undefined` to delete, or `current` to leave unchanged. + * @returns The final stored channel and whether storage updated, stayed unchanged, or deleted. + */ + async updateChannel( + channelId: string, + update: (current: Channel | undefined) => Channel | undefined, + ): Promise { + const key = this.channelKey(channelId); + while (true) { + const currentRaw = await this.client.get(key); + const current = currentRaw ? (JSON.parse(currentRaw) as Channel) : undefined; + const next = update(current); + + if (next === current) { + const result = await this.commitUpdate(key, currentRaw, "keep"); + if (result.applied) return { channel: current, status: "unchanged" }; + await sleep(this.lockRetryIntervalMs); + continue; + } + + if (!next) { + const result = await this.commitUpdate(key, currentRaw, "delete"); + if (result.applied) { + return { channel: undefined, status: current ? "deleted" : "unchanged" }; + } + await sleep(this.lockRetryIntervalMs); + continue; + } + + const nextRaw = JSON.stringify(next); + const result = await this.commitUpdate(key, currentRaw, "set", nextRaw); + if (result.applied) return { channel: next, status: "updated" }; + await sleep(this.lockRetryIntervalMs); + } + } + + /** + * Applies a channel mutation only if the key still contains the value that was inspected. + * + * @param key - Redis channel key to mutate. + * @param expectedRaw - Raw JSON value observed before running the update callback. + * @param operation - Mutation to apply when the observed value is still current. + * @param nextRaw - Raw JSON value to write for set operations. + * @returns Whether the mutation was applied. + */ + private async commitUpdate( + key: string, + expectedRaw: string | null, + operation: RedisUpdateOperation, + nextRaw = "", + ): Promise { + return parseRedisUpdateResult( + await this.client.eval(UPDATE_CHANNEL_SCRIPT, { + keys: [key], + arguments: [expectedRaw === null ? "0" : "1", expectedRaw ?? "", operation, nextRaw], + }), + ); + } + + /** + * Builds the Redis key for a stored channel record. + * + * @param channelId - The channel identifier. + * @returns Redis key for the channel JSON. + */ + private channelKey(channelId: string) { + return `${this.channelKeyPrefix}:${channelId.toLowerCase()}`; + } +} + +/** + * Parses the Redis script response. + * + * @param value - Raw response from the Redis client. + * @returns Whether the compare-and-write applied. + */ +function parseRedisUpdateResult(value: unknown): ParsedRedisUpdateResult { + if (!Array.isArray(value) || value.length < 1) { + throw new Error("Unexpected Redis update response"); + } + + const [applied, raw] = value; + if (applied !== 0 && applied !== 1) { + throw new Error("Unexpected Redis update status"); + } + + if (raw !== false && raw !== null && raw !== undefined && typeof raw !== "string") { + throw new Error("Unexpected Redis update value"); + } + + return { applied: applied === 1 }; +} + +/** + * Resolves after the requested delay. + * + * @param ms - Delay in milliseconds. + * @returns Promise resolved after the delay. + */ +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/scheme.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/scheme.ts new file mode 100644 index 0000000000..1a261c31e4 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/scheme.ts @@ -0,0 +1,427 @@ +import { + AssetAmount, + Network, + PaymentPayload, + PaymentRequirements, + Price, + SchemeNetworkServer, + SchemeServerHooks, + MoneyParser, +} from "@x402/core/types"; +import type { DeepReadonly } from "@x402/core/types"; +import type { SettleContext, SettleResultContext } from "@x402/core/server"; +import { convertToTokenAmount, numberToDecimalString } from "@x402/core/utils"; +import type { FacilitatorClient } from "@x402/core/server"; +import { getAddress } from "viem"; +import { BatchSettlementChannelManager } from "./channelManager"; +import { getDefaultAsset } from "../../shared/defaultAssets"; +import type { AuthorizerSigner } from "../types"; +import { BATCH_SETTLEMENT_SCHEME, MIN_WITHDRAW_DELAY } from "../constants"; +import { InMemoryChannelStorage, ChannelStorage, type Channel } from "./storage"; +import { + handleAfterVerify, + handleBeforeVerify, + handleEnrichPaymentRequiredResponse, + handleVerifyFailure, + handleVerifiedPaymentCanceled, +} from "./verify"; +import { + handleAfterSettle, + handleBeforeSettle, + handleEnrichSettlementPayload, + handleEnrichSettlementResponse, + handleSettleFailure, +} from "./settle"; + +export interface BatchSettlementEvmSchemeServerConfig { + storage?: ChannelStorage; + receiverAuthorizerSigner?: AuthorizerSigner; + withdrawDelay?: number; + onchainStateTtlMs?: number; +} + +export interface BatchSettlementRequestContext { + channelId?: string; + pendingId?: string; + channelSnapshot?: Channel; + localVerify?: boolean; +} + +/** + * Server-side implementation of the `batch-settlement` scheme for EVM networks. + */ +export class BatchSettlementEvmScheme implements SchemeNetworkServer { + readonly scheme = BATCH_SETTLEMENT_SCHEME; + readonly schemeHooks: SchemeServerHooks; + + private readonly requestContexts = new WeakMap< + DeepReadonly, + BatchSettlementRequestContext + >(); + private moneyParsers: MoneyParser[] = []; + private readonly storage: ChannelStorage; + private readonly receiverAuthorizerSigner: AuthorizerSigner | undefined; + private readonly receiverAddress: `0x${string}`; + private readonly withdrawDelay: number; + private readonly onchainStateTtlMs: number; + + /** + * Constructs a batched server scheme. + * + * @param receiverAddress - The server's receiver address (payTo). + * @param config - Optional configuration for storage, receiver-authorizer signer, and withdraw delay. + */ + constructor(receiverAddress: `0x${string}`, config?: BatchSettlementEvmSchemeServerConfig) { + this.receiverAddress = receiverAddress; + this.storage = config?.storage ?? new InMemoryChannelStorage(); + this.receiverAuthorizerSigner = config?.receiverAuthorizerSigner; + this.withdrawDelay = config?.withdrawDelay ?? MIN_WITHDRAW_DELAY; + this.onchainStateTtlMs = + config?.onchainStateTtlMs ?? defaultOnchainStateTtlMs(this.withdrawDelay); + this.schemeHooks = { + onBeforeVerify: ctx => handleBeforeVerify(this, ctx), + onAfterVerify: ctx => handleAfterVerify(this, ctx), + onBeforeSettle: ctx => handleBeforeSettle(this, ctx), + onAfterSettle: ctx => handleAfterSettle(this, ctx), + onVerifyFailure: ctx => handleVerifyFailure(this, ctx), + onSettleFailure: ctx => handleSettleFailure(this, ctx), + onVerifiedPaymentCanceled: ctx => handleVerifiedPaymentCanceled(this, ctx), + }; + } + + /** + * Adds server-owned settlement fields before facilitator settlement. + * + * @param ctx - Settlement context for the current payment. + * @returns Additive payload fields, or nothing when no enrichment is needed. + */ + enrichSettlementPayload = (ctx: SettleContext): Promise | void> => + handleEnrichSettlementPayload(this, ctx); + + /** + * Adds corrective channel state to payment-required responses when available. + * + * @param ctx - Payment-required response context for the current request. + * @returns Updated payment requirements, or nothing when no enrichment is needed. + */ + enrichPaymentRequiredResponse = ( + ctx: Parameters[1], + ): Promise => handleEnrichPaymentRequiredResponse(this, ctx); + + /** + * Adds server-owned extra fields after facilitator settlement. + * + * @param ctx - Settlement result context for the current payment. + * @returns Additive response extra fields, or nothing when no enrichment is needed. + */ + enrichSettlementResponse = (ctx: SettleResultContext): Promise | void> => + handleEnrichSettlementResponse(this, ctx); + + /** + * Merges batch-settlement state into the current request context. + * + * @param payload - Request-scoped payment payload object. + * @param context - Partial context fields to merge. + */ + mergeRequestContext( + payload: DeepReadonly, + context: BatchSettlementRequestContext, + ): void { + this.requestContexts.set(payload, { + ...this.requestContexts.get(payload), + ...context, + }); + } + + /** + * Reads batch-settlement state for the current request without clearing it. + * + * @param payload - Request-scoped payment payload object. + * @returns Request context, if one was recorded. + */ + readRequestContext( + payload: DeepReadonly, + ): BatchSettlementRequestContext | undefined { + return this.requestContexts.get(payload); + } + + /** + * Reads and clears batch-settlement state for the current request. + * + * @param payload - Request-scoped payment payload object. + * @returns Request context, if one was recorded. + */ + takeRequestContext( + payload: DeepReadonly, + ): BatchSettlementRequestContext | undefined { + const context = this.requestContexts.get(payload); + this.requestContexts.delete(payload); + return context; + } + + /** + * Stores a channel snapshot for the current settlement request. + * + * @param payload - Request-scoped payment payload object. + * @param channel - Channel state to use during response enrichment. + */ + rememberChannelSnapshot(payload: DeepReadonly, channel: Channel): void { + this.mergeRequestContext(payload, { + channelId: channel.channelId, + channelSnapshot: channel, + }); + } + + /** + * Reads and clears a channel snapshot for the current settlement request. + * + * @param payload - Request-scoped payment payload object. + * @returns Stored channel state, if one was recorded. + */ + takeChannelSnapshot(payload: DeepReadonly): Channel | undefined { + return this.takeRequestContext(payload)?.channelSnapshot; + } + + /** + * Clears this request's pending reservation without touching newer reservations. + * + * @param payload - Request-scoped payment payload object. + */ + async clearPendingRequest(payload: DeepReadonly): Promise { + const context = this.takeRequestContext(payload); + if (!context?.channelId || !context.pendingId) { + return; + } + + await this.storage.updateChannel(context.channelId, current => { + if (!current || current.pendingRequest?.pendingId !== context.pendingId) { + return current; + } + + if (!context.channelSnapshot) { + return undefined; + } + + return { + ...current, + pendingRequest: undefined, + }; + }); + } + + /** + * Registers a custom money parser for converting price strings to token amounts. + * + * @param parser - A parser function to try before the default USD→token conversion. + * @returns `this` for chaining. + */ + registerMoneyParser(parser: MoneyParser): BatchSettlementEvmScheme { + this.moneyParsers.push(parser); + return this; + } + + /** + * Resolves a human-readable price (e.g. `"$0.01"`) into an onchain token amount. + * + * @param price - A price string, number, or explicit {@link AssetAmount}. + * @param network - CAIP-2 network identifier for looking up the default asset. + * @returns Token amount with asset address and metadata. + */ + 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 = this.parseMoneyToDecimal(price); + + for (const parser of this.moneyParsers) { + const result = await parser(amount, network); + if (result !== null) { + return result; + } + } + + return this.defaultMoneyConversion(amount, network); + } + + /** + * Injects batched-specific fields into the payment requirements returned to + * the client (receiverAuthorizer, withdrawDelay, EIP-712 domain info). + * + * @param paymentRequirements - Base payment requirements from the middleware. + * @param supportedKind - Matched scheme/network kind (extra may contain overrides). + * @param supportedKind.x402Version - Protocol version from the matched kind. + * @param supportedKind.scheme - Scheme name from the matched kind. + * @param supportedKind.network - Network identifier from the matched kind. + * @param supportedKind.extra - Optional extra fields on the matched kind. + * @param _extensionKeys - Extension keys (unused). + * @returns Enhanced payment requirements with batched fields in `extra`. + */ + async enhancePaymentRequirements( + paymentRequirements: PaymentRequirements, + supportedKind: { + x402Version: number; + scheme: string; + network: Network; + extra?: Record; + }, + _extensionKeys: string[], + ): Promise { + void _extensionKeys; + + const assetInfo = getDefaultAsset(paymentRequirements.network as Network); + + const receiverAuthorizer = + this.receiverAuthorizerSigner?.address ?? + (typeof supportedKind.extra?.receiverAuthorizer === "string" + ? supportedKind.extra.receiverAuthorizer + : undefined); + + if ( + !receiverAuthorizer || + getAddress(receiverAuthorizer) === "0x0000000000000000000000000000000000000000" + ) { + throw new Error("Payment requirements must include a non-zero extra.receiverAuthorizer"); + } + + return { + ...paymentRequirements, + extra: { + ...paymentRequirements.extra, + receiverAuthorizer: getAddress(receiverAuthorizer), + withdrawDelay: this.withdrawDelay, + name: assetInfo.name, + version: assetInfo.version, + assetTransferMethod: + paymentRequirements.extra?.assetTransferMethod ?? assetInfo.assetTransferMethod, + }, + }; + } + + /** + * Returns the underlying channel storage instance. + * + * @returns The configured {@link ChannelStorage} backend. + */ + getStorage(): ChannelStorage { + return this.storage; + } + + /** + * Returns the server's receiver address. + * + * @returns Receiver wallet address for the payment channel. + */ + getReceiverAddress(): `0x${string}` { + return this.receiverAddress; + } + + /** + * Returns the configured withdraw delay (seconds). + * + * @returns Withdraw delay in seconds before uncooperative withdrawal is allowed. + */ + getWithdrawDelay(): number { + return this.withdrawDelay; + } + + /** + * Returns how long mirrored onchain channel state is trusted for local voucher verification. + * + * @returns Freshness window in milliseconds. + */ + getOnchainStateTtlMs(): number { + return this.onchainStateTtlMs; + } + + /** + * Returns the receiver-authorizer signer, if configured. + * + * @returns Receiver-authorizer signer, or `undefined` when not set. + */ + getReceiverAuthorizerSigner(): AuthorizerSigner | undefined { + return this.receiverAuthorizerSigner; + } + + /** + * Creates a {@link BatchSettlementChannelManager} pre-configured with this scheme's + * receiver, default token for the given network, and the provided facilitator. + * + * @param facilitator - Facilitator client for submitting onchain claims/settlements. + * @param network - CAIP-2 network identifier (e.g. `"eip155:84532"`). + * @returns A ready-to-use channel manager. + */ + createChannelManager( + facilitator: FacilitatorClient, + network: Network, + ): BatchSettlementChannelManager { + const token = getDefaultAsset(network).address as `0x${string}`; + return new BatchSettlementChannelManager({ + scheme: this, + facilitator, + receiver: this.receiverAddress, + token, + network, + }); + } + + /** + * Parses a human-readable money string (e.g. `"$1.50"`) into a decimal number. + * + * @param money - Money string (may include `$`) or numeric amount. + * @returns Parsed finite number. + */ + private parseMoneyToDecimal(money: string | number): number { + if (typeof money === "number") { + return money; + } + + const cleanMoney = money.replace(/^\$/, "").trim(); + const amount = parseFloat(cleanMoney); + + if (isNaN(amount)) { + throw new Error(`Invalid money format: ${money}`); + } + + return amount; + } + + /** + * Converts a decimal dollar amount to the network's default token amount. + * + * @param amount - Decimal amount in display units. + * @param network - Target chain/network for default asset resolution. + * @returns {@link AssetAmount} with integer token amount, contract address, and metadata. + */ + private defaultMoneyConversion(amount: number, network: Network): AssetAmount { + const assetInfo = getDefaultAsset(network); + const tokenAmount = convertToTokenAmount(numberToDecimalString(amount), assetInfo.decimals); + + return { + amount: tokenAmount, + asset: assetInfo.address, + extra: { + name: assetInfo.name, + version: assetInfo.version, + }, + }; + } +} + +/** + * Derives a reasonable onchain state freshness window from the channel withdraw delay. + * + * @param withdrawDelaySeconds - Onchain withdraw delay for the channel, in seconds. + * @returns TTL in milliseconds, clamped between 30 seconds and 5 minutes. + */ +function defaultOnchainStateTtlMs(withdrawDelaySeconds: number): number { + const withdrawDelayMs = Math.max(0, withdrawDelaySeconds) * 1000; + return Math.min(5 * 60 * 1000, Math.max(30 * 1000, Math.floor(withdrawDelayMs / 3))); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/settle.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/settle.ts new file mode 100644 index 0000000000..902bf1fdd8 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/settle.ts @@ -0,0 +1,421 @@ +import type { SettleResponse } from "@x402/core/types"; +import type { SettleContext, SettleFailureContext, SettleResultContext } from "@x402/core/server"; +import { signClaimBatch, signRefund } from "../authorizerSigner"; +import { + isBatchSettlementDepositPayload, + isBatchSettlementRefundPayload, + isBatchSettlementVoucherPayload, +} from "../types"; +import type { BatchSettlementPaymentResponseExtra, BatchSettlementVoucherClaim } from "../types"; +import { computeChannelId } from "../utils"; +import * as Errors from "../errors"; +import type { BatchSettlementEvmScheme } from "./scheme"; +import type { Channel } from "./storage"; +import { + parseRefundSettlementSnapshot, + readChannelStateExtra, + readExtraNumber, + readExtraString, +} from "./utils"; + +/** + * Converts stored channel state into the public response snapshot shape. + * + * @param channel - Stored channel state. + * @param chargedCumulativeAmount - Optional current charged cumulative amount. + * @returns Response-ready channel snapshot. + */ +function channelStateExtra( + channel: Pick< + Channel, + "channelId" | "balance" | "totalClaimed" | "withdrawRequestedAt" | "refundNonce" + >, + chargedCumulativeAmount?: string, +): NonNullable { + return { + channelId: channel.channelId as `0x${string}`, + balance: channel.balance, + totalClaimed: channel.totalClaimed, + withdrawRequestedAt: channel.withdrawRequestedAt, + refundNonce: String(channel.refundNonce), + ...(chargedCumulativeAmount !== undefined ? { chargedCumulativeAmount } : {}), + }; +} + +/** + * Lifecycle hook: runs before the facilitator settles a payment. + * + * For voucher payloads the server does NOT trigger an onchain settle. Instead, it + * increments the local `chargedCumulativeAmount` and returns a `skip` result so the + * middleware responds immediately. Cooperative refund payloads proceed to settlement + * enrichment before facilitator settlement. + * + * @param scheme - Owning `BatchSettlementEvmScheme` instance for storage access. + * @param ctx - Settle lifecycle context (payload and requirements). + * @returns Nothing to proceed; `abort` to fail; `skip` with a result to short-circuit settlement. + */ +export async function handleBeforeSettle( + scheme: BatchSettlementEvmScheme, + ctx: SettleContext, +): Promise< + void | { abort: true; reason: string; message?: string } | { skip: true; result: SettleResponse } +> { + const { paymentPayload, requirements } = ctx; + + const raw = paymentPayload.payload; + const storage = scheme.getStorage(); + + if (!isBatchSettlementVoucherPayload(raw)) { + return; + } + + const { voucher } = raw; + const channelId = voucher.channelId; + const pendingId = scheme.readRequestContext(paymentPayload)?.pendingId; + + const increment = BigInt(requirements.amount); + const signedCap = BigInt(voucher.maxClaimableAmount); + let outcome: + | { status: "missing" } + | { status: "pending_mismatch" } + | { status: "cap_exceeded"; charged: string } + | { status: "committed"; previous: Channel; current: Channel } + | undefined; + + const updateResult = await storage.updateChannel(channelId, current => { + if (!current) { + outcome = { status: "missing" }; + return current; + } + + if (!pendingId || current.pendingRequest?.pendingId !== pendingId) { + outcome = { status: "pending_mismatch" }; + return current; + } + + const newCharged = BigInt(current.chargedCumulativeAmount) + increment; + if (newCharged > signedCap) { + outcome = { status: "cap_exceeded", charged: newCharged.toString() }; + return { + ...current, + pendingRequest: undefined, + }; + } + + const updatedChannel: Channel = { + ...current, + chargedCumulativeAmount: newCharged.toString(), + signedMaxClaimable: voucher.maxClaimableAmount, + signature: voucher.signature, + lastRequestTimestamp: Date.now(), + pendingRequest: undefined, + }; + outcome = { status: "committed", previous: current, current: updatedChannel }; + return updatedChannel; + }); + + if (outcome?.status === "missing") { + scheme.takeRequestContext(paymentPayload); + return { + abort: true, + reason: Errors.ErrMissingChannel, + message: "No channel record", + }; + } + + if (outcome?.status === "cap_exceeded") { + scheme.takeRequestContext(paymentPayload); + return { + abort: true, + reason: Errors.ErrChargeExceedsSignedCumulative, + message: `Charged ${outcome.charged} exceeds signed max ${signedCap.toString()}`, + }; + } + + if (updateResult.status !== "updated" || outcome?.status !== "committed") { + scheme.takeRequestContext(paymentPayload); + return { + abort: true, + reason: Errors.ErrChannelBusy, + message: "Concurrent request modified channel state", + }; + } + scheme.takeRequestContext(paymentPayload); + + const skipExtra: BatchSettlementPaymentResponseExtra = { + channelState: channelStateExtra(outcome.previous, outcome.current.chargedCumulativeAmount), + chargedAmount: requirements.amount, + }; + + return { + skip: true, + result: { + success: true, + payer: outcome.previous.channelConfig.payer.toLowerCase() as `0x${string}`, + transaction: "", + network: requirements.network, + amount: "", + extra: skipExtra, + }, + }; +} + +/** + * Enriches cooperative refund vouchers with facilitator settlement fields. + * + * @param scheme - Owning `BatchSettlementEvmScheme` instance for storage and signer access. + * @param ctx - Settlement context for the current payment. + * @returns Additive refund settlement fields, or nothing for non-refund payloads. + */ +export async function handleEnrichSettlementPayload( + scheme: BatchSettlementEvmScheme, + ctx: SettleContext, +): Promise | void> { + const { paymentPayload, requirements } = ctx; + const raw = paymentPayload.payload; + if (!isBatchSettlementRefundPayload(raw)) { + return; + } + + const channelId = computeChannelId(raw.channelConfig, requirements.network); + if (raw.voucher.channelId !== channelId) { + throw new Error("refund channelId does not match channelConfig"); + } + + const channel = await scheme.getStorage().get(channelId); + if (!channel) { + throw new Error(Errors.ErrMissingChannel); + } + const pendingId = scheme.readRequestContext(paymentPayload)?.pendingId; + if (!pendingId || channel.pendingRequest?.pendingId !== pendingId) { + throw new Error(Errors.ErrChannelBusy); + } + if (BigInt(raw.voucher.maxClaimableAmount) !== BigInt(channel.chargedCumulativeAmount)) { + throw new Error(Errors.ErrCumulativeAmountMismatch); + } + if (raw.voucher.signature !== channel.signature) { + throw new Error(Errors.ErrInvalidVoucherSignature); + } + + const config = raw.channelConfig; + + const claimEntry: BatchSettlementVoucherClaim = { + voucher: { + channel: config, + maxClaimableAmount: raw.voucher.maxClaimableAmount, + }, + signature: raw.voucher.signature, + totalClaimed: channel.chargedCumulativeAmount, + }; + + const remainder = BigInt(channel.balance) - BigInt(channel.chargedCumulativeAmount); + if (remainder <= 0n) { + throw new Error(Errors.ErrRefundNoBalance); + } + + let refundAmountBig = remainder; + if (raw.amount !== undefined) { + if (!/^\d+$/.test(raw.amount)) { + throw new Error(Errors.ErrRefundAmountInvalid); + } + const requested = BigInt(raw.amount); + if (requested <= 0n) { + throw new Error(Errors.ErrRefundAmountInvalid); + } + refundAmountBig = requested; + } + + const refundAmount = refundAmountBig.toString(); + const nonce = String(channel.refundNonce ?? 0); + + const receiverAuthorizerSigner = scheme.getReceiverAuthorizerSigner(); + + const refundAuthorizerSignature = receiverAuthorizerSigner + ? await signRefund( + receiverAuthorizerSigner, + channelId as `0x${string}`, + refundAmount, + nonce, + requirements.network, + ) + : undefined; + + const claimAuthorizerSignature = receiverAuthorizerSigner + ? await signClaimBatch(receiverAuthorizerSigner, [claimEntry], requirements.network) + : undefined; + + scheme.rememberChannelSnapshot(paymentPayload, channel); + + return { + ...(raw.amount === undefined ? { amount: refundAmount } : {}), + refundNonce: nonce, + claims: [claimEntry], + refundAuthorizerSignature, + claimAuthorizerSignature, + }; +} + +/** + * Lifecycle hook: runs after the facilitator settles a payment. + * + * Updates channel state to reflect the settlement outcome — adjusting charged amounts, + * balances, and handling cooperative-refund cleanup (channel record deletion). + * + * @param scheme - Owning `BatchSettlementEvmScheme` instance for storage access. + * @param ctx - Post-settle lifecycle context. + * @param ctx.paymentPayload - Payment payload that was settled (possibly rewritten). + * @param ctx.requirements - Requirements used for settlement. + * @param ctx.result - Facilitator settle response. + * @returns Resolves when session updates are complete (no return value). + */ +export async function handleAfterSettle( + scheme: BatchSettlementEvmScheme, + ctx: SettleResultContext, +): Promise { + const { paymentPayload, requirements, result } = ctx; + if (!result.success) { + return; + } + + const raw = paymentPayload.payload; + const storage = scheme.getStorage(); + + if (isBatchSettlementRefundPayload(raw)) { + const channelId = computeChannelId(raw.channelConfig, requirements.network); + const pendingId = scheme.readRequestContext(paymentPayload)?.pendingId; + const now = Date.now(); + + const snapshot = parseRefundSettlementSnapshot(result.extra); + const updateResult = await storage.updateChannel(channelId, current => { + if (!current) { + return current; + } + if (!pendingId || current.pendingRequest?.pendingId !== pendingId) { + return current; + } + if (BigInt(snapshot.balance) <= BigInt(current.chargedCumulativeAmount)) { + return undefined; + } + return { + ...current, + ...snapshot, + onchainSyncedAt: now, + lastRequestTimestamp: now, + pendingRequest: undefined, + }; + }); + if (updateResult.status === "unchanged") { + throw new Error(Errors.ErrChannelBusy); + } + if (!updateResult.channel) { + return; + } + return; + } + + if (isBatchSettlementVoucherPayload(raw)) { + return; + } + + if (isBatchSettlementDepositPayload(raw)) { + const channelId = raw.voucher.channelId; + const pendingId = scheme.readRequestContext(paymentPayload)?.pendingId; + const ex = result.extra ?? {}; + const channelState = readChannelStateExtra(ex); + const config = raw.channelConfig; + const signedMaxClaimable = raw.voucher.maxClaimableAmount; + const now = Date.now(); + + const updateResult = await storage.updateChannel(channelId, current => { + if (!current) { + return current; + } + if (!pendingId || current.pendingRequest?.pendingId !== pendingId) { + return current; + } + const chargedActual = ( + BigInt(current.chargedCumulativeAmount) + BigInt(requirements.amount) + ).toString(); + return { + channelId, + channelConfig: config, + chargedCumulativeAmount: chargedActual, + signedMaxClaimable, + signature: raw.voucher.signature, + balance: readExtraString(channelState, "balance", current.balance), + totalClaimed: readExtraString(channelState, "totalClaimed", current.totalClaimed), + withdrawRequestedAt: readExtraNumber( + channelState, + "withdrawRequestedAt", + current.withdrawRequestedAt, + ), + refundNonce: readExtraNumber(channelState, "refundNonce", current.refundNonce), + onchainSyncedAt: now, + lastRequestTimestamp: now, + }; + }); + if (updateResult.status === "updated" && updateResult.channel) { + scheme.rememberChannelSnapshot(paymentPayload, updateResult.channel); + return; + } + scheme.takeRequestContext(paymentPayload); + throw new Error(Errors.ErrChannelBusy); + } +} + +/** + * Cleanup hook: clears this request's reservation after settlement throws. + * + * @param scheme - Owning `BatchSettlementEvmScheme` instance. + * @param ctx - Settle failure context for the current payment. + */ +export async function handleSettleFailure( + scheme: BatchSettlementEvmScheme, + ctx: SettleFailureContext, +): Promise { + await scheme.clearPendingRequest(ctx.paymentPayload); +} + +/** + * Supplies server-owned settlement response fields from the channel snapshot. + * + * @param scheme - Owning `BatchSettlementEvmScheme` instance for snapshot access. + * @param ctx - Settlement result context for the current payment. + * @returns Additive response extra fields, or nothing when no snapshot exists. + */ +export async function handleEnrichSettlementResponse( + scheme: BatchSettlementEvmScheme, + ctx: SettleResultContext, +): Promise | void> { + const raw = ctx.paymentPayload.payload; + if (isBatchSettlementVoucherPayload(raw)) { + return; + } + + const channel = scheme.takeChannelSnapshot(ctx.paymentPayload); + if (!channel) { + return; + } + + if (isBatchSettlementRefundPayload(raw)) { + return { + channelState: { + chargedCumulativeAmount: channel.chargedCumulativeAmount, + }, + }; + } + + if (isBatchSettlementDepositPayload(raw)) { + return { + channelState: { + chargedCumulativeAmount: channel.chargedCumulativeAmount, + }, + chargedAmount: ctx.requirements.amount, + }; + } + return { + channelState: { + chargedCumulativeAmount: channel.chargedCumulativeAmount, + }, + }; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/storage.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/storage.ts new file mode 100644 index 0000000000..f2e843cf9d --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/storage.ts @@ -0,0 +1,133 @@ +import type { ChannelConfig } from "../types"; + +export interface Channel { + channelId: string; + channelConfig: ChannelConfig; + chargedCumulativeAmount: string; + signedMaxClaimable: string; + signature: string; + balance: string; + totalClaimed: string; + withdrawRequestedAt: number; + refundNonce: number; + onchainSyncedAt?: number; + lastRequestTimestamp: number; + pendingRequest?: PendingRequest; +} + +export interface PendingRequest { + pendingId: string; + signedMaxClaimable: string; + expiresAt: number; +} + +export interface ChannelUpdateResult { + channel: Channel | undefined; + status: "updated" | "unchanged" | "deleted"; +} + +export interface ChannelStorage { + get(channelId: string): Promise; + list(): Promise; + /** + * Atomically inspects and mutates a channel 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, such as Redis/Valkey Lua scripts, SQL transactions, or Durable Objects. + * + * @param channelId - The channel identifier. + * @param update - Mutation callback. Return `undefined` to delete, or `current` to leave unchanged. + * @returns The final stored channel and whether storage updated, stayed unchanged, or deleted. + */ + updateChannel( + channelId: string, + update: (current: Channel | undefined) => Channel | undefined, + ): Promise; +} + +/** + * In-memory {@link ChannelStorage} backed by a Map keyed by `channelId`. + */ +export class InMemoryChannelStorage implements ChannelStorage { + private readonly channels = new Map(); + private readonly channelLocks = new Map>(); + + /** + * Returns the channel record for a channel, if present. + * + * @param channelId - The channel identifier. + * @returns The channel record or undefined when not found. + */ + async get(channelId: string): Promise { + return this.channels.get(channelId.toLowerCase()); + } + + /** + * Lists all stored channel records. + * + * @returns All channel records in storage. + */ + async list(): Promise { + return [...this.channels.values()]; + } + + /** + * Atomically inspects and mutates a channel record while holding a per-channel lock. + * + * @param channelId - The channel identifier. + * @param update - Mutation callback. Return `undefined` to delete, or `current` to leave unchanged. + * @returns The final stored channel and whether storage updated, stayed unchanged, or deleted. + */ + async updateChannel( + channelId: string, + update: (current: Channel | undefined) => Channel | undefined, + ): Promise { + const key = channelId.toLowerCase(); + return this.withChannelLock(key, async () => { + const current = this.channels.get(key); + const next = update(current); + + if (next === current) { + return { channel: current, status: "unchanged" }; + } + + if (!next) { + this.channels.delete(key); + return { channel: undefined, status: current ? "deleted" : "unchanged" }; + } + + this.channels.set(key, next); + return { channel: next, status: "updated" }; + }); + } + + /** + * Runs `fn` after any prior locked work for the same channel key has finished. + * + * @param key - Lowercased channel id used as the lock key. + * @param fn - Async work to run while holding the logical per-channel lock. + * @returns The resolved result of `fn`. + */ + private async withChannelLock(key: string, fn: () => Promise): Promise { + const previous = this.channelLocks.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise(resolve => { + release = resolve; + }); + const next = previous.catch(() => {}).then(() => current); + this.channelLocks.set(key, next); + + await previous.catch(() => {}); + try { + return await fn(); + } finally { + release(); + if (this.channelLocks.get(key) === next) { + this.channelLocks.delete(key); + } + } + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/utils.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/utils.ts new file mode 100644 index 0000000000..1c91b80bf5 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/utils.ts @@ -0,0 +1,118 @@ +import type { BatchSettlementChannelStateExtra } from "../types"; +import { ErrRefundPayload } from "../errors"; + +/** + * Reads the nested channel snapshot from payment response extra fields. + * + * @param extra - Payment response extra fields. + * @returns Channel state object, or undefined when absent. + */ +export function readChannelStateExtra( + extra: Record | undefined, +): Partial | undefined { + const value = extra?.channelState; + if (typeof value !== "object" || value === null) { + return undefined; + } + return value as Partial; +} + +/** + * Reads a string value from optional payment `extra`, with a fallback when missing or invalid. + * + * @param extra - Optional payment extra record. + * @param key - Key on `BatchSettlementPaymentResponseExtra` to read. + * @param fallback - Value returned when the entry is absent or not coercible to string. + * @returns String representation of the value, or `fallback`. + */ +export function readExtraString( + extra: Partial> | undefined, + key: keyof BatchSettlementChannelStateExtra, + fallback: string, +): string { + const value = extra?.[key]; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + return fallback; +} + +/** + * Reads a numeric value from optional payment `extra`, with a fallback when missing or invalid. + * + * @param extra - Optional payment extra record. + * @param key - Key on `BatchSettlementPaymentResponseExtra` to read. + * @param fallback - Value returned when the entry is absent or not parseable as a number. + * @returns Parsed number, or `fallback`. + */ +export function readExtraNumber( + extra: Partial> | undefined, + key: keyof BatchSettlementChannelStateExtra, + fallback: number, +): number { + const value = extra?.[key]; + if (typeof value === "number") return value; + if (typeof value === "string") return parseInt(value, 10) || fallback; + return fallback; +} + +export type RefundSettlementSnapshot = { + balance: string; + totalClaimed: string; + withdrawRequestedAt: number; + refundNonce: number; +}; + +/** + * Parses the facilitator's post-refund channel snapshot. + * + * @param extra - Settlement response extra fields. + * @returns Validated refund settlement snapshot. + */ +export function parseRefundSettlementSnapshot( + extra: Record | undefined, +): RefundSettlementSnapshot { + const channelState = readChannelStateExtra(extra); + return { + balance: parseUintStringExtra(channelState, "balance"), + totalClaimed: parseUintStringExtra(channelState, "totalClaimed"), + withdrawRequestedAt: parseUintNumberExtra(channelState, "withdrawRequestedAt"), + refundNonce: parseUintNumberExtra(channelState, "refundNonce"), + }; +} + +/** + * Parses a non-negative integer as a decimal string from refund snapshot `extra`. + * + * @param extra - Settlement response extra fields from the facilitator. + * @param key - Field name: `balance` or `totalClaimed`. + * @returns Decimal string representation of the uint (digits only). + */ +function parseUintStringExtra( + extra: Partial> | undefined, + key: "balance" | "totalClaimed", +): string { + const value = extra?.[key]; + if (typeof value === "string" && /^\d+$/.test(value)) return value; + if (typeof value === "number" && Number.isInteger(value) && value >= 0) return String(value); + throw new Error(ErrRefundPayload); +} + +/** + * Parses a non-negative integer from refund snapshot `extra`. + * + * @param extra - Settlement response extra fields from the facilitator. + * @param key - Field name: `withdrawRequestedAt` or `refundNonce`. + * @returns Parsed non-negative integer. + */ +function parseUintNumberExtra( + extra: Partial> | undefined, + key: "withdrawRequestedAt" | "refundNonce", +): number { + const value = extra?.[key]; + if (typeof value === "number" && Number.isInteger(value) && value >= 0) return value; + if (typeof value === "string" && /^\d+$/.test(value)) { + const parsed = parseInt(value, 10); + if (!Number.isNaN(parsed)) return parsed; + } + throw new Error(ErrRefundPayload); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/verify.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/verify.ts new file mode 100644 index 0000000000..adb4f17a50 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/verify.ts @@ -0,0 +1,543 @@ +import type { + VerifiedPaymentCanceledContext, + VerifyContext, + VerifyFailureContext, + VerifyResultContext, +} from "@x402/core/server"; +import type { VerifyResponse } from "@x402/core/types"; +import type { SchemePaymentRequiredContext } from "@x402/core/types"; +import { getAddress, verifyTypedData } from "viem"; +import { + type BatchSettlementDepositPayload, + type BatchSettlementRefundPayload, + type BatchSettlementVoucherPayload, + isBatchSettlementDepositPayload, + isBatchSettlementRefundPayload, + isBatchSettlementVoucherPayload, +} from "../types"; +import { BATCH_SETTLEMENT_SCHEME, voucherTypes } from "../constants"; +import type { ChannelConfig } from "../types"; +import { createNonce, getEvmChainId } from "../../utils"; +import { computeChannelId, getBatchSettlementEip712Domain } from "../utils"; +import { validateChannelConfig } from "../facilitator/utils"; +import * as Errors from "../errors"; +import type { BatchSettlementEvmScheme } from "./scheme"; +import type { Channel, PendingRequest } from "./storage"; +import { readExtraNumber, readExtraString } from "./utils"; + +// Framework cleanup hooks clear pending reservations for normal failures +// This bounded TTL releases channels when cleanup cannot run or complete +const MIN_PENDING_TTL_MS = 5_000; // 5 seconds +const MAX_PENDING_TTL_MS = 10 * 60 * 1000; // 600 seconds + +/** + * Computes the bounded pending reservation expiry time. + * + * @param maxTimeoutSeconds - Resource timeout from payment requirements. + * @param now - Current wall-clock time in milliseconds. + * @returns Expiry timestamp in milliseconds. + */ +function pendingExpiresAt(maxTimeoutSeconds: number | undefined, now: number): number { + const requestedMs = Math.max(0, maxTimeoutSeconds ?? 0) * 1000; + const ttlMs = Math.min(MAX_PENDING_TTL_MS, Math.max(MIN_PENDING_TTL_MS, requestedMs)); + return now + ttlMs; +} + +/** + * Checks whether a pending reservation still blocks same-channel work. + * + * @param pending - Pending reservation to inspect. + * @param now - Current wall-clock time in milliseconds. + * @returns Whether the reservation exists and has not expired. + */ +function isPendingLive(pending: PendingRequest | undefined, now: number): boolean { + return pending !== undefined && pending.expiresAt > now; +} + +/** + * Lifecycle hook: runs before the facilitator verifies a payment. + * + * For paid payloads, checks whether the client's cumulative amount matches server + * state. If mismatched, aborts with `invalid_batch_settlement_evm_cumulative_amount_mismatch`. + * + * Refund vouchers are zero-charge: the expected `maxClaimableAmount` equals + * the existing `chargedCumulativeAmount`. + * + * When no local channel record exists, verification is delegated to the facilitator (which checks onchain state); + * `handleAfterVerify` then rebuilds the channel record from the verify response. + * + * @param scheme - Owning `BatchSettlementEvmScheme` instance for storage access. + * @param ctx - Verify lifecycle context (payload, requirements, and related state). + * @returns Nothing to continue verification; or an object with `abort` to fail with a reason. + */ +export async function handleBeforeVerify( + scheme: BatchSettlementEvmScheme, + ctx: VerifyContext, +): Promise< + void | { abort: true; reason: string; message?: string } | { skip: true; result: VerifyResponse } +> { + const { paymentPayload, requirements } = ctx; + + const raw = paymentPayload.payload; + const isPaidPayload = + isBatchSettlementVoucherPayload(raw) || isBatchSettlementDepositPayload(raw); + const isZeroChargePayload = isBatchSettlementRefundPayload(raw); + if (!isPaidPayload && !isZeroChargePayload) { + return; + } + + const channelId = raw.voucher.channelId; + const now = Date.now(); + const pendingId = createNonce(); + let outcome: + | { status: "reserved"; channelSnapshot?: Channel } + | { status: "busy" } + | { status: "mismatch"; channel: Channel } + | undefined; + + await scheme.getStorage().updateChannel(channelId, current => { + if (isPendingLive(current?.pendingRequest, now)) { + outcome = { status: "busy" }; + return current; + } + + const chargedCumulativeAmount = + current?.chargedCumulativeAmount ?? + inferMissingLocalChargedAmount( + raw.voucher.maxClaimableAmount, + requirements.amount, + isPaidPayload, + ); + const expectedMaxClaimable = isZeroChargePayload + ? BigInt(chargedCumulativeAmount) + : BigInt(chargedCumulativeAmount) + BigInt(requirements.amount); + + if (BigInt(raw.voucher.maxClaimableAmount) !== expectedMaxClaimable) { + if (current) { + outcome = { status: "mismatch", channel: current }; + } else { + outcome = { + status: "mismatch", + channel: buildProvisionalChannel(raw, chargedCumulativeAmount), + }; + } + return current; + } + + const pendingRequest: PendingRequest = { + pendingId, + signedMaxClaimable: raw.voucher.maxClaimableAmount, + expiresAt: pendingExpiresAt(requirements.maxTimeoutSeconds, now), + }; + + outcome = { status: "reserved", channelSnapshot: current }; + return { + ...(current ?? buildProvisionalChannel(raw, chargedCumulativeAmount)), + pendingRequest, + lastRequestTimestamp: now, + }; + }); + + if (outcome?.status === "busy") { + return { + abort: true, + reason: Errors.ErrChannelBusy, + message: "Channel is already processing a request", + }; + } + + if (outcome?.status === "mismatch") { + scheme.rememberChannelSnapshot(paymentPayload, outcome.channel); + return { + abort: true, + reason: Errors.ErrCumulativeAmountMismatch, + message: "Client voucher base does not match server state", + }; + } + + if (outcome?.status === "reserved") { + scheme.mergeRequestContext(paymentPayload, { + channelId, + pendingId, + channelSnapshot: outcome.channelSnapshot, + }); + + if (isBatchSettlementVoucherPayload(raw)) { + const localResult = await verifyVoucherLocally( + scheme, + raw, + requirements, + outcome.channelSnapshot, + now, + ); + if (localResult) { + scheme.mergeRequestContext(paymentPayload, { localVerify: true }); + return { skip: true, result: localResult }; + } + } + } +} + +/** + * Adds server channel state to corrective 402 responses for cumulative mismatches. + * + * @param scheme - Owning `BatchSettlementEvmScheme` instance for storage access. + * @param ctx - Payment-required response context. + */ +export async function handleEnrichPaymentRequiredResponse( + scheme: BatchSettlementEvmScheme, + ctx: SchemePaymentRequiredContext, +): Promise { + if (ctx.error !== Errors.ErrCumulativeAmountMismatch) { + return; + } + + const { paymentPayload } = ctx; + if (!paymentPayload) { + return; + } + + const raw = paymentPayload.payload; + if ( + !isBatchSettlementVoucherPayload(raw) && + !isBatchSettlementDepositPayload(raw) && + !isBatchSettlementRefundPayload(raw) + ) { + return; + } + + const channel = + scheme.takeChannelSnapshot(paymentPayload) ?? + (await scheme.getStorage().get(raw.voucher.channelId)); + if (!channel) { + return; + } + + const accept = ctx.requirements.find( + req => + req.scheme === BATCH_SETTLEMENT_SCHEME && req.network === paymentPayload.accepted.network, + ); + if (!accept) { + return; + } + + accept.extra = { + ...accept.extra, + channelState: { + channelId: channel.channelId, + balance: channel.balance, + totalClaimed: channel.totalClaimed, + withdrawRequestedAt: channel.withdrawRequestedAt, + refundNonce: String(channel.refundNonce), + chargedCumulativeAmount: channel.chargedCumulativeAmount, + }, + voucherState: { + signedMaxClaimable: channel.signedMaxClaimable, + signature: channel.signature as `0x${string}`, + }, + }; +} + +/** + * Lifecycle hook: runs after the facilitator verifies a payment. + * + * Persists channel state (balance, totalClaimed, voucher info) so that + * subsequent requests can correctly calculate cumulative amounts and detect stale state. + * + * For refund payloads, additionally returns a `skipHandler` directive so that + * the resource server bypasses the application handler and settles inline. + * + * @param scheme - Owning `BatchSettlementEvmScheme` instance for storage access. + * @param ctx - Post-verify lifecycle context. + * @param ctx.paymentPayload - Incoming payment payload that was verified. + * @param ctx.requirements - Requirements used for verification. + * @param ctx.result - Facilitator verify response. + * @returns Optional `skipHandler` directive when this is a refund voucher; otherwise void. + */ +export async function handleAfterVerify( + scheme: BatchSettlementEvmScheme, + ctx: VerifyResultContext, +): Promise { + const { paymentPayload, result } = ctx; + if (!result.isValid || !result.payer) { + await scheme.clearPendingRequest(paymentPayload); + return; + } + + const raw = paymentPayload.payload; + let channelId: string; + let signedMaxClaimable: string; + let signature: `0x${string}`; + let channelConfig: ChannelConfig; + let isRefundVoucher = false; + + if (isBatchSettlementDepositPayload(raw)) { + channelId = raw.voucher.channelId; + signedMaxClaimable = raw.voucher.maxClaimableAmount; + signature = raw.voucher.signature; + channelConfig = raw.channelConfig; + } else if (isBatchSettlementVoucherPayload(raw)) { + channelId = raw.voucher.channelId; + signedMaxClaimable = raw.voucher.maxClaimableAmount; + signature = raw.voucher.signature; + channelConfig = raw.channelConfig; + } else if (isBatchSettlementRefundPayload(raw)) { + channelId = raw.voucher.channelId; + signedMaxClaimable = raw.voucher.maxClaimableAmount; + signature = raw.voucher.signature; + channelConfig = raw.channelConfig; + isRefundVoucher = true; + } else { + return; + } + + const ex = result.extra ?? {}; + const balance = readExtraString(ex, "balance", "0"); + const totalClaimed = readExtraString(ex, "totalClaimed", "0"); + const withdrawRequestedAt = readExtraNumber(ex, "withdrawRequestedAt", 0); + const refundNonce = readExtraNumber(ex, "refundNonce", 0); + const now = Date.now(); + + const storage = scheme.getStorage(); + const requestContext = scheme.readRequestContext(paymentPayload); + if (!requestContext?.pendingId) { + return; + } + if (requestContext.localVerify && isBatchSettlementVoucherPayload(raw)) { + return; + } + + const updateResult = await storage.updateChannel(channelId, current => { + if (!current || current.pendingRequest?.pendingId !== requestContext.pendingId) { + return current; + } + + const channel: Channel = { + channelId, + channelConfig, + chargedCumulativeAmount: current.chargedCumulativeAmount, + signedMaxClaimable, + signature, + balance, + totalClaimed, + withdrawRequestedAt, + refundNonce, + onchainSyncedAt: requestContext.localVerify ? current.onchainSyncedAt : now, + lastRequestTimestamp: now, + pendingRequest: current.pendingRequest, + }; + return channel; + }); + if (updateResult.status === "updated" && updateResult.channel) { + scheme.rememberChannelSnapshot(paymentPayload, updateResult.channel); + } + + if (isRefundVoucher && updateResult.status === "updated") { + return { + skipHandler: true, + response: { + contentType: "application/json", + body: { message: "Refund acknowledged", channelId }, + }, + }; + } +} + +/** + * Cleanup hook: clears this request's reservation after verify throws. + * + * @param scheme - Owning `BatchSettlementEvmScheme` instance. + * @param ctx - Verify failure context for the current payment. + */ +export async function handleVerifyFailure( + scheme: BatchSettlementEvmScheme, + ctx: VerifyFailureContext, +): Promise { + await scheme.clearPendingRequest(ctx.paymentPayload); +} + +/** + * Cleanup hook: clears this request's reservation when handler work is canceled. + * + * @param scheme - Owning `BatchSettlementEvmScheme` instance. + * @param ctx - Verified-payment cancellation context. + */ +export async function handleVerifiedPaymentCanceled( + scheme: BatchSettlementEvmScheme, + ctx: VerifiedPaymentCanceledContext, +): Promise { + if (ctx.reason !== "handler_threw" && ctx.reason !== "handler_failed") { + return; + } + await scheme.clearPendingRequest(ctx.paymentPayload); +} + +/** + * Verifies a voucher against locally cached channel state when that state is fresh. + * + * @param scheme - Batch settlement scheme (TTL for onchain sync freshness). + * @param raw - Decoded batch-settlement voucher payload. + * @param requirements - Payment requirements (network, etc.). + * @param channel - Cached channel row, if any. + * @param now - Current wall-clock time in milliseconds. + * @returns A {@link VerifyResponse}, or `undefined` to fall back to facilitator verification. + */ +async function verifyVoucherLocally( + scheme: BatchSettlementEvmScheme, + raw: BatchSettlementVoucherPayload, + requirements: VerifyContext["requirements"], + channel: Channel | undefined, + now: number, +): Promise { + if (!channel || !isOnchainStateFresh(channel, scheme.getOnchainStateTtlMs(), now)) { + return; + } + + if (raw.channelConfig.payerAuthorizer === "0x0000000000000000000000000000000000000000") { + return; + } + + const payer = raw.channelConfig.payer; + const configErr = validateChannelConfig( + raw.channelConfig, + raw.voucher.channelId, + requirements as Parameters[2], + ); + if (configErr) { + return invalidVerifyResponse(payer, configErr); + } + + if ( + computeChannelId(raw.channelConfig, requirements.network).toLowerCase() !== + channel.channelId.toLowerCase() + ) { + return invalidVerifyResponse(payer, Errors.ErrChannelIdMismatch); + } + + const signatureOk = await verifyLocalVoucherSignature(raw, requirements.network); + if (!signatureOk) { + return invalidVerifyResponse(payer, Errors.ErrInvalidVoucherSignature); + } + + const maxClaimableAmount = BigInt(raw.voucher.maxClaimableAmount); + if (maxClaimableAmount > BigInt(channel.balance)) { + return invalidVerifyResponse(payer, Errors.ErrCumulativeExceedsBalance); + } + + if (maxClaimableAmount <= BigInt(channel.totalClaimed)) { + return invalidVerifyResponse(payer, Errors.ErrCumulativeAmountBelowClaimed); + } + + return { + isValid: true, + payer, + extra: { + channelId: raw.voucher.channelId, + balance: channel.balance, + totalClaimed: channel.totalClaimed, + withdrawRequestedAt: channel.withdrawRequestedAt, + refundNonce: channel.refundNonce.toString(), + }, + }; +} + +/** + * Returns whether cached onchain fields for a channel are still within the freshness window. + * + * @param channel - Cached channel row. + * @param ttlMs - Maximum age of `onchainSyncedAt` in milliseconds. + * @param now - Current wall-clock time in milliseconds. + * @returns `true` if onchain sync time is present and still within `ttlMs` of `now`. + */ +function isOnchainStateFresh(channel: Channel, ttlMs: number, now: number): boolean { + return channel.onchainSyncedAt !== undefined && now - channel.onchainSyncedAt <= ttlMs; +} + +/** + * Verifies the EIP-712 voucher signature against the payer authorizer. + * + * @param raw - Decoded batch-settlement voucher payload. + * @param network - EVM network identifier for chain ID / domain. + * @returns Whether the typed-data signature is valid. + */ +async function verifyLocalVoucherSignature( + raw: BatchSettlementVoucherPayload, + network: string, +): Promise { + try { + return await verifyTypedData({ + address: getAddress(raw.channelConfig.payerAuthorizer), + domain: getBatchSettlementEip712Domain(getEvmChainId(network)), + types: voucherTypes, + primaryType: "Voucher", + message: { + channelId: raw.voucher.channelId, + maxClaimableAmount: BigInt(raw.voucher.maxClaimableAmount), + }, + signature: raw.voucher.signature, + }); + } catch { + return false; + } +} + +/** + * Builds a failed verify response with the payer address preserved for reporting. + * + * @param payer - Payer address from the payload. + * @param invalidReason - Machine-readable failure reason. + * @returns Invalid {@link VerifyResponse} with `isValid: false`. + */ +function invalidVerifyResponse(payer: `0x${string}`, invalidReason: string): VerifyResponse { + return { isValid: false, invalidReason, payer }; +} + +/** + * Builds the minimal local channel record needed to reserve missing state. + * + * @param raw - Batch-settlement payload containing channel config and voucher. + * @param chargedCumulativeAmount - Local charged base inferred before facilitator verification. + * @returns Provisional channel state. + */ +function buildProvisionalChannel( + raw: BatchSettlementVoucherPayload | BatchSettlementDepositPayload | BatchSettlementRefundPayload, + chargedCumulativeAmount: string, +): Channel { + return { + channelId: raw.voucher.channelId, + channelConfig: raw.channelConfig, + chargedCumulativeAmount, + signedMaxClaimable: raw.voucher.maxClaimableAmount, + signature: raw.voucher.signature, + balance: "0", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: Date.now(), + }; +} + +/** + * Infers the local charged base when storage has no channel record. + * + * @param signedMaxClaimable - Client-signed cumulative voucher cap. + * @param price - Current request amount. + * @param isPaidPayload - Whether the payload should add `price` to the local base. + * @returns Inferred charged base as a decimal string. + */ +function inferMissingLocalChargedAmount( + signedMaxClaimable: string, + price: string, + isPaidPayload: boolean, +): string { + if (!isPaidPayload) { + return signedMaxClaimable; + } + + const signed = BigInt(signedMaxClaimable); + const amount = BigInt(price); + if (signed < amount) { + return "0"; + } + return (signed - amount).toString(); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/storage-utils.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/storage-utils.ts new file mode 100644 index 0000000000..039ac8d0bd --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/storage-utils.ts @@ -0,0 +1,52 @@ +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +/** + * Returns true when `err` is a Node.js `ENOENT` filesystem error (file does not exist). + * + * @param err - The thrown value to inspect. + * @returns `true` for `ENOENT`, `false` for any other value or error code. + */ +export function isNodeEnoent(err: unknown): boolean { + if (!err || typeof err !== "object" || !("code" in err)) return false; + return (err as NodeJS.ErrnoException).code === "ENOENT"; +} + +/** + * Reads a JSON file and parses it. Returns `undefined` if the file does not exist. + * Other errors (permission, malformed JSON) are rethrown. + * + * @param filePath - Path to the JSON file. + * @returns Parsed value, or `undefined` for `ENOENT`. + */ +export async function readJsonFile(filePath: string): Promise { + try { + const raw = await readFile(filePath, "utf8"); + return JSON.parse(raw) as T; + } catch (err: unknown) { + if (isNodeEnoent(err)) return undefined; + throw err; + } +} + +/** + * Writes JSON to `filePath` atomically (temp file in the same directory, then rename). + * Creates parent directories as needed. + * + * @param filePath - Destination file path; parent dirs are created if missing. + * @param value - JSON-serializable value to persist. + */ +export async function writeJsonAtomic(filePath: string, value: unknown): Promise { + const dir = dirname(filePath); + await mkdir(dir, { recursive: true }); + const tmp = join(dir, `.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`); + const body = `${JSON.stringify(value, null, 2)}\n`; + await writeFile(tmp, body, "utf8"); + try { + await rename(tmp, filePath); + } catch { + // On Windows, rename() onto an existing file throws EEXIST; unlink + rename is intentional. + await unlink(filePath).catch(() => {}); + await rename(tmp, filePath); + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/types.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/types.ts new file mode 100644 index 0000000000..157a097563 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/types.ts @@ -0,0 +1,288 @@ +import type { TypedData } from "viem"; + +export interface AuthorizerSigner { + address: `0x${string}`; + signTypedData(params: { + domain: Record; + types: TypedData; + primaryType: string; + message: Record; + }): Promise<`0x${string}`>; +} + +export type ChannelState = { + balance: bigint; + totalClaimed: bigint; + withdrawRequestedAt: number; + refundNonce: bigint; +}; + +export type ChannelConfig = { + payer: `0x${string}`; + payerAuthorizer: `0x${string}`; + receiver: `0x${string}`; + receiverAuthorizer: `0x${string}`; + token: `0x${string}`; + withdrawDelay: number; + salt: `0x${string}`; +}; + +export type BatchSettlementErc3009Authorization = { + validAfter: string; + validBefore: string; + salt: `0x${string}`; + signature: `0x${string}`; +}; + +export type BatchSettlementPermit2Authorization = { + from: `0x${string}`; + permitted: { + token: `0x${string}`; + amount: string; + }; + spender: `0x${string}`; + nonce: string; + deadline: string; + witness: { + channelId: `0x${string}`; + }; + signature: `0x${string}`; +}; + +export type BatchSettlementAssetTransferMethod = "eip3009" | "permit2"; + +export type BatchSettlementDepositAuthorization = + | { + erc3009Authorization: BatchSettlementErc3009Authorization; + permit2Authorization?: never; + } + | { + erc3009Authorization?: never; + permit2Authorization: BatchSettlementPermit2Authorization; + }; + +export type BatchSettlementDepositPayload = { + type: "deposit"; + channelConfig: ChannelConfig; + voucher: BatchSettlementVoucherFields; + deposit: { + amount: string; + authorization: BatchSettlementDepositAuthorization; + }; +}; + +export type BatchSettlementVoucherPayload = { + type: "voucher"; + channelConfig: ChannelConfig; + voucher: BatchSettlementVoucherFields; +}; + +export type BatchSettlementRefundPayload = { + type: "refund"; + channelConfig: ChannelConfig; + voucher: BatchSettlementVoucherFields; + amount?: string; +}; + +export type BatchSettlementVoucherFields = { + channelId: `0x${string}`; + maxClaimableAmount: string; + signature: `0x${string}`; +}; + +export type BatchSettlementVoucherClaim = { + voucher: { + channel: ChannelConfig; + maxClaimableAmount: string; + }; + signature: `0x${string}`; + totalClaimed: string; +}; + +export type BatchSettlementChannelStateExtra = { + channelId: `0x${string}`; + balance: string; + totalClaimed: string; + withdrawRequestedAt: number; + refundNonce: string; + chargedCumulativeAmount?: string; +}; + +export type BatchSettlementVoucherStateExtra = { + signedMaxClaimable?: string; + signature?: `0x${string}`; +}; + +export type BatchSettlementPaymentRequirementsExtra = { + receiverAuthorizer: `0x${string}`; + withdrawDelay: number; + name: string; + version: string; + assetTransferMethod?: BatchSettlementAssetTransferMethod; + channelState?: BatchSettlementChannelStateExtra; + voucherState?: BatchSettlementVoucherStateExtra; +}; + +export type FileChannelStorageOptions = { + /** Root directory; channels are stored under `{directory}/{client|server}/{channelId}.json`. */ + directory: string; +}; + +export type BatchSettlementPaymentResponseExtra = { + chargedAmount?: string; + channelState?: BatchSettlementChannelStateExtra; + voucherState?: BatchSettlementVoucherStateExtra; +}; + +export type BatchSettlementClaimPayload = { + type: "claim"; + claims: BatchSettlementVoucherClaim[]; + claimAuthorizerSignature?: `0x${string}`; +}; + +export type BatchSettlementSettlePayload = { + type: "settle"; + receiver: `0x${string}`; + token: `0x${string}`; +}; + +export type BatchSettlementEnrichedRefundPayload = BatchSettlementRefundPayload & { + amount: string; + refundNonce: string; + claims: BatchSettlementVoucherClaim[]; + refundAuthorizerSignature?: `0x${string}`; + claimAuthorizerSignature?: `0x${string}`; +}; + +export type BatchSettlementPayload = + | BatchSettlementDepositPayload + | BatchSettlementVoucherPayload + | BatchSettlementRefundPayload; + +export type BatchSettlementFacilitatorSettlePayload = + | BatchSettlementDepositPayload + | BatchSettlementClaimPayload + | BatchSettlementSettlePayload + | BatchSettlementEnrichedRefundPayload; + +/** + * Returns true when the value is a non-null object (a usable record). + * + * @param payload - Value of unknown shape. + * @returns True if `payload` is an object that can be indexed by string keys. + */ +function isObject(payload: unknown): payload is Record { + return typeof payload === "object" && payload !== null; +} + +/** + * Type guard for internal voucher field shape (channel, amount, signature). + * + * @param payload - Unknown value to check. + * @returns True if `payload` is an object with `channelId`, `maxClaimableAmount`, and `signature`. + */ +function isVoucherFields(payload: unknown): payload is BatchSettlementVoucherFields { + return ( + isObject(payload) && + "channelId" in payload && + "maxClaimableAmount" in payload && + "signature" in payload + ); +} + +/** + * Type guard for {@link BatchSettlementDepositPayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a deposit payload (carries `deposit` and `voucher`). + */ +export function isBatchSettlementDepositPayload( + payload: unknown, +): payload is BatchSettlementDepositPayload { + return ( + isObject(payload) && + payload.type === "deposit" && + "channelConfig" in payload && + isVoucherFields(payload.voucher) && + isObject(payload.deposit) && + typeof payload.deposit.amount === "string" && + isObject(payload.deposit.authorization) + ); +} + +/** + * Type guard for {@link BatchSettlementVoucherPayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a voucher payload with channel and signature fields. + */ +export function isBatchSettlementVoucherPayload( + payload: unknown, +): payload is BatchSettlementVoucherPayload { + return ( + isObject(payload) && + payload.type === "voucher" && + "channelConfig" in payload && + isVoucherFields(payload.voucher) + ); +} + +/** + * Type guard for {@link BatchSettlementRefundPayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a refund payload with channel config and voucher fields. + */ +export function isBatchSettlementRefundPayload( + payload: unknown, +): payload is BatchSettlementRefundPayload { + return ( + isObject(payload) && + payload.type === "refund" && + "channelConfig" in payload && + isVoucherFields(payload.voucher) + ); +} + +/** + * Type guard for {@link BatchSettlementClaimPayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a settle-action `claimWithSignature` payload. + */ +export function isBatchSettlementClaimPayload( + payload: unknown, +): payload is BatchSettlementClaimPayload { + return isObject(payload) && payload.type === "claim" && "claims" in payload; +} + +/** + * Type guard for {@link BatchSettlementSettlePayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a settle-action `settle` payload. + */ +export function isBatchSettlementSettlePayload( + payload: unknown, +): payload is BatchSettlementSettlePayload { + return ( + isObject(payload) && payload.type === "settle" && "receiver" in payload && "token" in payload + ); +} + +/** + * Type guard for {@link BatchSettlementEnrichedRefundPayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a settle-action `refundWithSignature` payload. + */ +export function isBatchSettlementEnrichedRefundPayload( + payload: unknown, +): payload is BatchSettlementEnrichedRefundPayload { + return ( + isBatchSettlementRefundPayload(payload) && + "amount" in payload && + "refundNonce" in payload && + "claims" in payload + ); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/utils.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/utils.ts new file mode 100644 index 0000000000..4604205cc6 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/utils.ts @@ -0,0 +1,47 @@ +import { getAddress, hashTypedData } from "viem"; +import { BATCH_SETTLEMENT_ADDRESS, BATCH_SETTLEMENT_DOMAIN, channelConfigTypes } from "./constants"; +import type { ChannelConfig } from "./types"; +import { getEvmChainId } from "../utils"; + +/** + * Computes the chain-bound channel id from a {@link ChannelConfig} struct. + * + * @param config - The immutable channel configuration. + * @param networkOrChainId - CAIP-2 network identifier or numeric EVM chain id. + * @returns The `bytes32` channel id as a hex string. + */ +export function computeChannelId( + config: ChannelConfig, + networkOrChainId: string | number, +): `0x${string}` { + const chainId = + typeof networkOrChainId === "number" ? networkOrChainId : getEvmChainId(networkOrChainId); + return hashTypedData({ + domain: getBatchSettlementEip712Domain(chainId), + types: channelConfigTypes, + primaryType: "ChannelConfig", + message: { + payer: config.payer, + payerAuthorizer: config.payerAuthorizer, + receiver: config.receiver, + receiverAuthorizer: config.receiverAuthorizer, + token: config.token, + withdrawDelay: config.withdrawDelay, + salt: config.salt, + }, + }); +} + +/** + * Returns the full EIP-712 domain for the batch-settlement contract on the given chain. + * + * @param chainId - Numeric EVM chain id. + * @returns EIP-712 domain with `name`, `version`, `chainId`, and checksummed `verifyingContract`. + */ +export function getBatchSettlementEip712Domain(chainId: number) { + return { + ...BATCH_SETTLEMENT_DOMAIN, + chainId, + verifyingContract: getAddress(BATCH_SETTLEMENT_ADDRESS), + } as const; +} diff --git a/typescript/packages/mechanisms/evm/src/index.ts b/typescript/packages/mechanisms/evm/src/index.ts index 71e59ea69a..da0b194cc2 100644 --- a/typescript/packages/mechanisms/evm/src/index.ts +++ b/typescript/packages/mechanisms/evm/src/index.ts @@ -36,6 +36,52 @@ export { UptoEvmScheme } from "./upto"; export type { UptoPermit2Payload, UptoPermit2Witness, UptoPermit2Authorization } from "./types"; export { isUptoPermit2Payload } from "./types"; +// Batch-settlement scheme client +export { BatchSettlementEvmScheme } from "./batch-settlement"; + +// Batch-settlement types +export type { + AuthorizerSigner, + ChannelConfig, + ChannelState, + BatchSettlementDepositPayload, + BatchSettlementVoucherPayload, + BatchSettlementRefundPayload, + BatchSettlementVoucherFields, + BatchSettlementErc3009Authorization, + BatchSettlementClaimPayload, + BatchSettlementEnrichedRefundPayload, + BatchSettlementVoucherClaim, + BatchSettlementPayload, + BatchSettlementSettlePayload, + BatchSettlementFacilitatorSettlePayload, + BatchSettlementPaymentRequirementsExtra, + BatchSettlementPaymentResponseExtra, +} from "./types"; +export { + isBatchSettlementDepositPayload, + isBatchSettlementVoucherPayload, + isBatchSettlementRefundPayload, + isBatchSettlementClaimPayload, + isBatchSettlementSettlePayload, + isBatchSettlementEnrichedRefundPayload, +} from "./types"; + +// Batch-settlement constants +export { + BATCH_SETTLEMENT_ADDRESS, + BATCH_SETTLEMENT_SCHEME, + ERC3009_DEPOSIT_COLLECTOR_ADDRESS, + BATCH_SETTLEMENT_DOMAIN, + voucherTypes, + refundTypes, + claimBatchTypes, +} from "./batch-settlement/constants"; + +// Default stablecoins (USD string pricing → token address per chain) +export { getDefaultAsset } from "./shared/defaultAssets"; +export type { DefaultAssetInfo, ExactDefaultAssetInfo } from "./shared/defaultAssets"; + // Constants export { PERMIT2_ADDRESS, diff --git a/typescript/packages/mechanisms/evm/src/shared/extensions.ts b/typescript/packages/mechanisms/evm/src/shared/extensions.ts index 11baee6625..44a4518076 100644 --- a/typescript/packages/mechanisms/evm/src/shared/extensions.ts +++ b/typescript/packages/mechanisms/evm/src/shared/extensions.ts @@ -16,6 +16,7 @@ import { resolveExtensionRpcCapabilities, type ExactEvmSchemeOptions } from "./r * @param requirements - The payment requirements from the server * @param result - The payment payload result from the scheme * @param context - Optional context containing server extensions and metadata + * @param approvalAmount - Optional amount to approve instead of `requirements.amount` * @returns Extension data for EIP-2612 gas sponsoring, or undefined if not applicable */ export async function trySignEip2612PermitExtension( @@ -24,6 +25,7 @@ export async function trySignEip2612PermitExtension( requirements: PaymentRequirements, result: PaymentPayloadResult, context?: PaymentPayloadContext, + approvalAmount?: string, ): Promise | undefined> { const capabilities = resolveExtensionRpcCapabilities(requirements.network, signer, options); @@ -43,6 +45,7 @@ export async function trySignEip2612PermitExtension( const chainId = getEvmChainId(requirements.network); const tokenAddress = getAddress(requirements.asset) as `0x${string}`; + const requiredAllowance = approvalAmount ?? requirements.amount; try { const allowance = (await capabilities.readContract({ @@ -52,7 +55,7 @@ export async function trySignEip2612PermitExtension( args: [signer.address, PERMIT2_ADDRESS], })) as bigint; - if (allowance >= BigInt(requirements.amount)) { + if (allowance >= BigInt(requiredAllowance)) { return undefined; } } catch { @@ -75,7 +78,7 @@ export async function trySignEip2612PermitExtension( tokenVersion, chainId, deadline, - requirements.amount, + requiredAllowance, ); return { @@ -90,6 +93,7 @@ export async function trySignEip2612PermitExtension( * @param options - Optional RPC configuration for backfilling capabilities * @param requirements - The payment requirements from the server * @param context - Optional context containing server extensions and metadata + * @param approvalAmount - Optional amount to check for Permit2 allowance * @returns Extension data for ERC-20 approval gas sponsoring, or undefined if not applicable */ export async function trySignErc20ApprovalExtension( @@ -97,6 +101,7 @@ export async function trySignErc20ApprovalExtension( options: ExactEvmSchemeOptions | undefined, requirements: PaymentRequirements, context?: PaymentPayloadContext, + approvalAmount?: string, ): Promise | undefined> { const capabilities = resolveExtensionRpcCapabilities(requirements.network, signer, options); @@ -114,6 +119,7 @@ export async function trySignErc20ApprovalExtension( const chainId = getEvmChainId(requirements.network); const tokenAddress = getAddress(requirements.asset) as `0x${string}`; + const requiredAllowance = approvalAmount ?? requirements.amount; try { const allowance = (await capabilities.readContract({ @@ -123,7 +129,7 @@ export async function trySignErc20ApprovalExtension( args: [signer.address, PERMIT2_ADDRESS], })) as bigint; - if (allowance >= BigInt(requirements.amount)) { + if (allowance >= BigInt(requiredAllowance)) { return undefined; } } catch { diff --git a/typescript/packages/mechanisms/evm/src/signer.ts b/typescript/packages/mechanisms/evm/src/signer.ts index 66ec3de62d..4ec807bdd6 100644 --- a/typescript/packages/mechanisms/evm/src/signer.ts +++ b/typescript/packages/mechanisms/evm/src/signer.ts @@ -1,3 +1,5 @@ +import type { Log } from "viem"; + /** * ClientEvmSigner - Used by x402 clients to sign payment authorizations. * @@ -92,7 +94,10 @@ export type FacilitatorEvmSigner = { gas?: bigint; }): Promise<`0x${string}`>; sendTransaction(args: { to: `0x${string}`; data: `0x${string}` }): Promise<`0x${string}`>; - waitForTransactionReceipt(args: { hash: `0x${string}` }): Promise<{ status: string }>; + waitForTransactionReceipt(args: { hash: `0x${string}` }): Promise<{ + status: string; + logs?: readonly Log[]; + }>; getCode(args: { address: `0x${string}` }): Promise<`0x${string}` | undefined>; }; diff --git a/typescript/packages/mechanisms/evm/src/types.ts b/typescript/packages/mechanisms/evm/src/types.ts index 04633277ae..67bdc558b9 100644 --- a/typescript/packages/mechanisms/evm/src/types.ts +++ b/typescript/packages/mechanisms/evm/src/types.ts @@ -109,13 +109,44 @@ export type UptoPermit2Payload = { }; }; +// Batch-settlement EVM scheme payload types +export type { + AuthorizerSigner, + ChannelConfig, + ChannelState, + BatchSettlementDepositPayload, + BatchSettlementVoucherPayload, + BatchSettlementRefundPayload, + BatchSettlementVoucherFields, + BatchSettlementErc3009Authorization, + BatchSettlementPermit2Authorization, + BatchSettlementDepositAuthorization, + BatchSettlementAssetTransferMethod, + BatchSettlementClaimPayload, + BatchSettlementEnrichedRefundPayload, + BatchSettlementVoucherClaim, + BatchSettlementPayload, + BatchSettlementSettlePayload, + BatchSettlementFacilitatorSettlePayload, + BatchSettlementPaymentRequirementsExtra, + BatchSettlementPaymentResponseExtra, +} from "./batch-settlement/types"; +export { + isBatchSettlementDepositPayload, + isBatchSettlementVoucherPayload, + isBatchSettlementRefundPayload, + isBatchSettlementClaimPayload, + isBatchSettlementSettlePayload, + isBatchSettlementEnrichedRefundPayload, +} from "./batch-settlement/types"; + /** * Type guard to check if a payload is an upto Permit2 payload. * Validates structural presence of all required fields: signature, permit2Authorization * (with from, permitted, spender, nonce, deadline), and a witness containing facilitator. * - * @param payload - The payload to check - * @returns True if the payload is an upto Permit2 payload, false otherwise + * @param payload - The payload to check. + * @returns True if the payload is an upto Permit2 payload, false otherwise. */ export function isUptoPermit2Payload( payload: Record, diff --git a/typescript/packages/mechanisms/evm/test/integrations/batch-settlement-evm.test.ts b/typescript/packages/mechanisms/evm/test/integrations/batch-settlement-evm.test.ts new file mode 100644 index 0000000000..82edccb2fc --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/integrations/batch-settlement-evm.test.ts @@ -0,0 +1,438 @@ +import { randomBytes } from "node:crypto"; +import { beforeEach, describe, expect, it } from "vitest"; +import { x402Client, x402HTTPClient } from "@x402/core/client"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { + HTTPAdapter, + HTTPResponseInstructions, + x402HTTPResourceServer, + x402ResourceServer, + FacilitatorClient, +} from "@x402/core/server"; +import { + Network, + PaymentPayload, + PaymentRequirements, + VerifyResponse, + SettleResponse, + SupportedResponse, +} from "@x402/core/types"; +import { toClientEvmSigner, toFacilitatorEvmSigner } from "../../src"; +import { BatchSettlementEvmScheme as BatchSettlementEvmClient } from "../../src/batch-settlement/client/scheme"; +import { processSettleResponse } from "../../src/batch-settlement/client/channel"; +import { InMemoryClientChannelStorage } from "../../src/batch-settlement/client/storage"; +import { BatchSettlementEvmScheme as BatchSettlementEvmServer } from "../../src/batch-settlement/server/scheme"; +import { BatchSettlementEvmScheme as BatchSettlementEvmFacilitator } from "../../src/batch-settlement/facilitator/scheme"; +import type { AuthorizerSigner } from "../../src/batch-settlement/types"; +import { privateKeyToAccount } from "viem/accounts"; +import { createWalletClient, createPublicClient, http, getAddress } from "viem"; +import { baseSepolia } from "viem/chains"; +import { batchSettlementABI } from "../../src/batch-settlement/abi"; +import { BATCH_SETTLEMENT_ADDRESS } from "../../src/batch-settlement/constants"; + +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 RECEIVER_AUTHORIZER_PRIVATE_KEY = process.env.RECEIVER_AUTHORIZER_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( + "[batch-settlement-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"; + +/** + * Waits until an RPC read sees non-zero channel balance (some providers lag after receipt). + * + * @param publicClient - Viem public client for the chain. + * @param channelId - Channel id to poll. + */ +async function waitForChannelBalanceOnChain( + publicClient: ReturnType, + channelId: `0x${string}`, +): Promise { + const timeoutMs = 20000; + const intervalMs = 250; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const [balance] = (await publicClient.readContract({ + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "channels", + args: [channelId], + })) as [bigint, bigint]; + if (balance > 0n) return; + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + throw new Error(`Timed out waiting for channel ${channelId} balance > 0`); +} + +/** + * 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 suitable for the batched scheme on Base Sepolia. + * + * @param payTo - Receiver address. + * @param amount - Amount in smallest token units (USDC has 6 decimals). + * @param receiverAuthorizer - Receiver-authorizer address (must be non-zero on-chain). + * @returns Configured {@link PaymentRequirements}. + */ +function buildBatchSettlementRequirements( + payTo: `0x${string}`, + amount: string, + receiverAuthorizer: `0x${string}`, +): PaymentRequirements { + return { + scheme: "batch-settlement", + network: NETWORK, + asset: ASSET_USDC_BASE_SEPOLIA, + amount, + payTo, + maxTimeoutSeconds: 3600, + extra: { + name: "USDC", + version: "2", + assetTransferMethod: "eip3009", + receiverAuthorizer, + }, + }; +} + +/** + * Constructs the wired client + server + facilitator pipeline for Base Sepolia + * batch-settlement integration tests. + * + * @returns The configured client/server pair plus the receiver address. + */ +function buildPipeline(): { + client: x402Client; + server: x402ResourceServer; + receiverAddress: `0x${string}`; + clientAddress: `0x${string}`; + authorizerSigner: AuthorizerSigner; + publicClient: ReturnType; + batchSettlementClient: BatchSettlementEvmClient; + batchSettlementStorage: InMemoryClientChannelStorage; +} { + const clientAccount = privateKeyToAccount(CLIENT_PRIVATE_KEY!); + const facilitatorAccount = privateKeyToAccount(FACILITATOR_PRIVATE_KEY!); + const authorizerAccount = privateKeyToAccount( + RECEIVER_AUTHORIZER_PRIVATE_KEY ?? 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 authorizerSigner: AuthorizerSigner = { + address: authorizerAccount.address, + signTypedData: msg => + authorizerAccount.signTypedData({ + domain: msg.domain, + types: msg.types, + primaryType: msg.primaryType, + message: msg.message, + } as Parameters[0]), + }; + + const facilitator = new x402Facilitator().register( + NETWORK, + new BatchSettlementEvmFacilitator(facilitatorSigner, authorizerSigner), + ); + const facilitatorClient = new EvmFacilitatorClient(facilitator); + + const clientSigner = toClientEvmSigner(clientAccount, publicClient); + const channelSalt = `0x${randomBytes(32).toString("hex")}` as `0x${string}`; + const batchSettlementStorage = new InMemoryClientChannelStorage(); + const batchSettlementClient = new BatchSettlementEvmClient(clientSigner, { + depositPolicy: { depositMultiplier: 3 }, + salt: channelSalt, + storage: batchSettlementStorage, + }); + const client = new x402Client().register(NETWORK, batchSettlementClient); + + const server = new x402ResourceServer(facilitatorClient); + server.register( + NETWORK, + new BatchSettlementEvmServer(facilitatorAccount.address, { + receiverAuthorizerSigner: authorizerSigner, + }), + ); + + return { + client, + server, + receiverAddress: facilitatorAccount.address, + clientAddress: clientAccount.address, + authorizerSigner, + publicClient, + batchSettlementClient, + batchSettlementStorage, + }; +} + +describe("Batch-Settlement EVM Integration Tests", () => { + describeOnChain("x402Client / x402ResourceServer / x402Facilitator - direct API", () => { + let client: x402Client; + let server: x402ResourceServer; + let receiverAddress: `0x${string}`; + let clientAddress: `0x${string}`; + let receiverAuthorizer: `0x${string}`; + let batchSettlementStorage: InMemoryClientChannelStorage; + let publicClient: ReturnType; + + beforeEach(async () => { + const pipeline = buildPipeline(); + client = pipeline.client; + server = pipeline.server; + receiverAddress = pipeline.receiverAddress; + clientAddress = pipeline.clientAddress; + receiverAuthorizer = pipeline.authorizerSigner.address; + batchSettlementStorage = pipeline.batchSettlementStorage; + publicClient = pipeline.publicClient; + await server.initialize(); + }); + + it( + "verifies and settles a deposit-with-voucher payment, then a follow-up voucher payment", + { timeout: 60000 }, + async () => { + const accepts = [ + buildBatchSettlementRequirements(receiverAddress, "1000", receiverAuthorizer), + ]; + const resource = { + url: "https://example.com/api", + description: "Batched test resource", + mimeType: "application/json", + }; + + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const firstPayload = await client.createPaymentPayload(paymentRequired); + + expect(firstPayload.x402Version).toBe(2); + expect(firstPayload.accepted.scheme).toBe("batch-settlement"); + const firstRaw = firstPayload.payload as Record; + expect(firstRaw.type).toBe("deposit"); + + const accepted = server.findMatchingRequirements(accepts, firstPayload); + expect(accepted).toBeDefined(); + + const verifyResponse = await server.verifyPayment(firstPayload, accepted!); + expect(verifyResponse.isValid).toBe(true); + expect(verifyResponse.payer?.toLowerCase()).toBe(clientAddress.toLowerCase()); + + const settleResponse = await server.settlePayment(firstPayload, 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()); + + const depositChannelId = (firstPayload.payload as { voucher: { channelId: `0x${string}` } }) + .voucher.channelId; + await waitForChannelBalanceOnChain(publicClient, depositChannelId); + + await processSettleResponse(batchSettlementStorage, settleResponse); + + const followupRequired = await server.createPaymentRequiredResponse(accepts, resource); + const secondPayload = await client.createPaymentPayload(followupRequired); + const secondRaw = secondPayload.payload as Record; + expect(secondRaw.type).toBe("voucher"); + + const accepted2 = server.findMatchingRequirements(accepts, secondPayload); + expect(accepted2).toBeDefined(); + + const verify2 = await server.verifyPayment(secondPayload, accepted2!); + expect(verify2.isValid).toBe(true); + + const settle2 = await server.settlePayment(secondPayload, accepted2!); + expect(settle2.success, JSON.stringify(settle2)).toBe(true); + expect(settle2.payer?.toLowerCase()).toBe(clientAddress.toLowerCase()); + }, + ); + }); + + describeOnChain("x402HTTPClient / x402HTTPResourceServer / x402Facilitator - HTTP API", () => { + let httpClient: x402HTTPClient; + let httpServer: x402HTTPResourceServer; + let receiverAddress: `0x${string}`; + let clientAddress: `0x${string}`; + + const routes = { + "/api/protected": { + accepts: { + scheme: "batch-settlement", + payTo: "0x0000000000000000000000000000000000000000" as `0x${string}`, + price: "$0.001", + network: NETWORK, + }, + description: "Batched protected API", + mimeType: "application/json", + }, + }; + + const adapter: HTTPAdapter = { + getHeader: () => undefined, + getMethod: () => "GET", + getPath: () => "/api/protected", + getUrl: () => "https://example.com/api/protected", + getAcceptHeader: () => "application/json", + getUserAgent: () => "TestClient/1.0", + }; + + beforeEach(async () => { + const pipeline = buildPipeline(); + receiverAddress = pipeline.receiverAddress; + clientAddress = pipeline.clientAddress; + + routes["/api/protected"].accepts.payTo = receiverAddress; + + const resourceServer = new x402ResourceServer( + new EvmFacilitatorClient( + new x402Facilitator().register( + NETWORK, + new BatchSettlementEvmFacilitator( + toFacilitatorEvmSigner({ + address: privateKeyToAccount(FACILITATOR_PRIVATE_KEY!).address, + readContract: args => + pipeline.publicClient.readContract({ + ...args, + args: args.args || [], + } as never), + verifyTypedData: args => pipeline.publicClient.verifyTypedData(args as never), + writeContract: args => + createWalletClient({ + account: privateKeyToAccount(FACILITATOR_PRIVATE_KEY!), + chain: baseSepolia, + transport: http(), + }).writeContract({ ...args, args: args.args || [] } as never), + sendTransaction: args => + createWalletClient({ + account: privateKeyToAccount(FACILITATOR_PRIVATE_KEY!), + chain: baseSepolia, + transport: http(), + }).sendTransaction(args), + waitForTransactionReceipt: args => + pipeline.publicClient.waitForTransactionReceipt(args), + getCode: args => pipeline.publicClient.getCode(args), + }), + pipeline.authorizerSigner, + ), + ), + ), + ); + resourceServer.register( + NETWORK, + new BatchSettlementEvmServer(receiverAddress, { + receiverAuthorizerSigner: pipeline.authorizerSigner, + }), + ); + await resourceServer.initialize(); + + httpServer = new x402HTTPResourceServer(resourceServer, routes); + httpClient = new x402HTTPClient(pipeline.client) as x402HTTPClient; + }); + + it( + "negotiates a batched payment via HTTP middleware end-to-end", + { timeout: 60000 }, + async () => { + const context = { adapter, path: "/api/protected", method: "GET" }; + + const initial = (await httpServer.processHTTPRequest(context))!; + expect(initial.type).toBe("payment-error"); + const response402 = ( + initial as { type: "payment-error"; response: HTTPResponseInstructions } + ).response; + expect(response402.status).toBe(402); + expect(response402.headers["PAYMENT-REQUIRED"]).toBeDefined(); + + const paymentRequired = httpClient.getPaymentRequiredResponse( + name => response402.headers[name], + response402.body, + ); + const paymentPayload = await httpClient.createPaymentPayload(paymentRequired); + expect(paymentPayload.accepted.scheme).toBe("batch-settlement"); + + const requestHeaders = await httpClient.encodePaymentSignatureHeader(paymentPayload); + adapter.getHeader = (name: string) => + name === "PAYMENT-SIGNATURE" ? requestHeaders["PAYMENT-SIGNATURE"] : undefined; + + const verified = await httpServer.processHTTPRequest(context); + expect(verified.type).toBe("payment-verified"); + + const { paymentPayload: verifiedPayload, paymentRequirements: verifiedReqs } = verified as { + type: "payment-verified"; + paymentPayload: PaymentPayload; + paymentRequirements: PaymentRequirements; + }; + + const settlement = await httpServer.processSettlement(verifiedPayload, verifiedReqs, 200); + expect(settlement.success).toBe(true); + if (settlement.success) { + expect(settlement.headers["PAYMENT-RESPONSE"]).toBeDefined(); + } + expect(clientAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); + }, + ); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/channelManager.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/channelManager.test.ts new file mode 100644 index 0000000000..ee7df9dfe4 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/channelManager.test.ts @@ -0,0 +1,792 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import type { MockedFunction } from "vitest"; +import { privateKeyToAccount } from "viem/accounts"; +import { + BatchSettlementChannelManager, + type ClaimResult, + type SettleResult, + type RefundResult, +} from "../../../src/batch-settlement/server/channelManager"; +import { BatchSettlementEvmScheme } from "../../../src/batch-settlement/server/scheme"; +import { InMemoryChannelStorage, type Channel } from "../../../src/batch-settlement/server/storage"; +import { computeChannelId as computeChannelIdForNetwork } from "../../../src/batch-settlement/utils"; +import type { ChannelConfig, AuthorizerSigner } from "../../../src/batch-settlement/types"; +import type { FacilitatorClient } from "@x402/core/server"; +import type { + PaymentPayload, + PaymentRequirements, + SettleResponse, + VerifyResponse, + SupportedResponse, +} from "@x402/core/types"; + +const RECEIVER = "0x9876543210987654321098765432109876543210" as `0x${string}`; +const PAYER = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as `0x${string}`; +const TOKEN = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; +const ZERO = "0x0000000000000000000000000000000000000000" as `0x${string}`; +const NETWORK = "eip155:84532"; + +function computeChannelId(config: ChannelConfig): `0x${string}` { + return computeChannelIdForNetwork(config, NETWORK); +} + +function buildAuthorizerSigner(): AuthorizerSigner { + const account = privateKeyToAccount( + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + ); + return { + address: account.address, + signTypedData: msg => + account.signTypedData({ + domain: msg.domain, + types: msg.types, + primaryType: msg.primaryType, + message: msg.message, + } as Parameters[0]), + }; +} + +function buildChannelConfig(saltSuffix = "00"): ChannelConfig { + const salt = `0x${"00".repeat(31)}${saltSuffix.padStart(2, "0")}` as `0x${string}`; + return { + payer: PAYER, + payerAuthorizer: ZERO, + receiver: RECEIVER, + receiverAuthorizer: ZERO, + token: TOKEN, + withdrawDelay: 900, + salt, + }; +} + +function buildSession(overrides: Partial = {}): Channel { + const config = overrides.channelConfig ?? buildChannelConfig(); + const channelId = overrides.channelId ?? computeChannelId(config); + return { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xdeadbeef", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: Date.now(), + ...overrides, + }; +} + +async function storeChannel(storage: InMemoryChannelStorage, channel: Channel): Promise { + await storage.updateChannel(channel.channelId, () => channel); +} + +type FakeFacilitator = FacilitatorClient & { + verify: MockedFunction; + settle: MockedFunction; + getSupported: MockedFunction; +}; + +function buildFacilitator( + settleImpl: ( + payload: PaymentPayload, + reqs: PaymentRequirements, + ) => Promise = async (_, reqs) => ({ + success: true, + transaction: "0xtx", + network: reqs.network, + }), +): FakeFacilitator { + return { + verify: vi.fn( + async () => + ({ + isValid: true, + }) as VerifyResponse, + ), + settle: vi.fn(settleImpl), + getSupported: vi.fn( + async () => + ({ + kinds: [], + }) as unknown as SupportedResponse, + ), + }; +} + +function buildManager(opts?: { + authorizerSigner?: AuthorizerSigner; + facilitator?: FakeFacilitator; + storage?: InMemoryChannelStorage; +}): { + manager: BatchSettlementChannelManager; + scheme: BatchSettlementEvmScheme; + facilitator: FakeFacilitator; + storage: InMemoryChannelStorage; +} { + const storage = opts?.storage ?? new InMemoryChannelStorage(); + const scheme = new BatchSettlementEvmScheme(RECEIVER, { + storage, + receiverAuthorizerSigner: opts?.authorizerSigner, + }); + const facilitator = opts?.facilitator ?? buildFacilitator(); + const manager = new BatchSettlementChannelManager({ + scheme, + facilitator, + receiver: RECEIVER, + token: TOKEN, + network: NETWORK, + }); + return { manager, scheme, facilitator, storage }; +} + +describe("BatchSettlementChannelManager — claim()", () => { + it("returns no results when there are no claimable vouchers", async () => { + const { manager, facilitator } = buildManager(); + const results = await manager.claim(); + expect(results).toEqual([]); + expect(facilitator.settle).not.toHaveBeenCalled(); + }); + + it("submits a single claim batch when claimable vouchers exist", async () => { + const { manager, storage, facilitator } = buildManager(); + const session = buildSession({ chargedCumulativeAmount: "5000", totalClaimed: "0" }); + await storeChannel(storage, session); + + const results = await manager.claim(); + expect(results).toHaveLength(1); + expect(results[0].vouchers).toBe(1); + expect(results[0].transaction).toBe("0xtx"); + + expect(facilitator.settle).toHaveBeenCalledTimes(1); + const [paymentPayload] = facilitator.settle.mock.calls[0]; + const payload = paymentPayload.payload as Record; + expect(payload.type).toBe("claim"); + expect(payload.claims).toHaveLength(1); + }); + + it("splits claims across multiple batches respecting maxClaimsPerBatch", async () => { + const { manager, storage, facilitator } = buildManager(); + for (let i = 0; i < 5; i++) { + const config = buildChannelConfig(i.toString(16)); + await storeChannel( + storage, + buildSession({ + channelConfig: config, + channelId: computeChannelId(config), + chargedCumulativeAmount: String(1000 * (i + 1)), + totalClaimed: "0", + }), + ); + } + + const results = await manager.claim({ maxClaimsPerBatch: 2 }); + expect(results).toHaveLength(3); + expect(results.map(r => r.vouchers)).toEqual([2, 2, 1]); + expect(facilitator.settle).toHaveBeenCalledTimes(3); + }); + + it("defaults to 100 vouchers per claim batch", async () => { + const { manager, storage, facilitator } = buildManager(); + for (let i = 0; i < 101; i++) { + const config = buildChannelConfig(i.toString(16)); + await storeChannel( + storage, + buildSession({ + channelConfig: config, + channelId: computeChannelId(config), + chargedCumulativeAmount: String(1000 * (i + 1)), + totalClaimed: "0", + }), + ); + } + + const results = await manager.claim(); + expect(results).toHaveLength(2); + expect(results.map(r => r.vouchers)).toEqual([100, 1]); + expect(facilitator.settle).toHaveBeenCalledTimes(2); + }); + + it("skips sessions that are not idle long enough when idleSecs is set", async () => { + const { manager, storage, facilitator } = buildManager(); + const fresh = buildSession({ + chargedCumulativeAmount: "5000", + lastRequestTimestamp: Date.now(), + }); + await storeChannel(storage, fresh); + + const results = await manager.claim({ idleSecs: 60 }); + expect(results).toEqual([]); + expect(facilitator.settle).not.toHaveBeenCalled(); + }); + + it("claims only channels selected by the claim selector", async () => { + const { manager, storage, facilitator } = buildManager(); + const selectedConfig = buildChannelConfig("01"); + const skippedConfig = buildChannelConfig("02"); + const selected = buildSession({ + channelConfig: selectedConfig, + channelId: computeChannelId(selectedConfig), + chargedCumulativeAmount: "5000", + }); + const skipped = buildSession({ + channelConfig: skippedConfig, + channelId: computeChannelId(skippedConfig), + chargedCumulativeAmount: "7000", + }); + await storeChannel(storage, selected); + await storeChannel(storage, skipped); + + const results = await manager.claim({ + selectClaimChannels: channels => + channels.filter(channel => channel.channelId === selected.channelId), + }); + + expect(results).toHaveLength(1); + expect(results[0].vouchers).toBe(1); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + expect((await storage.get(selected.channelId))?.totalClaimed).toBe("5000"); + expect((await storage.get(skipped.channelId))?.totalClaimed).toBe("0"); + }); + + it("updates session.totalClaimed after a successful claim", async () => { + const { manager, storage } = buildManager(); + const session = buildSession({ chargedCumulativeAmount: "5000", totalClaimed: "0" }); + await storeChannel(storage, session); + + await manager.claim(); + + const updated = await storage.get(session.channelId); + expect(updated?.totalClaimed).toBe("5000"); + }); + + it("includes a claim authorizer signature when an authorizer signer is configured", async () => { + const authorizer = buildAuthorizerSigner(); + const config = buildChannelConfig(); + const channelId = computeChannelId({ ...config, receiverAuthorizer: authorizer.address }); + const { manager, storage, facilitator } = buildManager({ authorizerSigner: authorizer }); + await storeChannel( + storage, + buildSession({ + channelId, + channelConfig: { ...config, receiverAuthorizer: authorizer.address }, + chargedCumulativeAmount: "5000", + }), + ); + + await manager.claim(); + + const [paymentPayload] = facilitator.settle.mock.calls[0]; + const payload = paymentPayload.payload as Record; + expect(payload.claimAuthorizerSignature).toMatch(/^0x[0-9a-f]+$/i); + }); + + it("propagates a facilitator failure as a thrown error and leaves session intact", async () => { + const facilitator = buildFacilitator(async () => ({ + success: false, + errorReason: "boom", + errorMessage: "Claim reverted", + transaction: "", + network: NETWORK, + })); + const { manager, storage } = buildManager({ facilitator }); + const session = buildSession({ chargedCumulativeAmount: "5000" }); + await storeChannel(storage, session); + + await expect(manager.claim()).rejects.toThrow(/Claim failed/); + + const stored = await storage.get(session.channelId); + expect(stored?.totalClaimed).toBe("0"); + }); +}); + +describe("BatchSettlementChannelManager — settle()", () => { + it('calls the facilitator with a type="settle" payload', async () => { + const { manager, facilitator } = buildManager(); + const result = await manager.settle(); + + expect(result.transaction).toBe("0xtx"); + const [paymentPayload, reqs] = facilitator.settle.mock.calls[0]; + expect((paymentPayload.payload as Record).type).toBe("settle"); + expect((paymentPayload.payload as Record).receiver).toBe(RECEIVER); + expect((paymentPayload.payload as Record).token).toBe(TOKEN); + expect(reqs.network).toBe(NETWORK); + }); + + it("throws when the facilitator reports a failure", async () => { + const facilitator = buildFacilitator(async () => ({ + success: false, + errorReason: "boom", + errorMessage: "settle reverted", + transaction: "", + network: NETWORK, + })); + const { manager } = buildManager({ facilitator }); + await expect(manager.settle()).rejects.toThrow(/Settle failed/); + }); +}); + +describe("BatchSettlementChannelManager — claimAndSettle()", () => { + it("returns only the empty claims when no claimable vouchers exist", async () => { + const { manager, facilitator } = buildManager(); + const result = await manager.claimAndSettle(); + expect(result.claims).toEqual([]); + expect(result.settle).toBeUndefined(); + expect(facilitator.settle).not.toHaveBeenCalled(); + }); + + it("runs both claim and settle when there are claimable vouchers", async () => { + const { manager, storage, facilitator } = buildManager(); + const session = buildSession({ chargedCumulativeAmount: "5000" }); + await storeChannel(storage, session); + + const result = await manager.claimAndSettle(); + expect(result.claims).toHaveLength(1); + expect(result.settle?.transaction).toBe("0xtx"); + expect(facilitator.settle).toHaveBeenCalledTimes(2); + }); + + it("does not settle when the claim selector returns no claim work", async () => { + const { manager, storage, facilitator } = buildManager(); + const session = buildSession({ chargedCumulativeAmount: "5000" }); + await storeChannel(storage, session); + + const result = await manager.claimAndSettle({ + selectClaimChannels: () => [], + }); + + expect(result.claims).toEqual([]); + expect(result.settle).toBeUndefined(); + expect(facilitator.settle).not.toHaveBeenCalled(); + expect((await storage.get(session.channelId))?.totalClaimed).toBe("0"); + }); +}); + +describe("BatchSettlementChannelManager — refund()", () => { + it("returns no channels when storage is empty", async () => { + const { manager, facilitator } = buildManager(); + const result = await manager.refund(); + expect(result).toEqual([]); + expect(facilitator.settle).not.toHaveBeenCalled(); + }); + + it("filters by provided channel ids (case-insensitive)", async () => { + const { manager, storage, facilitator } = buildManager(); + const session = buildSession({ chargedCumulativeAmount: "1000", balance: "10000" }); + await storeChannel(storage, session); + + const idUpper = session.channelId.toUpperCase().replace("0X", "0x"); + const result = await manager.refund([idUpper]); + + expect(result).toEqual([{ channel: session.channelId, transaction: "0xtx" }]); + expect(facilitator.settle).toHaveBeenCalledTimes(1); + const [paymentPayload] = facilitator.settle.mock.calls[0]; + const payload = paymentPayload.payload as Record; + expect(payload.type).toBe("refund"); + expect(payload.amount).toBe("9000"); + expect(payload.chargedCumulativeAmount).toBeUndefined(); + }); + + it("includes outstanding voucher claims and deletes session on success", async () => { + const { manager, storage } = buildManager(); + const session = buildSession({ + chargedCumulativeAmount: "3000", + totalClaimed: "1000", + balance: "10000", + }); + await storeChannel(storage, session); + + await manager.refund(); + + const stored = await storage.get(session.channelId); + expect(stored).toBeUndefined(); + }); + + it("refunds multiple channels with one facilitator transaction per channel", async () => { + const { manager, storage, facilitator } = buildManager(); + for (let i = 0; i < 2; i++) { + const config = buildChannelConfig(i.toString(16)); + await storeChannel( + storage, + buildSession({ + channelConfig: config, + channelId: computeChannelId(config), + chargedCumulativeAmount: "1000", + balance: "10000", + }), + ); + } + + const result = await manager.refund(); + + expect(result).toHaveLength(2); + expect(facilitator.settle).toHaveBeenCalledTimes(2); + for (const { channel } of result) { + expect(await storage.get(channel)).toBeUndefined(); + } + }); + + it("refundIdleChannels only refunds channels idle long enough", async () => { + const { manager, storage } = buildManager(); + const idleConfig = buildChannelConfig("01"); + const freshConfig = buildChannelConfig("02"); + const idle = buildSession({ + channelConfig: idleConfig, + channelId: computeChannelId(idleConfig), + lastRequestTimestamp: Date.now() - 120_000, + }); + const fresh = buildSession({ + channelConfig: freshConfig, + channelId: computeChannelId(freshConfig), + lastRequestTimestamp: Date.now(), + }); + await storeChannel(storage, idle); + await storeChannel(storage, fresh); + + const result = await manager.refundIdleChannels({ idleSecs: 60 }); + + expect(result).toEqual([{ channel: idle.channelId, transaction: "0xtx" }]); + expect(await storage.get(idle.channelId)).toBeUndefined(); + expect(await storage.get(fresh.channelId)).toBeDefined(); + }); + + it("throws when facilitator reports failure", async () => { + const facilitator = buildFacilitator(async () => ({ + success: false, + errorReason: "boom", + errorMessage: "refund reverted", + transaction: "", + network: NETWORK, + })); + const { manager, storage } = buildManager({ facilitator }); + const session = buildSession({ chargedCumulativeAmount: "1000", balance: "10000" }); + await storeChannel(storage, session); + + await expect(manager.refund()).rejects.toThrow(/Refund failed/); + + const stored = await storage.get(session.channelId); + expect(stored).toBeDefined(); + }); +}); + +describe("BatchSettlementChannelManager — start()/stop() loop", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("schedules configured auto job timers and clears them on stop", async () => { + const { manager } = buildManager(); + const setIntervalSpy = vi.spyOn(global, "setInterval"); + const clearIntervalSpy = vi.spyOn(global, "clearInterval"); + + manager.start({ claimIntervalSecs: 1, settleIntervalSecs: 2, refundIntervalSecs: 3 }); + expect(setIntervalSpy).toHaveBeenCalledTimes(3); + + await manager.stop(); + expect(clearIntervalSpy).toHaveBeenCalledTimes(3); + }); + + it("is a no-op to call start twice (single timer)", () => { + const { manager } = buildManager(); + const setIntervalSpy = vi.spyOn(global, "setInterval"); + + manager.start({ claimIntervalSecs: 1 }); + manager.start({ settleIntervalSecs: 1 }); + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + }); + + it("flushes pending claims and settles on stop({ flush: true })", async () => { + const { manager, storage, facilitator } = buildManager(); + const session = buildSession({ chargedCumulativeAmount: "5000" }); + await storeChannel(storage, session); + + manager.start(); + await manager.stop({ flush: true }); + + expect(facilitator.settle).toHaveBeenCalledTimes(2); + }); + + it("flushes pending claims and settles without refunding on stop({ flush: true })", async () => { + const { manager, storage } = buildManager(); + const session = buildSession({ chargedCumulativeAmount: "1000", balance: "10000" }); + await storeChannel(storage, session); + + const onRefund = vi.fn<(r: RefundResult) => void>(); + manager.start({ onRefund }); + await manager.stop({ flush: true }); + + expect(onRefund).not.toHaveBeenCalled(); + expect(await storage.get(session.channelId)).toBeDefined(); + }); + + it("uses the configured claim selector during stop({ flush: true })", async () => { + const { manager, storage, facilitator } = buildManager(); + const selectedConfig = buildChannelConfig("01"); + const skippedConfig = buildChannelConfig("02"); + const selected = buildSession({ + channelConfig: selectedConfig, + channelId: computeChannelId(selectedConfig), + chargedCumulativeAmount: "5000", + }); + const skipped = buildSession({ + channelConfig: skippedConfig, + channelId: computeChannelId(skippedConfig), + chargedCumulativeAmount: "7000", + }); + await storeChannel(storage, selected); + await storeChannel(storage, skipped); + + manager.start({ + selectClaimChannels: channels => + channels.filter(channel => channel.channelId === selected.channelId), + }); + await manager.stop({ flush: true }); + + expect(facilitator.settle).toHaveBeenCalledTimes(2); + expect((await storage.get(selected.channelId))?.totalClaimed).toBe("5000"); + expect((await storage.get(skipped.channelId))?.totalClaimed).toBe("0"); + }); + + it("forwards flush errors from claimAndSettle", async () => { + const facilitator = buildFacilitator(async payload => { + const action = (payload.payload as Record).type; + if (action === "claim") { + return { + success: false, + errorReason: "boom", + errorMessage: "claim reverted", + transaction: "", + network: NETWORK, + }; + } + return { success: true, transaction: "0xtx", network: NETWORK }; + }); + const { manager, storage } = buildManager({ facilitator }); + const session = buildSession({ chargedCumulativeAmount: "1000", balance: "10000" }); + await storeChannel(storage, session); + + manager.start(); + + await expect(manager.stop({ flush: true })).rejects.toThrow(/Claim failed/); + }); +}); + +describe("BatchSettlementChannelManager — auto-loop tick policies", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("runs aligned claim and settle timers in priority order", async () => { + const { manager, storage, facilitator } = buildManager(); + const session = buildSession({ chargedCumulativeAmount: "5000" }); + await storeChannel(storage, session); + + const onClaim = vi.fn<(r: ClaimResult) => void>(); + const onSettle = vi.fn<(r: SettleResult) => void>(); + manager.start({ + claimIntervalSecs: 1, + settleIntervalSecs: 1, + onClaim, + onSettle, + }); + + await vi.advanceTimersByTimeAsync(1100); + await vi.runAllTicks(); + + await manager.stop(); + expect(onClaim).toHaveBeenCalled(); + expect(onSettle).toHaveBeenCalled(); + const settleTypes = facilitator.settle.mock.calls.map( + ([p]) => (p.payload as Record).type, + ); + expect(settleTypes).toEqual(["claim", "settle"]); + }); + + it("coalesces repeated same-type timer events while a job is running", async () => { + vi.useRealTimers(); + const flushMicrotasks = async () => { + for (let i = 0; i < 20; i++) { + await Promise.resolve(); + } + }; + let onClaimInterval: () => void = () => {}; + const setIntervalSpy = vi + .spyOn(globalThis, "setInterval") + .mockImplementation((handler: TimerHandler) => { + onClaimInterval = typeof handler === "function" ? (handler as () => void) : () => {}; + return 999 as unknown as ReturnType; + }); + const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {}); + + const { manager } = buildManager(); + let releaseFirstSelection: (() => void) | undefined; + const firstSelection = new Promise(resolve => { + releaseFirstSelection = resolve; + }); + const selectClaimChannels = vi.fn(async (channels: Channel[]) => { + if (selectClaimChannels.mock.calls.length === 1) { + await firstSelection; + } + return channels; + }); + + try { + manager.start({ + claimIntervalSecs: 1, + selectClaimChannels, + }); + + onClaimInterval(); + await flushMicrotasks(); + expect(selectClaimChannels).toHaveBeenCalledTimes(1); + + onClaimInterval(); + onClaimInterval(); + + releaseFirstSelection?.(); + await flushMicrotasks(); + + await manager.stop(); + expect(selectClaimChannels).toHaveBeenCalledTimes(2); + } finally { + setIntervalSpy.mockRestore(); + clearIntervalSpy.mockRestore(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2025-01-01T00:00:00Z")); + } + }); + + it("settles pending claims without reading the full channel list", async () => { + const { manager, storage, facilitator } = buildManager(); + const session = buildSession({ chargedCumulativeAmount: "5000" }); + await storeChannel(storage, session); + await manager.claim(); + facilitator.settle.mockClear(); + + const listSpy = vi.spyOn(storage, "list"); + const onSettle = vi.fn<(r: SettleResult) => void>(); + manager.start({ + settleIntervalSecs: 1, + onSettle, + }); + + await vi.advanceTimersByTimeAsync(1100); + await vi.runAllTicks(); + + await manager.stop(); + expect(onSettle).toHaveBeenCalled(); + expect(listSpy).not.toHaveBeenCalled(); + const settleTypes = facilitator.settle.mock.calls.map( + ([p]) => (p.payload as Record).type, + ); + expect(settleTypes).toEqual(["settle"]); + }); + + it("claims only channels selected by the auto claim selector", async () => { + const { manager, storage } = buildManager(); + const selectedConfig = buildChannelConfig("01"); + const skippedConfig = buildChannelConfig("02"); + const selected = buildSession({ + channelConfig: selectedConfig, + channelId: computeChannelId(selectedConfig), + chargedCumulativeAmount: "5000", + }); + const skipped = buildSession({ + channelConfig: skippedConfig, + channelId: computeChannelId(skippedConfig), + chargedCumulativeAmount: "7000", + }); + await storeChannel(storage, selected); + await storeChannel(storage, skipped); + + manager.start({ + claimIntervalSecs: 1, + selectClaimChannels: channels => + channels.filter(channel => channel.channelId === selected.channelId), + }); + + await vi.advanceTimersByTimeAsync(1100); + await vi.runAllTicks(); + + await manager.stop(); + expect((await storage.get(selected.channelId))?.totalClaimed).toBe("5000"); + expect((await storage.get(skipped.channelId))?.totalClaimed).toBe("0"); + }); + + it("does not refund or read channels without a refund selector", async () => { + const { manager, storage, facilitator } = buildManager(); + const listSpy = vi.spyOn(storage, "list"); + const onRefund = vi.fn<(r: RefundResult) => void>(); + + manager.start({ refundIntervalSecs: 1, onRefund }); + + await vi.advanceTimersByTimeAsync(1100); + await vi.runAllTicks(); + + await manager.stop(); + expect(listSpy).not.toHaveBeenCalled(); + expect(onRefund).not.toHaveBeenCalled(); + expect(facilitator.settle).not.toHaveBeenCalled(); + }); + + it("refunds only channels selected by the refund selector", async () => { + const { manager, storage } = buildManager(); + const selectedConfig = buildChannelConfig("01"); + const skippedConfig = buildChannelConfig("02"); + const selected = buildSession({ + channelConfig: selectedConfig, + channelId: computeChannelId(selectedConfig), + balance: "10000", + }); + const skipped = buildSession({ + channelConfig: skippedConfig, + channelId: computeChannelId(skippedConfig), + balance: "10000", + }); + await storeChannel(storage, selected); + await storeChannel(storage, skipped); + + const onRefund = vi.fn<(r: RefundResult) => void>(); + manager.start({ + refundIntervalSecs: 1, + selectRefundChannels: channels => + channels.filter(channel => channel.channelId === selected.channelId), + onRefund, + }); + + await vi.advanceTimersByTimeAsync(1100); + await vi.runAllTicks(); + + await manager.stop(); + expect(onRefund).toHaveBeenCalledWith({ channel: selected.channelId, transaction: "0xtx" }); + expect(await storage.get(selected.channelId)).toBeUndefined(); + expect(await storage.get(skipped.channelId)).toBeDefined(); + }); + + it("invokes onError when an auto job throws", async () => { + const facilitator = buildFacilitator(async () => { + throw new Error("network down"); + }); + const { manager, storage } = buildManager({ facilitator }); + const session = buildSession({ chargedCumulativeAmount: "5000" }); + await storeChannel(storage, session); + + const onError = vi.fn<(e: unknown) => void>(); + manager.start({ claimIntervalSecs: 1, onError }); + + await vi.advanceTimersByTimeAsync(1100); + await vi.runAllTicks(); + + await manager.stop(); + expect(onError).toHaveBeenCalled(); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/client.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/client.test.ts new file mode 100644 index 0000000000..0760defb28 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/client.test.ts @@ -0,0 +1,1223 @@ +import { describe, it, expect, vi } from "vitest"; +import { privateKeyToAccount } from "viem/accounts"; +import { getAddress } from "viem"; +import { x402HTTPClient } from "@x402/core/client"; +import { BatchSettlementEvmScheme } from "../../../src/batch-settlement/client/scheme"; +import { + type BatchSettlementClientDeps, + buildChannelConfig, + getChannel, + hasChannel, + processSettleResponse, + recoverChannel, + updateChannelAfterRefund, +} from "../../../src/batch-settlement/client/channel"; +import { processCorrectivePaymentRequired } from "../../../src/batch-settlement/client/recovery"; +import { InMemoryClientChannelStorage } from "../../../src/batch-settlement/client/storage"; +import { computeChannelId as computeChannelIdForNetwork } from "../../../src/batch-settlement/utils"; +import { PERMIT2_ADDRESS } from "../../../src/constants"; +import { PERMIT2_DEPOSIT_COLLECTOR_ADDRESS } from "../../../src/batch-settlement/constants"; +import { + isBatchSettlementDepositPayload, + isBatchSettlementVoucherPayload, +} from "../../../src/batch-settlement/types"; +import { createBatchSettlementClientHooks } from "../../../src/batch-settlement/client/hooks"; +import type { ClientEvmSigner } from "../../../src/signer"; +import type { + PaymentPayload, + PaymentRequirements, + SettleResponse, + PaymentRequired, +} from "@x402/core/types"; +import * as Errors from "../../../src/batch-settlement/errors"; + +const PAYER_PRIVATE_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; +const VOUCHER_PRIVATE_KEY = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"; +const RECEIVER_ADDRESS = "0x9876543210987654321098765432109876543210" as `0x${string}`; +const RECEIVER_AUTHORIZER = "0x1111111111111111111111111111111111111111" as `0x${string}`; +const ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; +const NETWORK = "eip155:84532"; +const DEFAULT_SALT = + "0x0000000000000000000000000000000000000000000000000000000000000000" as `0x${string}`; + +function computeChannelId(config: ReturnType): `0x${string}` { + return computeChannelIdForNetwork(config, NETWORK); +} + +function buildSigner(privateKey: `0x${string}`): ClientEvmSigner { + const account = privateKeyToAccount(privateKey); + return { + address: account.address, + signTypedData: msg => + account.signTypedData({ + domain: msg.domain, + types: msg.types, + primaryType: msg.primaryType, + message: msg.message, + } as Parameters[0]), + }; +} + +function buildSignerWithRead( + privateKey: `0x${string}`, + readContract: ClientEvmSigner["readContract"], +): ClientEvmSigner { + const base = buildSigner(privateKey); + return { ...base, readContract }; +} + +function makeRequirements(overrides?: Partial): PaymentRequirements { + return { + scheme: "batch-settlement", + network: NETWORK, + amount: "1000", + asset: ASSET, + payTo: RECEIVER_ADDRESS, + maxTimeoutSeconds: 3600, + extra: { + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + withdrawDelay: 900, + }, + ...overrides, + }; +} + +function makePaymentPayload(payload: Record): PaymentPayload { + return { + x402Version: 2, + accepted: makeRequirements(), + payload, + }; +} + +interface ClientShape { + signer: ClientEvmSigner; + storage?: InMemoryClientChannelStorage; + salt?: `0x${string}`; + payerAuthorizer?: `0x${string}`; + voucherSigner?: ClientEvmSigner; +} + +function makeDeps(c: ClientShape): BatchSettlementClientDeps { + return { + signer: c.signer, + storage: c.storage ?? new InMemoryClientChannelStorage(), + salt: c.salt ?? DEFAULT_SALT, + payerAuthorizer: c.payerAuthorizer, + voucherSigner: c.voucherSigner, + }; +} + +describe("BatchSettlementEvmScheme — construction", () => { + it("exposes the batch-settlement scheme id", () => { + const client = new BatchSettlementEvmScheme(buildSigner(PAYER_PRIVATE_KEY)); + expect(client.scheme).toBe("batch-settlement"); + }); + + it("accepts a bare deposit policy as second argument", () => { + const client = new BatchSettlementEvmScheme(buildSigner(PAYER_PRIVATE_KEY), { + depositMultiplier: 5, + }); + expect(client).toBeDefined(); + }); + + it("accepts full options object", () => { + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(buildSigner(PAYER_PRIVATE_KEY), { + storage, + depositPolicy: { depositMultiplier: 3 }, + salt: "0x0000000000000000000000000000000000000000000000000000000000000077", + }); + expect(client).toBeDefined(); + }); + + it("rejects non-integer depositMultiplier", () => { + expect( + () => + new BatchSettlementEvmScheme(buildSigner(PAYER_PRIVATE_KEY), { + depositMultiplier: 1.5, + }), + ).toThrow(/depositMultiplier/); + }); + + it("rejects depositMultiplier < 3", () => { + expect( + () => + new BatchSettlementEvmScheme(buildSigner(PAYER_PRIVATE_KEY), { + depositMultiplier: 2, + }), + ).toThrow(/depositMultiplier/); + }); + + it("rejects payerAuthorizer that does not match voucherSigner", () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const voucherSigner = buildSigner(VOUCHER_PRIVATE_KEY); + expect( + () => + new BatchSettlementEvmScheme(signer, { + payerAuthorizer: "0x0000000000000000000000000000000000000001", + voucherSigner, + }), + ).toThrow(/payerAuthorizer address must match voucherSigner.address/); + }); + + it("accepts payerAuthorizer matching voucherSigner.address", () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const voucherSigner = buildSigner(VOUCHER_PRIVATE_KEY); + expect( + () => + new BatchSettlementEvmScheme(signer, { + payerAuthorizer: voucherSigner.address, + voucherSigner, + }), + ).not.toThrow(); + }); +}); + +describe("buildChannelConfig", () => { + it("uses signer's address as payer and payerAuthorizer when not overridden", () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + + expect(config.payer).toBe(signer.address); + expect(config.payerAuthorizer).toBe(getAddress(signer.address)); + expect(config.receiver).toBe(RECEIVER_ADDRESS); + expect(config.token).toBe(ASSET); + expect(config.withdrawDelay).toBe(900); + expect(config.salt).toBe("0x0000000000000000000000000000000000000000000000000000000000000000"); + }); + + it("uses payerAuthorizer override when provided", () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const voucherSigner = buildSigner(VOUCHER_PRIVATE_KEY); + const config = buildChannelConfig( + makeDeps({ signer, voucherSigner, payerAuthorizer: voucherSigner.address }), + makeRequirements(), + ); + expect(config.payerAuthorizer).toBe(getAddress(voucherSigner.address)); + }); + + it("falls back to voucherSigner.address when payerAuthorizer is not set", () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const voucherSigner = buildSigner(VOUCHER_PRIVATE_KEY); + const config = buildChannelConfig(makeDeps({ signer, voucherSigner }), makeRequirements()); + expect(config.payerAuthorizer).toBe(getAddress(voucherSigner.address)); + }); + + it("uses receiverAuthorizer from extra when present", () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const recv = "0x2222222222222222222222222222222222222222" as `0x${string}`; + const cfg = buildChannelConfig( + makeDeps({ signer }), + makeRequirements({ extra: { receiverAuthorizer: recv } }), + ); + expect(cfg.receiverAuthorizer).toBe(getAddress(recv)); + }); + + it("throws when receiverAuthorizer is missing or zero", () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + expect(() => buildChannelConfig(makeDeps({ signer }), makeRequirements({ extra: {} }))).toThrow( + /receiverAuthorizer/, + ); + expect(() => + buildChannelConfig( + makeDeps({ signer }), + makeRequirements({ + extra: { receiverAuthorizer: "0x0000000000000000000000000000000000000000" }, + }), + ), + ).toThrow(/receiverAuthorizer/); + }); + + it("defaults withdrawDelay to 900 when not in extra", () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const cfg = buildChannelConfig( + makeDeps({ signer }), + makeRequirements({ extra: { receiverAuthorizer: RECEIVER_AUTHORIZER } }), + ); + expect(cfg.withdrawDelay).toBe(900); + }); + + it("respects custom salt from options", () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const salt = + "0xabc1230000000000000000000000000000000000000000000000000000000099" as `0x${string}`; + const cfg = buildChannelConfig(makeDeps({ signer, salt }), makeRequirements()); + expect(cfg.salt).toBe(salt); + }); +}); + +describe("BatchSettlementEvmScheme — createPaymentPayload", () => { + it("returns a deposit-then-voucher payload on first request (no balance)", async () => { + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, async () => [0n, 0n]); + const client = new BatchSettlementEvmScheme(signer); + + const result = await client.createPaymentPayload(2, makeRequirements()); + expect(result.x402Version).toBe(2); + expect(isBatchSettlementDepositPayload(result.payload as Record)).toBe(true); + }); + + it("voucher.maxClaimableAmount equals charged + amount on first request", async () => { + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, async () => [0n, 0n]); + const client = new BatchSettlementEvmScheme(signer); + + const result = await client.createPaymentPayload(2, makeRequirements({ amount: "5000" })); + const payload = result.payload as { voucher: { maxClaimableAmount: string } }; + expect(payload.voucher.maxClaimableAmount).toBe("5000"); + }); + + it("returns a voucher-only payload when balance is sufficient", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage }); + + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "0", + balance: "10000", + totalClaimed: "0", + }); + + const result = await client.createPaymentPayload(2, makeRequirements({ amount: "1000" })); + expect(isBatchSettlementVoucherPayload(result.payload as Record)).toBe(true); + expect( + (result.payload as { voucher: { maxClaimableAmount: string } }).voucher.maxClaimableAmount, + ).toBe("1000"); + }); + + it("creates a top-up deposit when balance is insufficient", async () => { + const readContract = vi.fn().mockResolvedValue([100n, 0n]); + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, readContract); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage }); + + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "100", + balance: "100", + totalClaimed: "0", + }); + + const result = await client.createPaymentPayload(2, makeRequirements({ amount: "1000" })); + expect(isBatchSettlementDepositPayload(result.payload as Record)).toBe(true); + }); + + it("allows depositStrategy to skip a top-up and return a voucher", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const depositStrategy = vi.fn(() => false); + const client = new BatchSettlementEvmScheme(signer, { + storage, + depositStrategy, + }); + + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "0", + balance: "100", + totalClaimed: "0", + }); + + const result = await client.createPaymentPayload(2, makeRequirements({ amount: "1000" })); + expect(isBatchSettlementVoucherPayload(result.payload as Record)).toBe(true); + expect(depositStrategy).toHaveBeenCalledTimes(1); + }); + + it("createPaymentPayload keeps refund requests on the refund() path", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage }); + + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "0", + balance: "10000", + totalClaimed: "0", + }); + + const result = await client.createPaymentPayload(2, makeRequirements()); + expect(result.payload.type).toBe("voucher"); + expect( + (client as unknown as { requestRefund?: (id: string) => void }).requestRefund, + ).toBeUndefined(); + }); + + it("uses voucherSigner to sign the voucher when provided", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const voucherSigner = buildSigner(VOUCHER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage, voucherSigner }); + + const config = buildChannelConfig(makeDeps({ signer, voucherSigner }), makeRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "0", + balance: "10000", + totalClaimed: "0", + }); + + const result = await client.createPaymentPayload(2, makeRequirements()); + expect(isBatchSettlementVoucherPayload(result.payload as Record)).toBe(true); + }); + + it("computed channelId matches the on-the-wire payload channelId", async () => { + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, async () => [0n, 0n]); + const client = new BatchSettlementEvmScheme(signer); + + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const expectedId = computeChannelId(config); + + const result = await client.createPaymentPayload(2, makeRequirements()); + const payload = result.payload as { voucher: { channelId: string } }; + expect(payload.voucher.channelId.toLowerCase()).toBe(expectedId.toLowerCase()); + }); + + it("throws when EIP-712 domain (name/version) is missing for deposit flow", async () => { + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, async () => [0n, 0n]); + const client = new BatchSettlementEvmScheme(signer); + + await expect( + client.createPaymentPayload( + 2, + makeRequirements({ + extra: { receiverAuthorizer: RECEIVER_AUTHORIZER, withdrawDelay: 900 }, + }), + ), + ).rejects.toThrow(/EIP-712 domain parameters/); + }); + + it("respects depositMultiplier in deposit amount", async () => { + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, async () => [0n, 0n]); + const client = new BatchSettlementEvmScheme(signer, { depositMultiplier: 7 }); + + const result = await client.createPaymentPayload(2, makeRequirements({ amount: "1000" })); + const payload = result.payload as { deposit: { amount: string } }; + expect(payload.deposit.amount).toBe("7000"); + }); + + it("allows depositStrategy to cap deposits when the cap covers the request", async () => { + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, async () => [0n, 0n]); + const client = new BatchSettlementEvmScheme(signer, { + depositPolicy: { depositMultiplier: 100 }, + depositStrategy: ({ depositAmount }) => { + const maxDeposit = 5000n; + const amount = BigInt(depositAmount); + return amount > maxDeposit ? maxDeposit : amount; + }, + }); + const result = await client.createPaymentPayload(2, makeRequirements({ amount: "1000" })); + const payload = result.payload as { deposit: { amount: string } }; + expect(payload.deposit.amount).toBe("5000"); + }); + + it("calls depositStrategy for initial deposits and top-ups", async () => { + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, async () => [0n, 0n]); + const storage = new InMemoryClientChannelStorage(); + const depositStrategy = vi.fn(({ depositAmount }) => depositAmount); + const client = new BatchSettlementEvmScheme(signer, { storage, depositStrategy }); + + await client.createPaymentPayload(2, makeRequirements({ amount: "1000" })); + + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "100", + balance: "100", + totalClaimed: "0", + }); + + await client.createPaymentPayload(2, makeRequirements({ amount: "1000" })); + + expect(depositStrategy).toHaveBeenCalledTimes(2); + expect(depositStrategy.mock.calls[0][0]).toMatchObject({ + requestAmount: "1000", + maxClaimableAmount: "1000", + currentBalance: "0", + minimumDepositAmount: "1000", + depositAmount: "5000", + }); + expect(depositStrategy.mock.calls[1][0]).toMatchObject({ + requestAmount: "1000", + maxClaimableAmount: "1100", + currentBalance: "100", + minimumDepositAmount: "1000", + depositAmount: "5000", + }); + }); + + it("allows depositStrategy to skip an initial deposit", async () => { + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, async () => [0n, 0n]); + const depositStrategy = vi.fn(() => false); + const client = new BatchSettlementEvmScheme(signer, { depositStrategy }); + + const result = await client.createPaymentPayload(2, makeRequirements({ amount: "1000" })); + + expect(isBatchSettlementVoucherPayload(result.payload as Record)).toBe(true); + expect(depositStrategy).toHaveBeenCalledTimes(1); + }); + + it("rejects insufficient strategy-returned deposit amounts before signing", async () => { + const baseSigner = buildSigner(PAYER_PRIVATE_KEY); + const signer = { + ...baseSigner, + signTypedData: vi.fn(baseSigner.signTypedData), + }; + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { + storage, + depositStrategy: () => "999", + }); + + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "100", + balance: "100", + totalClaimed: "0", + }); + + await expect( + client.createPaymentPayload(2, makeRequirements({ amount: "1000" })), + ).rejects.toThrow(/below required top-up/); + expect(signer.signTypedData).not.toHaveBeenCalled(); + }); + + it("creates a Permit2 deposit payload when requested by assetTransferMethod", async () => { + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, async () => [0n, 0n]); + const client = new BatchSettlementEvmScheme(signer); + const result = await client.createPaymentPayload( + 2, + makeRequirements({ + extra: { + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + assetTransferMethod: "permit2", + }, + }), + ); + + expect(isBatchSettlementDepositPayload(result.payload as Record)).toBe(true); + const payload = result.payload as { + voucher: { channelId: `0x${string}` }; + deposit: { + amount: string; + authorization: { + permit2Authorization: { + from: `0x${string}`; + permitted: { token: `0x${string}`; amount: string }; + spender: `0x${string}`; + witness: { channelId: `0x${string}` }; + }; + }; + }; + }; + + const auth = payload.deposit.authorization.permit2Authorization; + expect(payload.deposit.amount).toBe("5000"); + expect(auth.from).toBe(signer.address); + expect(auth.permitted.token).toBe(ASSET); + expect(auth.permitted.amount).toBe(payload.deposit.amount); + expect(auth.spender).toBe(getAddress(PERMIT2_DEPOSIT_COLLECTOR_ADDRESS)); + expect(auth.witness.channelId).toBe(payload.voucher.channelId); + }); + + it("signs EIP-2612 Permit2 approval for deposit.amount", async () => { + const storage = new InMemoryClientChannelStorage(); + const baseSigner = buildSigner(PAYER_PRIVATE_KEY); + const config = buildChannelConfig( + makeDeps({ signer: baseSigner, storage }), + makeRequirements(), + ); + await storage.set(computeChannelId(config).toLowerCase(), {}); + + const readContract = vi.fn(async ({ functionName }: { functionName: string }) => { + if (functionName === "allowance") return 0n; + if (functionName === "nonces") return 7n; + return [0n, 0n]; + }); + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, readContract); + const client = new BatchSettlementEvmScheme(signer, { storage }); + const result = await client.createPaymentPayload( + 2, + makeRequirements({ + extra: { + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + assetTransferMethod: "permit2", + }, + }), + { extensions: { eip2612GasSponsoring: {} } } as never, + ); + + const extensions = result.extensions as + | Record }> + | undefined; + const info = extensions?.eip2612GasSponsoring?.info as + | { amount?: string; spender?: string } + | undefined; + expect(info?.amount).toBe("5000"); + expect(info?.spender).toBe(getAddress(PERMIT2_ADDRESS)); + }); +}); + +describe("processSettleResponse / schemeHooks", () => { + it("updates session fields from settle response extras", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + + const channelId = "0xabc1230000000000000000000000000000000000000000000000000000000001"; + + const settle: SettleResponse = { + success: true, + transaction: "0x", + network: NETWORK, + payer: signer.address, + extra: { + channelState: { + channelId, + chargedCumulativeAmount: "1000", + balance: "9000", + totalClaimed: "500", + }, + }, + }; + + await processSettleResponse(storage, settle); + const ctx = await storage.get(channelId.toLowerCase()); + expect(ctx?.chargedCumulativeAmount).toBe("1000"); + expect(ctx?.balance).toBe("9000"); + expect(ctx?.totalClaimed).toBe("500"); + }); + + it("ignores settle responses with no channelId", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + + await processSettleResponse(storage, { + success: true, + transaction: "0x", + network: NETWORK, + payer: signer.address, + extra: {}, + } as SettleResponse); + + const all = await Promise.all( + ["0xabc1230000000000000000000000000000000000000000000000000000000001"].map(id => + storage.get(id), + ), + ); + expect(all.every(c => c === undefined)).toBe(true); + }); + + it("deletes channel record after a full refund response", async () => { + const storage = new InMemoryClientChannelStorage(); + + const channelId = "0xabc1230000000000000000000000000000000000000000000000000000000002"; + await storage.set(channelId.toLowerCase(), { chargedCumulativeAmount: "1000" }); + + await updateChannelAfterRefund(storage, channelId.toLowerCase(), { + channelState: { channelId, balance: "0" }, + }); + + expect(await storage.get(channelId.toLowerCase())).toBeUndefined(); + }); + + it("schemeHooks.onPaymentResponse delegates to processSettleResponse", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage }); + + const channelId = "0xabc1230000000000000000000000000000000000000000000000000000000003"; + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: makePaymentPayload({ type: "voucher" }), + requirements: makeRequirements(), + settleResponse: { + success: true, + transaction: "0x", + network: NETWORK, + payer: signer.address, + extra: { channelState: { channelId, chargedCumulativeAmount: "42" } }, + } as SettleResponse, + } as Parameters>[0]); + + const ctx = await storage.get(channelId.toLowerCase()); + expect(ctx?.chargedCumulativeAmount).toBe("42"); + }); + + it("routes refund settle responses through refund reconciliation", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const deps = makeDeps({ signer, storage }); + const hooks = createBatchSettlementClientHooks(deps); + const channelId = "0xabc1230000000000000000000000000000000000000000000000000000000004"; + const config = buildChannelConfig(deps, makeRequirements()); + + await storage.set(channelId.toLowerCase(), { chargedCumulativeAmount: "1000" }); + await hooks.onPaymentResponse!({ + paymentPayload: makePaymentPayload({ + type: "refund", + channelConfig: config, + voucher: { + channelId, + maxClaimableAmount: "1000", + signature: "0xdead", + }, + }), + requirements: makeRequirements(), + settleResponse: { + success: true, + transaction: "0x", + network: NETWORK, + payer: signer.address, + extra: { channelState: { channelId, balance: "0" } }, + } as SettleResponse, + }); + + expect(await storage.get(channelId.toLowerCase())).toBeUndefined(); + + await hooks.onPaymentResponse!({ + paymentPayload: makePaymentPayload({ type: "voucher" }), + requirements: makeRequirements(), + settleResponse: { + success: true, + transaction: "0x", + network: NETWORK, + payer: signer.address, + extra: { channelState: { channelId, balance: "0" } }, + } as SettleResponse, + }); + + expect((await storage.get(channelId.toLowerCase()))?.balance).toBe("0"); + }); +}); + +describe("recoverChannel / hasChannel / getChannel", () => { + it("recoverChannel reads on-chain channels() and stores context", async () => { + const readContract = vi.fn().mockResolvedValue([5000n, 1000n]); + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, readContract); + const storage = new InMemoryClientChannelStorage(); + + const ctx = await recoverChannel(makeDeps({ signer, storage }), makeRequirements()); + expect(ctx.balance).toBe("5000"); + expect(ctx.totalClaimed).toBe("1000"); + expect(ctx.chargedCumulativeAmount).toBe("1000"); + + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const id = computeChannelId(config); + expect((await storage.get(id.toLowerCase()))?.balance).toBe("5000"); + }); + + it("recoverChannel throws when readContract is unavailable", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + await expect(recoverChannel(makeDeps({ signer, storage }), makeRequirements())).rejects.toThrow( + /readContract/, + ); + }); + + it("hasChannel / getChannel reflect storage state", async () => { + const storage = new InMemoryClientChannelStorage(); + + const id = "0xabc1230000000000000000000000000000000000000000000000000000000010"; + expect(await hasChannel(storage, id)).toBe(false); + expect(await getChannel(storage, id)).toBeUndefined(); + + await storage.set(id.toLowerCase(), { chargedCumulativeAmount: "100" }); + expect(await hasChannel(storage, id)).toBe(true); + expect((await getChannel(storage, id))?.chargedCumulativeAmount).toBe("100"); + }); +}); + +describe("processCorrectivePaymentRequired", () => { + function makeAccept( + channelState: Record, + voucherState: Record, + ): PaymentRequirements { + return makeRequirements({ + extra: { ...makeRequirements().extra, channelState, voucherState }, + }); + } + + it("returns false for unrelated error codes", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + + const ok = await processCorrectivePaymentRequired(makeDeps({ signer, storage }), { + x402Version: 2, + error: "some_other_error", + accepts: [makeRequirements()], + } as unknown as PaymentRequired); + expect(ok).toBe(false); + }); + + it("returns false when no batch-settlement accept entry is present", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + + const ok = await processCorrectivePaymentRequired(makeDeps({ signer, storage }), { + x402Version: 2, + error: Errors.ErrCumulativeAmountMismatch, + accepts: [{ ...makeRequirements(), scheme: "exact" }], + } as unknown as PaymentRequired); + expect(ok).toBe(false); + }); + + it("recoverFromOnChainState updates session when no signature is present", async () => { + const readContract = vi.fn().mockResolvedValue([10000n, 2500n]); + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, readContract); + const storage = new InMemoryClientChannelStorage(); + + const ok = await processCorrectivePaymentRequired(makeDeps({ signer, storage }), { + x402Version: 2, + error: Errors.ErrCumulativeAmountMismatch, + accepts: [makeRequirements()], + } as unknown as PaymentRequired); + + expect(ok).toBe(true); + const id = computeChannelId(buildChannelConfig(makeDeps({ signer }), makeRequirements())); + const ctx = await storage.get(id.toLowerCase()); + expect(ctx?.chargedCumulativeAmount).toBe("2500"); + expect(ctx?.balance).toBe("10000"); + }); + + it("recoverFromSignature succeeds when signature comes from this client's signer", async () => { + const readContract = vi.fn().mockResolvedValue([10000n, 500n]); + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, readContract); + const storage = new InMemoryClientChannelStorage(); + + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const channelId = computeChannelId(config); + + const { signVoucher } = await import("../../../src/batch-settlement/client/voucher"); + const signed = await signVoucher(signer, channelId, "1500", NETWORK); + + const channelState = { + chargedCumulativeAmount: "1000", + }; + const voucherState = { + signedMaxClaimable: signed.maxClaimableAmount, + signature: signed.signature, + }; + const accept = makeAccept(channelState, voucherState); + + const ok = await processCorrectivePaymentRequired(makeDeps({ signer, storage }), { + x402Version: 2, + error: Errors.ErrCumulativeAmountMismatch, + accepts: [accept], + } as unknown as PaymentRequired); + + expect(ok).toBe(true); + const ctx = await storage.get(channelId.toLowerCase()); + expect(ctx?.chargedCumulativeAmount).toBe("1000"); + expect(ctx?.signedMaxClaimable).toBe("1500"); + expect(ctx?.balance).toBe("10000"); + expect(ctx?.totalClaimed).toBe("500"); + }); + + it("recoverFromSignature returns false when signature is from a different signer", async () => { + const readContract = vi.fn().mockResolvedValue([10000n, 500n]); + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, readContract); + const storage = new InMemoryClientChannelStorage(); + + const otherSigner = buildSigner(VOUCHER_PRIVATE_KEY); + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const channelId = computeChannelId(config); + const { signVoucher } = await import("../../../src/batch-settlement/client/voucher"); + const signed = await signVoucher(otherSigner, channelId, "1500", NETWORK); + + const channelState = { + chargedCumulativeAmount: "1000", + }; + const voucherState = { + signedMaxClaimable: signed.maxClaimableAmount, + signature: signed.signature, + }; + const accept = makeAccept(channelState, voucherState); + + const ok = await processCorrectivePaymentRequired(makeDeps({ signer, storage }), { + x402Version: 2, + error: Errors.ErrCumulativeAmountMismatch, + accepts: [accept], + } as unknown as PaymentRequired); + expect(ok).toBe(false); + }); + + it("recoverFromSignature returns false when charged > signedMaxClaimable", async () => { + const readContract = vi.fn().mockResolvedValue([10000n, 500n]); + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, readContract); + const storage = new InMemoryClientChannelStorage(); + + const channelState = { + chargedCumulativeAmount: "2000", + }; + const voucherState = { + signedMaxClaimable: "1500", + signature: "0xdead", + }; + const accept = makeAccept(channelState, voucherState); + + const ok = await processCorrectivePaymentRequired(makeDeps({ signer, storage }), { + x402Version: 2, + error: Errors.ErrCumulativeAmountMismatch, + accepts: [accept], + } as unknown as PaymentRequired); + expect(ok).toBe(false); + }); + + it("recoverFromSignature returns false when charged < on-chain totalClaimed", async () => { + const readContract = vi.fn().mockResolvedValue([10000n, 5000n]); + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, readContract); + const storage = new InMemoryClientChannelStorage(); + + const config = buildChannelConfig(makeDeps({ signer }), makeRequirements()); + const channelId = computeChannelId(config); + const { signVoucher } = await import("../../../src/batch-settlement/client/voucher"); + const signed = await signVoucher(signer, channelId, "2000", NETWORK); + + const channelState = { + chargedCumulativeAmount: "1000", + }; + const voucherState = { + signedMaxClaimable: signed.maxClaimableAmount, + signature: signed.signature, + }; + const accept = makeAccept(channelState, voucherState); + + const ok = await processCorrectivePaymentRequired(makeDeps({ signer, storage }), { + x402Version: 2, + error: Errors.ErrCumulativeAmountMismatch, + accepts: [accept], + } as unknown as PaymentRequired); + expect(ok).toBe(false); + }); + + it("schemeHooks.onPaymentResponse returns { recovered: true } on a successful corrective", async () => { + const readContract = vi.fn().mockResolvedValue([10000n, 0n]); + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, readContract); + const client = new BatchSettlementEvmScheme(signer); + + const result = await client.schemeHooks.onPaymentResponse!({ + paymentPayload: makePaymentPayload({ type: "voucher" }), + requirements: makeRequirements(), + paymentRequired: { + x402Version: 2, + error: Errors.ErrCumulativeAmountMismatch, + accepts: [makeRequirements()], + } as unknown as PaymentRequired, + } as Parameters>[0]); + + expect(result).toEqual({ recovered: true }); + }); +}); + +describe("BatchSettlementEvmScheme — refund()", () => { + const REFUND_URL = "https://example.test/protected"; + + function buildRefundRequirements(): PaymentRequirements { + return makeRequirements({ + extra: { + name: "USDC", + version: "2", + withdrawDelay: 900, + receiverAuthorizer: "0x1111111111111111111111111111111111111111", + }, + }); + } + + function makeFetch( + handlers: Array<(input: RequestInfo | URL, init?: RequestInit) => Promise>, + ): typeof fetch { + let i = 0; + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const handler = handlers[i++] ?? handlers[handlers.length - 1]; + return handler(input, init); + }) as typeof fetch; + } + + async function probe402Response(): Promise { + const { encodePaymentRequiredHeader } = await import("@x402/core/http"); + const reqs = buildRefundRequirements(); + const header = encodePaymentRequiredHeader({ + x402Version: 2, + accepts: [reqs], + } as unknown as PaymentRequired); + return new Response(null, { status: 402, headers: { "PAYMENT-REQUIRED": header } }); + } + + async function refundSuccessResponse( + extra: Record, + amount?: string, + ): Promise { + const { encodePaymentResponseHeader } = await import("@x402/core/http"); + const settle: SettleResponse = { + success: true, + transaction: "0xtx", + network: NETWORK, + payer: privateKeyToAccount(PAYER_PRIVATE_KEY).address, + ...(amount !== undefined ? { amount } : {}), + extra, + } as SettleResponse; + const header = encodePaymentResponseHeader(settle); + return new Response(null, { status: 200, headers: { "PAYMENT-RESPONSE": header } }); + } + + it("performs a full refund: probes, sends voucher, deletes channel record", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage }); + + const config = buildChannelConfig(makeDeps({ signer }), buildRefundRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "500", + balance: "10000", + totalClaimed: "0", + }); + + let capturedSig: string | undefined; + const fetchImpl = makeFetch([ + async () => probe402Response(), + async (_url, init) => { + capturedSig = (init?.headers as Record | undefined)?.["PAYMENT-SIGNATURE"]; + return refundSuccessResponse( + { + channelState: { + channelId, + balance: "0", + totalClaimed: "500", + withdrawRequestedAt: 0, + refundNonce: "1", + chargedCumulativeAmount: "500", + }, + }, + "9500", + ); + }, + ]); + const processSpy = vi.spyOn(x402HTTPClient.prototype, "processPaymentResult"); + + const settle = await client.refund(REFUND_URL, { fetch: fetchImpl }); + expect(settle.success).toBe(true); + expect(settle.amount).toBe("9500"); + expect(settle.extra?.channelState).toMatchObject({ + channelId, + balance: "0", + chargedCumulativeAmount: "500", + }); + expect(processSpy).toHaveBeenCalledTimes(1); + expect(capturedSig).toBeTruthy(); + const { decodePaymentSignatureHeader } = await import("@x402/core/http"); + const sentPayload = decodePaymentSignatureHeader(capturedSig!); + expect(sentPayload.payload.type).toBe("refund"); + expect(sentPayload.accepted).toEqual(buildRefundRequirements()); + expect((sentPayload.payload as { amount?: string }).amount).toBeUndefined(); + expect(await storage.get(channelId.toLowerCase())).toBeUndefined(); + processSpy.mockRestore(); + }); + + it("performs a partial refund: keeps the channel record and updates balance", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage }); + + const config = buildChannelConfig(makeDeps({ signer }), buildRefundRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "500", + balance: "10000", + totalClaimed: "0", + }); + + const fetchImpl = makeFetch([ + async () => probe402Response(), + async () => + refundSuccessResponse({ + channelState: { + channelId, + balance: "8000", + chargedCumulativeAmount: "500", + totalClaimed: "0", + }, + }), + ]); + + const settle = await client.refund(REFUND_URL, { amount: "2000", fetch: fetchImpl }); + expect(settle.success).toBe(true); + + const ctx = await storage.get(channelId.toLowerCase()); + expect(ctx?.balance).toBe("8000"); + expect(ctx?.chargedCumulativeAmount).toBe("500"); + }); + + it("rejects an invalid refund amount before contacting the server", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const client = new BatchSettlementEvmScheme(signer); + const fetchImpl = vi.fn() as unknown as typeof fetch; + + await expect(client.refund(REFUND_URL, { amount: "0", fetch: fetchImpl })).rejects.toThrow( + /Invalid refund amount/, + ); + await expect(client.refund(REFUND_URL, { amount: "1.5", fetch: fetchImpl })).rejects.toThrow( + /Invalid refund amount/, + ); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("recovers from a corrective 402 and retries once", async () => { + const readContract = vi.fn().mockResolvedValue([10000n, 500n]); + const signer = buildSignerWithRead(PAYER_PRIVATE_KEY, readContract); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage }); + + const config = buildChannelConfig(makeDeps({ signer }), buildRefundRequirements()); + const channelId = computeChannelId(config); + + const { encodePaymentRequiredHeader } = await import("@x402/core/http"); + const correctiveHeader = encodePaymentRequiredHeader({ + x402Version: 2, + error: Errors.ErrCumulativeAmountBelowClaimed, + accepts: [buildRefundRequirements()], + } as PaymentRequired); + + const fetchImpl = makeFetch([ + async () => probe402Response(), + async () => + new Response(null, { status: 402, headers: { "PAYMENT-REQUIRED": correctiveHeader } }), + async () => refundSuccessResponse({ channelState: { channelId, balance: "0" } }), + ]); + const processSpy = vi.spyOn(x402HTTPClient.prototype, "processPaymentResult"); + + const settle = await client.refund(REFUND_URL, { fetch: fetchImpl }); + expect(settle.success).toBe(true); + expect(readContract).toHaveBeenCalled(); + expect(processSpy).toHaveBeenCalledTimes(2); + processSpy.mockRestore(); + }); + + it("throws when the probe receives a non-402 response", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const client = new BatchSettlementEvmScheme(signer); + const fetchImpl = makeFetch([async () => new Response("ok", { status: 200 })]); + + await expect(client.refund(REFUND_URL, { fetch: fetchImpl })).rejects.toThrow( + /Refund probe expected 402/, + ); + }); + + it("fails fast (no retry) when server returns 402 with refund no balance error", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage }); + + const config = buildChannelConfig(makeDeps({ signer }), buildRefundRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "500", + balance: "10000", + totalClaimed: "0", + }); + + const { encodePaymentResponseHeader } = await import("@x402/core/http"); + const failureSettle: SettleResponse = { + success: false, + transaction: "", + network: NETWORK, + payer: signer.address, + errorReason: Errors.ErrRefundNoBalance, + errorMessage: "Channel has no remaining balance to refund", + } as SettleResponse; + const failureHeader = encodePaymentResponseHeader(failureSettle); + + const refundCall = vi.fn(async () => { + return new Response(null, { status: 402, headers: { "PAYMENT-RESPONSE": failureHeader } }); + }); + const fetchImpl = makeFetch([async () => probe402Response(), refundCall]); + + await expect(client.refund(REFUND_URL, { fetch: fetchImpl })).rejects.toThrow( + new RegExp(Errors.ErrRefundNoBalance), + ); + expect(refundCall).toHaveBeenCalledTimes(1); + }); + + it("fails fast on a verify-side 402 with a non-recoverable refund error", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage }); + + const config = buildChannelConfig(makeDeps({ signer }), buildRefundRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "500", + balance: "10000", + totalClaimed: "0", + }); + + const { encodePaymentRequiredHeader } = await import("@x402/core/http"); + const requiredHeader = encodePaymentRequiredHeader({ + x402Version: 2, + error: Errors.ErrRefundNoBalance, + accepts: [buildRefundRequirements()], + } as PaymentRequired); + + const refundCall = vi.fn(async () => { + return new Response(null, { status: 402, headers: { "PAYMENT-REQUIRED": requiredHeader } }); + }); + const fetchImpl = makeFetch([async () => probe402Response(), refundCall]); + + await expect(client.refund(REFUND_URL, { fetch: fetchImpl })).rejects.toThrow( + new RegExp(Errors.ErrRefundNoBalance), + ); + expect(refundCall).toHaveBeenCalledTimes(1); + }); + + it("throws before any PAYMENT-SIGNATURE request when local session shows the channel is drained", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const storage = new InMemoryClientChannelStorage(); + const client = new BatchSettlementEvmScheme(signer, { storage }); + + const config = buildChannelConfig(makeDeps({ signer }), buildRefundRequirements()); + const channelId = computeChannelId(config); + await storage.set(channelId.toLowerCase(), { + chargedCumulativeAmount: "61800", + balance: "61800", + totalClaimed: "61800", + }); + + const refundCall = vi.fn(async () => { + throw new Error("refund request should not have been sent"); + }); + const fetchImpl = makeFetch([async () => probe402Response(), refundCall]); + + await expect(client.refund(REFUND_URL, { fetch: fetchImpl })).rejects.toThrow( + /channel has no remaining balance/, + ); + expect(refundCall).not.toHaveBeenCalled(); + }); + + it("throws when the receiver lacks a configured receiverAuthorizer", async () => { + const signer = buildSigner(PAYER_PRIVATE_KEY); + const client = new BatchSettlementEvmScheme(signer); + + const { encodePaymentRequiredHeader } = await import("@x402/core/http"); + const reqs = makeRequirements({ extra: { name: "USDC", version: "2", withdrawDelay: 900 } }); + const header = encodePaymentRequiredHeader({ + x402Version: 2, + accepts: [reqs], + } as unknown as PaymentRequired); + const fetchImpl = makeFetch([ + async () => new Response(null, { status: 402, headers: { "PAYMENT-REQUIRED": header } }), + ]); + + await expect(client.refund(REFUND_URL, { fetch: fetchImpl })).rejects.toThrow( + /receiverAuthorizer/, + ); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/facilitator.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/facilitator.test.ts new file mode 100644 index 0000000000..0861cc28c5 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/facilitator.test.ts @@ -0,0 +1,1118 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { MockedFunction } from "vitest"; +import { privateKeyToAccount } from "viem/accounts"; +import { encodeAbiParameters, encodeEventTopics, getAddress, isAddress } from "viem"; +import type { Log } from "viem"; + +vi.mock("../../../src/multicall", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, multicall: vi.fn() }; +}); + +import { multicall } from "../../../src/multicall"; +import { BatchSettlementEvmScheme } from "../../../src/batch-settlement/facilitator/scheme"; +import { computeChannelId as computeChannelIdForNetwork } from "../../../src/batch-settlement/utils"; +import { + BATCH_SETTLEMENT_ADDRESS, + ERC3009_DEPOSIT_COLLECTOR_ADDRESS, + PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, +} from "../../../src/batch-settlement/constants"; +import { batchSettlementABI } from "../../../src/batch-settlement/abi"; +import * as Errors from "../../../src/batch-settlement/errors"; +import type { + ChannelConfig, + AuthorizerSigner, + BatchSettlementDepositPayload, + BatchSettlementVoucherPayload, + BatchSettlementRefundPayload, + BatchSettlementClaimPayload, + BatchSettlementSettlePayload, + BatchSettlementEnrichedRefundPayload, +} from "../../../src/batch-settlement/types"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; + +const mockedMulticall = multicall as unknown as MockedFunction; + +const PAYER = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as `0x${string}`; +const RECEIVER = "0x9876543210987654321098765432109876543210" as `0x${string}`; +const ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; +const FACILITATOR_ADDRESS = "0xFAC11174700123456789012345678901234aBCDe" as `0x${string}`; +const NETWORK = "eip155:84532"; + +function computeChannelId(config: ChannelConfig): `0x${string}` { + return computeChannelIdForNetwork(config, NETWORK); +} + +function buildAuthorizerSigner(): AuthorizerSigner { + const account = privateKeyToAccount( + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + ); + return { + address: account.address, + signTypedData: msg => + account.signTypedData({ + domain: msg.domain, + types: msg.types, + primaryType: msg.primaryType, + message: msg.message, + } as Parameters[0]), + }; +} + +const ZERO_ADDR = "0x0000000000000000000000000000000000000000" as `0x${string}`; +const RECEIVER_AUTHORIZER = "0x1111111111111111111111111111111111111111" as `0x${string}`; + +function buildChannelConfig(overrides: Partial = {}): ChannelConfig { + return { + payer: PAYER, + payerAuthorizer: ZERO_ADDR, + receiver: RECEIVER, + receiverAuthorizer: RECEIVER_AUTHORIZER, + token: ASSET, + withdrawDelay: 900, + salt: "0x0000000000000000000000000000000000000000000000000000000000000000", + ...overrides, + }; +} + +function makeRequirements(overrides: Partial = {}): PaymentRequirements { + return { + scheme: "batch-settlement", + network: NETWORK, + amount: "1000", + asset: ASSET, + payTo: RECEIVER, + maxTimeoutSeconds: 3600, + extra: { + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + assetTransferMethod: "eip3009", + withdrawDelay: 900, + }, + ...overrides, + }; +} + +function buildSigner(overrides: Partial = {}): FacilitatorEvmSigner { + return { + getAddresses: () => [FACILITATOR_ADDRESS], + readContract: vi.fn().mockResolvedValue(undefined), + verifyTypedData: vi.fn().mockResolvedValue(true), + writeContract: vi.fn().mockResolvedValue("0xtxhash" as `0x${string}`), + sendTransaction: vi.fn(), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), + getCode: vi.fn(), + ...overrides, + }; +} + +function buildSettledLog( + overrides: { + receiver?: `0x${string}`; + token?: `0x${string}`; + sender?: `0x${string}`; + amount?: string; + address?: `0x${string}`; + } = {}, +): Log { + const receiver = overrides.receiver ?? RECEIVER; + const token = overrides.token ?? ASSET; + const sender = overrides.sender ?? FACILITATOR_ADDRESS; + + return { + address: overrides.address ?? BATCH_SETTLEMENT_ADDRESS, + topics: encodeEventTopics({ + abi: batchSettlementABI, + eventName: "Settled", + args: { receiver, token, sender }, + }), + data: encodeAbiParameters([{ type: "uint128" }], [BigInt(overrides.amount ?? "2500")]), + blockHash: null, + blockNumber: null, + logIndex: null, + transactionHash: null, + transactionIndex: null, + removed: false, + } as Log; +} + +function envelopeVoucher(payload: BatchSettlementVoucherPayload): PaymentPayload { + return { + x402Version: 2, + accepted: { scheme: "batch-settlement", network: NETWORK }, + payload: payload as unknown as Record, + } as unknown as PaymentPayload; +} + +function envelopeRefund(payload: BatchSettlementRefundPayload): PaymentPayload { + return { + x402Version: 2, + accepted: { scheme: "batch-settlement", network: NETWORK }, + payload: payload as unknown as Record, + } as unknown as PaymentPayload; +} + +function envelopeDeposit(payload: BatchSettlementDepositPayload): PaymentPayload { + return { + x402Version: 2, + accepted: { scheme: "batch-settlement", network: NETWORK }, + payload: payload as unknown as Record, + } as unknown as PaymentPayload; +} + +function envelopeSettle(payload: Record): PaymentPayload { + return { + x402Version: 2, + accepted: { scheme: "batch-settlement", network: NETWORK }, + payload, + } as unknown as PaymentPayload; +} + +beforeEach(() => { + mockedMulticall.mockReset(); +}); + +describe("BatchSettlementEvmScheme (Facilitator) — construction & metadata", () => { + const authorizer = buildAuthorizerSigner(); + + it("exposes scheme id and CAIP family", () => { + const scheme = new BatchSettlementEvmScheme(buildSigner(), authorizer); + expect(scheme.scheme).toBe("batch-settlement"); + expect(scheme.caipFamily).toBe("eip155:*"); + }); + + it("getExtra returns the receiver-authorizer address from authorizerSigner", () => { + const scheme = new BatchSettlementEvmScheme(buildSigner(), authorizer); + expect(scheme.getExtra(NETWORK)).toEqual({ receiverAuthorizer: authorizer.address }); + }); + + it("getSigners returns the facilitator addresses", () => { + const scheme = new BatchSettlementEvmScheme(buildSigner(), authorizer); + expect(scheme.getSigners(NETWORK)).toEqual([FACILITATOR_ADDRESS]); + }); +}); + +describe("BatchSettlementEvmScheme (Facilitator) — verify routing", () => { + const authorizer = buildAuthorizerSigner(); + + it("rejects with InvalidScheme when accepted.scheme mismatches", async () => { + const scheme = new BatchSettlementEvmScheme(buildSigner(), authorizer); + const config = buildChannelConfig(); + const result = await scheme.verify( + { + x402Version: 2, + accepted: { scheme: "exact", network: NETWORK }, + payload: { type: "voucher", channelConfig: config } as Record, + } as unknown as PaymentPayload, + makeRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrInvalidScheme); + }); + + it("rejects with NetworkMismatch when accepted.network mismatches requirements", async () => { + const scheme = new BatchSettlementEvmScheme(buildSigner(), authorizer); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const voucher: BatchSettlementVoucherPayload = { + type: "voucher", + channelConfig: config, + voucher: { + channelId, + maxClaimableAmount: "1000", + signature: "0xdead", + }, + }; + const result = await scheme.verify( + { + x402Version: 2, + accepted: { scheme: "batch-settlement", network: "eip155:1" }, + payload: voucher as unknown as Record, + } as unknown as PaymentPayload, + makeRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrNetworkMismatch); + }); + + it("rejects with InvalidPayloadType for an unknown payload shape", async () => { + const scheme = new BatchSettlementEvmScheme(buildSigner(), authorizer); + const result = await scheme.verify( + { + x402Version: 2, + accepted: { scheme: "batch-settlement", network: NETWORK }, + payload: { foo: "bar" } as Record, + } as unknown as PaymentPayload, + makeRequirements(), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrInvalidPayloadType); + }); +}); + +describe("BatchSettlementEvmScheme (Facilitator) — verifyVoucher", () => { + const authorizer = buildAuthorizerSigner(); + + function makeVoucherPayload( + overrides: { + config?: ChannelConfig; + voucher?: Partial; + } = {}, + ): { payload: PaymentPayload; channelId: `0x${string}`; config: ChannelConfig } { + const config = overrides.config ?? buildChannelConfig(); + const channelId = computeChannelId(config); + const voucher: BatchSettlementVoucherPayload = { + type: "voucher", + channelConfig: config, + voucher: { + channelId, + maxClaimableAmount: overrides.voucher?.maxClaimableAmount ?? "1000", + signature: overrides.voucher?.signature ?? ("0xdead" as `0x${string}`), + }, + }; + return { payload: envelopeVoucher(voucher), channelId, config }; + } + + it("returns isValid=true with channel state in extra on happy path", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [10000n, 0n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload, channelId } = makeVoucherPayload(); + + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(true); + expect(result.payer).toBe(PAYER); + expect(result.extra?.channelId).toBe(channelId); + expect(result.extra?.balance).toBe("10000"); + expect(result.extra?.totalClaimed).toBe("0"); + }); + + it("returns InvalidVoucherSignature when verifyTypedData fails", async () => { + const signer = buildSigner({ verifyTypedData: vi.fn().mockResolvedValue(false) }); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig({ + payerAuthorizer: "0x0000000000000000000000000000000000000000", + }); + const channelId = computeChannelId(config); + const payload = envelopeVoucher({ + type: "voucher", + channelConfig: config, + voucher: { + channelId, + maxClaimableAmount: "1000", + signature: "0xdead", + }, + }); + + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrInvalidVoucherSignature); + }); + + it("uses ECDSA path (not ERC-1271) when payerAuthorizer is non-zero", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const account = privateKeyToAccount( + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", + ); + const config = buildChannelConfig({ payerAuthorizer: account.address }); + const channelId = computeChannelId(config); + const sig = await account.signTypedData({ + domain: { + name: "x402 Batch Settlement", + version: "1", + chainId: 84532, + verifyingContract: getAddress(BATCH_SETTLEMENT_ADDRESS), + }, + types: { + Voucher: [ + { name: "channelId", type: "bytes32" }, + { name: "maxClaimableAmount", type: "uint128" }, + ], + }, + primaryType: "Voucher", + message: { channelId, maxClaimableAmount: 1000n }, + }); + + mockedMulticall.mockResolvedValue([ + { status: "success", result: [10000n, 0n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + + const payload = envelopeVoucher({ + type: "voucher", + channelConfig: config, + voucher: { + channelId, + maxClaimableAmount: "1000", + signature: sig, + }, + }); + + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(true); + expect(signer.verifyTypedData).not.toHaveBeenCalled(); + }); + + it("propagates ErrRpcReadFailed when multicall reads fail", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "failure", error: new Error("revert") }, + { status: "failure", error: new Error("revert") }, + { status: "failure", error: new Error("revert") }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = makeVoucherPayload(); + + await expect(scheme.verify(payload, makeRequirements())).rejects.toThrow( + Errors.ErrRpcReadFailed, + ); + }); + + it("returns ChannelNotFound when balance is zero", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [0n, 0n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = makeVoucherPayload(); + + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrChannelNotFound); + }); + + it("returns CumulativeExceedsBalance when maxClaimable > balance", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [500n, 0n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = makeVoucherPayload({ voucher: { maxClaimableAmount: "1000" } }); + + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrCumulativeExceedsBalance); + }); + + it("returns CumulativeAmountBelowClaimed when maxClaimable <= totalClaimed", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [10000n, 1000n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = makeVoucherPayload({ voucher: { maxClaimableAmount: "1000" } }); + + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrCumulativeAmountBelowClaimed); + }); + + it("accepts a refund payload whose maxClaimable equals totalClaimed", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [10000n, 1000n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const refundVoucher: BatchSettlementRefundPayload = { + type: "refund", + channelConfig: config, + voucher: { + channelId, + maxClaimableAmount: "1000", + signature: "0xdead", + }, + }; + + const result = await scheme.verify(envelopeRefund(refundVoucher), makeRequirements()); + expect(result.isValid).toBe(true); + }); + + it("still rejects a refund payload whose maxClaimable is below totalClaimed", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [10000n, 1000n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const refundVoucher: BatchSettlementRefundPayload = { + type: "refund", + channelConfig: config, + voucher: { + channelId, + maxClaimableAmount: "500", + signature: "0xdead", + }, + }; + + const result = await scheme.verify(envelopeRefund(refundVoucher), makeRequirements()); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrCumulativeAmountBelowClaimed); + }); + + it("returns ChannelIdMismatch when payload channelId does not match config", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig(); + const payload = envelopeVoucher({ + type: "voucher", + channelConfig: config, + voucher: { + channelId: + "0x0000000000000000000000000000000000000000000000000000000000000099" as `0x${string}`, + maxClaimableAmount: "1000", + signature: "0xdead", + }, + }); + + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrChannelIdMismatch); + }); +}); + +describe("BatchSettlementEvmScheme (Facilitator) — verifyDeposit", () => { + const authorizer = buildAuthorizerSigner(); + + function buildDeposit(overrides: Partial = {}): { + payload: PaymentPayload; + channelId: `0x${string}`; + } { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const now = Math.floor(Date.now() / 1000); + const dp: BatchSettlementDepositPayload = { + type: "deposit", + channelConfig: config, + voucher: { + channelId, + maxClaimableAmount: "1000", + signature: "0xcafebabe", + }, + deposit: { + amount: "10000", + authorization: { + erc3009Authorization: { + validAfter: String(now - 600), + validBefore: String(now + 3600), + salt: "0x0000000000000000000000000000000000000000000000000000000000000001", + signature: "0xfeedface", + }, + }, + ...overrides, + }, + }; + return { payload: envelopeDeposit(dp), channelId }; + } + + it("returns isValid=true on the happy path", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [0n, 0n] }, + { status: "success", result: 1_000_000n }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload, channelId } = buildDeposit(); + + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(true); + expect(result.payer).toBe(PAYER); + expect(result.extra?.channelId).toBe(channelId); + }); + + it("returns InsufficientBalance when payer balance < deposit amount", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [0n, 0n] }, + { status: "success", result: 1n }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = buildDeposit(); + + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrInsufficientBalance); + }); + + it("returns InvalidReceiveAuthorizationSignature when verifyTypedData fails", async () => { + const signer = buildSigner({ verifyTypedData: vi.fn().mockResolvedValue(false) }); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = buildDeposit(); + + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrInvalidReceiveAuthorizationSignature); + }); + + it("returns ErrErc3009AuthorizationRequired when authorization is absent", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const dp: BatchSettlementDepositPayload = { + type: "deposit", + channelConfig: config, + voucher: { channelId, maxClaimableAmount: "1000", signature: "0xcafebabe" }, + deposit: { + amount: "10000", + authorization: {} as BatchSettlementDepositPayload["deposit"]["authorization"], + }, + }; + const result = await scheme.verify(envelopeDeposit(dp), makeRequirements()); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrErc3009AuthorizationRequired); + }); + + it("returns ErrMissingEip712Domain when extra lacks name/version", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = buildDeposit(); + const reqs = makeRequirements({ + extra: { receiverAuthorizer: RECEIVER_AUTHORIZER, assetTransferMethod: "eip3009" }, + }); + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrMissingEip712Domain); + }); + + it("returns ErrInvalidPayloadType when assetTransferMethod is not eip3009", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = buildDeposit(); + const reqs = makeRequirements({ + extra: { + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + assetTransferMethod: "permit2", + }, + }); + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrInvalidPayloadType); + }); + + it("returns ErrValidBeforeExpired when validBefore is in the past", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = buildDeposit({ + authorization: { + erc3009Authorization: { + validAfter: "0", + validBefore: "1", + salt: "0x0000000000000000000000000000000000000000000000000000000000000001", + signature: "0xfeedface", + }, + }, + }); + const result = await scheme.verify(payload, makeRequirements()); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrValidBeforeExpired); + }); + + function buildPermit2Deposit( + overrides: Partial< + NonNullable + > = {}, + ): { payload: PaymentPayload; channelId: `0x${string}` } { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const now = Math.floor(Date.now() / 1000); + const authorization = { + from: PAYER, + permitted: { token: ASSET, amount: "10000" }, + spender: PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, + nonce: "123", + deadline: String(now + 3600), + witness: { channelId }, + signature: "0xfeedface" as `0x${string}`, + ...overrides, + }; + const dp: BatchSettlementDepositPayload = { + type: "deposit", + channelConfig: config, + voucher: { channelId, maxClaimableAmount: "1000", signature: "0xcafebabe" }, + deposit: { + amount: "10000", + authorization: { permit2Authorization: authorization }, + }, + }; + return { payload: envelopeDeposit(dp), channelId }; + } + + it("accepts a Permit2 deposit and simulates with the Permit2 collector", async () => { + const readContract = vi.fn(async ({ functionName }: { functionName: string }) => { + if (functionName === "allowance") return 1_000_000n; + return undefined; + }); + const signer = buildSigner({ readContract }); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [0n, 0n] }, + { status: "success", result: 1_000_000n }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = buildPermit2Deposit(); + + const result = await scheme.verify( + payload, + makeRequirements({ + extra: { + assetTransferMethod: "permit2", + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + }, + }), + ); + + expect(result.isValid).toBe(true); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "deposit", + args: expect.arrayContaining([getAddress(PERMIT2_DEPOSIT_COLLECTOR_ADDRESS)]), + }), + ); + }); + + it("rejects Permit2 deposits with a wrong spender", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = buildPermit2Deposit({ + spender: "0x0000000000000000000000000000000000000001", + }); + + const result = await scheme.verify( + payload, + makeRequirements({ + extra: { + assetTransferMethod: "permit2", + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + }, + }), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrPermit2InvalidSpender); + }); + + it("rejects Permit2 deposits whose amount differs from deposit.amount", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = buildPermit2Deposit({ + permitted: { token: ASSET, amount: "9999" }, + }); + + const result = await scheme.verify( + payload, + makeRequirements({ + extra: { + assetTransferMethod: "permit2", + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + }, + }), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrPermit2AmountMismatch); + }); + + it("rejects Permit2 deposits without Permit2 allowance or sponsoring data", async () => { + const readContract = vi.fn(async ({ functionName }: { functionName: string }) => { + if (functionName === "allowance") return 1n; + return undefined; + }); + const signer = buildSigner({ readContract }); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const { payload } = buildPermit2Deposit(); + + const result = await scheme.verify( + payload, + makeRequirements({ + extra: { + assetTransferMethod: "permit2", + name: "USDC", + version: "2", + receiverAuthorizer: RECEIVER_AUTHORIZER, + }, + }), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrPermit2AllowanceRequired); + }); +}); + +describe("BatchSettlementEvmScheme (Facilitator) — settle routing", () => { + const authorizer = buildAuthorizerSigner(); + + it("returns InvalidPayloadType for an unknown settle payload", async () => { + const scheme = new BatchSettlementEvmScheme(buildSigner(), authorizer); + const result = await scheme.settle(envelopeSettle({ unknown: true }), makeRequirements()); + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrInvalidPayloadType); + }); + + it("dispatches deposit settle payloads via settleDeposit", async () => { + const signer = buildSigner(); + // verifyDeposit uses a 4-call batch; post-tx readChannelState uses 3 calls with a + // different shape — reusing the 4-tuple for the second batch mis-associates + // token balance with pendingWithdrawals and throws. + mockedMulticall + .mockResolvedValueOnce([ + { status: "success", result: [0n, 0n] }, + { status: "success", result: 1_000_000n }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]) + .mockResolvedValue([ + { status: "success", result: [10_000n, 0n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const now = Math.floor(Date.now() / 1000); + + const dp: BatchSettlementDepositPayload = { + type: "deposit", + channelConfig: config, + voucher: { channelId, maxClaimableAmount: "1000", signature: "0xcafebabe" }, + deposit: { + amount: "10000", + authorization: { + erc3009Authorization: { + validAfter: String(now - 600), + validBefore: String(now + 3600), + salt: "0x0000000000000000000000000000000000000000000000000000000000000001", + signature: "0xfeedface", + }, + }, + }, + }; + + const result = await scheme.settle(envelopeDeposit(dp), makeRequirements()); + expect(result.success).toBe(true); + expect(result.amount).toBe("10000"); + expect(result.extra).toMatchObject({ + channelState: { + channelId, + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: "0", + }, + }); + expect(signer.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + functionName: "deposit", + }), + ); + }); + + it('rejects voucher-less type:"deposit" envelopes as unknown payload type', async () => { + const scheme = new BatchSettlementEvmScheme(buildSigner(), authorizer); + const config = buildChannelConfig(); + const now = Math.floor(Date.now() / 1000); + + const voucherLessDeposit = { + type: "deposit", + channelConfig: config, + deposit: { + amount: "10000", + authorization: { + erc3009Authorization: { + validAfter: String(now - 600), + validBefore: String(now + 3600), + salt: "0x0000000000000000000000000000000000000000000000000000000000000002", + signature: "0xfeedface", + }, + }, + }, + }; + + const result = await scheme.settle( + envelopeSettle(voucherLessDeposit as unknown as Record), + makeRequirements(), + ); + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrInvalidPayloadType); + }); + + it("dispatches settle payloads via executeSettle", async () => { + const signer = buildSigner({ + waitForTransactionReceipt: vi.fn().mockResolvedValue({ + status: "success", + logs: [buildSettledLog({ amount: "4321" })], + }), + }); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const sp: BatchSettlementSettlePayload = { + type: "settle", + receiver: RECEIVER, + token: ASSET, + }; + const result = await scheme.settle( + envelopeSettle(sp as unknown as Record), + makeRequirements(), + ); + expect(result.success).toBe(true); + expect(result.amount).toBe("4321"); + expect(signer.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "settle", + }), + ); + }); + + it("returns zero amount for no-op settle receipts without a Settled event", async () => { + const signer = buildSigner({ + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success", logs: [] }), + }); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const sp: BatchSettlementSettlePayload = { + type: "settle", + receiver: RECEIVER, + token: ASSET, + }; + const result = await scheme.settle( + envelopeSettle(sp as unknown as Record), + makeRequirements(), + ); + expect(result.success).toBe(true); + expect(result.amount).toBe("0"); + }); + + it("returns empty amount when settle receipt logs are unavailable", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const sp: BatchSettlementSettlePayload = { + type: "settle", + receiver: RECEIVER, + token: ASSET, + }; + const result = await scheme.settle( + envelopeSettle(sp as unknown as Record), + makeRequirements(), + ); + expect(result.success).toBe(true); + expect(result.amount).toBe(""); + }); + + it("dispatches claim payloads via executeClaimWithSignature", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig({ receiverAuthorizer: authorizer.address }); + const cp: BatchSettlementClaimPayload = { + type: "claim", + claims: [ + { + voucher: { channel: config, maxClaimableAmount: "1000" }, + signature: "0xcafe", + totalClaimed: "1000", + }, + ], + }; + const result = await scheme.settle( + envelopeSettle(cp as unknown as Record), + makeRequirements(), + ); + expect(result.success).toBe(true); + expect(result.amount).toBe(""); + expect(signer.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "claimWithSignature" }), + ); + }); + + it("returns AuthorizerAddressMismatch when claim authorizer doesn't match config", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig({ + receiverAuthorizer: "0x1111111111111111111111111111111111111111", + }); + const cp: BatchSettlementClaimPayload = { + type: "claim", + claims: [ + { + voucher: { channel: config, maxClaimableAmount: "1000" }, + signature: "0xcafe", + totalClaimed: "1000", + }, + ], + }; + const result = await scheme.settle( + envelopeSettle(cp as unknown as Record), + makeRequirements(), + ); + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrAuthorizerAddressMismatch); + }); + + it("dispatches enriched refund payloads via executeRefundWithSignature", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [10000n, 0n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig({ receiverAuthorizer: authorizer.address }); + const channelId = computeChannelId(config); + const rp: BatchSettlementEnrichedRefundPayload = { + type: "refund", + channelConfig: config, + voucher: { + channelId, + maxClaimableAmount: "0", + signature: "0xdead", + }, + amount: "9000", + refundNonce: "0", + claims: [], + }; + const result = await scheme.settle( + envelopeSettle(rp as unknown as Record), + makeRequirements(), + ); + expect(result.success).toBe(true); + expect(result.amount).toBe("9000"); + expect(result.extra).toMatchObject({ + channelState: { + channelId, + balance: "1000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: "1", + }, + }); + expect(mockedMulticall).toHaveBeenCalledTimes(1); + expect(signer.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "refundWithSignature" }), + ); + }); + + it("polls post-refund state when a withdrawal is pending", async () => { + const signer = buildSigner(); + mockedMulticall + .mockResolvedValueOnce([ + { status: "success", result: [10000n, 0n] }, + { status: "success", result: [5000n, 1234n] }, + { status: "success", result: 7n }, + ]) + .mockResolvedValueOnce([ + { status: "success", result: [8000n, 0n] }, + { status: "success", result: [3000n, 1234n] }, + { status: "success", result: 8n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const config = buildChannelConfig({ receiverAuthorizer: authorizer.address }); + const channelId = computeChannelId(config); + const rp: BatchSettlementEnrichedRefundPayload = { + type: "refund", + channelConfig: config, + voucher: { + channelId, + maxClaimableAmount: "0", + signature: "0xdead", + }, + amount: "2000", + refundNonce: "7", + claims: [], + }; + + const result = await scheme.settle( + envelopeSettle(rp as unknown as Record), + makeRequirements(), + ); + + expect(result.success).toBe(true); + expect(result.amount).toBe("2000"); + expect(result.extra).toMatchObject({ + channelState: { + channelId, + balance: "8000", + withdrawRequestedAt: 1234, + refundNonce: "8", + }, + }); + expect(mockedMulticall).toHaveBeenCalledTimes(2); + }); + + it("returns ErrSettleSimulationFailed when settle simulation reverts", async () => { + const signer = buildSigner({ + readContract: vi.fn().mockRejectedValue(new Error("revert")), + }); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const sp: BatchSettlementSettlePayload = { + type: "settle", + receiver: RECEIVER, + token: ASSET, + }; + const result = await scheme.settle( + envelopeSettle(sp as unknown as Record), + makeRequirements(), + ); + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettleSimulationFailed); + }); + + it("returns ErrSettleTransactionFailed when settle receipt is not success", async () => { + const signer = buildSigner({ + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "reverted" }), + }); + const scheme = new BatchSettlementEvmScheme(signer, authorizer); + const sp: BatchSettlementSettlePayload = { + type: "settle", + receiver: RECEIVER, + token: ASSET, + }; + const result = await scheme.settle( + envelopeSettle(sp as unknown as Record), + makeRequirements(), + ); + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrSettleTransactionFailed); + }); +}); + +describe("BatchSettlementEvmScheme (Facilitator) — handler contract constants", () => { + it("exposes well-formed distinct contract addresses", () => { + const addrs = [ + BATCH_SETTLEMENT_ADDRESS, + ERC3009_DEPOSIT_COLLECTOR_ADDRESS, + PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, + ]; + for (const a of addrs) { + expect(isAddress(a)).toBe(true); + } + expect(new Set(addrs.map(a => getAddress(a))).size).toBe(3); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/server.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/server.test.ts new file mode 100644 index 0000000000..92cb87c8d9 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/server.test.ts @@ -0,0 +1,1919 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { BatchSettlementEvmScheme } from "../../../src/batch-settlement/server/scheme"; +import { BatchSettlementChannelManager } from "../../../src/batch-settlement/server/channelManager"; +import { InMemoryChannelStorage, type Channel } from "../../../src/batch-settlement/server/storage"; +import { computeChannelId as computeChannelIdForNetwork } from "../../../src/batch-settlement/utils"; +import { signVoucher } from "../../../src/batch-settlement/client/voucher"; +import type { + ChannelConfig, + AuthorizerSigner, + BatchSettlementVoucherPayload, + BatchSettlementDepositPayload, + BatchSettlementRefundPayload, +} from "../../../src/batch-settlement/types"; +import type { + PaymentRequirements, + PaymentPayload, + VerifyResponse, + SettleResponse, +} from "@x402/core/types"; +import type { FacilitatorClient } from "@x402/core/server"; +import { privateKeyToAccount } from "viem/accounts"; +import * as Errors from "../../../src/batch-settlement/errors"; + +function buildManager(scheme: BatchSettlementEvmScheme): BatchSettlementChannelManager { + return new BatchSettlementChannelManager({ + scheme, + facilitator: {} as FacilitatorClient, + receiver: RECEIVER, + token: ASSET_BASE_SEPOLIA, + network: NETWORK, + }); +} + +async function storeChannel( + storage: InMemoryChannelStorage, + channelId: string, + channel: Channel, +): Promise { + await storage.updateChannel(channelId, () => channel); +} + +async function deleteChannel(storage: InMemoryChannelStorage, channelId: string): Promise { + await storage.updateChannel(channelId, () => undefined); +} + +async function reservePending( + server: BatchSettlementEvmScheme, + paymentPayload: PaymentPayload, + requirements: PaymentRequirements, +): Promise { + const result = await server.schemeHooks.onBeforeVerify!({ + paymentPayload, + requirements, + } as never); + expect(result).toBeUndefined(); +} + +const PAYER = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as `0x${string}`; +const RECEIVER = "0x9876543210987654321098765432109876543210" as `0x${string}`; +const RECEIVER_AUTHORIZER = "0x1111111111111111111111111111111111111111" as `0x${string}`; +const ASSET_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; +const NETWORK = "eip155:84532"; + +function computeChannelId(config: ChannelConfig): `0x${string}` { + return computeChannelIdForNetwork(config, NETWORK); +} + +function buildAuthorizerSigner(): AuthorizerSigner { + const account = privateKeyToAccount( + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + ); + return { + address: account.address, + signTypedData: msg => + account.signTypedData({ + domain: msg.domain, + types: msg.types, + primaryType: msg.primaryType, + message: msg.message, + } as Parameters[0]), + }; +} + +function buildChannelConfig(overrides: Partial = {}): ChannelConfig { + return { + payer: PAYER, + payerAuthorizer: PAYER, + receiver: RECEIVER, + receiverAuthorizer: RECEIVER_AUTHORIZER, + token: ASSET_BASE_SEPOLIA, + withdrawDelay: 900, + salt: "0x0000000000000000000000000000000000000000000000000000000000000000", + ...overrides, + }; +} + +function buildVoucherPayload( + channelId: string, + maxClaimableAmount: string, + config: ChannelConfig, + signature: `0x${string}` = "0xdeadbeef", +): PaymentPayload { + const payload: BatchSettlementVoucherPayload = { + type: "voucher", + channelConfig: config, + voucher: { + channelId: channelId as `0x${string}`, + maxClaimableAmount, + signature, + }, + }; + return { + x402Version: 2, + accepted: makeRequirements(), + payload: payload as unknown as Record, + }; +} + +async function buildSignedVoucherPayload( + channelId: `0x${string}`, + maxClaimableAmount: string, + config: ChannelConfig, +): Promise { + const voucher = await signVoucher( + buildAuthorizerSigner(), + channelId, + maxClaimableAmount, + NETWORK, + ); + return buildVoucherPayload(channelId, maxClaimableAmount, config, voucher.signature); +} + +function buildRefundPayload( + channelId: string, + maxClaimableAmount: string, + config: ChannelConfig, + amount?: string, +): PaymentPayload { + const payload: BatchSettlementRefundPayload = { + type: "refund", + channelConfig: config, + voucher: { + channelId: channelId as `0x${string}`, + maxClaimableAmount, + signature: "0xdeadbeef", + }, + ...(amount !== undefined ? { amount } : {}), + }; + return { + x402Version: 2, + accepted: makeRequirements(), + payload: payload as unknown as Record, + }; +} + +function buildDepositPayload( + channelId: string, + config: ChannelConfig, + amount: string, + maxClaimable: string, +): PaymentPayload { + const payload: BatchSettlementDepositPayload = { + type: "deposit", + channelConfig: config, + voucher: { + channelId: channelId as `0x${string}`, + maxClaimableAmount: maxClaimable, + signature: "0xcafebabe", + }, + deposit: { + amount, + authorization: { + erc3009Authorization: { + validAfter: "0", + validBefore: "9999999999", + salt: "0x0000000000000000000000000000000000000000000000000000000000000001", + signature: "0xfeedbeef", + }, + }, + }, + }; + return { + x402Version: 2, + accepted: makeRequirements(), + payload: payload as unknown as Record, + }; +} + +function makeRequirements(overrides: Partial = {}): PaymentRequirements { + return { + scheme: "batch-settlement", + network: NETWORK, + amount: "1000", + asset: ASSET_BASE_SEPOLIA, + payTo: RECEIVER, + maxTimeoutSeconds: 3600, + extra: { receiverAuthorizer: RECEIVER_AUTHORIZER }, + ...overrides, + }; +} + +class CountingChannelStorage extends InMemoryChannelStorage { + readonly getCalls: string[] = []; + + override async get(channelId: string): Promise { + this.getCalls.push(channelId); + return super.get(channelId); + } +} + +describe("BatchSettlementEvmScheme — construction", () => { + it("uses an in-memory channel storage by default", () => { + const server = new BatchSettlementEvmScheme(RECEIVER); + expect(server.scheme).toBe("batch-settlement"); + expect(server.getStorage()).toBeInstanceOf(InMemoryChannelStorage); + expect(server.getReceiverAddress()).toBe(RECEIVER); + expect(server.getWithdrawDelay()).toBe(900); + expect(server.getOnchainStateTtlMs()).toBe(300_000); + expect(server.getReceiverAuthorizerSigner()).toBeUndefined(); + }); + + it("allows custom storage, withdrawDelay, and onchain state TTL", () => { + const storage = new InMemoryChannelStorage(); + const signer = buildAuthorizerSigner(); + const server = new BatchSettlementEvmScheme(RECEIVER, { + storage, + withdrawDelay: 1800, + onchainStateTtlMs: 45_000, + receiverAuthorizerSigner: signer, + }); + expect(server.getStorage()).toBe(storage); + expect(server.getWithdrawDelay()).toBe(1800); + expect(server.getOnchainStateTtlMs()).toBe(45_000); + expect(server.getReceiverAuthorizerSigner()).toBe(signer); + }); +}); + +describe("BatchSettlementEvmScheme — parsePrice", () => { + const server = new BatchSettlementEvmScheme(RECEIVER); + + it("converts $ strings to USDC base units on Base Sepolia", async () => { + const result = await server.parsePrice("$0.10", NETWORK); + expect(result.amount).toBe("100000"); + expect(result.asset).toBe(ASSET_BASE_SEPOLIA); + }); + + it("converts plain decimal strings", async () => { + const result = await server.parsePrice("0.50", NETWORK); + expect(result.amount).toBe("500000"); + }); + + it("converts numeric prices", async () => { + const result = await server.parsePrice(1, NETWORK); + expect(result.amount).toBe("1000000"); + }); + + it("returns AssetAmount as-is when an explicit asset is provided", async () => { + const result = await server.parsePrice( + { + amount: "12345", + asset: "0x1111111111111111111111111111111111111111", + extra: { foo: "bar" }, + }, + NETWORK, + ); + expect(result.amount).toBe("12345"); + expect(result.asset).toBe("0x1111111111111111111111111111111111111111"); + expect(result.extra).toEqual({ foo: "bar" }); + }); + + it("throws when AssetAmount is missing the asset address", async () => { + await expect(server.parsePrice({ amount: "100" } as never, NETWORK)).rejects.toThrow( + /Asset address must be specified/, + ); + }); + + it("throws on invalid money strings", async () => { + await expect(server.parsePrice("not-a-price!", NETWORK)).rejects.toThrow( + /Invalid money format/, + ); + }); + + it("uses a registered custom money parser when it returns a result", async () => { + const server2 = new BatchSettlementEvmScheme(RECEIVER); + server2.registerMoneyParser(async (amount, network) => { + if (network === NETWORK) { + return { + amount: (amount * 1_000_000_000_000_000_000).toString(), + asset: "0x2222222222222222222222222222222222222222", + extra: {}, + }; + } + return null; + }); + const result = await server2.parsePrice("1", NETWORK); + expect(result.amount).toBe("1000000000000000000"); + expect(result.asset).toBe("0x2222222222222222222222222222222222222222"); + }); + + it("falls back to default conversion when custom parser returns null", async () => { + const server2 = new BatchSettlementEvmScheme(RECEIVER); + server2.registerMoneyParser(async () => null); + const result = await server2.parsePrice("1", NETWORK); + expect(result.amount).toBe("1000000"); + }); +}); + +describe("BatchSettlementEvmScheme — enhancePaymentRequirements", () => { + const baseReqs = makeRequirements(); + + it("injects withdrawDelay, facilitator receiverAuthorizer, name, version", async () => { + const server = new BatchSettlementEvmScheme(RECEIVER, { withdrawDelay: 1800 }); + const enhanced = await server.enhancePaymentRequirements( + baseReqs, + { + x402Version: 2, + scheme: "batch-settlement", + network: NETWORK, + extra: { receiverAuthorizer: RECEIVER_AUTHORIZER }, + }, + [], + ); + + expect(enhanced.extra?.withdrawDelay).toBe(1800); + expect(enhanced.extra?.receiverAuthorizer).toBe(RECEIVER_AUTHORIZER); + expect(enhanced.extra?.name).toBe("USDC"); + expect(enhanced.extra?.version).toBe("2"); + }); + + it("throws when neither server nor facilitator provides receiverAuthorizer", async () => { + const server = new BatchSettlementEvmScheme(RECEIVER); + await expect( + server.enhancePaymentRequirements( + baseReqs, + { x402Version: 2, scheme: "batch-settlement", network: NETWORK }, + [], + ), + ).rejects.toThrow(/receiverAuthorizer/); + }); + + it("propagates receiver-authorizer from configured signer", async () => { + const signer = buildAuthorizerSigner(); + const server = new BatchSettlementEvmScheme(RECEIVER, { receiverAuthorizerSigner: signer }); + const enhanced = await server.enhancePaymentRequirements( + baseReqs, + { x402Version: 2, scheme: "batch-settlement", network: NETWORK }, + [], + ); + expect(enhanced.extra?.receiverAuthorizer).toBe(signer.address); + }); + + it("falls back to receiverAuthorizer from supportedKind.extra when no signer is configured", async () => { + const server = new BatchSettlementEvmScheme(RECEIVER); + const enhanced = await server.enhancePaymentRequirements( + baseReqs, + { + x402Version: 2, + scheme: "batch-settlement", + network: NETWORK, + extra: { receiverAuthorizer: "0xabcdefABCDef0000000000000000000000000001" }, + }, + [], + ); + expect(enhanced.extra?.receiverAuthorizer).toBe("0xaBCDEFABcdEf0000000000000000000000000001"); + }); + + it("preserves existing extra entries", async () => { + const server = new BatchSettlementEvmScheme(RECEIVER); + const enhanced = await server.enhancePaymentRequirements( + makeRequirements({ extra: { custom: "yes" } }), + { + x402Version: 2, + scheme: "batch-settlement", + network: NETWORK, + extra: { receiverAuthorizer: RECEIVER_AUTHORIZER }, + }, + [], + ); + expect(enhanced.extra?.custom).toBe("yes"); + }); + + it("preserves explicit assetTransferMethod from payment requirements", async () => { + const server = new BatchSettlementEvmScheme(RECEIVER); + const enhanced = await server.enhancePaymentRequirements( + makeRequirements({ extra: { assetTransferMethod: "permit2" } }), + { + x402Version: 2, + scheme: "batch-settlement", + network: NETWORK, + extra: { receiverAuthorizer: RECEIVER_AUTHORIZER }, + }, + [], + ); + + expect(enhanced.extra?.assetTransferMethod).toBe("permit2"); + }); +}); + +describe("BatchSettlementEvmScheme — onBeforeVerify", () => { + let server: BatchSettlementEvmScheme; + let storage: InMemoryChannelStorage; + + beforeEach(() => { + storage = new InMemoryChannelStorage(); + server = new BatchSettlementEvmScheme(RECEIVER, { storage }); + }); + + it("does nothing when payload is not a batch-settlement cumulative payload", async () => { + const result = await server.schemeHooks.onBeforeVerify!({ + paymentPayload: { + x402Version: 2, + accepted: makeRequirements(), + payload: { type: "other" }, + }, + requirements: makeRequirements(), + } as never); + expect(result).toBeUndefined(); + }); + + it("does nothing when no channel record is stored yet", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const result = await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildVoucherPayload(channelId, "1000", config), + requirements: makeRequirements(), + } as never); + expect(result).toBeUndefined(); + }); + + it("does nothing when client cumulative matches expected", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + onchainSyncedAt: 123, + lastRequestTimestamp: 0, + }); + + const result = await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildVoucherPayload(channelId, "2000", config), + requirements: makeRequirements({ amount: "1000" }), + } as never); + expect(result).toBeUndefined(); + }); + + it("locally verifies a fresh EOA-authorized voucher", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 2, + onchainSyncedAt: Date.now(), + lastRequestTimestamp: 0, + }); + + const result = (await server.schemeHooks.onBeforeVerify!({ + paymentPayload: await buildSignedVoucherPayload(channelId, "2000", config), + requirements: makeRequirements({ amount: "1000" }), + } as never)) as unknown as { skip: true; result: VerifyResponse }; + + expect(result?.skip).toBe(true); + expect(result.result).toMatchObject({ + isValid: true, + payer: PAYER, + extra: { + channelId, + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: "2", + }, + }); + expect((await storage.get(channelId))?.pendingRequest).toBeDefined(); + }); + + it("does not refresh onchain sync time after a local voucher verify", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const onchainSyncedAt = Date.now() - 1_000; + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + onchainSyncedAt, + lastRequestTimestamp: 0, + }); + + const paymentPayload = await buildSignedVoucherPayload(channelId, "2000", config); + const verifyResult = (await server.schemeHooks.onBeforeVerify!({ + paymentPayload, + requirements: makeRequirements({ amount: "1000" }), + } as never)) as unknown as { skip: true; result: VerifyResponse }; + + await server.schemeHooks.onAfterVerify!({ + paymentPayload, + requirements: makeRequirements({ amount: "1000" }), + result: verifyResult.result, + } as never); + + expect((await storage.get(channelId))?.onchainSyncedAt).toBe(onchainSyncedAt); + }); + + it("falls through to facilitator verification when mirrored onchain state is stale", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + onchainSyncedAt: Date.now() - server.getOnchainStateTtlMs() - 1, + lastRequestTimestamp: 0, + }); + + const result = await server.schemeHooks.onBeforeVerify!({ + paymentPayload: await buildSignedVoucherPayload(channelId, "2000", config), + requirements: makeRequirements({ amount: "1000" }), + } as never); + + expect(result).toBeUndefined(); + expect((await storage.get(channelId))?.pendingRequest).toBeDefined(); + }); + + it("falls through to facilitator verification for EIP-1271 vouchers", async () => { + const config = buildChannelConfig({ + payerAuthorizer: "0x0000000000000000000000000000000000000000", + }); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + onchainSyncedAt: Date.now(), + lastRequestTimestamp: 0, + }); + + const result = await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildVoucherPayload(channelId, "2000", config), + requirements: makeRequirements({ amount: "1000" }), + } as never); + + expect(result).toBeUndefined(); + }); + + it("rejects a locally invalid voucher signature without facilitator verification", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + onchainSyncedAt: Date.now(), + lastRequestTimestamp: 0, + }); + + const result = (await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildVoucherPayload(channelId, "2000", config), + requirements: makeRequirements({ amount: "1000" }), + } as never)) as unknown as { skip: true; result: VerifyResponse }; + + expect(result?.skip).toBe(true); + expect(result.result).toMatchObject({ + isValid: false, + invalidReason: Errors.ErrInvalidVoucherSignature, + }); + }); + + it("rejects locally when the voucher cumulative amount exceeds balance", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0x", + balance: "1500", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + onchainSyncedAt: Date.now(), + lastRequestTimestamp: 0, + }); + + const result = (await server.schemeHooks.onBeforeVerify!({ + paymentPayload: await buildSignedVoucherPayload(channelId, "2000", config), + requirements: makeRequirements({ amount: "1000" }), + } as never)) as unknown as { skip: true; result: VerifyResponse }; + + expect(result?.skip).toBe(true); + expect(result.result.invalidReason).toBe(Errors.ErrCumulativeExceedsBalance); + }); + + it("rejects locally when the voucher cumulative amount is already claimed", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0x", + balance: "10000", + totalClaimed: "2000", + withdrawRequestedAt: 0, + refundNonce: 0, + onchainSyncedAt: Date.now(), + lastRequestTimestamp: 0, + }); + + const result = (await server.schemeHooks.onBeforeVerify!({ + paymentPayload: await buildSignedVoucherPayload(channelId, "2000", config), + requirements: makeRequirements({ amount: "1000" }), + } as never)) as unknown as { skip: true; result: VerifyResponse }; + + expect(result?.skip).toBe(true); + expect(result.result.invalidReason).toBe(Errors.ErrCumulativeAmountBelowClaimed); + }); + + it("does not abort initial deposit payloads with no server channel state", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const result = await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildDepositPayload(channelId, config, "10000", "1500"), + requirements: makeRequirements({ amount: "1000" }), + } as never); + + expect(result).toBeUndefined(); + }); + + it("accepts a deposit payload whose maxClaimable equals chargedCumulativeAmount plus amount", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + onchainSyncedAt: 123, + lastRequestTimestamp: 0, + }); + + const result = await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildDepositPayload(channelId, config, "10000", "2000"), + requirements: makeRequirements({ amount: "1000" }), + } as never); + + expect(result).toBeUndefined(); + }); + + it("aborts a deposit payload whose maxClaimable does not match chargedCumulativeAmount plus amount", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + onchainSyncedAt: 123, + lastRequestTimestamp: 0, + }); + + const result = (await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildDepositPayload(channelId, config, "10000", "1500"), + requirements: makeRequirements({ amount: "1000" }), + } as never)) as { abort: true; reason: string }; + + expect(result?.abort).toBe(true); + expect(result?.reason).toBe(Errors.ErrCumulativeAmountMismatch); + }); + + it("does nothing for a refund voucher when no channel record exists (defers to facilitator for on-chain recovery)", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const result = await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildRefundPayload(channelId, "0", config), + requirements: makeRequirements({ amount: "0" }), + } as never); + + expect(result).toBeUndefined(); + }); + + it("accepts a zero-charge refund voucher whose maxClaimable equals chargedCumulativeAmount", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1500", + signedMaxClaimable: "1500", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + + const result = await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildRefundPayload(channelId, "1500", config), + requirements: makeRequirements({ amount: "1000" }), + } as never); + + expect(result).toBeUndefined(); + }); + + it("aborts a refund voucher whose maxClaimable does not match chargedCumulativeAmount", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1500", + signedMaxClaimable: "1500", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + + const result = (await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildRefundPayload(channelId, "2500", config), + requirements: makeRequirements({ amount: "0" }), + } as never)) as { abort: true; reason: string }; + + expect(result?.abort).toBe(true); + expect(result?.reason).toBe(Errors.ErrCumulativeAmountMismatch); + }); + + it("aborts with cumulative_amount_mismatch when client cumulative is wrong", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + + const reqs = makeRequirements({ amount: "1000" }); + const result = (await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildVoucherPayload(channelId, "500", config), + requirements: reqs, + } as never)) as { + abort: true; + reason: string; + }; + + expect(result?.abort).toBe(true); + expect(result?.reason).toBe(Errors.ErrCumulativeAmountMismatch); + expect(reqs.extra?.chargedCumulativeAmount).toBeUndefined(); + }); + + it("adds channel state to corrective payment-required accepts via fallback storage read", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + + const requirements = [makeRequirements({ amount: "1000" })]; + await server.enrichPaymentRequiredResponse({ + requirements, + paymentPayload: buildVoucherPayload(channelId, "500", config), + resourceInfo: { url: "https://example.com" }, + error: Errors.ErrCumulativeAmountMismatch, + paymentRequiredResponse: { + x402Version: 2, + resource: { url: "https://example.com" }, + accepts: requirements, + }, + }); + + expect(requirements[0].extra.channelState).toMatchObject({ + channelId, + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: "0", + chargedCumulativeAmount: "1000", + }); + expect(requirements[0].extra.voucherState).toMatchObject({ + signedMaxClaimable: "1000", + signature: "0xabcd", + }); + }); + + it("adds channel state to corrective payment-required accepts for deposit mismatches", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + + const requirements = [makeRequirements({ amount: "1000" })]; + await server.enrichPaymentRequiredResponse({ + requirements, + paymentPayload: buildDepositPayload(channelId, config, "10000", "1500"), + resourceInfo: { url: "https://example.com" }, + error: Errors.ErrCumulativeAmountMismatch, + paymentRequiredResponse: { + x402Version: 2, + resource: { url: "https://example.com" }, + accepts: requirements, + }, + }); + + expect(requirements[0].extra.channelState).toMatchObject({ + channelId, + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: "0", + chargedCumulativeAmount: "1000", + }); + expect(requirements[0].extra.voucherState).toMatchObject({ + signedMaxClaimable: "1000", + signature: "0xabcd", + }); + }); + + it("reuses the mismatch channel snapshot for corrective payment-required accepts", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const countingStorage = new CountingChannelStorage(); + const snapshotServer = new BatchSettlementEvmScheme(RECEIVER, { storage: countingStorage }); + const channel: Channel = { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }; + await storeChannel(countingStorage, channelId, channel); + + const paymentPayload = buildVoucherPayload(channelId, "500", config); + const result = (await snapshotServer.schemeHooks.onBeforeVerify!({ + paymentPayload, + requirements: makeRequirements({ amount: "1000" }), + } as never)) as { + abort: true; + reason: string; + }; + + expect(result?.abort).toBe(true); + expect(result?.reason).toBe(Errors.ErrCumulativeAmountMismatch); + expect(countingStorage.getCalls).toHaveLength(0); + + await deleteChannel(countingStorage, channelId); + const requirements = [makeRequirements({ amount: "1000" })]; + await snapshotServer.enrichPaymentRequiredResponse({ + requirements, + paymentPayload, + resourceInfo: { url: "https://example.com" }, + error: Errors.ErrCumulativeAmountMismatch, + paymentRequiredResponse: { + x402Version: 2, + resource: { url: "https://example.com" }, + accepts: requirements, + }, + }); + + expect(requirements[0].extra.channelState).toMatchObject({ + channelId, + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: "0", + chargedCumulativeAmount: "1000", + }); + expect(requirements[0].extra.voucherState).toMatchObject({ + signedMaxClaimable: "1000", + signature: "0xabcd", + }); + expect(countingStorage.getCalls).toHaveLength(0); + + const laterRequirements = [makeRequirements({ amount: "1000" })]; + await snapshotServer.enrichPaymentRequiredResponse({ + requirements: laterRequirements, + paymentPayload, + resourceInfo: { url: "https://example.com" }, + error: Errors.ErrCumulativeAmountMismatch, + paymentRequiredResponse: { + x402Version: 2, + resource: { url: "https://example.com" }, + accepts: laterRequirements, + }, + }); + + expect(laterRequirements[0].extra.channelState).toBeUndefined(); + expect(countingStorage.getCalls).toHaveLength(1); + }); + + it("rejects a live same-channel reservation as busy", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "0", + signedMaxClaimable: "0", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + + await reservePending( + server, + buildVoucherPayload(channelId, "1000", config), + makeRequirements({ amount: "1000" }), + ); + + const result = (await server.schemeHooks.onBeforeVerify!({ + paymentPayload: buildVoucherPayload(channelId, "1000", config), + requirements: makeRequirements({ amount: "1000" }), + } as never)) as { abort: true; reason: string }; + + expect(result?.abort).toBe(true); + expect(result?.reason).toBe(Errors.ErrChannelBusy); + }); + + it("replaces an expired pending reservation", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "0", + signedMaxClaimable: "0", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + pendingRequest: { + pendingId: "expired", + signedMaxClaimable: "1000", + expiresAt: Date.now() - 1, + }, + }); + + await reservePending( + server, + buildVoucherPayload(channelId, "1000", config), + makeRequirements({ amount: "1000" }), + ); + + const updated = await storage.get(channelId); + expect(updated?.pendingRequest?.pendingId).not.toBe("expired"); + }); +}); + +describe("BatchSettlementEvmScheme — pending cleanup hooks", () => { + let server: BatchSettlementEvmScheme; + let storage: InMemoryChannelStorage; + + beforeEach(() => { + storage = new InMemoryChannelStorage(); + server = new BatchSettlementEvmScheme(RECEIVER, { storage }); + }); + + it("clears a pending marker when facilitator verification returns invalid", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const payload = buildDepositPayload(channelId, config, "10000", "1000"); + await reservePending(server, payload, makeRequirements({ amount: "1000" })); + + await server.schemeHooks.onAfterVerify!({ + paymentPayload: payload, + requirements: makeRequirements({ amount: "1000" }), + result: { isValid: false } as VerifyResponse, + } as never); + + expect(await storage.get(channelId)).toBeUndefined(); + }); + + it("clears only its matching pending marker on verified-payment cancellation", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "0", + signedMaxClaimable: "0", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + const payload = buildVoucherPayload(channelId, "1000", config); + await reservePending(server, payload, makeRequirements({ amount: "1000" })); + const firstPendingId = (await storage.get(channelId))?.pendingRequest?.pendingId; + + await storage.updateChannel(channelId, current => + current + ? { + ...current, + pendingRequest: { + pendingId: "newer", + signedMaxClaimable: "1000", + expiresAt: Date.now() + 60_000, + }, + } + : current, + ); + + await server.schemeHooks.onVerifiedPaymentCanceled!({ + paymentPayload: payload, + requirements: makeRequirements({ amount: "1000" }), + declaredExtensions: {}, + reason: "handler_failed", + responseStatus: 500, + } as never); + + const updated = await storage.get(channelId); + expect(firstPendingId).toBeDefined(); + expect(updated?.pendingRequest?.pendingId).toBe("newer"); + }); + + it("clears a pending marker on verify and settle failures", async () => { + const config = buildChannelConfig(); + const verifyChannelId = computeChannelId(config); + const verifyPayload = buildVoucherPayload(verifyChannelId, "1000", config); + await reservePending(server, verifyPayload, makeRequirements({ amount: "1000" })); + + await server.schemeHooks.onVerifyFailure!({ + paymentPayload: verifyPayload, + requirements: makeRequirements({ amount: "1000" }), + declaredExtensions: {}, + error: new Error("verify failed"), + } as never); + expect((await storage.get(verifyChannelId))?.pendingRequest).toBeUndefined(); + + const settleConfig = buildChannelConfig({ + salt: "0x00000000000000000000000000000000000000000000000000000000000000aa", + }); + const settleChannelId = computeChannelId(settleConfig); + const settlePayload = buildVoucherPayload(settleChannelId, "1000", settleConfig); + await storeChannel(storage, settleChannelId, { + channelId: settleChannelId, + channelConfig: settleConfig, + chargedCumulativeAmount: "0", + signedMaxClaimable: "0", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + await reservePending(server, settlePayload, makeRequirements({ amount: "1000" })); + + await server.schemeHooks.onSettleFailure!({ + paymentPayload: settlePayload, + requirements: makeRequirements({ amount: "1000" }), + declaredExtensions: {}, + error: new Error("settle failed"), + } as never); + expect((await storage.get(settleChannelId))?.pendingRequest).toBeUndefined(); + }); +}); + +describe("BatchSettlementEvmScheme — onAfterVerify", () => { + let server: BatchSettlementEvmScheme; + let storage: InMemoryChannelStorage; + + beforeEach(() => { + storage = new InMemoryChannelStorage(); + server = new BatchSettlementEvmScheme(RECEIVER, { storage }); + }); + + it("creates a channel record from a deposit payload after a successful verify", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const paymentPayload = buildDepositPayload(channelId, config, "10000", "1000"); + const requirements = makeRequirements({ amount: "1000" }); + await reservePending(server, paymentPayload, requirements); + const result: VerifyResponse = { + isValid: true, + payer: PAYER, + extra: { balance: "10000", totalClaimed: "0", refundNonce: "0" }, + } as VerifyResponse; + + await server.schemeHooks.onAfterVerify!({ + paymentPayload, + requirements, + result, + } as never); + + const channel = await storage.get(channelId); + expect(channel?.balance).toBe("10000"); + expect(channel?.signedMaxClaimable).toBe("1000"); + expect(channel?.signature).toBe("0xcafebabe"); + expect(channel?.onchainSyncedAt).toBeGreaterThan(0); + }); + + it("does not create channel record when result.isValid is false", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await server.schemeHooks.onAfterVerify!({ + paymentPayload: buildVoucherPayload(channelId, "1000", config), + requirements: makeRequirements(), + result: { isValid: false } as VerifyResponse, + } as never); + expect(await storage.get(channelId)).toBeUndefined(); + }); + + it("returns a skipHandler directive for a refund voucher", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const paymentPayload = buildRefundPayload(channelId, "0", config); + const requirements = makeRequirements({ amount: "0" }); + await reservePending(server, paymentPayload, requirements); + const result: VerifyResponse = { + isValid: true, + payer: PAYER, + extra: { balance: "10000", totalClaimed: "0", refundNonce: "0" }, + } as VerifyResponse; + + const directive = await server.schemeHooks.onAfterVerify!({ + paymentPayload, + requirements, + result, + } as never); + + expect(directive).toBeDefined(); + expect(directive!.skipHandler).toBe(true); + expect(directive!.response?.contentType).toBe("application/json"); + expect((directive!.response?.body as { message: string }).message).toBe("Refund acknowledged"); + expect((directive!.response?.body as { channelId: string }).channelId).toBe(channelId); + }); + + it("does not return a skipHandler directive for a non-refund voucher", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const paymentPayload = buildVoucherPayload(channelId, "1000", config); + const requirements = makeRequirements({ amount: "1000" }); + await reservePending(server, paymentPayload, requirements); + const result: VerifyResponse = { + isValid: true, + payer: PAYER, + extra: { balance: "10000", totalClaimed: "1000", refundNonce: "0" }, + } as VerifyResponse; + + const directive = await server.schemeHooks.onAfterVerify!({ + paymentPayload, + requirements, + result, + } as never); + + expect(directive).toBeUndefined(); + }); +}); + +describe("BatchSettlementEvmScheme — onBeforeSettle", () => { + let server: BatchSettlementEvmScheme; + let storage: InMemoryChannelStorage; + + beforeEach(() => { + storage = new InMemoryChannelStorage(); + server = new BatchSettlementEvmScheme(RECEIVER, { storage }); + }); + + it("aborts a voucher payload when no channel record exists", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const result = (await server.schemeHooks.onBeforeSettle!({ + paymentPayload: buildVoucherPayload(channelId, "1000", config), + requirements: makeRequirements({ amount: "1000" }), + } as never)) as { abort: true; reason: string }; + expect(result?.abort).toBe(true); + expect(result?.reason).toBe(Errors.ErrMissingChannel); + }); + + it("aborts when charged exceeds the signed cap", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "900", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + const paymentPayload = buildVoucherPayload(channelId, "950", config); + await reservePending(server, paymentPayload, makeRequirements({ amount: "50" })); + + const result = (await server.schemeHooks.onBeforeSettle!({ + paymentPayload, + requirements: makeRequirements({ amount: "500" }), + } as never)) as { abort: true; reason: string }; + expect(result?.abort).toBe(true); + expect(result?.reason).toBe(Errors.ErrChargeExceedsSignedCumulative); + }); + + it("returns skip+result for a normal voucher and updates channel record", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "0", + signedMaxClaimable: "0", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + onchainSyncedAt: 123, + lastRequestTimestamp: 0, + }); + + const paymentPayload = buildVoucherPayload(channelId, "1000", config); + await reservePending(server, paymentPayload, makeRequirements({ amount: "1000" })); + + const result = (await server.schemeHooks.onBeforeSettle!({ + paymentPayload, + requirements: makeRequirements({ amount: "1000" }), + } as never)) as { skip: true; result: SettleResponse }; + + expect(result?.skip).toBe(true); + expect(result?.result.success).toBe(true); + expect(Object.keys(result?.result ?? {}).slice(0, 5)).toEqual([ + "success", + "payer", + "transaction", + "network", + "amount", + ]); + expect(result?.result.amount).toBe(""); + expect(result?.result.extra?.chargedAmount).toBe("1000"); + expect(result?.result.extra?.channelState).toMatchObject({ + channelId, + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: "0", + chargedCumulativeAmount: "1000", + }); + + const updated = await storage.get(channelId); + expect(updated?.chargedCumulativeAmount).toBe("1000"); + expect(updated?.signedMaxClaimable).toBe("1000"); + expect(updated?.onchainSyncedAt).toBe(123); + }); + + it("enriches a zero-charge refund voucher into a full refundWithSignature payload", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "500", + signedMaxClaimable: "500", + signature: "0xdeadbeef", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 1, + lastRequestTimestamp: 0, + }); + + // Zero-charge voucher: maxClaimableAmount equals the existing chargedCumulativeAmount. + const payload = buildRefundPayload(channelId, "500", config); + await reservePending(server, payload, makeRequirements({ amount: "0" })); + const ret = await server.schemeHooks.onBeforeSettle!({ + paymentPayload: payload, + requirements: makeRequirements({ amount: "0" }), + } as never); + expect(ret).toBeUndefined(); + + const enrichment = await server.enrichSettlementPayload({ + paymentPayload: payload, + requirements: makeRequirements({ amount: "0" }), + } as never); + expect(enrichment?.amount).toBe("9500"); + expect(enrichment?.refundNonce).toBe("1"); + }); + + it("recovers a refund voucher channel record from facilitator extras when local state was lost", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + + // Local server state is empty (channel record loss scenario). + expect(await storage.get(channelId)).toBeUndefined(); + + // 1. handleBeforeVerify must not abort — it should defer to the facilitator. + const refundPayload = buildRefundPayload(channelId, "1500", config); + await reservePending(server, refundPayload, makeRequirements({ amount: "0" })); + + // 2. handleAfterVerify rebuilds the channel record from on-chain snapshot returned by the facilitator. + const verifyResult: VerifyResponse = { + isValid: true, + payer: PAYER, + extra: { balance: "10000", totalClaimed: "1500", refundNonce: "3" }, + } as VerifyResponse; + await server.schemeHooks.onAfterVerify!({ + paymentPayload: refundPayload, + requirements: makeRequirements({ amount: "0" }), + result: verifyResult, + } as never); + const recovered = await storage.get(channelId); + expect(recovered).toBeDefined(); + expect(recovered?.balance).toBe("10000"); + expect(recovered?.totalClaimed).toBe("1500"); + expect(recovered?.chargedCumulativeAmount).toBe("1500"); + expect(recovered?.refundNonce).toBe(3); + + // 3. Settlement enrichment builds a refundWithSignature payload with amount = balance - totalClaimed. + const settleRet = await server.schemeHooks.onBeforeSettle!({ + paymentPayload: refundPayload, + requirements: makeRequirements({ amount: "0" }), + } as never); + expect(settleRet).toBeUndefined(); + + const enrichment = await server.enrichSettlementPayload({ + paymentPayload: refundPayload, + requirements: makeRequirements({ amount: "0" }), + } as never); + expect(enrichment?.amount).toBe("8500"); + expect(enrichment?.refundNonce).toBe("3"); + }); + + it("aborts a recovered refund voucher with refund_no_balance when channel is fully drained", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + + // Local state is empty; on-chain shows the channel was already drained (balance == totalClaimed). + const verifyResult: VerifyResponse = { + isValid: true, + payer: PAYER, + extra: { balance: "61800", totalClaimed: "61800", refundNonce: "1" }, + } as VerifyResponse; + const settlePayload = buildRefundPayload(channelId, "61800", config); + await reservePending(server, settlePayload, makeRequirements({ amount: "0" })); + await server.schemeHooks.onAfterVerify!({ + paymentPayload: settlePayload, + requirements: makeRequirements({ amount: "0" }), + result: verifyResult, + } as never); + + const ret = await server.schemeHooks.onBeforeSettle!({ + paymentPayload: settlePayload, + requirements: makeRequirements({ amount: "0" }), + } as never); + expect(ret).toBeUndefined(); + + await expect( + server.enrichSettlementPayload({ + paymentPayload: settlePayload, + requirements: makeRequirements({ amount: "0" }), + } as never), + ).rejects.toThrow(Errors.ErrRefundNoBalance); + }); + + it("honors refund amount on a partial refund payload", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "500", + signedMaxClaimable: "500", + signature: "0xdeadbeef", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + + const payload = buildRefundPayload(channelId, "500", config, "1000"); + await reservePending(server, payload, makeRequirements({ amount: "0" })); + + const ret = await server.schemeHooks.onBeforeSettle!({ + paymentPayload: payload, + requirements: makeRequirements({ amount: "0" }), + } as never); + expect(ret).toBeUndefined(); + + const enrichment = await server.enrichSettlementPayload({ + paymentPayload: payload, + requirements: makeRequirements({ amount: "0" }), + } as never); + expect(enrichment?.amount).toBeUndefined(); + expect(enrichment?.refundNonce).toBe("0"); + }); +}); + +describe("BatchSettlementEvmScheme — onAfterSettle", () => { + let server: BatchSettlementEvmScheme; + let storage: InMemoryChannelStorage; + + beforeEach(() => { + storage = new InMemoryChannelStorage(); + server = new BatchSettlementEvmScheme(RECEIVER, { storage }); + }); + + it("updates channel record and exposes deposit response enrichment", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const payload = buildDepositPayload(channelId, config, "10000", "1000"); + await reservePending(server, payload, makeRequirements({ amount: "1000" })); + const result: SettleResponse = { + success: true, + transaction: "0xtx", + network: NETWORK, + payer: PAYER, + extra: { + channelState: { + channelId, + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: "0", + }, + }, + } as SettleResponse; + + await server.schemeHooks.onAfterSettle!({ + paymentPayload: payload, + requirements: makeRequirements({ amount: "1000" }), + result, + } as never); + + const channel = await storage.get(channelId); + expect(channel?.chargedCumulativeAmount).toBe("1000"); + expect(channel?.balance).toBe("10000"); + expect(channel?.onchainSyncedAt).toBeGreaterThan(0); + expect((result.extra as Record).chargedCumulativeAmount).toBeUndefined(); + + const enrichment = await server.enrichSettlementResponse({ + paymentPayload: payload, + requirements: makeRequirements({ amount: "1000" }), + result, + } as never); + expect(enrichment).toEqual({ + channelState: { + chargedCumulativeAmount: "1000", + }, + chargedAmount: "1000", + }); + }); + + it("deletes channel record and exposes refund response enrichment after a full refund", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const channel: Channel = { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }; + await storeChannel(storage, channelId, channel); + + const refundPayload = { + x402Version: 2, + scheme: "batch-settlement", + network: NETWORK, + payload: { + type: "refund", + channelConfig: config, + voucher: { + channelId: channelId as `0x${string}`, + maxClaimableAmount: "1000", + signature: "0xabcd", + }, + amount: "9000", + refundNonce: "0", + claims: [ + { + voucher: { channel: config, maxClaimableAmount: "1000" }, + signature: "0xabcd" as `0x${string}`, + totalClaimed: "1000", + }, + ], + } as unknown as Record, + } as unknown as PaymentPayload; + await reservePending(server, refundPayload, makeRequirements({ amount: "0" })); + server.rememberChannelSnapshot(refundPayload, channel); + + const result: SettleResponse = { + success: true, + transaction: "0xref", + network: NETWORK, + payer: PAYER, + extra: { + channelState: { + channelId, + balance: "1000", + totalClaimed: "1000", + withdrawRequestedAt: 0, + refundNonce: "1", + }, + }, + } as SettleResponse; + + await server.schemeHooks.onAfterSettle!({ + paymentPayload: refundPayload, + requirements: makeRequirements(), + result, + } as never); + + expect(await storage.get(channelId)).toBeUndefined(); + expect((result.extra as Record).refund).toBeUndefined(); + + const enrichment = await server.enrichSettlementResponse({ + paymentPayload: refundPayload, + requirements: makeRequirements(), + result, + } as never); + expect(enrichment).toEqual({ + channelState: { + chargedCumulativeAmount: "1000", + }, + }); + }); + + it("retains channel record and increments refundNonce on a partial refundWithSignature", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const channel: Channel = { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 2, + lastRequestTimestamp: 0, + }; + await storeChannel(storage, channelId, channel); + + const refundPayload = { + x402Version: 2, + scheme: "batch-settlement", + network: NETWORK, + payload: { + type: "refund", + channelConfig: config, + voucher: { + channelId: channelId as `0x${string}`, + maxClaimableAmount: "1000", + signature: "0xabcd", + }, + amount: "2000", + refundNonce: "2", + claims: [ + { + voucher: { channel: config, maxClaimableAmount: "1000" }, + signature: "0xabcd" as `0x${string}`, + totalClaimed: "1000", + }, + ], + } as unknown as Record, + } as unknown as PaymentPayload; + await reservePending(server, refundPayload, makeRequirements({ amount: "0" })); + server.rememberChannelSnapshot(refundPayload, channel); + + const result: SettleResponse = { + success: true, + transaction: "0xref", + network: NETWORK, + payer: PAYER, + extra: { + channelState: { + channelId, + balance: "8000", + totalClaimed: "1000", + withdrawRequestedAt: 0, + refundNonce: "3", + }, + refundedAmount: "2000", + }, + } as SettleResponse; + + await server.schemeHooks.onAfterSettle!({ + paymentPayload: refundPayload, + requirements: makeRequirements(), + result, + } as never); + + const updated = await storage.get(channelId); + expect(updated).toBeDefined(); + expect(updated?.balance).toBe("8000"); + expect(updated?.refundNonce).toBe(3); + expect(updated?.onchainSyncedAt).toBeGreaterThan(0); + expect((result.extra as Record).refundedAmount).toBe("2000"); + + const enrichment = await server.enrichSettlementResponse({ + paymentPayload: refundPayload, + requirements: makeRequirements(), + result, + } as never); + expect(enrichment).toEqual({ + channelState: { + chargedCumulativeAmount: "1000", + }, + }); + }); + + it("does not modify state when result.success is false", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await server.schemeHooks.onAfterSettle!({ + paymentPayload: buildDepositPayload(channelId, config, "10000", "1000"), + requirements: makeRequirements(), + result: { success: false } as SettleResponse, + } as never); + expect(await storage.get(channelId)).toBeUndefined(); + }); +}); + +describe("BatchSettlementChannelManager — getClaimableVouchers", () => { + let server: BatchSettlementEvmScheme; + let manager: BatchSettlementChannelManager; + let storage: InMemoryChannelStorage; + + beforeEach(() => { + storage = new InMemoryChannelStorage(); + server = new BatchSettlementEvmScheme(RECEIVER, { storage }); + manager = buildManager(server); + }); + + it("returns [] when no channel records exist", async () => { + expect(await manager.getClaimableVouchers()).toEqual([]); + }); + + it("filters out channel records that have nothing to claim", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "1000", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: Date.now(), + }); + expect(await manager.getClaimableVouchers()).toEqual([]); + }); + + it("returns claimable vouchers when charged > totalClaimed", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "5000", + signedMaxClaimable: "5000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "1000", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: Date.now(), + }); + + const claims = await manager.getClaimableVouchers(); + expect(claims).toHaveLength(1); + expect(claims[0].voucher.maxClaimableAmount).toBe("5000"); + expect(claims[0].totalClaimed).toBe("5000"); + expect(claims[0].signature).toBe("0xabcd"); + expect(claims[0].voucher.channel).toEqual(config); + }); + + it("respects idleSecs filter", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "5000", + signedMaxClaimable: "5000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "1000", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: Date.now(), + }); + + expect(await manager.getClaimableVouchers({ idleSecs: 60 })).toEqual([]); + }); + + it("claims an older voucher while preserving newer pending request state", async () => { + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "5000", + signedMaxClaimable: "5000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "1000", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: Date.now(), + pendingRequest: { + pendingId: "pending", + signedMaxClaimable: "6000", + expiresAt: Date.now() + 60_000, + }, + }); + const facilitator = { + settle: vi.fn(async () => ({ + success: true, + transaction: "0xclaim", + })), + } as unknown as FacilitatorClient; + const claimManager = new BatchSettlementChannelManager({ + scheme: server, + facilitator, + receiver: RECEIVER, + token: ASSET_BASE_SEPOLIA, + network: NETWORK, + }); + + const results = await claimManager.claim(); + + expect(results).toEqual([{ vouchers: 1, transaction: "0xclaim" }]); + const updated = await storage.get(channelId); + expect(updated?.totalClaimed).toBe("5000"); + expect(updated?.pendingRequest?.pendingId).toBe("pending"); + }); +}); + +describe("BatchSettlementChannelManager — refund pending channels", () => { + it("skips channels with live pending requests", async () => { + const storage = new InMemoryChannelStorage(); + const server = new BatchSettlementEvmScheme(RECEIVER, { storage }); + const facilitator = { + settle: vi.fn(async () => ({ + success: true, + transaction: "0xref", + })), + } as unknown as FacilitatorClient; + const manager = new BatchSettlementChannelManager({ + scheme: server, + facilitator, + receiver: RECEIVER, + token: ASSET_BASE_SEPOLIA, + network: NETWORK, + }); + + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + await storeChannel(storage, channelId, { + channelId, + channelConfig: config, + chargedCumulativeAmount: "1000", + signedMaxClaimable: "1000", + signature: "0xabcd", + balance: "10000", + totalClaimed: "1000", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + pendingRequest: { + pendingId: "pending", + signedMaxClaimable: "1000", + expiresAt: Date.now() + 60_000, + }, + }); + + const result = await manager.refund(); + + expect(result).toEqual([]); + expect(facilitator.settle).not.toHaveBeenCalled(); + expect(await storage.get(channelId)).toBeDefined(); + }); +}); + +describe("BatchSettlementChannelManager — getWithdrawalPendingSessions", () => { + it("returns channel records with withdrawRequestedAt > 0", async () => { + const storage = new InMemoryChannelStorage(); + const server = new BatchSettlementEvmScheme(RECEIVER, { storage }); + const manager = buildManager(server); + + const config1 = buildChannelConfig(); + const id1 = computeChannelId(config1); + const config2 = buildChannelConfig({ + salt: "0x0000000000000000000000000000000000000000000000000000000000000099", + }); + const id2 = computeChannelId(config2); + + await storeChannel(storage, id1, { + channelId: id1, + channelConfig: config1, + chargedCumulativeAmount: "0", + signedMaxClaimable: "0", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + await storeChannel(storage, id2, { + channelId: id2, + channelConfig: config2, + chargedCumulativeAmount: "0", + signedMaxClaimable: "0", + signature: "0x", + balance: "10000", + totalClaimed: "0", + withdrawRequestedAt: 12345, + refundNonce: 0, + lastRequestTimestamp: 0, + }); + + const result = await manager.getWithdrawalPendingSessions(); + expect(result).toHaveLength(1); + expect(result[0].channelId).toBe(id2); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/storage.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/storage.test.ts new file mode 100644 index 0000000000..21e968f843 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/storage.test.ts @@ -0,0 +1,429 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { InMemoryChannelStorage, type Channel } from "../../../src/batch-settlement/server/storage"; +import { + RedisChannelStorage, + type RedisChannelStorageClient, + type RedisEvalOptions, + type RedisScanOptions, + type RedisSetOptions, +} from "../../../src/batch-settlement/server/redisStorage"; +import { + InMemoryClientChannelStorage, + type BatchSettlementClientContext, +} from "../../../src/batch-settlement/client/storage"; +import type { ChannelConfig } from "../../../src/batch-settlement/types"; + +const CHANNEL_CONFIG: ChannelConfig = { + payer: "0x1234567890123456789012345678901234567890", + payerAuthorizer: "0x1234567890123456789012345678901234567890", + receiver: "0x9876543210987654321098765432109876543210", + receiverAuthorizer: "0x0000000000000000000000000000000000000000", + token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + withdrawDelay: 900, + salt: "0x0000000000000000000000000000000000000000000000000000000000000000", +}; + +const CHANNEL_ID = "0xabc1230000000000000000000000000000000000000000000000000000000001"; + +const buildSession = (overrides: Partial = {}): Channel => ({ + channelId: CHANNEL_ID, + channelConfig: CHANNEL_CONFIG, + chargedCumulativeAmount: "0", + signedMaxClaimable: "0", + signature: "0x", + balance: "10000000", + totalClaimed: "0", + withdrawRequestedAt: 0, + refundNonce: 0, + lastRequestTimestamp: 0, + ...overrides, +}); + +describe("InMemoryChannelStorage", () => { + let storage: InMemoryChannelStorage; + + beforeEach(() => { + storage = new InMemoryChannelStorage(); + }); + + describe("get/updateChannel", () => { + it("returns undefined when no session exists", async () => { + expect(await storage.get(CHANNEL_ID)).toBeUndefined(); + }); + + it("stores and retrieves a session", async () => { + const session = buildSession({ chargedCumulativeAmount: "1000" }); + await storage.updateChannel(CHANNEL_ID, () => session); + expect(await storage.get(CHANNEL_ID)).toEqual(session); + }); + + it("treats channelId case-insensitively", async () => { + const session = buildSession({ chargedCumulativeAmount: "500" }); + await storage.updateChannel(CHANNEL_ID.toUpperCase(), () => session); + expect(await storage.get(CHANNEL_ID.toLowerCase())).toEqual(session); + }); + + it("overwrites a session on subsequent update", async () => { + await storage.updateChannel(CHANNEL_ID, () => buildSession({ chargedCumulativeAmount: "1" })); + await storage.updateChannel(CHANNEL_ID, () => buildSession({ chargedCumulativeAmount: "2" })); + const got = await storage.get(CHANNEL_ID); + expect(got?.chargedCumulativeAmount).toBe("2"); + }); + + it("deletes a session", async () => { + await storage.updateChannel(CHANNEL_ID, () => buildSession()); + await storage.updateChannel(CHANNEL_ID, () => undefined); + expect(await storage.get(CHANNEL_ID)).toBeUndefined(); + }); + + it("delete is a no-op when nothing is stored", async () => { + await expect(storage.updateChannel(CHANNEL_ID, () => undefined)).resolves.toEqual({ + channel: undefined, + status: "unchanged", + }); + }); + }); + + describe("list", () => { + it("returns [] for an empty storage", async () => { + expect(await storage.list()).toEqual([]); + }); + + it("returns all stored sessions", async () => { + const id1 = "0x1111111111111111111111111111111111111111111111111111111111111111"; + const id2 = "0x2222222222222222222222222222222222222222222222222222222222222222"; + await storage.updateChannel(id1, () => buildSession({ channelId: id1 })); + await storage.updateChannel(id2, () => buildSession({ channelId: id2 })); + const all = await storage.list(); + expect(all).toHaveLength(2); + expect(all.map(s => s.channelId).sort()).toEqual([id1, id2].sort()); + }); + }); + + describe("updateChannel", () => { + it("inserts a new session when none exists", async () => { + const session = buildSession({ chargedCumulativeAmount: "100" }); + const result = await storage.updateChannel(CHANNEL_ID, () => session); + expect(result).toEqual({ channel: session, status: "updated" }); + expect(await storage.get(CHANNEL_ID)).toEqual(session); + }); + + it("updates from the current stored value", async () => { + await storage.updateChannel(CHANNEL_ID, () => + buildSession({ chargedCumulativeAmount: "500" }), + ); + const updated = buildSession({ chargedCumulativeAmount: "750" }); + const result = await storage.updateChannel(CHANNEL_ID, current => + current?.chargedCumulativeAmount === "500" ? updated : current, + ); + expect(result.status).toBe("updated"); + expect((await storage.get(CHANNEL_ID))?.chargedCumulativeAmount).toBe("750"); + }); + + it("can leave the channel unchanged", async () => { + await storage.updateChannel(CHANNEL_ID, () => + buildSession({ chargedCumulativeAmount: "500" }), + ); + const updated = buildSession({ chargedCumulativeAmount: "750" }); + const result = await storage.updateChannel(CHANNEL_ID, current => + current?.chargedCumulativeAmount === "499" ? updated : current, + ); + expect(result.status).toBe("unchanged"); + expect((await storage.get(CHANNEL_ID))?.chargedCumulativeAmount).toBe("500"); + }); + + it("serializes concurrent updateChannel mutations", async () => { + await storage.updateChannel(CHANNEL_ID, () => buildSession({ chargedCumulativeAmount: "0" })); + const winner = buildSession({ chargedCumulativeAmount: "100" }); + const loser = buildSession({ chargedCumulativeAmount: "200" }); + + const [a, b] = await Promise.all([ + storage.updateChannel(CHANNEL_ID, current => + current?.chargedCumulativeAmount === "0" ? winner : current, + ), + storage.updateChannel(CHANNEL_ID, current => + current?.chargedCumulativeAmount === "0" ? loser : current, + ), + ]); + + expect([a, b].filter(result => result.status === "updated")).toHaveLength(1); + const final = await storage.get(CHANNEL_ID); + expect(["100", "200"]).toContain(final?.chargedCumulativeAmount); + }); + }); +}); + +type RedisValue = { + expiresAt?: number; + value: string; +}; + +class MockRedisClient implements RedisChannelStorageClient { + readonly store = new Map(); + updateConflicts = 0; + nextChannelGetDelay: Deferred | undefined; + nextUpdateEvalDelay: Deferred | undefined; + + async get(key: string): Promise { + await this.maybeDelayChannelGet(key); + this.expireKey(key); + return this.store.get(key)?.value ?? null; + } + + async set(key: string, value: string, options?: RedisSetOptions): Promise { + this.expireKey(key); + if (options?.NX && this.store.has(key)) { + return null; + } + + this.store.set(key, { + value, + ...(options?.PX ? { expiresAt: Date.now() + options.PX } : {}), + }); + return "OK"; + } + + async del(key: string): Promise { + this.expireKey(key); + return this.store.delete(key) ? 1 : 0; + } + + async eval(script: string, options: RedisEvalOptions): Promise { + const [key] = options.keys; + this.expireKey(key); + if (!script.includes("expectedExists")) { + throw new Error("Unsupported Redis script"); + } + + if (this.nextUpdateEvalDelay) { + const delay = this.nextUpdateEvalDelay; + this.nextUpdateEvalDelay = undefined; + await delay.promise; + } + + const [expectedExists, expected, operation, nextValue] = options.arguments; + const current = this.store.get(key); + const matches = expectedExists === "0" ? current === undefined : current?.value === expected; + + if (!matches) { + this.updateConflicts += 1; + return [0, current?.value ?? null]; + } + + if (operation === "delete") { + this.store.delete(key); + return [1, null]; + } + + if (operation === "set") { + this.store.set(key, { value: nextValue }); + return [1, nextValue]; + } + + if (operation === "keep") { + return [1, current?.value ?? null]; + } + + throw new Error("Unsupported Redis update operation"); + } + + async *scanIterator(options: RedisScanOptions): AsyncIterable { + const prefix = options.MATCH?.replace(/\*$/, "") ?? ""; + this.expireAll(); + yield [...this.store.keys()].filter(key => key.startsWith(prefix)); + } + + private expireAll() { + for (const key of this.store.keys()) { + this.expireKey(key); + } + } + + private expireKey(key: string) { + const value = this.store.get(key); + if (value?.expiresAt && value.expiresAt <= Date.now()) { + this.store.delete(key); + } + } + + private async maybeDelayChannelGet(key: string) { + if (key.endsWith(":lock") || !this.nextChannelGetDelay) return; + const delay = this.nextChannelGetDelay; + this.nextChannelGetDelay = undefined; + await delay.promise; + } +} + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +const deferred = (): Deferred => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const waitFor = async (condition: () => boolean) => { + const startedAt = Date.now(); + while (!condition()) { + if (Date.now() - startedAt > 500) { + throw new Error("Timed out waiting for condition"); + } + await new Promise(resolve => setTimeout(resolve, 1)); + } +}; + +describe("RedisChannelStorage", () => { + let client: MockRedisClient; + let storage: RedisChannelStorage; + + beforeEach(() => { + client = new MockRedisClient(); + storage = new RedisChannelStorage({ + client, + keyPrefix: "test:x402", + lockRetryIntervalMs: 1, + }); + }); + + it("returns undefined when no channel exists", async () => { + expect(await storage.get(CHANNEL_ID)).toBeUndefined(); + }); + + it("stores and retrieves a channel", async () => { + const channel = buildSession({ chargedCumulativeAmount: "1000" }); + await storage.updateChannel(CHANNEL_ID, () => channel); + expect(await storage.get(CHANNEL_ID)).toEqual(channel); + }); + + it("treats channelId case-insensitively", async () => { + const channel = buildSession({ chargedCumulativeAmount: "500" }); + await storage.updateChannel(CHANNEL_ID.toUpperCase(), () => channel); + expect(await storage.get(CHANNEL_ID.toLowerCase())).toEqual(channel); + }); + + it("lists stored channels sorted by channelId", async () => { + const id1 = "0x2222222222222222222222222222222222222222222222222222222222222222"; + const id2 = "0x1111111111111111111111111111111111111111111111111111111111111111"; + await storage.updateChannel(id1, () => buildSession({ channelId: id1 })); + await storage.updateChannel(id2, () => buildSession({ channelId: id2 })); + await client.set(`test:x402:server:channel:${id1}:lock`, "other"); + + expect((await storage.list()).map(channel => channel.channelId)).toEqual([id2, id1]); + }); + + it("reports unchanged when the callback returns the current channel", async () => { + const channel = buildSession({ chargedCumulativeAmount: "500" }); + await storage.updateChannel(CHANNEL_ID, () => channel); + + await expect(storage.updateChannel(CHANNEL_ID, current => current)).resolves.toEqual({ + channel, + status: "unchanged", + }); + }); + + it("deletes a channel", async () => { + const channel = buildSession(); + await storage.updateChannel(CHANNEL_ID, () => channel); + + await expect(storage.updateChannel(CHANNEL_ID, () => undefined)).resolves.toEqual({ + channel: undefined, + status: "deleted", + }); + expect(await storage.get(CHANNEL_ID)).toBeUndefined(); + }); + + it("delete is a no-op when nothing is stored", async () => { + await expect(storage.updateChannel(CHANNEL_ID, () => undefined)).resolves.toEqual({ + channel: undefined, + status: "unchanged", + }); + }); + + it("retries concurrent updateChannel mutations after Redis compare conflicts", async () => { + await storage.updateChannel(CHANNEL_ID, () => buildSession({ chargedCumulativeAmount: "0" })); + const firstEvalDelay = deferred(); + client.nextUpdateEvalDelay = firstEvalDelay; + + const first = storage.updateChannel(CHANNEL_ID, current => + buildSession({ + chargedCumulativeAmount: String(Number(current?.chargedCumulativeAmount ?? "0") + 1), + }), + ); + + await waitFor(() => client.nextUpdateEvalDelay === undefined); + + const second = storage.updateChannel(CHANNEL_ID, current => + buildSession({ + chargedCumulativeAmount: String(Number(current?.chargedCumulativeAmount ?? "0") + 1), + }), + ); + + await waitFor(() => + (client.store.get(`test:x402:server:channel:${CHANNEL_ID}`)?.value ?? "").includes( + '"chargedCumulativeAmount":"1"', + ), + ); + firstEvalDelay.resolve(); + + const results = await Promise.all([first, second]); + expect(results.map(result => result.status)).toEqual(["updated", "updated"]); + expect(client.updateConflicts).toBe(1); + expect((await storage.get(CHANNEL_ID))?.chargedCumulativeAmount).toBe("2"); + }); +}); + +describe("InMemoryClientChannelStorage", () => { + let storage: InMemoryClientChannelStorage; + + beforeEach(() => { + storage = new InMemoryClientChannelStorage(); + }); + + it("returns undefined when no context exists", async () => { + expect(await storage.get(CHANNEL_ID)).toBeUndefined(); + }); + + it("stores and retrieves a context", async () => { + const ctx: BatchSettlementClientContext = { + chargedCumulativeAmount: "1000", + balance: "10000000", + totalClaimed: "0", + depositAmount: "10000000", + signedMaxClaimable: "1000", + signature: "0xdeadbeef", + }; + await storage.set(CHANNEL_ID, ctx); + expect(await storage.get(CHANNEL_ID)).toEqual(ctx); + }); + + it("overwrites a context on subsequent set", async () => { + await storage.set(CHANNEL_ID, { chargedCumulativeAmount: "1" }); + await storage.set(CHANNEL_ID, { chargedCumulativeAmount: "2" }); + const got = await storage.get(CHANNEL_ID); + expect(got?.chargedCumulativeAmount).toBe("2"); + }); + + it("deletes a context", async () => { + await storage.set(CHANNEL_ID, { chargedCumulativeAmount: "5" }); + await storage.delete(CHANNEL_ID); + expect(await storage.get(CHANNEL_ID)).toBeUndefined(); + }); + + it("delete is a no-op when nothing is stored", async () => { + await expect(storage.delete(CHANNEL_ID)).resolves.toBeUndefined(); + }); + + it("uses keys verbatim (no normalization)", async () => { + await storage.set(CHANNEL_ID.toUpperCase(), { chargedCumulativeAmount: "1" }); + expect(await storage.get(CHANNEL_ID.toLowerCase())).toBeUndefined(); + expect(await storage.get(CHANNEL_ID.toUpperCase())).toEqual({ chargedCumulativeAmount: "1" }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/types.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/types.test.ts new file mode 100644 index 0000000000..5e73a020c7 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/types.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect } from "vitest"; +import { + isBatchSettlementDepositPayload, + isBatchSettlementVoucherPayload, + isBatchSettlementRefundPayload, + isBatchSettlementClaimPayload, + isBatchSettlementSettlePayload, + isBatchSettlementEnrichedRefundPayload, +} from "../../../src/batch-settlement/types"; +import type { + ChannelConfig, + BatchSettlementDepositPayload, + BatchSettlementVoucherPayload, + BatchSettlementRefundPayload, + BatchSettlementClaimPayload, + BatchSettlementSettlePayload, + BatchSettlementEnrichedRefundPayload, +} from "../../../src/batch-settlement/types"; + +const CHANNEL_CONFIG: ChannelConfig = { + payer: "0x1234567890123456789012345678901234567890", + payerAuthorizer: "0x1234567890123456789012345678901234567890", + receiver: "0x9876543210987654321098765432109876543210", + receiverAuthorizer: "0x0000000000000000000000000000000000000000", + token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + withdrawDelay: 900, + salt: "0x0000000000000000000000000000000000000000000000000000000000000000", +}; + +const VALID_DEPOSIT_PAYLOAD: BatchSettlementDepositPayload = { + type: "deposit", + channelConfig: CHANNEL_CONFIG, + voucher: { + channelId: "0xabc1230000000000000000000000000000000000000000000000000000000001", + maxClaimableAmount: "1000000", + signature: "0xcafebabe", + }, + deposit: { + amount: "10000000", + authorization: { + erc3009Authorization: { + validAfter: "0", + validBefore: "9999999999", + salt: "0x0000000000000000000000000000000000000000000000000000000000000001", + signature: "0xdeadbeef", + }, + }, + }, +}; + +const VALID_PERMIT2_DEPOSIT_PAYLOAD: BatchSettlementDepositPayload = { + type: "deposit", + channelConfig: CHANNEL_CONFIG, + voucher: { + channelId: "0xabc1230000000000000000000000000000000000000000000000000000000001", + maxClaimableAmount: "1000000", + signature: "0xcafebabe", + }, + deposit: { + amount: "10000000", + authorization: { + permit2Authorization: { + from: "0x1234567890123456789012345678901234567890", + permitted: { + token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + amount: "10000000", + }, + spender: "0x4020e27bcea6C226BF888C61b6C520C0fcC50005", + nonce: "123", + deadline: "9999999999", + witness: { + channelId: "0xabc1230000000000000000000000000000000000000000000000000000000001", + }, + signature: "0xdeadbeef", + }, + }, + }, +}; + +const VALID_VOUCHER_PAYLOAD: BatchSettlementVoucherPayload = { + type: "voucher", + channelConfig: CHANNEL_CONFIG, + voucher: { + channelId: "0xabc1230000000000000000000000000000000000000000000000000000000001", + maxClaimableAmount: "2000000", + signature: "0xfeedface", + }, +}; + +const VALID_REFUND_PAYLOAD: BatchSettlementRefundPayload = { + type: "refund", + channelConfig: CHANNEL_CONFIG, + voucher: { + channelId: "0xabc1230000000000000000000000000000000000000000000000000000000001", + maxClaimableAmount: "2000000", + signature: "0xfeedface", + }, +}; + +const VALID_CLAIM_PAYLOAD: BatchSettlementClaimPayload = { + type: "claim", + claims: [ + { + voucher: { channel: CHANNEL_CONFIG, maxClaimableAmount: "1000000" }, + signature: "0xaa", + totalClaimed: "1000000", + }, + ], +}; + +const VALID_SETTLE_PAYLOAD: BatchSettlementSettlePayload = { + type: "settle", + receiver: "0x9876543210987654321098765432109876543210", + token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", +}; + +const VALID_ENRICHED_REFUND_PAYLOAD: BatchSettlementEnrichedRefundPayload = { + ...VALID_REFUND_PAYLOAD, + amount: "100000", + refundNonce: "0", + claims: [], +}; + +describe("isBatchSettlementDepositPayload", () => { + it("returns true for a complete deposit payload", () => { + expect(isBatchSettlementDepositPayload(VALID_DEPOSIT_PAYLOAD)).toBe(true); + }); + + it("returns true for a Permit2 deposit payload", () => { + expect(isBatchSettlementDepositPayload(VALID_PERMIT2_DEPOSIT_PAYLOAD)).toBe(true); + }); + + it("returns false for a voucher-only payload", () => { + expect(isBatchSettlementDepositPayload(VALID_VOUCHER_PAYLOAD)).toBe(false); + }); + + it("returns false when type is missing", () => { + const { type, ...rest } = VALID_DEPOSIT_PAYLOAD; + void type; + expect(isBatchSettlementDepositPayload(rest as unknown as Record)).toBe(false); + }); + + it("returns false when deposit is missing", () => { + const { deposit, ...rest } = VALID_DEPOSIT_PAYLOAD; + void deposit; + expect(isBatchSettlementDepositPayload(rest as unknown as Record)).toBe(false); + }); + + it("returns false when voucher is missing", () => { + const { voucher, ...rest } = VALID_DEPOSIT_PAYLOAD; + void voucher; + expect(isBatchSettlementDepositPayload(rest as unknown as Record)).toBe(false); + }); + + it("returns false for an empty object", () => { + expect(isBatchSettlementDepositPayload({})).toBe(false); + }); + + it("returns false for a settle-action payload", () => { + expect( + isBatchSettlementDepositPayload(VALID_SETTLE_PAYLOAD as unknown as Record), + ).toBe(false); + }); +}); + +describe("isBatchSettlementVoucherPayload", () => { + it("returns true for a valid voucher payload", () => { + expect(isBatchSettlementVoucherPayload(VALID_VOUCHER_PAYLOAD)).toBe(true); + }); + + it("returns false for a deposit payload", () => { + expect(isBatchSettlementVoucherPayload(VALID_DEPOSIT_PAYLOAD)).toBe(false); + }); + + it("returns false when channelConfig is missing", () => { + const { channelConfig, ...rest } = VALID_VOUCHER_PAYLOAD; + void channelConfig; + expect(isBatchSettlementVoucherPayload(rest as unknown as Record)).toBe(false); + }); + + it("returns false when channelId is missing", () => { + const { channelId, ...voucher } = VALID_VOUCHER_PAYLOAD.voucher; + void channelId; + expect(isBatchSettlementVoucherPayload({ ...VALID_VOUCHER_PAYLOAD, voucher })).toBe(false); + }); + + it("returns false when maxClaimableAmount is missing", () => { + const { maxClaimableAmount, ...voucher } = VALID_VOUCHER_PAYLOAD.voucher; + void maxClaimableAmount; + expect(isBatchSettlementVoucherPayload({ ...VALID_VOUCHER_PAYLOAD, voucher })).toBe(false); + }); + + it("returns false when signature is missing", () => { + const { signature, ...voucher } = VALID_VOUCHER_PAYLOAD.voucher; + void signature; + expect(isBatchSettlementVoucherPayload({ ...VALID_VOUCHER_PAYLOAD, voucher })).toBe(false); + }); + + it("returns false for an empty object", () => { + expect(isBatchSettlementVoucherPayload({})).toBe(false); + }); +}); + +describe("settle payload guards (mutual exclusivity)", () => { + const guards: Array<{ + name: string; + fn: (p: Record) => boolean; + matching: Record; + }> = [ + { + name: "isBatchSettlementClaimPayload", + fn: isBatchSettlementClaimPayload, + matching: VALID_CLAIM_PAYLOAD as unknown as Record, + }, + { + name: "isBatchSettlementSettlePayload", + fn: isBatchSettlementSettlePayload, + matching: VALID_SETTLE_PAYLOAD as unknown as Record, + }, + { + name: "isBatchSettlementEnrichedRefundPayload", + fn: isBatchSettlementEnrichedRefundPayload, + matching: VALID_ENRICHED_REFUND_PAYLOAD as unknown as Record, + }, + ]; + + for (const guard of guards) { + it(`${guard.name} matches its own payload`, () => { + expect(guard.fn(guard.matching)).toBe(true); + }); + + for (const other of guards) { + if (other === guard) continue; + it(`${guard.name} rejects ${other.name}'s payload`, () => { + expect(guard.fn(other.matching)).toBe(false); + }); + } + + it(`${guard.name} rejects payment payloads (deposit/voucher)`, () => { + expect(guard.fn(VALID_DEPOSIT_PAYLOAD as unknown as Record)).toBe(false); + expect(guard.fn(VALID_VOUCHER_PAYLOAD as unknown as Record)).toBe(false); + }); + + it(`${guard.name} rejects empty object`, () => { + expect(guard.fn({})).toBe(false); + }); + } +}); + +describe("isBatchSettlementRefundPayload", () => { + it("returns true for a valid refund payload", () => { + expect(isBatchSettlementRefundPayload(VALID_REFUND_PAYLOAD)).toBe(true); + }); + + it("does not require amount for a full refund", () => { + expect(isBatchSettlementRefundPayload(VALID_REFUND_PAYLOAD)).toBe(true); + }); +}); + +describe("isBatchSettlementClaimPayload (specific fields)", () => { + it("returns false when claims array is missing", () => { + const { claims, ...rest } = VALID_CLAIM_PAYLOAD; + void claims; + expect(isBatchSettlementClaimPayload(rest as unknown as Record)).toBe(false); + }); +}); + +describe("isBatchSettlementSettlePayload (specific fields)", () => { + it("returns false when receiver is missing", () => { + const { receiver, ...rest } = VALID_SETTLE_PAYLOAD; + void receiver; + expect(isBatchSettlementSettlePayload(rest as unknown as Record)).toBe(false); + }); + + it("returns false when token is missing", () => { + const { token, ...rest } = VALID_SETTLE_PAYLOAD; + void token; + expect(isBatchSettlementSettlePayload(rest as unknown as Record)).toBe(false); + }); +}); + +describe("isBatchSettlementEnrichedRefundPayload (specific fields)", () => { + it("returns false when refundNonce is missing", () => { + const { refundNonce, ...rest } = VALID_ENRICHED_REFUND_PAYLOAD; + void refundNonce; + expect(isBatchSettlementEnrichedRefundPayload(rest as unknown as Record)).toBe( + false, + ); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/utils.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/utils.test.ts new file mode 100644 index 0000000000..30d4f6d68a --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/utils.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect } from "vitest"; +import { computeChannelId as computeChannelIdForNetwork } from "../../../src/batch-settlement/utils"; +import { + channelIdsEqual, + validateChannelConfig, + erc3009AuthorizationTimeInvalidReason, +} from "../../../src/batch-settlement/facilitator/utils"; +import { + ErrChannelIdMismatch, + ErrReceiverMismatch, + ErrReceiverAuthorizerMismatch, + ErrTokenMismatch, + ErrWithdrawDelayMismatch, + ErrWithdrawDelayOutOfRange, + ErrValidAfterInFuture, + ErrValidBeforeExpired, +} from "../../../src/batch-settlement/errors"; +import { MIN_WITHDRAW_DELAY, MAX_WITHDRAW_DELAY } from "../../../src/batch-settlement/constants"; +import type { ChannelConfig } from "../../../src/batch-settlement/types"; +import type { PaymentRequirements } from "@x402/core/types"; + +const BASE_CONFIG: ChannelConfig = { + payer: "0x1234567890123456789012345678901234567890", + payerAuthorizer: "0x1234567890123456789012345678901234567890", + receiver: "0x9876543210987654321098765432109876543210", + receiverAuthorizer: "0x1111111111111111111111111111111111111111", + token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + withdrawDelay: 900, + salt: "0x0000000000000000000000000000000000000000000000000000000000000000", +}; + +const BASE_REQUIREMENTS: PaymentRequirements = { + scheme: "batch-settlement", + network: "eip155:84532", + amount: "1000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x9876543210987654321098765432109876543210", + maxTimeoutSeconds: 3600, + extra: { + receiverAuthorizer: "0x1111111111111111111111111111111111111111", + withdrawDelay: 900, + }, +}; + +function computeChannelId( + config: ChannelConfig, + network = BASE_REQUIREMENTS.network, +): `0x${string}` { + return computeChannelIdForNetwork(config, network); +} + +describe("computeChannelId", () => { + it("is deterministic for identical configs", () => { + expect(computeChannelId(BASE_CONFIG)).toBe(computeChannelId({ ...BASE_CONFIG })); + }); + + it("returns a 32-byte hex string (0x + 64 chars)", () => { + const id = computeChannelId(BASE_CONFIG); + expect(id).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it("changes when the chain changes", () => { + expect(computeChannelId(BASE_CONFIG, "eip155:84532")).not.toBe( + computeChannelId(BASE_CONFIG, "eip155:8453"), + ); + }); + + it.each([ + ["payer", { payer: "0x1111111111111111111111111111111111111111" as `0x${string}` }], + [ + "payerAuthorizer", + { payerAuthorizer: "0x2222222222222222222222222222222222222222" as `0x${string}` }, + ], + ["receiver", { receiver: "0x3333333333333333333333333333333333333333" as `0x${string}` }], + [ + "receiverAuthorizer", + { receiverAuthorizer: "0x4444444444444444444444444444444444444444" as `0x${string}` }, + ], + ["token", { token: "0x5555555555555555555555555555555555555555" as `0x${string}` }], + ["withdrawDelay", { withdrawDelay: 901 }], + [ + "salt", + { + salt: "0x0000000000000000000000000000000000000000000000000000000000000001" as `0x${string}`, + }, + ], + ])("changes when %s changes", (_field, override) => { + const base = computeChannelId(BASE_CONFIG); + const changed = computeChannelId({ ...BASE_CONFIG, ...override }); + expect(changed).not.toBe(base); + }); +}); + +describe("channelIdsEqual", () => { + const id = "0xABCdef0123456789ABCDEF0123456789abcdef0123456789ABCdef0123456789" as `0x${string}`; + + it("matches case-insensitively", () => { + expect(channelIdsEqual(id, id.toLowerCase())).toBe(true); + expect(channelIdsEqual(id, id.toUpperCase())).toBe(true); + }); + + it("returns false for non-string input", () => { + expect(channelIdsEqual(id, 123)).toBe(false); + expect(channelIdsEqual(id, null)).toBe(false); + expect(channelIdsEqual(id, undefined)).toBe(false); + }); + + it("returns false for empty string", () => { + expect(channelIdsEqual(id, "")).toBe(false); + }); + + it("returns false for completely different ids", () => { + expect( + channelIdsEqual(id, "0x1111111111111111111111111111111111111111111111111111111111111111"), + ).toBe(false); + }); +}); + +describe("validateChannelConfig", () => { + it("returns undefined when config matches requirements and computed id", () => { + const channelId = computeChannelId(BASE_CONFIG); + expect(validateChannelConfig(BASE_CONFIG, channelId, BASE_REQUIREMENTS)).toBeUndefined(); + }); + + it("returns ErrChannelIdMismatch when channelId does not match config hash", () => { + const fakeId = + "0x0000000000000000000000000000000000000000000000000000000000000001" as `0x${string}`; + expect(validateChannelConfig(BASE_CONFIG, fakeId, BASE_REQUIREMENTS)).toBe( + ErrChannelIdMismatch, + ); + }); + + it("returns ErrReceiverMismatch when receiver mismatches requirements.payTo", () => { + const config: ChannelConfig = { + ...BASE_CONFIG, + receiver: "0x1111111111111111111111111111111111111111", + }; + const channelId = computeChannelId(config); + expect(validateChannelConfig(config, channelId, BASE_REQUIREMENTS)).toBe(ErrReceiverMismatch); + }); + + it("returns ErrReceiverAuthorizerMismatch when receiverAuthorizer differs from extra", () => { + const config: ChannelConfig = { + ...BASE_CONFIG, + receiverAuthorizer: "0x2222222222222222222222222222222222222222", + }; + const requirements: PaymentRequirements = { + ...BASE_REQUIREMENTS, + extra: { + ...BASE_REQUIREMENTS.extra, + receiverAuthorizer: "0x3333333333333333333333333333333333333333", + }, + }; + const channelId = computeChannelId(config); + expect(validateChannelConfig(config, channelId, requirements)).toBe( + ErrReceiverAuthorizerMismatch, + ); + }); + + it("returns ErrTokenMismatch when token differs from requirements.asset", () => { + const config: ChannelConfig = { + ...BASE_CONFIG, + token: "0xaaaa000000000000000000000000000000000000", + }; + const channelId = computeChannelId(config); + expect(validateChannelConfig(config, channelId, BASE_REQUIREMENTS)).toBe(ErrTokenMismatch); + }); + + it("returns ErrWithdrawDelayMismatch when withdrawDelay differs from extra", () => { + const config: ChannelConfig = { ...BASE_CONFIG, withdrawDelay: 1800 }; + const channelId = computeChannelId(config); + expect(validateChannelConfig(config, channelId, BASE_REQUIREMENTS)).toBe( + ErrWithdrawDelayMismatch, + ); + }); + + it("returns ErrWithdrawDelayOutOfRange when below minimum (and extra not present)", () => { + const config: ChannelConfig = { ...BASE_CONFIG, withdrawDelay: MIN_WITHDRAW_DELAY - 1 }; + const channelId = computeChannelId(config); + const requirements: PaymentRequirements = { + ...BASE_REQUIREMENTS, + extra: { receiverAuthorizer: BASE_CONFIG.receiverAuthorizer }, + }; + expect(validateChannelConfig(config, channelId, requirements)).toBe(ErrWithdrawDelayOutOfRange); + }); + + it("returns ErrWithdrawDelayOutOfRange when above maximum (and extra not present)", () => { + const config: ChannelConfig = { ...BASE_CONFIG, withdrawDelay: MAX_WITHDRAW_DELAY + 1 }; + const channelId = computeChannelId(config); + const requirements: PaymentRequirements = { + ...BASE_REQUIREMENTS, + extra: { receiverAuthorizer: BASE_CONFIG.receiverAuthorizer }, + }; + expect(validateChannelConfig(config, channelId, requirements)).toBe(ErrWithdrawDelayOutOfRange); + }); + + it("returns ErrReceiverAuthorizerMismatch when receiverAuthorizer is missing from extra", () => { + const config: ChannelConfig = { + ...BASE_CONFIG, + receiverAuthorizer: "0x2222222222222222222222222222222222222222", + }; + const channelId = computeChannelId(config); + const requirements: PaymentRequirements = { + ...BASE_REQUIREMENTS, + extra: { withdrawDelay: 900 }, + }; + expect(validateChannelConfig(config, channelId, requirements)).toBe( + ErrReceiverAuthorizerMismatch, + ); + }); + + it("returns ErrReceiverAuthorizerMismatch when receiverAuthorizer is zero", () => { + const config: ChannelConfig = { + ...BASE_CONFIG, + receiverAuthorizer: "0x0000000000000000000000000000000000000000", + }; + const requirements: PaymentRequirements = { + ...BASE_REQUIREMENTS, + extra: { + ...BASE_REQUIREMENTS.extra, + receiverAuthorizer: "0x0000000000000000000000000000000000000000", + }, + }; + const channelId = computeChannelId(config); + expect(validateChannelConfig(config, channelId, requirements)).toBe( + ErrReceiverAuthorizerMismatch, + ); + }); +}); + +describe("erc3009AuthorizationTimeInvalidReason", () => { + const now = () => BigInt(Math.floor(Date.now() / 1000)); + + it("returns undefined inside a comfortable window", () => { + const va = now() - 60n; + const vb = now() + 3600n; + expect(erc3009AuthorizationTimeInvalidReason(va, vb)).toBeUndefined(); + }); + + it("returns ErrValidAfterInFuture when validAfter is far in future", () => { + const va = now() + 3600n; + const vb = now() + 7200n; + expect(erc3009AuthorizationTimeInvalidReason(va, vb)).toBe(ErrValidAfterInFuture); + }); + + it("returns ErrValidBeforeExpired when validBefore is in past", () => { + const va = now() - 7200n; + const vb = now() - 3600n; + expect(erc3009AuthorizationTimeInvalidReason(va, vb)).toBe(ErrValidBeforeExpired); + }); + + it("returns ErrValidBeforeExpired when validBefore is right at the edge of clock-skew margin", () => { + const va = now() - 60n; + const vb = now() + 1n; + expect(erc3009AuthorizationTimeInvalidReason(va, vb)).toBe(ErrValidBeforeExpired); + }); +}); diff --git a/typescript/packages/mechanisms/evm/tsup.config.ts b/typescript/packages/mechanisms/evm/tsup.config.ts index db132c689a..2fcd5c4c25 100644 --- a/typescript/packages/mechanisms/evm/tsup.config.ts +++ b/typescript/packages/mechanisms/evm/tsup.config.ts @@ -12,6 +12,9 @@ const baseConfig = { "upto/client/index": "src/upto/client/index.ts", "upto/server/index": "src/upto/server/index.ts", "upto/facilitator/index": "src/upto/facilitator/index.ts", + "batch-settlement/client/index": "src/batch-settlement/client/index.ts", + "batch-settlement/server/index": "src/batch-settlement/server/index.ts", + "batch-settlement/facilitator/index": "src/batch-settlement/facilitator/index.ts", }, dts: { resolve: true, diff --git a/typescript/packages/mechanisms/evm/vitest.integration.config.ts b/typescript/packages/mechanisms/evm/vitest.integration.config.ts index 0dd9d401ce..33c360cada 100644 --- a/typescript/packages/mechanisms/evm/vitest.integration.config.ts +++ b/typescript/packages/mechanisms/evm/vitest.integration.config.ts @@ -6,6 +6,7 @@ export default defineConfig(({ mode }) => ({ test: { env: loadEnv(mode, process.cwd(), ""), include: ["test/integrations/**/*.test.ts"], // Only include integration tests + fileParallelism: false, // Prevent race conditions on tx nonces }, plugins: [tsconfigPaths({ projects: ["."] })], })); diff --git a/typescript/site/app/facilitator/index.ts b/typescript/site/app/facilitator/index.ts index d0fac8fabc..93e6c93616 100644 --- a/typescript/site/app/facilitator/index.ts +++ b/typescript/site/app/facilitator/index.ts @@ -5,7 +5,8 @@ import { toFacilitatorAptosSigner } from "@x402/aptos"; import { ExactAptosScheme } from "@x402/aptos/exact/facilitator"; import { x402Facilitator } from "@x402/core/facilitator"; import { Network } from "@x402/core/types"; -import { toFacilitatorEvmSigner } from "@x402/evm"; +import { type AuthorizerSigner, toFacilitatorEvmSigner } from "@x402/evm"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/facilitator"; import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; import { ExactEvmSchemeV1 } from "@x402/evm/exact/v1/facilitator"; import { UptoEvmScheme } from "@x402/evm/upto/facilitator"; @@ -94,6 +95,12 @@ async function createFacilitator(): Promise { getCode: (args: { address: `0x${string}` }) => viemClient.getCode(args), }); + const receiverAuthorizerSigner: AuthorizerSigner = { + address: evmAccount.address, + signTypedData: params => + evmAccount.signTypedData(params as Parameters[0]), + }; + // Initialize the SVM account from private key const svmAccount = await createKeyPairSignerFromBytes( base58.decode(process.env.FACILITATOR_SVM_PRIVATE_KEY as string), @@ -107,6 +114,7 @@ async function createFacilitator(): Promise { .register("eip155:84532", new ExactEvmScheme(evmSigner)) .registerV1("base-sepolia" as Network, new ExactEvmSchemeV1(evmSigner)) .register("eip155:84532", new UptoEvmScheme(evmSigner)) + .register("eip155:84532", new BatchSettlementEvmScheme(evmSigner, receiverAuthorizerSigner)) .register("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", new ExactSvmScheme(svmSigner)) .registerV1("solana-devnet" as Network, new ExactSvmSchemeV1(svmSigner));