diff --git a/NOTICE b/NOTICE index 8d70233813..adc443ff31 100644 --- a/NOTICE +++ b/NOTICE @@ -13,3 +13,17 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + +--- + +auth-capture scheme: + +The auth-capture payment scheme and its TypeScript implementation in +@x402/evm were contributed by the x402r team. See proposal issue +#1011 and specification PR #1425. The implementation was developed +in https://github.com/BackTrackCo/x402r-scheme prior to upstreaming. + +The implementation originated from the "x402-escrow" proposal by +Agentokratia in issue #834, published as @agentokratia/x402-escrow +on npm under the MIT License, and incorporates code from that +package. diff --git a/e2e/clients/axios/index.ts b/e2e/clients/axios/index.ts index 5b16a67e9f..f291661cab 100644 --- a/e2e/clients/axios/index.ts +++ b/e2e/clients/axios/index.ts @@ -10,6 +10,7 @@ import { type UptoEvmSchemeOptions, } from "@x402/evm/upto/client"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; import { ExactEvmSchemeV1 } from "@x402/evm/v1"; import { toClientEvmSigner } from "@x402/evm"; import { ExactSvmScheme } from "@x402/svm/exact/client"; @@ -112,6 +113,7 @@ const client = new x402Client() .register("eip155:*", new ExactEvmScheme(evmSigner, evmSchemeOptions)) .register("eip155:*", new UptoEvmClientScheme(evmSigner, uptoSchemeOptions)) .register("eip155:*", batchSettlementScheme) + .register("eip155:*", new AuthCaptureEvmScheme(evmSigner)) .registerV1("base-sepolia", new ExactEvmSchemeV1(evmSigner)) .registerV1("base", new ExactEvmSchemeV1(evmSigner)) .register("solana:*", new ExactSvmScheme(svmSigner)) diff --git a/e2e/clients/axios/test.config.json b/e2e/clients/axios/test.config.json index 52aa785cd9..5fe7d90ece 100644 --- a/e2e/clients/axios/test.config.json +++ b/e2e/clients/axios/test.config.json @@ -17,7 +17,8 @@ "schemes": [ "exact", "upto", - "batch-settlement" + "batch-settlement", + "auth-capture" ], "evm": { "assetTransferMethods": [ diff --git a/e2e/clients/fetch/index.ts b/e2e/clients/fetch/index.ts index da3164a42f..99122f3021 100644 --- a/e2e/clients/fetch/index.ts +++ b/e2e/clients/fetch/index.ts @@ -9,6 +9,7 @@ import { type UptoEvmSchemeOptions, } from "@x402/evm/upto/client"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; import { ExactEvmSchemeV1 } from "@x402/evm/v1"; import { toClientEvmSigner } from "@x402/evm"; import { ExactSvmScheme } from "@x402/svm/exact/client"; @@ -111,6 +112,7 @@ const client = new x402Client() .register("eip155:*", new ExactEvmScheme(evmSigner, evmSchemeOptions)) .register("eip155:*", new UptoEvmClientScheme(evmSigner, uptoSchemeOptions)) .register("eip155:*", batchSettlementScheme) + .register("eip155:*", new AuthCaptureEvmScheme(evmSigner)) .registerV1("base-sepolia", new ExactEvmSchemeV1(evmSigner)) .registerV1("base", new ExactEvmSchemeV1(evmSigner)) .register("solana:*", new ExactSvmScheme(svmSigner)) diff --git a/e2e/clients/fetch/test.config.json b/e2e/clients/fetch/test.config.json index debd8acf1b..73eb30ba7c 100644 --- a/e2e/clients/fetch/test.config.json +++ b/e2e/clients/fetch/test.config.json @@ -17,7 +17,8 @@ "schemes": [ "exact", "upto", - "batch-settlement" + "batch-settlement", + "auth-capture" ], "evm": { "assetTransferMethods": [ diff --git a/e2e/facilitators/typescript/index.ts b/e2e/facilitators/typescript/index.ts index e714cbeb1c..92b1242f04 100644 --- a/e2e/facilitators/typescript/index.ts +++ b/e2e/facilitators/typescript/index.ts @@ -32,6 +32,7 @@ import { VerifyResponse, } from "@x402/core/types"; import { type AuthorizerSigner, toFacilitatorEvmSigner } from "@x402/evm"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/facilitator"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/facilitator"; import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; import { UptoEvmScheme } from "@x402/evm/upto/facilitator"; @@ -422,6 +423,7 @@ facilitator EVM_NETWORK as Network, new BatchSettlementEvmScheme(evmSigner, authorizerSigner), ) + .register(EVM_NETWORK as Network, new AuthCaptureEvmScheme(evmSigner)) .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)); diff --git a/e2e/facilitators/typescript/test.config.json b/e2e/facilitators/typescript/test.config.json index 6d2fbca7c0..07815d45c0 100644 --- a/e2e/facilitators/typescript/test.config.json +++ b/e2e/facilitators/typescript/test.config.json @@ -17,7 +17,8 @@ "schemes": [ "exact", "upto", - "batch-settlement" + "batch-settlement", + "auth-capture" ], "extensions": [ "bazaar", @@ -49,7 +50,8 @@ "HEDERA_NETWORK", "HEDERA_NODE_URL", "STELLAR_NETWORK", - "EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY" + "EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY", + "EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER" ] } } diff --git a/e2e/servers/express/index.ts b/e2e/servers/express/index.ts index ad1915b305..fb167d2dac 100644 --- a/e2e/servers/express/index.ts +++ b/e2e/servers/express/index.ts @@ -5,6 +5,7 @@ import { ExactAvmScheme } from "@x402/avm/exact/server"; import { ExactEvmScheme } from "@x402/evm/exact/server"; import { UptoEvmScheme } from "@x402/evm/upto/server"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/server"; import { ExactSvmScheme } from "@x402/svm/exact/server"; import { ExactAptosScheme } from "@x402/aptos/exact/server"; import { ExactHederaScheme } from "@x402/hedera/exact/server"; @@ -94,6 +95,16 @@ server.register( ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), }), ); +server.register("eip155:*", new AuthCaptureEvmScheme()); + +// captureAuthorizer for the auth-capture scheme. Address allowed to call +// authorize/capture/void/refund/charge on AuthCaptureEscrow: either the +// facilitator's submitter EOA, or a smart contract that ultimately calls +// escrow as msg.sender (e.g., a refund-arbiter). Optional — when unset, +// auth-capture routes are skipped. +const EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER = process.env.EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER as + | `0x${string}` + | undefined; server.register("solana:*", new ExactSvmScheme()); if (APTOS_PAYEE_ADDRESS) { server.register("aptos:*", new ExactAptosScheme()); @@ -261,6 +272,46 @@ app.use( ...declareErc20ApprovalGasSponsoringExtension(), }, }, + ...(EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER + ? { + "GET /auth-capture/evm/eip3009": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "auth-capture", + price: "$0.001", + network: EVM_NETWORK, + extra: { + captureAuthorizer: EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: EVM_PAYEE_ADDRESS, + minFeeBps: 0, + maxFeeBps: 100, + assetTransferMethod: "eip3009", + autoCapture: true, + }, + }, + }, + "GET /auth-capture/evm/permit2": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "auth-capture", + price: "$0.001", + network: EVM_NETWORK, + extra: { + captureAuthorizer: EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: EVM_PAYEE_ADDRESS, + minFeeBps: 0, + maxFeeBps: 100, + assetTransferMethod: "permit2", + autoCapture: true, + }, + }, + }, + } + : {}), "GET /exact/evm/eip3009": { accepts: { payTo: EVM_PAYEE_ADDRESS, @@ -597,6 +648,22 @@ app.get("/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring", (req, res) = }); }); +app.get("/auth-capture/evm/eip3009", (req, res) => { + res.json({ + message: "auth-capture EIP-3009 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "auth-capture-eip3009", + }); +}); + +app.get("/auth-capture/evm/permit2", (req, res) => { + res.json({ + message: "auth-capture Permit2 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "auth-capture-permit2", + }); +}); + /** * Protected endpoint - requires payment to access * diff --git a/e2e/servers/express/test.config.json b/e2e/servers/express/test.config.json index c46e6a6f33..2a8936fcce 100644 --- a/e2e/servers/express/test.config.json +++ b/e2e/servers/express/test.config.json @@ -73,6 +73,27 @@ "coldstart": true } }, + { + "path": "/auth-capture/evm/eip3009", + "method": "GET", + "description": "auth-capture EIP-3009 endpoint (autoCapture single-shot)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "auth-capture", + "assetTransferMethod": "eip3009" + }, + { + "path": "/auth-capture/evm/permit2", + "method": "GET", + "description": "auth-capture Permit2 endpoint (autoCapture single-shot, pre-approved Permit2)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "auth-capture", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } + }, { "path": "/exact/evm/permit2", "method": "GET", diff --git a/e2e/servers/fastify/index.ts b/e2e/servers/fastify/index.ts index 0b9863a9c7..e0abd146da 100644 --- a/e2e/servers/fastify/index.ts +++ b/e2e/servers/fastify/index.ts @@ -4,6 +4,7 @@ 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 { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/server"; import { ExactSvmScheme } from "@x402/svm/exact/server"; import { ExactAptosScheme } from "@x402/aptos/exact/server"; import { ExactHederaScheme } from "@x402/hedera/exact/server"; @@ -90,6 +91,16 @@ server.register( ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), }), ); +server.register("eip155:*", new AuthCaptureEvmScheme()); + +// captureAuthorizer for the auth-capture scheme. Address allowed to call +// authorize/capture/void/refund/charge on AuthCaptureEscrow: either the +// facilitator's submitter EOA, or a smart contract that ultimately calls +// escrow as msg.sender (e.g., a refund-arbiter). Optional — when unset, +// auth-capture routes are skipped. +const EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER = process.env.EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER as + | `0x${string}` + | undefined; server.register("solana:*", new ExactSvmScheme()); if (APTOS_PAYEE_ADDRESS) { server.register("aptos:*", new ExactAptosScheme()); @@ -233,6 +244,46 @@ paymentMiddleware( ...declareErc20ApprovalGasSponsoringExtension(), }, }, + ...(EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER + ? { + "GET /auth-capture/evm/eip3009": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "auth-capture", + price: "$0.001", + network: EVM_NETWORK, + extra: { + captureAuthorizer: EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: EVM_PAYEE_ADDRESS, + minFeeBps: 0, + maxFeeBps: 100, + assetTransferMethod: "eip3009", + autoCapture: true, + }, + }, + }, + "GET /auth-capture/evm/permit2": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "auth-capture", + price: "$0.001", + network: EVM_NETWORK, + extra: { + captureAuthorizer: EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: EVM_PAYEE_ADDRESS, + minFeeBps: 0, + maxFeeBps: 100, + assetTransferMethod: "permit2", + autoCapture: true, + }, + }, + }, + } + : {}), "GET /exact/evm/eip3009": { accepts: { payTo: EVM_PAYEE_ADDRESS, @@ -551,6 +602,22 @@ app.get("/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring", async () => }; }); +app.get("/auth-capture/evm/eip3009", async () => { + return { + message: "auth-capture EIP-3009 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "auth-capture-eip3009", + }; +}); + +app.get("/auth-capture/evm/permit2", async () => { + return { + message: "auth-capture Permit2 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "auth-capture-permit2", + }; +}); + /** * Protected endpoint - requires payment to access * diff --git a/e2e/servers/fastify/test.config.json b/e2e/servers/fastify/test.config.json index 3473e02ed7..b22aaeeb95 100644 --- a/e2e/servers/fastify/test.config.json +++ b/e2e/servers/fastify/test.config.json @@ -73,6 +73,27 @@ "coldstart": true } }, + { + "path": "/auth-capture/evm/eip3009", + "method": "GET", + "description": "auth-capture EIP-3009 endpoint (autoCapture single-shot)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "auth-capture", + "assetTransferMethod": "eip3009" + }, + { + "path": "/auth-capture/evm/permit2", + "method": "GET", + "description": "auth-capture Permit2 endpoint (autoCapture single-shot, pre-approved Permit2)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "auth-capture", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } + }, { "path": "/exact/evm/permit2", "method": "GET", diff --git a/e2e/servers/hono/index.ts b/e2e/servers/hono/index.ts index 8a31c20004..6d6750034a 100644 --- a/e2e/servers/hono/index.ts +++ b/e2e/servers/hono/index.ts @@ -5,6 +5,7 @@ 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 { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/server"; import { ExactSvmScheme } from "@x402/svm/exact/server"; import { ExactAptosScheme } from "@x402/aptos/exact/server"; import { ExactHederaScheme } from "@x402/hedera/exact/server"; @@ -95,6 +96,16 @@ x402Server.register( ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), }), ); +x402Server.register("eip155:*", new AuthCaptureEvmScheme()); + +// captureAuthorizer for the auth-capture scheme. Address allowed to call +// authorize/capture/void/refund/charge on AuthCaptureEscrow: either the +// facilitator's submitter EOA, or a smart contract that ultimately calls +// escrow as msg.sender (e.g., a refund-arbiter). Optional — when unset, +// auth-capture routes are skipped. +const EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER = process.env.EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER as + | `0x${string}` + | undefined; x402Server.register("solana:*", new ExactSvmScheme()); if (APTOS_PAYEE_ADDRESS) { x402Server.register("aptos:*", new ExactAptosScheme()); @@ -272,6 +283,46 @@ app.use( ...declareErc20ApprovalGasSponsoringExtension(), }, }, + ...(EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER + ? { + "GET /auth-capture/evm/eip3009": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "auth-capture", + price: "$0.001", + network: EVM_NETWORK, + extra: { + captureAuthorizer: EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: EVM_PAYEE_ADDRESS, + minFeeBps: 0, + maxFeeBps: 100, + assetTransferMethod: "eip3009", + autoCapture: true, + }, + }, + }, + "GET /auth-capture/evm/permit2": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "auth-capture", + price: "$0.001", + network: EVM_NETWORK, + extra: { + captureAuthorizer: EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: EVM_PAYEE_ADDRESS, + minFeeBps: 0, + maxFeeBps: 100, + assetTransferMethod: "permit2", + autoCapture: true, + }, + }, + }, + } + : {}), "GET /exact/evm/eip3009": { accepts: { payTo: EVM_PAYEE_ADDRESS, @@ -592,6 +643,22 @@ app.get("/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring", c => { }); }); +app.get("/auth-capture/evm/eip3009", c => { + return c.json({ + message: "auth-capture EIP-3009 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "auth-capture-eip3009", + }); +}); + +app.get("/auth-capture/evm/permit2", c => { + return c.json({ + message: "auth-capture Permit2 endpoint accessed successfully", + timestamp: new Date().toISOString(), + method: "auth-capture-permit2", + }); +}); + /** * Protected endpoint - requires payment to access * diff --git a/e2e/servers/hono/test.config.json b/e2e/servers/hono/test.config.json index ecc20da640..59a9e41e47 100644 --- a/e2e/servers/hono/test.config.json +++ b/e2e/servers/hono/test.config.json @@ -73,6 +73,27 @@ "coldstart": true } }, + { + "path": "/auth-capture/evm/eip3009", + "method": "GET", + "description": "auth-capture EIP-3009 endpoint (autoCapture single-shot)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "auth-capture", + "assetTransferMethod": "eip3009" + }, + { + "path": "/auth-capture/evm/permit2", + "method": "GET", + "description": "auth-capture Permit2 endpoint (autoCapture single-shot, pre-approved Permit2)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "auth-capture", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } + }, { "path": "/exact/evm/permit2", "method": "GET", diff --git a/e2e/servers/next/app/api/auth-capture/evm/eip3009/withx402/route.ts b/e2e/servers/next/app/api/auth-capture/evm/eip3009/withx402/route.ts new file mode 100644 index 0000000000..b195c6b1ca --- /dev/null +++ b/e2e/servers/next/app/api/auth-capture/evm/eip3009/withx402/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withX402 } from "@x402/next"; +import { + server, + EVM_PAYEE_ADDRESS, + EVM_NETWORK, + EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, +} from "@/proxy"; + +const handler = async (_: NextRequest) => { + return NextResponse.json({ + message: "auth-capture endpoint accessed successfully (withX402)", + timestamp: new Date().toISOString(), + }); +}; + +const unconfiguredHandler = async () => + NextResponse.json( + { error: "auth-capture not configured: set EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER" }, + { status: 503 }, + ); + +/** + * Protected auth-capture EVM endpoint (EIP-3009 transfer) using the withX402 wrapper. + * Returns 503 if EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER is not set. + */ +export const GET = EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER + ? withX402( + handler, + { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "auth-capture", + price: "$0.001", + network: EVM_NETWORK, + extra: { + captureAuthorizer: EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: EVM_PAYEE_ADDRESS, + minFeeBps: 0, + maxFeeBps: 100, + assetTransferMethod: "eip3009", + autoCapture: true, + }, + }, + }, + server, + ) + : unconfiguredHandler; diff --git a/e2e/servers/next/app/api/auth-capture/evm/permit2/withx402/route.ts b/e2e/servers/next/app/api/auth-capture/evm/permit2/withx402/route.ts new file mode 100644 index 0000000000..2814ce46b2 --- /dev/null +++ b/e2e/servers/next/app/api/auth-capture/evm/permit2/withx402/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withX402 } from "@x402/next"; +import { + server, + EVM_PAYEE_ADDRESS, + EVM_NETWORK, + EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, +} from "@/proxy"; + +const handler = async (_: NextRequest) => { + return NextResponse.json({ + message: "auth-capture endpoint accessed successfully (withX402)", + timestamp: new Date().toISOString(), + }); +}; + +const unconfiguredHandler = async () => + NextResponse.json( + { error: "auth-capture not configured: set EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER" }, + { status: 503 }, + ); + +/** + * Protected auth-capture EVM endpoint (Permit2 transfer) using the withX402 wrapper. + * Returns 503 if EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER is not set. + */ +export const GET = EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER + ? withX402( + handler, + { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "auth-capture", + price: "$0.001", + network: EVM_NETWORK, + extra: { + captureAuthorizer: EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: EVM_PAYEE_ADDRESS, + minFeeBps: 0, + maxFeeBps: 100, + assetTransferMethod: "permit2", + autoCapture: true, + }, + }, + }, + server, + ) + : unconfiguredHandler; diff --git a/e2e/servers/next/proxy.ts b/e2e/servers/next/proxy.ts index 0f16f53857..fd223469be 100644 --- a/e2e/servers/next/proxy.ts +++ b/e2e/servers/next/proxy.ts @@ -3,6 +3,7 @@ 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 { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/server"; import { privateKeyToAccount } from "viem/accounts"; import { ExactSvmScheme } from "@x402/svm/exact/server"; import { ExactAptosScheme } from "@x402/aptos/exact/server"; @@ -32,6 +33,14 @@ export const HEDERA_AMOUNT = process.env.HEDERA_AMOUNT ?? "100000"; // price in export const STELLAR_NETWORK = (process.env.STELLAR_NETWORK || "stellar:testnet") as `${string}:${string}`; const EVM_PERMIT2_ASSET = process.env.EVM_PERMIT2_ASSET as `0x${string}`; +// captureAuthorizer for the auth-capture scheme. Address allowed to call +// authorize/capture/void/refund/charge on AuthCaptureEscrow: either the +// facilitator's submitter EOA, or a smart contract that ultimately calls +// escrow as msg.sender (e.g., a refund-arbiter). Optional — when unset, +// auth-capture routes are skipped (analogous to how +// EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY gates batch-settlement features). +export const EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER = process.env + .EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER as `0x${string}` | undefined; const facilitatorUrl = process.env.FACILITATOR_URL; if (!facilitatorUrl) { @@ -70,6 +79,7 @@ server.register( ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), }), ); +server.register("eip155:*", new AuthCaptureEvmScheme()); server.register("solana:*", new ExactSvmScheme()); if (APTOS_PAYEE_ADDRESS) { server.register("aptos:*", new ExactAptosScheme()); @@ -141,6 +151,46 @@ export const proxy = paymentProxy( ...declareErc20ApprovalGasSponsoringExtension(), }, }, + ...(EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER + ? { + "/api/auth-capture/evm/eip3009/proxy": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "auth-capture", + price: "$0.001", + network: EVM_NETWORK, + extra: { + captureAuthorizer: EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: EVM_PAYEE_ADDRESS, + minFeeBps: 0, + maxFeeBps: 100, + assetTransferMethod: "eip3009", + autoCapture: true, + }, + }, + }, + "/api/auth-capture/evm/permit2/proxy": { + accepts: { + payTo: EVM_PAYEE_ADDRESS, + scheme: "auth-capture", + price: "$0.001", + network: EVM_NETWORK, + extra: { + captureAuthorizer: EVM_AUTH_CAPTURE_CAPTURE_AUTHORIZER, + captureDeadlineSeconds: 3600, + refundDeadlineSeconds: 7200, + feeRecipient: EVM_PAYEE_ADDRESS, + minFeeBps: 0, + maxFeeBps: 100, + assetTransferMethod: "permit2", + autoCapture: true, + }, + }, + }, + } + : {}), "/api/exact/evm/eip3009/proxy": { accepts: { payTo: EVM_PAYEE_ADDRESS, @@ -463,5 +513,7 @@ export const config = { "/api/batch-settlement/evm/permit2/proxy", "/api/batch-settlement/evm/permit2-eip2612GasSponsoring/proxy", "/api/batch-settlement/evm/permit2-erc20ApprovalGasSponsoring/proxy", + "/api/auth-capture/evm/eip3009/proxy", + "/api/auth-capture/evm/permit2/proxy", ], }; diff --git a/e2e/servers/next/test.config.json b/e2e/servers/next/test.config.json index bd9507ad08..ab45087b37 100644 --- a/e2e/servers/next/test.config.json +++ b/e2e/servers/next/test.config.json @@ -197,6 +197,48 @@ "scheme": "batch-settlement", "assetTransferMethod": "eip3009" }, + { + "path": "/api/auth-capture/evm/eip3009/proxy", + "method": "GET", + "description": "auth-capture EVM endpoint (proxy middleware, EIP-3009 transfer, autoCapture)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "auth-capture", + "assetTransferMethod": "eip3009" + }, + { + "path": "/api/auth-capture/evm/permit2/proxy", + "method": "GET", + "description": "auth-capture EVM endpoint (proxy middleware, Permit2 transfer, autoCapture)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "auth-capture", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } + }, + { + "path": "/api/auth-capture/evm/eip3009/withx402", + "method": "GET", + "description": "auth-capture EVM endpoint (withX402 wrapper, EIP-3009 transfer, autoCapture)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "auth-capture", + "assetTransferMethod": "eip3009" + }, + { + "path": "/api/auth-capture/evm/permit2/withx402", + "method": "GET", + "description": "auth-capture EVM endpoint (withX402 wrapper, Permit2 transfer, autoCapture)", + "requiresPayment": true, + "protocolFamily": "evm", + "scheme": "auth-capture", + "assetTransferMethod": "permit2", + "schemeOptions": { + "permit2Direct": true + } + }, { "path": "/api/exact/svm/withx402", "method": "GET", diff --git a/e2e/src/cli/args.ts b/e2e/src/cli/args.ts index 847f1bfcec..28036fb6e1 100644 --- a/e2e/src/cli/args.ts +++ b/e2e/src/cli/args.ts @@ -150,7 +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,tvm)'); - console.log(' --schemes= Payment schemes: exact, upto, batch-settlement'); + console.log(' --schemes= Payment schemes: exact, upto, batch-settlement, auth-capture'); console.log(' --endpoints= Comma-separated endpoint paths or regex patterns (auto-anchored)'); console.log(''); console.log('Options:'); diff --git a/e2e/src/cli/filters.ts b/e2e/src/cli/filters.ts index c60261d5fa..5401265282 100644 --- a/e2e/src/cli/filters.ts +++ b/e2e/src/cli/filters.ts @@ -1,7 +1,7 @@ import { TestScenario, endpointPaymentScheme } from '../types'; /** x402 payment scheme for filtering (non-EVM counts as exact). */ -export type PaymentSchemeKind = 'exact' | 'upto' | 'batch-settlement'; +export type PaymentSchemeKind = 'exact' | 'upto' | 'batch-settlement' | 'auth-capture'; /** * Classify a scenario's payment scheme for filtering (`endpoint.scheme`, default `exact` on EVM). diff --git a/e2e/src/cli/interactive.ts b/e2e/src/cli/interactive.ts index 27c77dbe67..331c56ff04 100644 --- a/e2e/src/cli/interactive.ts +++ b/e2e/src/cli/interactive.ts @@ -312,7 +312,7 @@ export async function runInteractiveMode( message: 'Select payment schemes', choices: schemeChoices, min: 1, - hint: 'exact = eip3009/permit2-style; upto = usage-based; batch-settlement = voucher channel', + hint: 'exact = eip3009/permit2-style; upto = usage-based; batch-settlement = voucher channel; auth-capture = escrow auth + capture', instructions: false, }); diff --git a/e2e/src/types.ts b/e2e/src/types.ts index f3a3698a9d..4a17dd1961 100644 --- a/e2e/src/types.ts +++ b/e2e/src/types.ts @@ -2,7 +2,7 @@ import type { NetworkSet } from './networks/networks'; export type ProtocolFamily = 'evm' | 'svm' | 'avm' | 'aptos' | 'hedera' | 'stellar' | 'tvm'; export type Transport = 'http' | 'mcp'; -export type PaymentScheme = 'exact' | 'upto' | 'batch-settlement'; +export type PaymentScheme = 'exact' | 'upto' | 'batch-settlement' | 'auth-capture'; export type AssetTransferMethod = 'eip3009' | 'permit2'; /** diff --git a/e2e/templates/server/test.config.json b/e2e/templates/server/test.config.json index 7472a982bc..1bba03e39b 100644 --- a/e2e/templates/server/test.config.json +++ b/e2e/templates/server/test.config.json @@ -15,7 +15,7 @@ "description": "", "requiresPayment": true, "protocolFamily": "", - "scheme": " (EVM paid endpoints)", + "scheme": " (EVM paid endpoints)", "assetTransferMethod": "" }, { diff --git a/examples/typescript/clients/auth-capture/.prettierignore b/examples/typescript/clients/auth-capture/.prettierignore new file mode 100644 index 0000000000..5bd240ba90 --- /dev/null +++ b/examples/typescript/clients/auth-capture/.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/auth-capture/.prettierrc b/examples/typescript/clients/auth-capture/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/clients/auth-capture/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "avoid", + "printWidth": 100, + "proseWrap": "never" +} diff --git a/examples/typescript/clients/auth-capture/README.md b/examples/typescript/clients/auth-capture/README.md new file mode 100644 index 0000000000..14c450913c --- /dev/null +++ b/examples/typescript/clients/auth-capture/README.md @@ -0,0 +1,40 @@ +# auth-capture Client Example + +Fetch-based client that pays for a single request to an [auth-capture](../../../specs/schemes/auth-capture/scheme_auth-capture_evm.md)-protected endpoint. Signs an ERC-3009 `ReceiveWithAuthorization` whose `nonce` is the payer-agnostic PaymentInfo hash (per the [scheme spec](../../../specs/schemes/auth-capture/scheme_auth-capture_evm.md#nonce-derivation-both-methods)). + +## Prerequisites + +- Node.js v20+, pnpm v10 +- A running [auth-capture server](../../servers/auth-capture) +- A funded EVM key holding the requested asset (USDC on Base Sepolia by default) + +## Setup + +```bash +cp .env-local .env +# Fill EVM_PRIVATE_KEY (and override RESOURCE_SERVER_URL if needed) + +cd ../../.. +pnpm install && pnpm build +cd examples/clients/auth-capture + +pnpm start +``` + +## Environment + +| Variable | Required | Default | +| :-------------------- | :------- | :---------------------- | +| `EVM_PRIVATE_KEY` | Yes | — | +| `RESOURCE_SERVER_URL` | No | `http://localhost:4021` | +| `ENDPOINT_PATH` | No | `/weather` | + +## What happens + +1. Client builds and signs an ERC-3009 payload with the `Eip3009Payload` shape. +2. `wrapFetchWithPayment` retries the request with the `PAYMENT-SIGNATURE` header on first `402`. +3. Server verifies, then asks the facilitator to settle. +4. Facilitator submits `AuthCaptureEscrow.authorize(...)` (two-phase) — funds are locked in escrow under the captureAuthorizer's control. +5. Server returns the resource; the example prints the body and the payment response. + +Capture, void, and refund are performed by whoever holds the `captureAuthorizer` role and are out of scope for the client. diff --git a/examples/typescript/clients/auth-capture/eslint.config.js b/examples/typescript/clients/auth-capture/eslint.config.js new file mode 100644 index 0000000000..ca28b5c47f --- /dev/null +++ b/examples/typescript/clients/auth-capture/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/auth-capture/index.ts b/examples/typescript/clients/auth-capture/index.ts new file mode 100644 index 0000000000..288a5597ba --- /dev/null +++ b/examples/typescript/clients/auth-capture/index.ts @@ -0,0 +1,56 @@ +import { AuthCaptureEvmScheme } from "@x402/evm/auth-capture/client"; +import { x402Client, wrapFetchWithPayment, x402HTTPClient } from "@x402/fetch"; +import { config } from "dotenv"; +import { privateKeyToAccount } from "viem/accounts"; + +config(); + +const evmPrivateKeyRaw = process.env.EVM_PRIVATE_KEY?.trim(); +const baseURL = process.env.RESOURCE_SERVER_URL?.trim() || "http://localhost:4021"; +const endpointPath = process.env.ENDPOINT_PATH?.trim() || "/weather"; +const url = `${baseURL}${endpointPath}`; + +if (!evmPrivateKeyRaw) { + console.error("EVM_PRIVATE_KEY environment variable is required"); + process.exit(1); +} +const evmPrivateKey = evmPrivateKeyRaw as `0x${string}`; + +/** + * Runs a single paid request against an auth-capture-protected endpoint. + * + * The scheme signs a payer-agnostic PaymentInfo hash (as the ERC-3009 nonce by + * default; Permit2 is also supported). The facilitator submits the resulting + * authorization to the AuthCaptureEscrow contract; funds are locked there until + * the captureAuthorizer captures, voids, or the authorization expires. + * + * @returns Resolves after the request completes and the payment response is logged. + */ +async function main(): Promise { + const evmAccount = privateKeyToAccount(evmPrivateKey); + + const client = new x402Client(); + client.register("eip155:*", new AuthCaptureEvmScheme(evmAccount)); + + const fetchWithPayment = wrapFetchWithPayment(fetch, client); + + console.log(`Payer: ${evmAccount.address}`); + console.log(`Making request to: ${url}\n`); + + const response = await fetchWithPayment(url, { method: "GET" }); + const contentType = response.headers.get("content-type") ?? ""; + const body = contentType.includes("application/json") + ? await response.json() + : await response.text(); + console.log("Response body:", body); + + const paymentResponse = new x402HTTPClient(client).getPaymentSettleResponse(name => + response.headers.get(name), + ); + console.log("\nPayment response:", JSON.stringify(paymentResponse, null, 2)); +} + +main().catch(error => { + console.error(error?.response?.data?.error ?? error); + process.exit(1); +}); diff --git a/examples/typescript/clients/auth-capture/package.json b/examples/typescript/clients/auth-capture/package.json new file mode 100644 index 0000000000..4f24fc1f08 --- /dev/null +++ b/examples/typescript/clients/auth-capture/package.json @@ -0,0 +1,33 @@ +{ + "name": "@x402/auth-capture-client-example", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts", + "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/fetch": "workspace:*", + "dotenv": "^16.4.7", + "viem": "^2.48.11" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^22.13.4", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-prettier": "^5.2.6", + "prettier": "3.5.2", + "tsx": "^4.21.0", + "typescript": "^5.7.3" + } +} diff --git a/examples/typescript/clients/auth-capture/tsconfig.json b/examples/typescript/clients/auth-capture/tsconfig.json new file mode 100644 index 0000000000..78f9479b1b --- /dev/null +++ b/examples/typescript/clients/auth-capture/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "baseUrl": ".", + "types": ["node"] + }, + "include": ["index.ts"] +} diff --git a/examples/typescript/facilitator/auth-capture/.prettierignore b/examples/typescript/facilitator/auth-capture/.prettierignore new file mode 100644 index 0000000000..5bd240ba90 --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/.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/facilitator/auth-capture/.prettierrc b/examples/typescript/facilitator/auth-capture/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "avoid", + "printWidth": 100, + "proseWrap": "never" +} diff --git a/examples/typescript/facilitator/auth-capture/README.md b/examples/typescript/facilitator/auth-capture/README.md new file mode 100644 index 0000000000..03e4b47326 --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/README.md @@ -0,0 +1,39 @@ +# auth-capture Facilitator Example + +Standard x402 facilitator that exposes `POST /verify`, `POST /settle`, and `GET /supported`, with the [auth-capture](../../../specs/schemes/auth-capture/scheme_auth-capture_evm.md) scheme registered for Base Sepolia (`eip155:84532`). + +The facilitator submits `AuthCaptureEscrow.authorize(...)` on settle (two-phase). When `captureAuthorizer` is a smart contract, the SDK routes the call through that contract (which forwards to the escrow). Capture, void, and refund operations are the **captureAuthorizer's** responsibility and are not handled by this facilitator. + +## Prerequisites + +- Node.js v20+, pnpm v10 +- A funded EVM key with enough ETH for tx submissions on Base Sepolia + +## Setup + +```bash +cp .env-local .env +# Fill EVM_PRIVATE_KEY + +cd ../../.. +pnpm install && pnpm build +cd examples/facilitator/auth-capture + +pnpm start +``` + +## Environment + +| Variable | Required | Default | Notes | +| :---------------- | :------- | :------------------------- | :--------------------------------------------------------- | +| `EVM_PRIVATE_KEY` | Yes | — | Submits `authorize` / `charge` transactions to the escrow. | +| `EVM_RPC_URL` | No | `https://sepolia.base.org` | Base Sepolia RPC endpoint. | +| `PORT` | No | `4022` | Local listen port. | + +## Endpoints + +``` +POST /verify # off-chain verification (signature + payload + simulate) +POST /settle # submits AuthCaptureEscrow.authorize(...) on success +GET /supported # advertises the auth-capture scheme on eip155:84532 +``` diff --git a/examples/typescript/facilitator/auth-capture/eslint.config.js b/examples/typescript/facilitator/auth-capture/eslint.config.js new file mode 100644 index 0000000000..ca28b5c47f --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/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/facilitator/auth-capture/index.ts b/examples/typescript/facilitator/auth-capture/index.ts new file mode 100644 index 0000000000..7b9bb6df67 --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/index.ts @@ -0,0 +1,111 @@ +import { AuthCaptureEvmScheme as AuthCaptureEvmFacilitator } from "@x402/evm/auth-capture/facilitator"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { + PaymentPayload, + PaymentRequirements, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import { toFacilitatorEvmSigner } from "@x402/evm"; +import { config } from "dotenv"; +import express from "express"; +import { createWalletClient, http, nonceManager, publicActions } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; + +config(); + +const PORT = Number(process.env.PORT?.trim() || "4022"); +const NETWORK = "eip155:84532" as const; +const evmRpcUrl = process.env.EVM_RPC_URL?.trim() || "https://sepolia.base.org"; +const evmPrivateKey = process.env.EVM_PRIVATE_KEY?.trim() as `0x${string}` | undefined; + +if (!evmPrivateKey) { + console.error("EVM_PRIVATE_KEY environment variable is required"); + process.exit(1); +} + +const evmAccount = privateKeyToAccount(evmPrivateKey, { nonceManager }); + +console.log(`EVM Facilitator account: ${evmAccount.address}`); + +const viemClient = createWalletClient({ + account: evmAccount, + chain: baseSepolia, + transport: http(evmRpcUrl), +}).extend(publicActions); + +const evmSigner = toFacilitatorEvmSigner({ + address: evmAccount.address, + getCode: args => viemClient.getCode(args), + readContract: args => + viemClient.readContract({ ...args, args: args.args ?? [] } as Parameters< + typeof viemClient.readContract + >[0]), + 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(); +facilitator.register(NETWORK, new AuthCaptureEvmFacilitator(evmSigner)); + +const app = express(); +app.use(express.json()); + +app.post("/verify", async (req, res) => { + try { + const { paymentPayload, paymentRequirements } = req.body as { + paymentPayload: PaymentPayload; + paymentRequirements: PaymentRequirements; + }; + if (!paymentPayload || !paymentRequirements) { + return res.status(400).json({ error: "Missing paymentPayload or paymentRequirements" }); + } + const response: VerifyResponse = await facilitator.verify(paymentPayload, paymentRequirements); + res.json(response); + } catch (error) { + console.error("Verify error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +app.post("/settle", async (req, res) => { + try { + const { paymentPayload, paymentRequirements } = req.body; + if (!paymentPayload || !paymentRequirements) { + return res.status(400).json({ error: "Missing paymentPayload or paymentRequirements" }); + } + const response: SettleResponse = await facilitator.settle( + paymentPayload as PaymentPayload, + paymentRequirements as PaymentRequirements, + ); + res.json(response); + } catch (error) { + console.error("Settle error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +app.get("/supported", async (_req, res) => { + try { + res.json(facilitator.getSupported()); + } catch (error) { + console.error("Supported error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +app.listen(PORT, () => { + console.log(`auth-capture facilitator listening at http://localhost:${PORT}`); +}); diff --git a/examples/typescript/facilitator/auth-capture/package.json b/examples/typescript/facilitator/auth-capture/package.json new file mode 100644 index 0000000000..60372a48ca --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/package.json @@ -0,0 +1,34 @@ +{ + "name": "@x402/auth-capture-facilitator-example", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts", + "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:*", + "dotenv": "^16.4.7", + "express": "^4.18.2", + "viem": "^2.48.11" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^22.13.4", + "@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", + "tsx": "^4.21.0", + "typescript": "^5.7.3" + } +} diff --git a/examples/typescript/facilitator/auth-capture/tsconfig.json b/examples/typescript/facilitator/auth-capture/tsconfig.json new file mode 100644 index 0000000000..fc0e5250ed --- /dev/null +++ b/examples/typescript/facilitator/auth-capture/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/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index 7e4c3c3837..746ee0a34a 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -1127,6 +1127,58 @@ importers: specifier: ^5.7.3 version: 5.9.3 + clients/auth-capture: + dependencies: + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + '@x402/fetch': + specifier: workspace:* + version: link:../../../../typescript/packages/http/fetch + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + viem: + specifier: ^2.48.11 + version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/node': + specifier: ^22.13.4 + version: 22.17.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + clients/axios: dependencies: '@scure/base': @@ -1719,6 +1771,61 @@ importers: specifier: ^5.7.3 version: 5.9.3 + facilitator/auth-capture: + dependencies: + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.21.2 + viem: + specifier: ^2.48.11 + version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/express': + specifier: ^5.0.1 + version: 5.0.3 + '@types/node': + specifier: ^22.13.4 + version: 22.17.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + facilitator/basic: dependencies: '@scure/base': @@ -2142,6 +2249,64 @@ importers: specifier: ^5.7.3 version: 5.9.3 + servers/auth-capture: + dependencies: + '@x402/core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@x402/evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + '@x402/express': + specifier: workspace:* + version: link:../../../../typescript/packages/http/express + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.21.2 + viem: + specifier: ^2.48.11 + version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.33.0 + '@types/express': + specifier: ^5.0.1 + version: 5.0.3 + '@types/node': + specifier: ^22.13.4 + version: 22.17.2 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) + eslint: + specifier: ^9.24.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.33.0(jiti@2.6.1))(prettier@3.5.2) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + servers/batch-settlement: dependencies: '@x402/core': diff --git a/examples/typescript/servers/auth-capture/.prettierignore b/examples/typescript/servers/auth-capture/.prettierignore new file mode 100644 index 0000000000..5bd240ba90 --- /dev/null +++ b/examples/typescript/servers/auth-capture/.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/auth-capture/.prettierrc b/examples/typescript/servers/auth-capture/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/examples/typescript/servers/auth-capture/.prettierrc @@ -0,0 +1,11 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "avoid", + "printWidth": 100, + "proseWrap": "never" +} diff --git a/examples/typescript/servers/auth-capture/README.md b/examples/typescript/servers/auth-capture/README.md new file mode 100644 index 0000000000..317b5122b2 --- /dev/null +++ b/examples/typescript/servers/auth-capture/README.md @@ -0,0 +1,42 @@ +# auth-capture Server Example + +Express resource server protected by the [auth-capture](../../../specs/schemes/auth-capture/scheme_auth-capture_evm.md) scheme. The server publishes payment requirements with all spec-mandated `extra` fields and delegates verify/settle to a configured facilitator. + +`autoCapture` is omitted (defaults to `false`), so the facilitator calls `AuthCaptureEscrow.authorize(...)` — the canonical two-phase flow. Funds are locked in the escrow under the captureAuthorizer's control. Capture, void, and refund happen separately, decided by whichever entity holds the captureAuthorizer role. + +## Prerequisites + +- Node.js v20+, pnpm v10 +- A running [auth-capture facilitator](../../facilitator/auth-capture) +- An EVM address to receive payments (`EVM_ADDRESS`) +- The address that holds capture authority (`CAPTURE_AUTHORIZER`). Per [spec](../../../specs/schemes/auth-capture/scheme_auth-capture_evm.md), in a facilitator-submits flow this must be **either the facilitator's EOA** (so the facilitator's transaction passes the escrow's `onlySender(paymentInfo.operator)` gate) **or a smart contract** that forwards calls to the escrow (the contract then becomes `msg.sender` at escrow). The SDK auto-detects which via `getCode`. If neither condition holds (e.g., an unrelated EOA), the escrow's `onlySender` gate reverts with `InvalidSender` during the facilitator's verify-step simulation, which the SDK maps to `invalid_capture_authorizer` on the `VerifyResponse`. + +## Setup + +```bash +cp .env-local .env +# Fill EVM_ADDRESS, CAPTURE_AUTHORIZER, FACILITATOR_URL + +cd ../../.. +pnpm install && pnpm build +cd examples/servers/auth-capture + +pnpm start +``` + +## Environment + +| Variable | Required | Default | Notes | +| :------------------- | :------- | :------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `EVM_ADDRESS` | Yes | — | Pay-to address (the merchant's receiver). | +| `CAPTURE_AUTHORIZER` | Yes | — | Committed on-chain as `PaymentInfo.operator`. EOA path: must equal the facilitator's submitter EOA. Contract path: any contract that forwards calls to the escrow. | +| `FACILITATOR_URL` | Yes | — | Base URL of the auth-capture facilitator (POST `/verify`, POST `/settle`). | +| `PORT` | No | `4021` | Local listen port. | + +## Deadlines + +The example sets `captureDeadline` and `refundDeadline` once at boot, as absolute Unix seconds 30 / 60 days into the future. Every authorization the server hands out shares the same absolute deadline. Production servers commonly compute these per request via custom middleware so each authorization has a fresh window — out of scope for this minimal demo. + +## Lifecycle beyond authorize + +This example demonstrates the authorize phase only. Capture, void, and refund are the captureAuthorizer's responsibility and are not handled by this server; refer to the [scheme spec](../../../specs/schemes/auth-capture/scheme_auth-capture_evm.md) for the protocol-level surface. diff --git a/examples/typescript/servers/auth-capture/eslint.config.js b/examples/typescript/servers/auth-capture/eslint.config.js new file mode 100644 index 0000000000..e2fde7b3b8 --- /dev/null +++ b/examples/typescript/servers/auth-capture/eslint.config.js @@ -0,0 +1,73 @@ +import js from "@eslint/js"; +import ts from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import prettier from "eslint-plugin-prettier"; +import jsdoc from "eslint-plugin-jsdoc"; +import importPlugin from "eslint-plugin-import"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["**/*.ts"], + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2020, + globals: { + process: "readonly", + __dirname: "readonly", + module: "readonly", + require: "readonly", + Buffer: "readonly", + console: "readonly", + exports: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + }, + }, + plugins: { + "@typescript-eslint": ts, + prettier: prettier, + jsdoc: jsdoc, + import: importPlugin, + }, + rules: { + ...ts.configs.recommended.rules, + "import/first": "error", + "prettier/prettier": "error", + "@typescript-eslint/member-ordering": "error", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_$" }], + "jsdoc/tag-lines": ["error", "any", { startLines: 1 }], + "jsdoc/check-alignment": "error", + "jsdoc/no-undefined-types": "off", + "jsdoc/check-param-names": "error", + "jsdoc/check-tag-names": "error", + "jsdoc/check-types": "error", + "jsdoc/implements-on-classes": "error", + "jsdoc/require-description": "error", + "jsdoc/require-jsdoc": [ + "error", + { + require: { + FunctionDeclaration: true, + MethodDefinition: true, + ClassDeclaration: true, + ArrowFunctionExpression: false, + FunctionExpression: false, + }, + }, + ], + "jsdoc/require-param": "error", + "jsdoc/require-param-description": "error", + "jsdoc/require-param-type": "off", + "jsdoc/require-returns": "error", + "jsdoc/require-returns-description": "error", + "jsdoc/require-returns-type": "off", + "jsdoc/require-hyphen-before-param-description": ["error", "always"], + }, + }, +]; diff --git a/examples/typescript/servers/auth-capture/index.ts b/examples/typescript/servers/auth-capture/index.ts new file mode 100644 index 0000000000..e748515145 --- /dev/null +++ b/examples/typescript/servers/auth-capture/index.ts @@ -0,0 +1,86 @@ +import { AuthCaptureEvmScheme as AuthCaptureEvmServer } from "@x402/evm/auth-capture/server"; +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { paymentMiddleware, x402ResourceServer } from "@x402/express"; +import { config } from "dotenv"; +import express from "express"; +import { isAddress, zeroAddress } from "viem"; + +config(); + +const NETWORK = "eip155:84532" as const; +const PORT = Number(process.env.PORT?.trim() || "4021"); + +const evmAddress = process.env.EVM_ADDRESS?.trim() as `0x${string}` | undefined; +const captureAuthorizer = process.env.CAPTURE_AUTHORIZER?.trim() as `0x${string}` | undefined; +const facilitatorUrl = process.env.FACILITATOR_URL?.trim(); + +if (!evmAddress || !isAddress(evmAddress)) { + console.error("Missing or invalid EVM_ADDRESS"); + process.exit(1); +} +if (!captureAuthorizer || !isAddress(captureAuthorizer)) { + console.error("Missing or invalid CAPTURE_AUTHORIZER"); + process.exit(1); +} +if (!facilitatorUrl) { + console.error("Missing FACILITATOR_URL"); + process.exit(1); +} + +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); +const resourceServer = new x402ResourceServer(facilitatorClient).register( + NETWORK, + new AuthCaptureEvmServer(), +); + +const app = express(); + +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { + scheme: "auth-capture", + price: "$0.01", + network: NETWORK, + payTo: evmAddress, + extra: { + captureAuthorizer, + // Capture / refund windows are seconds-from-now relative offsets. + // The scheme converts them to absolute Unix-second deadlines per + // request inside `enhancePaymentRequirements`, so each + // authorization carries a fresh window. The values are arbiter + // policy: pick what your captureAuthorizer actually supports. + captureDeadlineSeconds: 30 * 86400, + refundDeadlineSeconds: 60 * 86400, + // address(0) lets the captureAuthorizer pick any non-zero fee + // recipient at capture/charge time (deferred selection, per spec). + // Production setups can pin a specific receiver instead. + feeRecipient: zeroAddress, + minFeeBps: 0, + maxFeeBps: 100, + }, + }, + description: "Weather data", + mimeType: "application/json", + }, + }, + resourceServer, + ), +); + +app.get("/weather", (_req, res) => { + res.send({ + report: { + weather: "sunny", + temperature: 70, + }, + }); +}); + +app.listen(PORT, () => { + console.log(`auth-capture server listening at http://localhost:${PORT}`); + console.log(" GET /weather"); + console.log(` Pay-to: ${evmAddress}`); + console.log(` captureAuthorizer: ${captureAuthorizer}`); +}); diff --git a/examples/typescript/servers/auth-capture/package.json b/examples/typescript/servers/auth-capture/package.json new file mode 100644 index 0000000000..e4757252c9 --- /dev/null +++ b/examples/typescript/servers/auth-capture/package.json @@ -0,0 +1,35 @@ +{ + "name": "@x402/auth-capture-server-example", + "private": true, + "type": "module", + "scripts": { + "start": "tsx index.ts", + "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.48.11" + }, + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^22.13.4", + "@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", + "tsx": "^4.21.0", + "typescript": "^5.7.3" + } +} diff --git a/examples/typescript/servers/auth-capture/tsconfig.json b/examples/typescript/servers/auth-capture/tsconfig.json new file mode 100644 index 0000000000..78f9479b1b --- /dev/null +++ b/examples/typescript/servers/auth-capture/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "baseUrl": ".", + "types": ["node"] + }, + "include": ["index.ts"] +} diff --git a/typescript/.changeset/auth-capture-evm-scheme.md b/typescript/.changeset/auth-capture-evm-scheme.md new file mode 100644 index 0000000000..24163414fe --- /dev/null +++ b/typescript/.changeset/auth-capture-evm-scheme.md @@ -0,0 +1,5 @@ +--- +"@x402/evm": minor +--- + +Implemented auth-capture scheme diff --git a/typescript/packages/mechanisms/evm/package.json b/typescript/packages/mechanisms/evm/package.json index a1060c47fa..14b24b8227 100644 --- a/typescript/packages/mechanisms/evm/package.json +++ b/typescript/packages/mechanisms/evm/package.json @@ -209,6 +209,36 @@ "types": "./dist/cjs/batch-settlement/facilitator/index.d.ts", "default": "./dist/cjs/batch-settlement/facilitator/index.js" } + }, + "./auth-capture/client": { + "import": { + "types": "./dist/esm/auth-capture/client/index.d.mts", + "default": "./dist/esm/auth-capture/client/index.mjs" + }, + "require": { + "types": "./dist/cjs/auth-capture/client/index.d.ts", + "default": "./dist/cjs/auth-capture/client/index.js" + } + }, + "./auth-capture/server": { + "import": { + "types": "./dist/esm/auth-capture/server/index.d.mts", + "default": "./dist/esm/auth-capture/server/index.mjs" + }, + "require": { + "types": "./dist/cjs/auth-capture/server/index.d.ts", + "default": "./dist/cjs/auth-capture/server/index.js" + } + }, + "./auth-capture/facilitator": { + "import": { + "types": "./dist/esm/auth-capture/facilitator/index.d.mts", + "default": "./dist/esm/auth-capture/facilitator/index.mjs" + }, + "require": { + "types": "./dist/cjs/auth-capture/facilitator/index.d.ts", + "default": "./dist/cjs/auth-capture/facilitator/index.js" + } } }, "files": [ diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/README.md b/typescript/packages/mechanisms/evm/src/auth-capture/README.md new file mode 100644 index 0000000000..8ac659b657 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/README.md @@ -0,0 +1,197 @@ +# AuthCapture EVM Scheme (`@x402r/evm/auth-capture`) + +The **auth-capture** scheme adds refundable payments to x402, built on Base's audited [Commerce Payments Protocol](https://github.com/base/commerce-payments). The client signs a single payload (ERC-3009 or Permit2). The facilitator submits to `AuthCaptureEscrow`, where funds are escrowed under a `captureAuthorizer` role rather than transferred straight to the merchant — enabling capture, void, and refund flows before settlement is final. + +Two settle paths: + +- **Two-phase** (`autoCapture: false`, default) — funds are authorized into escrow; the captureAuthorizer captures (or voids) later. +- **Single-shot** (`autoCapture: true`) — `authorize` and `capture` collapse into one transaction, with a refund window until `refundDeadline`. + +See the [scheme specification](https://github.com/x402-foundation/x402/blob/main/specs/schemes/auth-capture/scheme_auth-capture_evm.md) for full protocol details. + +## Import Paths + +| Role | Import | +| ----------- | ------------------------------------ | +| Client | `@x402r/evm/auth-capture/client` | +| Server | `@x402r/evm/auth-capture/server` | +| Facilitator | `@x402r/evm/auth-capture/facilitator` | + +## Client Usage + +Register `AuthCaptureEvmScheme` with an `x402Client`. The client signs the payer-agnostic PaymentInfo hash and emits an ERC-3009 (default) or Permit2 payload. + +```typescript +import { x402Client } from "@x402/core/client"; +import { AuthCaptureEvmScheme } from "@x402r/evm/auth-capture/client"; +import { privateKeyToAccount } from "viem/accounts"; + +const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`); + +const client = new x402Client(); +client.register("eip155:*", new AuthCaptureEvmScheme(account)); +``` + +`ClientEvmSigner` only needs `address` + `signTypedData`; a bare viem `LocalAccount` satisfies the shape, no `PublicClient` required. + +## Server Usage + +Register `AuthCaptureEvmScheme` with an `x402ResourceServer` and publish payment requirements with the spec-mandated `extra` fields: + +```typescript +import { HTTPFacilitatorClient } from "@x402/core/server"; +import { AuthCaptureEvmScheme } from "@x402r/evm/auth-capture/server"; +import { paymentMiddleware, x402ResourceServer } from "@x402/express"; +import { zeroAddress } from "viem"; + +const facilitator = new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL! }); +const resourceServer = new x402ResourceServer(facilitator).register( + "eip155:84532", + new AuthCaptureEvmScheme(), +); + +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { + scheme: "auth-capture", + price: "$0.01", + network: "eip155:84532", + payTo: receiverAddress, + extra: { + captureAuthorizer, // EOA = facilitator submitter, or contract that forwards to escrow + captureDeadlineSeconds: 3600, // seconds-from-now; scheme converts to absolute per request + refundDeadlineSeconds: 7200, + feeRecipient: zeroAddress, // address(0) = captureAuthorizer picks at capture time + minFeeBps: 0, + maxFeeBps: 100, + }, + }, + description: "Weather data", + mimeType: "application/json", + }, + }, + resourceServer, + ), +); +``` + +### Required `extra` fields + +| Field | Type | Notes | +| --- | --- | --- | +| `captureAuthorizer` | `address` | Committed on-chain as `PaymentInfo.operator`. See [captureAuthorizer](#captureauthorizer) below. | +| `feeRecipient` | `address` | `address(0)` lets the captureAuthorizer pick a non-zero recipient at capture/charge time. | +| `minFeeBps` | `uint16` | Floor on the captureAuthorizer's fee. `0` = no minimum. | +| `maxFeeBps` | `uint16` | Cap on the captureAuthorizer's fee. | + +Either set the deadline windows as relative offsets (recommended) or as absolute Unix seconds: + +| Field | Type | Notes | +| --- | --- | --- | +| `captureDeadlineSeconds` | `number` | Seconds-from-now. The scheme converts to `captureDeadline` (absolute) inside `enhancePaymentRequirements` per request, then strips this key from the published `extra`. | +| `refundDeadlineSeconds` | `number` | Seconds-from-now. Converted to `refundDeadline` the same way. | +| `captureDeadline` | `uint48` | Absolute Unix seconds. Use this when the deadline is tied to an external commitment (e.g., a delivery date). Wins over `captureDeadlineSeconds` if both are set. | +| `refundDeadline` | `uint48` | Absolute Unix seconds. Same precedence rule as `captureDeadline`. | + +If neither is set for a window, `enhancePaymentRequirements` throws server-side with a message naming the missing field, so misconfiguration surfaces in the merchant's logs immediately rather than as an `invalid_auth_capture_extra` 402 to the payer. Capture / refund windows are arbiter policy; pick what your captureAuthorizer actually supports. + +The server-side fail-fast also covers the other directly-merchant-set fields (`captureAuthorizer`, `feeRecipient`, `minFeeBps`, `maxFeeBps`); a missing or wrongly-typed value throws at enhance time with a message naming the offending key. + +### Auto-populated by the scheme + +`AuthCaptureEvmScheme.parsePrice` resolves decimal prices like `"$0.01"` against `@x402/evm`'s default-asset table and writes `name` / `version` (EIP-712 token domain) and, where the chain's default uses Permit2, `assetTransferMethod: "permit2"` into the resulting `AssetAmount.extra` for you. The middleware merges these into the published `requirements.extra`, so merchants using decimal pricing do not need to set them by hand. Merchants supplying their own `AssetAmount` (custom token) must set `name` / `version` themselves on the `AssetAmount.extra`; the facilitator's `isAuthCaptureExtra` guard catches the case where they're missing on the wire side (no server-side fail-fast, matching how `batch-settlement` handles its scheme-auto-populated EIP-712 domain fields). + +### Optional `extra` fields + +| Field | Default | Notes | +| --- | --- | --- | +| `autoCapture` | `false` | `true` → `charge` (atomic). `false` → `authorize` (two-phase). See [Settle Paths](#settle-paths). | +| `assetTransferMethod` | `"eip3009"` | `"eip3009"` (ERC-3009) or `"permit2"` (Uniswap Permit2). See [Asset Transfer Methods](#asset-transfer-methods). | + +## Facilitator Usage + +Register the scheme with an `x402Facilitator` instance: + +```typescript +import { x402Facilitator } from "@x402/core/facilitator"; +import { toFacilitatorEvmSigner } from "@x402/evm"; +import { AuthCaptureEvmScheme } from "@x402r/evm/auth-capture/facilitator"; +import { createWalletClient, http, publicActions, nonceManager } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; + +const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY as `0x${string}`, { nonceManager }); +const viemClient = createWalletClient({ + account, + chain: baseSepolia, + transport: http(), +}).extend(publicActions); + +const evmSigner = toFacilitatorEvmSigner({ + address: account.address, + getCode: args => viemClient.getCode(args), + readContract: args => viemClient.readContract(args), + verifyTypedData: args => viemClient.verifyTypedData(args), + writeContract: args => viemClient.writeContract(args), + sendTransaction: args => viemClient.sendTransaction(args), + waitForTransactionReceipt: args => viemClient.waitForTransactionReceipt(args), +}); + +const facilitator = new x402Facilitator(); +facilitator.register("eip155:84532", new AuthCaptureEvmScheme(evmSigner)); +``` + +`verify` performs envelope shape checks, scheme/network agreement, `extra` validation, deadline-ordering invariants, per-method field checks, signature verification (with EIP-6492 unwrap), nonce binding to the payer-agnostic PaymentInfo hash, and an on-chain `simulateContract` of `authorize` / `charge` so typed escrow reverts surface as stable `invalidReason` strings. + +`settle` re-verifies then submits `authorize` (two-phase) or `charge` (single-shot) to the escrow contract, or — if the `captureAuthorizer` is a smart contract — routes the call through that contract (auto-detected, see below). + +## Supported Networks + +| Network | CAIP-2 ID | +| ------------ | -------------- | +| Base Mainnet | `eip155:8453` | +| Base Sepolia | `eip155:84532` | + +Requires the canonical `AuthCaptureEscrow` and EIP-3009 / Permit2 token collectors deployed at universal CREATE2 addresses on the target network. The constants live in [`./constants.ts`](./constants.ts). + +## Settle Paths + +| `autoCapture` | Contract call | Settlement semantics | +| --- | --- | --- | +| `false` (default) | `escrow.authorize(...)` | Two-phase. Funds locked under `captureAuthorizer`; capture / void / refund happens separately via the captureAuthorizer. | +| `true` | `escrow.charge(...)` | Single-shot. `authorize` + `capture` in one transaction. Refunds still possible until `refundDeadline`. | + +## Asset Transfer Methods + +The `assetTransferMethod` field on `extra` selects how the payer's funds reach escrow: + +| Method | Description | Wire shape | +| --- | --- | --- | +| `"eip3009"` (default) | ERC-3009 `ReceiveWithAuthorization` to the canonical EIP-3009 token collector. EIP-712 domain is bound to the **token contract**. | `Eip3009Payload` | +| `"permit2"` | Uniswap Permit2 `PermitTransferFrom` to the canonical Permit2 token collector. Useful for tokens without `receiveWithAuthorization` (e.g., BSC USDC, Tempo pathUSD). | `Permit2Payload` | + +A server MAY advertise multiple `accepts[]` entries with different `assetTransferMethod` values so the client picks whichever matches its token approvals. + +## captureAuthorizer + +`extra.captureAuthorizer` is the address authorized to call `authorize`, `capture`, `void`, `refund`, and `charge` against the escrow. The escrow gates those operations on `msg.sender == paymentInfo.operator`, so in the facilitator-submits flow the value must satisfy one of: + +- An **EOA** — must equal the facilitator's submitter address (its tx `msg.sender` equals `paymentInfo.operator`). +- A **smart contract** that forwards calls to the escrow — the contract becomes `msg.sender` at the escrow. + +The SDK auto-detects which path applies via `getCode(captureAuthorizer)`: empty bytecode routes the settle call directly to escrow; non-empty bytecode routes through the captureAuthorizer contract using the same ABI selectors. See the spec for protocol-level detail. + +If neither condition holds (e.g., an unrelated EOA), the escrow's `onlySender` gate reverts with `InvalidSender` during the verify-step simulation, which the SDK maps to `invalid_capture_authorizer` on the `VerifyResponse`. + +## Examples + +- [Client example](../../../../../examples/clients/auth-capture) +- [Server example](../../../../../examples/servers/auth-capture) +- [Facilitator example](../../../../../examples/facilitator/auth-capture) + +## See Also + +- [Scheme specification](https://github.com/x402-foundation/x402/blob/main/specs/schemes/auth-capture/scheme_auth-capture_evm.md) +- [`AuthCaptureEscrow` contract](https://github.com/base/commerce-payments) diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/abi.ts b/typescript/packages/mechanisms/evm/src/auth-capture/abi.ts new file mode 100644 index 0000000000..47273cf10e --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/abi.ts @@ -0,0 +1,145 @@ +// PaymentInfo struct for AuthCaptureEscrow (matches base/commerce-payments contract). +// Field names are canonical Solidity — do not rename. Spec-level field renames +// (captureAuthorizer, captureDeadline, refundDeadline, feeRecipient) live at the +// extra/wire layer; this struct preserves the canonical EIP-712 typehash. +export const PAYMENT_INFO_COMPONENTS = [ + { name: "operator", type: "address" }, + { name: "payer", type: "address" }, + { name: "receiver", type: "address" }, + { name: "token", type: "address" }, + { name: "maxAmount", type: "uint120" }, + { name: "preApprovalExpiry", type: "uint48" }, + { name: "authorizationExpiry", type: "uint48" }, + { name: "refundExpiry", type: "uint48" }, + { name: "minFeeBps", type: "uint16" }, + { name: "maxFeeBps", type: "uint16" }, + { name: "feeReceiver", type: "address" }, + { name: "salt", type: "uint256" }, +] as const; + +export const ESCROW_ABI = [ + { + name: "authorize", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256" }, + { name: "tokenCollector", type: "address" }, + { name: "collectorData", type: "bytes" }, + ], + outputs: [], + }, + { + name: "charge", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + { name: "amount", type: "uint256" }, + { name: "tokenCollector", type: "address" }, + { name: "collectorData", type: "bytes" }, + { name: "feeBps", type: "uint16" }, + { name: "feeReceiver", type: "address" }, + ], + outputs: [], + }, +] as const; + +// ERC-20 balanceOf ABI for balance checks +export const ERC20_BALANCE_OF_ABI = [ + { + name: "balanceOf", + type: "function", + stateMutability: "view", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "balance", type: "uint256" }], + }, +] as const; + +// View functions on AuthCaptureEscrow used by tests / introspection. Not part +// of ESCROW_ABI because settle/simulate paths only need authorize + charge. +export const ESCROW_VIEW_ABI = [ + { + name: "getHash", + type: "function", + stateMutability: "view", + inputs: [ + { + name: "paymentInfo", + type: "tuple", + components: PAYMENT_INFO_COMPONENTS, + }, + ], + outputs: [{ type: "bytes32" }], + }, + { + name: "paymentState", + type: "function", + stateMutability: "view", + inputs: [{ name: "paymentInfoHash", type: "bytes32" }], + outputs: [ + { + name: "state", + type: "tuple", + components: [ + { name: "hasCollectedPayment", type: "bool" }, + { name: "capturableAmount", type: "uint120" }, + { name: "refundableAmount", type: "uint120" }, + ], + }, + ], + }, +] as const; + +// AuthCaptureEscrow custom errors. Spliced into the ABI passed to simulateContract +// so viem can decode `ContractFunctionRevertedError.data.errorName` instead of +// falling back to an opaque hex selector. Names mirror the Solidity definitions +// at base/commerce-payments/src/AuthCaptureEscrow.sol. +export const ESCROW_ERRORS_ABI = [ + { type: "error", name: "InvalidSender", inputs: [{ type: "address" }, { type: "address" }] }, + { type: "error", name: "ZeroAmount", inputs: [] }, + { type: "error", name: "AmountOverflow", inputs: [{ type: "uint256" }, { type: "uint256" }] }, + { type: "error", name: "ExceedsMaxAmount", inputs: [{ type: "uint256" }, { type: "uint256" }] }, + { + type: "error", + name: "AfterPreApprovalExpiry", + inputs: [{ type: "uint48" }, { type: "uint48" }], + }, + { + type: "error", + name: "InvalidExpiries", + inputs: [{ type: "uint48" }, { type: "uint48" }, { type: "uint48" }], + }, + { type: "error", name: "FeeBpsOverflow", inputs: [{ type: "uint16" }] }, + { type: "error", name: "InvalidFeeBpsRange", inputs: [{ type: "uint16" }, { type: "uint16" }] }, + { + type: "error", + name: "FeeBpsOutOfRange", + inputs: [{ type: "uint16" }, { type: "uint16" }, { type: "uint16" }], + }, + { type: "error", name: "ZeroFeeReceiver", inputs: [] }, + { type: "error", name: "InvalidFeeReceiver", inputs: [{ type: "address" }, { type: "address" }] }, + { type: "error", name: "InvalidCollectorForOperation", inputs: [] }, + { type: "error", name: "TokenCollectionFailed", inputs: [] }, + { type: "error", name: "PaymentAlreadyCollected", inputs: [{ type: "bytes32" }] }, + { + type: "error", + name: "AfterAuthorizationExpiry", + inputs: [{ type: "uint48" }, { type: "uint48" }], + }, + { + type: "error", + name: "InsufficientAuthorization", + inputs: [{ type: "bytes32" }, { type: "uint256" }, { type: "uint256" }], + }, + { type: "error", name: "ZeroAuthorization", inputs: [{ type: "bytes32" }] }, +] as const; diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/client/index.ts b/typescript/packages/mechanisms/evm/src/auth-capture/client/index.ts new file mode 100644 index 0000000000..224777b216 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/client/index.ts @@ -0,0 +1 @@ +export { AuthCaptureEvmScheme } from "./scheme"; diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/client/scheme.ts b/typescript/packages/mechanisms/evm/src/auth-capture/client/scheme.ts new file mode 100644 index 0000000000..8463fd884e --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/client/scheme.ts @@ -0,0 +1,167 @@ +/** + * AuthCapture Scheme - Client + * Builds payment payloads for auth-capture payments. + * + * Implements x402's SchemeNetworkClient interface so it can be registered + * on an x402Client via client.register('eip155:84532', new AuthCaptureEvmScheme(signer)). + */ + +import type { + PaymentPayloadContext, + PaymentPayloadResult, + PaymentRequirements, + SchemeNetworkClient, +} from "@x402/core/types"; +import type { ClientEvmSigner } from "../../signer"; +import { hexToBigInt } from "viem"; +import { + AUTH_CAPTURE_SCHEME, + EIP3009_TOKEN_COLLECTOR_ADDRESS, + PERMIT2_TOKEN_COLLECTOR_ADDRESS, +} from "../constants"; +import { + computePayerAgnosticPaymentInfoHash, + generateSalt, + signERC3009, + signPermit2, +} from "../nonce"; +import type { AuthCaptureExtra, Eip3009Payload, PaymentInfoStruct, Permit2Payload } from "../types"; +import { parseChainId } from "../utils"; + +/** + * Client-side implementation of the auth-capture scheme: derives the canonical + * payer-agnostic PaymentInfo hash, signs an ERC-3009 ReceiveWithAuthorization + * (default) or a Permit2 PermitTransferFrom against it, and returns a wire + * payload the facilitator can settle. Implements `SchemeNetworkClient`. + */ +export class AuthCaptureEvmScheme implements SchemeNetworkClient { + readonly scheme = AUTH_CAPTURE_SCHEME; + + /** + * Construct a client-side auth-capture scheme bound to a specific signer. + * + * @param signer - Client-side signer that exposes `address` and `signTypedData`. + */ + constructor(private readonly signer: ClientEvmSigner) {} + + /** + * Build and sign an auth-capture payment payload for the given requirements. + * Validates all spec-mandated `extra` fields and the asset-transfer method + * (default `eip3009`, alternative `permit2`), reconstructs the on-chain + * PaymentInfo struct, computes its payer-agnostic hash, and returns the + * signed wire payload. + * + * @param x402Version - Wire protocol version; only `2` is supported. + * @param requirements - Resource server's payment requirements (includes scheme `extra`). + * @param _ - Unused FacilitatorContext (interface compatibility). + * @returns The signed wire payload tagged with the x402 protocol version. + * @throws If `x402Version !== 2` or any required `extra` field is missing. + */ + async createPaymentPayload( + x402Version: number, + requirements: PaymentRequirements, + _?: PaymentPayloadContext, + ): Promise { + if (x402Version !== 2) { + throw new Error(`Unsupported x402Version: ${x402Version}. Only version 2 is supported.`); + } + + const extra = requirements.extra as unknown as AuthCaptureExtra; + + // Validate required EIP-712 token-domain parameters + if (!extra.name) { + throw new Error( + `EIP-712 domain parameter 'name' is required in payment requirements for asset ${requirements.asset}`, + ); + } + if (!extra.version) { + throw new Error( + `EIP-712 domain parameter 'version' is required in payment requirements for asset ${requirements.asset}`, + ); + } + if (!extra.captureAuthorizer) { + throw new Error(`'captureAuthorizer' is required in payment requirements extra`); + } + if (!extra.feeRecipient) { + throw new Error(`'feeRecipient' is required in payment requirements extra`); + } + if (typeof extra.captureDeadline !== "number") { + throw new Error(`'captureDeadline' is required in payment requirements extra`); + } + if (typeof extra.refundDeadline !== "number") { + throw new Error(`'refundDeadline' is required in payment requirements extra`); + } + if (typeof extra.minFeeBps !== "number") { + throw new Error(`'minFeeBps' is required in payment requirements extra`); + } + if (typeof extra.maxFeeBps !== "number") { + throw new Error(`'maxFeeBps' is required in payment requirements extra`); + } + if (typeof requirements.maxTimeoutSeconds !== "number") { + throw new Error( + `'maxTimeoutSeconds' is required in PaymentRequirements (used to derive preApprovalExpiry)`, + ); + } + + const chainId = parseChainId(requirements.network); + const maxAmount = requirements.amount; + const nowSeconds = Math.floor(Date.now() / 1000); + const preApprovalExpiry = nowSeconds + requirements.maxTimeoutSeconds; + const salt = generateSalt(); + const assetTransferMethod = extra.assetTransferMethod ?? "eip3009"; + + // Build the canonical PaymentInfo struct (Solidity field names — do not rename). + const paymentInfo: PaymentInfoStruct = { + operator: extra.captureAuthorizer, + payer: this.signer.address, + receiver: requirements.payTo as `0x${string}`, + token: requirements.asset as `0x${string}`, + maxAmount, + preApprovalExpiry, + authorizationExpiry: extra.captureDeadline, + refundExpiry: extra.refundDeadline, + minFeeBps: extra.minFeeBps, + maxFeeBps: extra.maxFeeBps, + feeReceiver: extra.feeRecipient, + salt, + }; + + // Payer-agnostic PaymentInfo hash — used as ERC-3009 nonce or Permit2 nonce. + const nonce = computePayerAgnosticPaymentInfoHash(chainId, paymentInfo); + + if (assetTransferMethod === "permit2") { + const permit2Authorization: Permit2Payload["permit2Authorization"] = { + from: this.signer.address, + permitted: { + token: requirements.asset as `0x${string}`, + amount: maxAmount, + }, + spender: PERMIT2_TOKEN_COLLECTOR_ADDRESS, + nonce: hexToBigInt(nonce).toString(), + deadline: String(preApprovalExpiry), + }; + const signature = await signPermit2(this.signer, permit2Authorization, chainId); + const payload: Permit2Payload = { permit2Authorization, signature, salt }; + return { x402Version, payload: payload as unknown as Record }; + } + + // Default: EIP-3009 ReceiveWithAuthorization to the canonical EIP-3009 token collector. + const authorization: Eip3009Payload["authorization"] = { + from: this.signer.address, + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: maxAmount, + validAfter: "0", + validBefore: String(preApprovalExpiry), + nonce, + }; + const signature = await signERC3009( + this.signer, + authorization, + extra, + requirements.asset as `0x${string}`, + chainId, + ); + const payload: Eip3009Payload = { authorization, signature, salt }; + return { x402Version, payload: payload as unknown as Record }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/constants.ts b/typescript/packages/mechanisms/evm/src/auth-capture/constants.ts new file mode 100644 index 0000000000..b760e4877f --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/constants.ts @@ -0,0 +1,43 @@ +// Scheme identifier for the auth-capture payment scheme. +export const AUTH_CAPTURE_SCHEME = "auth-capture" as const; + +// Canonical AuthCaptureEscrow + token collector deployments from +// base/commerce-payments (https://github.com/base/commerce-payments). These are +// the audited, live addresses listed in the upstream README — the source of +// truth for this scheme. Universal constants — not configurable per merchant. +// +// Currently live on: Base (8453) and Base Sepolia (84532). Additional EVM chains +// will land at the same addresses as the upstream extends coverage; expand the +// supported-chain list here as those deployments ship. +export const AUTH_CAPTURE_ESCROW_ADDRESS = + "0xBdEA0D1bcC5966192B070Fdf62aB4EF5b4420cff" as const satisfies `0x${string}`; +export const EIP3009_TOKEN_COLLECTOR_ADDRESS = + "0x0E3dF9510de65469C4518D7843919c0b8C7A7757" as const satisfies `0x${string}`; +export const PERMIT2_TOKEN_COLLECTOR_ADDRESS = + "0x992476B9Ee81d52a5BdA0622C333938D0Af0aB26" as const satisfies `0x${string}`; + +// ERC-3009 ReceiveWithAuthorization EIP-712 types +export const RECEIVE_AUTHORIZATION_TYPES = { + 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; + +// Uniswap Permit2 PermitTransferFrom EIP-712 types +export const PERMIT2_TRANSFER_FROM_TYPES = { + PermitTransferFrom: [ + { name: "permitted", type: "TokenPermissions" }, + { name: "spender", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], + TokenPermissions: [ + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + ], +} as const; diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/errors.ts b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/errors.ts new file mode 100644 index 0000000000..2a55ef9b0c --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/errors.ts @@ -0,0 +1,73 @@ +/** + * Named error reason constants for the auth-capture EVM facilitator. + * + * Mirrors the upstream x402 mechanisms/evm pattern (see exact/upto/batch-settlement + * facilitator/errors.ts). The string values are wire-level identifiers locked + * by the merged auth-capture-EVM spec (specs/schemes/auth-capture/scheme_auth-capture_evm.md, + * "Error Codes" section) — do not rename them without a corresponding spec PR. + */ + +// Verify errors — pre-simulation +export const ErrInvalidPayloadFormat = "invalid_payload_format"; +export const ErrUnsupportedScheme = "unsupported_scheme"; +export const ErrNetworkMismatch = "network_mismatch"; +export const ErrInvalidNetwork = "invalid_network"; +export const ErrInvalidAuthCaptureExtra = "invalid_auth_capture_extra"; +export const ErrUnsupportedAssetTransferMethod = "unsupported_asset_transfer_method"; +export const ErrPayloadMethodMismatch = "payload_method_mismatch"; +export const ErrCaptureDeadlineExpired = "capture_deadline_expired"; +export const ErrInvalidDeadlineOrdering = "invalid_deadline_ordering"; +export const ErrAuthorizationExpired = "authorization_expired"; +export const ErrAuthorizationNotYetValid = "authorization_not_yet_valid"; +export const ErrTokenCollectorMismatch = "token_collector_mismatch"; +export const ErrTokenMismatch = "token_mismatch"; +export const ErrInvalidAuthCaptureSignature = "invalid_auth_capture_signature"; +export const ErrAmountMismatch = "amount_mismatch"; +export const ErrNonceMismatch = "nonce_mismatch"; +export const ErrInsufficientBalance = "insufficient_balance"; +export const ErrSimulationFailed = "simulation_failed"; + +// Typed simulation reverts — surfaced when the on-chain simulate call reverts +// with a known AuthCaptureEscrow custom error. +export const ErrPaymentAlreadyCollected = "payment_already_collected"; +export const ErrTokenCollectionFailed = "token_collection_failed"; +export const ErrInvalidCollector = "invalid_collector"; +export const ErrInvalidCaptureAuthorizer = "invalid_capture_authorizer"; +export const ErrAmountOverflow = "amount_overflow"; +export const ErrInvalidFeeBps = "invalid_fee_bps"; +export const ErrInvalidFeeBpsRange = "invalid_fee_bps_range"; +export const ErrFeeBpsOutOfRange = "fee_bps_out_of_range"; +export const ErrZeroFeeReceiver = "zero_fee_receiver"; +export const ErrInvalidFeeReceiver = "invalid_fee_receiver"; +export const ErrInsufficientAuthorization = "insufficient_authorization"; +export const ErrZeroAuthorization = "zero_authorization"; + +// Settle errors +export const ErrVerificationFailed = "verification_failed"; +export const ErrTransactionReverted = "transaction_reverted"; + +/** + * Map an AuthCaptureEscrow custom-error name (decoded by viem from a + * ContractFunctionRevertedError) to a stable invalidReason string. Anything + * unmapped falls through to ErrSimulationFailed so verify() never leaks raw + * selectors to callers. + */ +export const ESCROW_ERROR_TO_INVALID_REASON: Record = { + AfterPreApprovalExpiry: ErrAuthorizationExpired, + InvalidExpiries: ErrInvalidDeadlineOrdering, + ExceedsMaxAmount: ErrAmountMismatch, + PaymentAlreadyCollected: ErrPaymentAlreadyCollected, + TokenCollectionFailed: ErrTokenCollectionFailed, + InvalidCollectorForOperation: ErrInvalidCollector, + InvalidSender: ErrInvalidCaptureAuthorizer, + ZeroAmount: ErrAmountMismatch, + AmountOverflow: ErrAmountOverflow, + FeeBpsOverflow: ErrInvalidFeeBps, + InvalidFeeBpsRange: ErrInvalidFeeBpsRange, + FeeBpsOutOfRange: ErrFeeBpsOutOfRange, + ZeroFeeReceiver: ErrZeroFeeReceiver, + InvalidFeeReceiver: ErrInvalidFeeReceiver, + AfterAuthorizationExpiry: ErrCaptureDeadlineExpired, + InsufficientAuthorization: ErrInsufficientAuthorization, + ZeroAuthorization: ErrZeroAuthorization, +}; diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/index.ts b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/index.ts new file mode 100644 index 0000000000..224777b216 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/index.ts @@ -0,0 +1 @@ +export { AuthCaptureEvmScheme } from "./scheme"; diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts new file mode 100644 index 0000000000..7d7f5dc2bf --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/facilitator/scheme.ts @@ -0,0 +1,629 @@ +/** + * AuthCapture Scheme - Facilitator + * Handles verification and settlement of auth-capture payments. + * + * Implements x402's SchemeNetworkFacilitator interface so the auth-capture scheme + * is a drop-in for the x402 facilitator, just like ExactEvmScheme. + * + * The facilitator is captureAuthorizer-agnostic: capture-authorizer addresses are + * set by the merchant and arrive in `requirements.extra` at verify/settle time. + * Escrow + token-collector addresses are universal constants and never come from + * the wire format. + */ + +import type { + FacilitatorContext, + PaymentPayload, + PaymentRequirements, + SchemeNetworkFacilitator, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import type { FacilitatorEvmSigner } from "../../signer"; +import { BaseError, ContractFunctionRevertedError, hexToBigInt, parseErc6492Signature } from "viem"; +import { ERC20_BALANCE_OF_ABI, ESCROW_ABI, ESCROW_ERRORS_ABI } from "../abi"; +import { + AUTH_CAPTURE_ESCROW_ADDRESS, + AUTH_CAPTURE_SCHEME, + EIP3009_TOKEN_COLLECTOR_ADDRESS, + PERMIT2_TOKEN_COLLECTOR_ADDRESS, +} from "../constants"; +import { + ESCROW_ERROR_TO_INVALID_REASON, + ErrAmountMismatch, + ErrAuthorizationExpired, + ErrAuthorizationNotYetValid, + ErrCaptureDeadlineExpired, + ErrInsufficientBalance, + ErrInvalidAuthCaptureExtra, + ErrInvalidAuthCaptureSignature, + ErrInvalidDeadlineOrdering, + ErrInvalidNetwork, + ErrInvalidPayloadFormat, + ErrNetworkMismatch, + ErrNonceMismatch, + ErrPayloadMethodMismatch, + ErrSimulationFailed, + ErrTokenCollectorMismatch, + ErrTokenMismatch, + ErrTransactionReverted, + ErrUnsupportedAssetTransferMethod, + ErrUnsupportedScheme, + ErrVerificationFailed, +} from "./errors"; +import { + computePayerAgnosticPaymentInfoHash, + verifyERC3009Signature, + verifyPermit2Signature, +} from "../nonce"; +import { + isAuthCaptureExtra, + isAuthCapturePayload, + isEip3009Payload, + isPermit2Payload, +} from "../types"; +import type { + AuthCaptureExtra, + AuthCapturePayload, + Eip3009Payload, + PaymentInfoStruct, + Permit2Payload, +} from "../types"; +import { parseChainId } from "../utils"; + +/** + * Reconstruct the on-chain PaymentInfo struct from the inputs the facilitator + * has after verifying a wire payload. Wire-only inputs: `payer` and `salt` + * (both from the payload). `preApprovalExpiry` is computed by the caller from + * the payload (ERC-3009 `validBefore` or Permit2 `deadline`). The remaining + * fields come from `requirements` (receiver/token/maxAmount) and + * `requirements.extra` (capture/refund deadlines, fee policy, captureAuthorizer). + * + * @param payer - Address recovered from the wire payload's signature. + * @param preApprovalExpiry - Pre-approval expiry in Unix seconds (from the wire payload). + * @param salt - 32-byte salt from the wire payload. + * @param requirements - The payment requirements published by the server. + * @param extra - The validated `AuthCaptureExtra` subset of `requirements.extra`. + * @returns A PaymentInfo struct ready to hand to the escrow contract. + */ +function reconstructPaymentInfo( + payer: `0x${string}`, + preApprovalExpiry: number, + salt: `0x${string}`, + requirements: PaymentRequirements, + extra: AuthCaptureExtra, +): PaymentInfoStruct { + return { + operator: extra.captureAuthorizer, + payer, + receiver: requirements.payTo as `0x${string}`, + token: requirements.asset as `0x${string}`, + maxAmount: requirements.amount, + preApprovalExpiry, + authorizationExpiry: extra.captureDeadline, + refundExpiry: extra.refundDeadline, + minFeeBps: extra.minFeeBps, + maxFeeBps: extra.maxFeeBps, + feeReceiver: extra.feeRecipient, + salt, + }; +} + +/** + * Convert a JS-side PaymentInfo struct (string `maxAmount` and `salt`) into + * the bigint-typed form viem expects when encoding the on-chain tuple. + * + * @param p - PaymentInfo with string-form numeric fields. + * @returns The same struct with `maxAmount` and `salt` coerced to bigint. + */ +function paymentInfoToContractTuple(p: PaymentInfoStruct) { + return { ...p, maxAmount: BigInt(p.maxAmount), salt: BigInt(p.salt) }; +} + +/** + * AuthCapture Facilitator Scheme - implements x402's SchemeNetworkFacilitator. + * + * Settle dispatch: + * - extra.autoCapture === true → escrow.charge() (single-shot, funds direct to receiver) + * - extra.autoCapture !== true → escrow.authorize() (two-phase; captureAuthorizer captures later) + * + * Asset-transfer dispatch (extra.assetTransferMethod): + * - 'eip3009' (default) → ERC-3009 ReceiveWithAuthorization, EIP3009_TOKEN_COLLECTOR + * - 'permit2' → Permit2 PermitTransferFrom, PERMIT2_TOKEN_COLLECTOR + */ +export class AuthCaptureEvmScheme implements SchemeNetworkFacilitator { + readonly scheme = AUTH_CAPTURE_SCHEME; + readonly caipFamily = "eip155:*"; + + /** + * Construct a facilitator-side auth-capture scheme bound to a specific signer. + * + * @param signer - Facilitator signer with on-chain read + write capability. + */ + constructor(private signer: FacilitatorEvmSigner) {} + + /** + * Return the EOA address(es) this facilitator submits transactions from. + * Advertised via `/supported` so merchants can decide whether to set + * `extra.captureAuthorizer = facilitator-EOA` for the EOA-captureAuthorizer + * path. + * + * @param _ - Unused network argument (interface compatibility). + * @returns The facilitator's submitter address(es) on this network. + */ + getSigners(_: string): string[] { + return [...this.signer.getAddresses()]; + } + + /** + * Facilitator-injected `extra` fields for `/supported`. auth-capture injects + * none — every wire-format address is a universal canonical constant, and + * `captureAuthorizer`, `feeRecipient`, and the deadlines are merchant-set + * per request. + * + * @param _ - Unused network argument (interface compatibility). + * @returns Always `undefined`. + */ + getExtra(_: string): Record | undefined { + return undefined; + } + + /** + * Verify a payment payload against the published requirements without + * touching state. Performs envelope shape checks, scheme/network agreement, + * `extra` validation, deadline-ordering invariants, per-method field checks + * (collector address, token, amount), signature verification (with + * EIP-6492 unwrap), nonce binding to the payer-agnostic PaymentInfo hash, + * and an on-chain `simulateContract` of `authorize` / `charge` so typed + * escrow reverts surface as stable invalidReason strings. + * + * @param payload - The wire payload from the payer. + * @param requirements - The server's published payment requirements. + * @param _ - Unused FacilitatorContext (interface compatibility). + * @returns A `VerifyResponse` with `isValid` and, on failure, a stable `invalidReason`. + */ + async verify( + payload: PaymentPayload, + requirements: PaymentRequirements, + _?: FacilitatorContext, + ): Promise { + if (!isAuthCapturePayload(payload.payload)) { + return { isValid: false, invalidReason: ErrInvalidPayloadFormat }; + } + const wirePayload = payload.payload as AuthCapturePayload; + const payer = isEip3009Payload(wirePayload) + ? wirePayload.authorization.from + : (wirePayload as Permit2Payload).permit2Authorization.from; + + if ( + payload.accepted.scheme !== AUTH_CAPTURE_SCHEME || + requirements.scheme !== AUTH_CAPTURE_SCHEME + ) { + return { isValid: false, invalidReason: ErrUnsupportedScheme, payer }; + } + + if (payload.accepted.network !== requirements.network) { + return { isValid: false, invalidReason: ErrNetworkMismatch, payer }; + } + + const networkParts = requirements.network.split(":"); + if (networkParts.length !== 2 || networkParts[0] !== "eip155") { + return { isValid: false, invalidReason: ErrInvalidNetwork, payer }; + } + + if (!isAuthCaptureExtra(requirements.extra)) { + return { isValid: false, invalidReason: ErrInvalidAuthCaptureExtra, payer }; + } + const extra = requirements.extra as AuthCaptureExtra; + const chainId = parseChainId(requirements.network); + const assetTransferMethod = extra.assetTransferMethod ?? "eip3009"; + + if (assetTransferMethod !== "eip3009" && assetTransferMethod !== "permit2") { + return { isValid: false, invalidReason: ErrUnsupportedAssetTransferMethod, payer }; + } + if (assetTransferMethod === "eip3009" && !isEip3009Payload(wirePayload)) { + return { isValid: false, invalidReason: ErrPayloadMethodMismatch, payer }; + } + if (assetTransferMethod === "permit2" && !isPermit2Payload(wirePayload)) { + return { isValid: false, invalidReason: ErrPayloadMethodMismatch, payer }; + } + + const now = Math.floor(Date.now() / 1000); + const SAFETY_MARGIN_SECONDS = 6; + if (extra.captureDeadline <= now + SAFETY_MARGIN_SECONDS) { + return { isValid: false, invalidReason: ErrCaptureDeadlineExpired, payer }; + } + if (extra.refundDeadline < extra.captureDeadline) { + return { isValid: false, invalidReason: ErrInvalidDeadlineOrdering, payer }; + } + // Mirror AuthCaptureEscrow._validatePayment ordering check upfront so the + // facilitator rejects with a typed reason instead of letting the contract + // revert with InvalidExpiries. preApprovalExpiry is client-derived from + // requirements.maxTimeoutSeconds; if a merchant pairs a tight captureDeadline + // with a generous maxTimeoutSeconds, the inequality breaks. + + let preApprovalExpiry: number; + let amount: bigint; + let signatureForVerify: `0x${string}`; + let signatureValid = false; + + if (assetTransferMethod === "eip3009") { + const eipPayload = wirePayload as Eip3009Payload; + preApprovalExpiry = Number(eipPayload.authorization.validBefore); + amount = BigInt(eipPayload.authorization.value); + + if (preApprovalExpiry <= now + SAFETY_MARGIN_SECONDS) { + return { isValid: false, invalidReason: ErrAuthorizationExpired, payer }; + } + if (Number(eipPayload.authorization.validAfter) > now) { + return { isValid: false, invalidReason: ErrAuthorizationNotYetValid, payer }; + } + if ( + eipPayload.authorization.to.toLowerCase() !== EIP3009_TOKEN_COLLECTOR_ADDRESS.toLowerCase() + ) { + return { isValid: false, invalidReason: ErrTokenCollectorMismatch, payer }; + } + + const parsed = parseErc6492Signature(eipPayload.signature); + signatureForVerify = parsed.signature; + signatureValid = await verifyERC3009Signature( + this.signer, + eipPayload.authorization, + signatureForVerify, + { ...extra, chainId }, + requirements.asset as `0x${string}`, + ); + } else { + const permitPayload = wirePayload as Permit2Payload; + preApprovalExpiry = Number(permitPayload.permit2Authorization.deadline); + amount = BigInt(permitPayload.permit2Authorization.permitted.amount); + + if (preApprovalExpiry <= now + SAFETY_MARGIN_SECONDS) { + return { isValid: false, invalidReason: ErrAuthorizationExpired, payer }; + } + if ( + permitPayload.permit2Authorization.spender.toLowerCase() !== + PERMIT2_TOKEN_COLLECTOR_ADDRESS.toLowerCase() + ) { + return { isValid: false, invalidReason: ErrTokenCollectorMismatch, payer }; + } + if ( + permitPayload.permit2Authorization.permitted.token.toLowerCase() !== + requirements.asset.toLowerCase() + ) { + return { isValid: false, invalidReason: ErrTokenMismatch, payer }; + } + + const parsed = parseErc6492Signature(permitPayload.signature); + signatureForVerify = parsed.signature; + signatureValid = await verifyPermit2Signature( + this.signer, + permitPayload.permit2Authorization, + signatureForVerify, + chainId, + ); + } + + if (!signatureValid) { + return { isValid: false, invalidReason: ErrInvalidAuthCaptureSignature, payer }; + } + + if (amount !== BigInt(requirements.amount)) { + return { isValid: false, invalidReason: ErrAmountMismatch, payer }; + } + + if (preApprovalExpiry > extra.captureDeadline) { + // AuthCaptureEscrow._validatePayment requires preApprovalExp <= authorizationExp <= refundExp. + // Surface this as the same invalid_deadline_ordering reason rather than letting the + // contract revert with InvalidExpiries on settle. + return { isValid: false, invalidReason: ErrInvalidDeadlineOrdering, payer }; + } + + // Reconstruct PaymentInfo and verify the wire nonce matches the + // payer-agnostic hash. This binds the signature to all PaymentInfo fields. + const paymentInfo = reconstructPaymentInfo( + payer, + preApprovalExpiry, + wirePayload.salt, + requirements, + extra, + ); + const expectedNonce = computePayerAgnosticPaymentInfoHash(chainId, paymentInfo); + + if (assetTransferMethod === "eip3009") { + const wireNonce = (wirePayload as Eip3009Payload).authorization.nonce; + if (wireNonce.toLowerCase() !== expectedNonce.toLowerCase()) { + return { isValid: false, invalidReason: ErrNonceMismatch, payer }; + } + } else { + const wireNonce = BigInt((wirePayload as Permit2Payload).permit2Authorization.nonce); + if (wireNonce !== hexToBigInt(expectedNonce)) { + return { isValid: false, invalidReason: ErrNonceMismatch, payer }; + } + } + + // Simulate the settle call to catch issues before spending gas. + const settleResult = await this.simulateSettle(paymentInfo, amount, wirePayload, extra, payer); + if (settleResult !== "ok") { + // For balance-related failures, return a more actionable reason. + try { + const balance = (await this.signer.readContract({ + address: requirements.asset as `0x${string}`, + abi: ERC20_BALANCE_OF_ABI, + functionName: "balanceOf", + args: [payer], + })) as bigint; + if (balance < BigInt(requirements.amount)) { + return { isValid: false, invalidReason: ErrInsufficientBalance, payer }; + } + } catch { + /* ignore — fall through */ + } + return { isValid: false, invalidReason: settleResult, payer }; + } + + return { isValid: true, payer }; + } + + /** + * Verify-then-settle. Re-runs `verify()` against the payload, then submits + * `authorize` (two-phase, default) or `charge` (single-shot, when + * `extra.autoCapture === true`) to the escrow contract. If the merchant has + * set `captureAuthorizer` to a smart contract, the call is routed through + * that contract instead of directly to the escrow (see `resolveSettleTarget`). + * Waits for the transaction receipt with a 60-second timeout. + * + * @param payload - The wire payload from the payer. + * @param requirements - The server's published payment requirements. + * @param _ - Unused FacilitatorContext (interface compatibility). + * @returns A `SettleResponse` with `success`, the transaction hash (on + * success), and a stable `errorReason` (on failure). + */ + async settle( + payload: PaymentPayload, + requirements: PaymentRequirements, + _?: FacilitatorContext, + ): Promise { + const verification = await this.verify(payload, requirements); + if (!verification.isValid) { + return { + success: false, + errorReason: verification.invalidReason ?? ErrVerificationFailed, + transaction: "", + network: requirements.network, + payer: verification.payer, + }; + } + + const wirePayload = payload.payload as unknown as AuthCapturePayload; + const extra = requirements.extra as unknown as AuthCaptureExtra; + const assetTransferMethod = extra.assetTransferMethod ?? "eip3009"; + const payer = verification.payer as `0x${string}`; + + const { preApprovalExpiry, amount, tokenCollector, collectorData } = unpackForSettle( + wirePayload, + assetTransferMethod, + ); + const paymentInfo = reconstructPaymentInfo( + payer, + preApprovalExpiry, + wirePayload.salt, + requirements, + extra, + ); + + const functionName = extra.autoCapture === true ? "charge" : "authorize"; + const tuple = paymentInfoToContractTuple(paymentInfo); + // charge() takes 6 args (adds feeBps + feeReceiver); authorize() takes 4. + // Use minFeeBps as the safe default within the merchant's signed [min, max] + // range; feeReceiver mirrors paymentInfo.feeReceiver (= extra.feeRecipient) + // because _validateFee requires actual to match configured when configured != 0. + const args = + functionName === "charge" + ? ([ + tuple, + amount, + tokenCollector, + collectorData, + paymentInfo.minFeeBps, + paymentInfo.feeReceiver, + ] as const) + : ([tuple, amount, tokenCollector, collectorData] as const); + + const settleTarget = await this.resolveSettleTarget(extra.captureAuthorizer); + + try { + const txHash = await this.signer.writeContract({ + address: settleTarget, + abi: ESCROW_ABI, + functionName, + args, + }); + + const receiptPromise = this.signer.waitForTransactionReceipt({ hash: txHash }); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("Transaction receipt timeout after 60s")), 60_000), + ); + const receipt = await Promise.race([receiptPromise, timeoutPromise]); + + if (receipt.status !== "success") { + return { + success: false, + errorReason: ErrTransactionReverted, + transaction: txHash, + network: requirements.network, + payer, + }; + } + + return { + success: true, + transaction: txHash, + network: requirements.network, + payer, + }; + } catch (error) { + return { + success: false, + errorReason: error instanceof Error ? error.message : "Settlement failed", + transaction: "", + network: requirements.network, + payer, + }; + } + } + + /** + * Simulate the settle call via `eth_call` and translate the result to a + * stable wire-level reason. Returns `"ok"` on simulated success; on revert + * viem walks the error chain for `ContractFunctionRevertedError` and decodes + * the custom-error name against `ESCROW_ABI + ESCROW_ERRORS_ABI`. Known + * errors map to typed reasons via `ESCROW_ERROR_TO_INVALID_REASON`; anything + * unmapped (e.g. token-collector reverts like a consumed ERC-3009 nonce) + * falls through to `simulation_failed`. + * + * @param paymentInfo - The reconstructed PaymentInfo struct. + * @param amount - Settle amount in token base units. + * @param wirePayload - The payer's wire payload. + * @param extra - Validated `AuthCaptureExtra` from `requirements.extra`. + * @param _ - Unused payer address (interface compatibility). + * @returns `"ok"` on simulated success, or a stable `invalidReason` string. + */ + private async simulateSettle( + paymentInfo: PaymentInfoStruct, + amount: bigint, + wirePayload: AuthCapturePayload, + extra: AuthCaptureExtra, + _: `0x${string}`, + ): Promise<"ok" | string> { + const assetTransferMethod = extra.assetTransferMethod ?? "eip3009"; + const { tokenCollector, collectorData } = unpackForSettle(wirePayload, assetTransferMethod); + const functionName = extra.autoCapture === true ? "charge" : "authorize"; + const tuple = paymentInfoToContractTuple(paymentInfo); + const args = + functionName === "charge" + ? ([ + tuple, + amount, + tokenCollector, + collectorData, + paymentInfo.minFeeBps, + paymentInfo.feeReceiver, + ] as const) + : ([tuple, amount, tokenCollector, collectorData] as const); + + const settleTarget = await this.resolveSettleTarget(extra.captureAuthorizer); + + try { + await this.signer.readContract({ + address: settleTarget, + abi: ESCROW_ABI_WITH_ERRORS, + functionName, + args, + // Simulate as the facilitator EOA so escrow's `onlySender(operator)` + // gate is evaluated against the same `msg.sender` that the real + // settle tx will have (EOA path: facilitator EOA; contract path: + // captureAuthorizer contract, which forwards as itself). + account: this.signer.getAddresses()[0], + }); + return "ok"; + } catch (err) { + return decodeRevertReason(err); + } + } + + /** + * Resolve the on-chain target for an `authorize`/`charge` call per spec. + * Per `scheme_auth-capture_evm.md`, the facilitator may call escrow `"either + * directly or through a smart contract set as the captureAuthorizer"`. + * Probes `getCode(captureAuthorizer)`: + * + * - **EOA** (empty or `0x` bytecode) → call the canonical escrow directly. + * The escrow's `onlySender(paymentInfo.operator)` gate is satisfied + * because the facilitator's tx `msg.sender` equals the captureAuthorizer + * EOA. + * - **Contract** (non-empty bytecode) → call the captureAuthorizer + * contract, which MUST expose the literal `authorize`/`charge` escrow + * selectors and forward to escrow. The contract becomes `msg.sender` at + * the escrow, satisfying the gate. + * + * @param captureAuthorizer - Address from `extra.captureAuthorizer`. + * @returns The address to target with the settle write/simulate. + */ + private async resolveSettleTarget(captureAuthorizer: `0x${string}`): Promise<`0x${string}`> { + const code = await this.signer.getCode({ address: captureAuthorizer }); + if (!code || code === "0x") return AUTH_CAPTURE_ESCROW_ADDRESS; + return captureAuthorizer; + } +} + +// Combined ABI: function definitions + custom-error definitions. viem decodes +// revert data against any error in the ABI passed to the call. +const ESCROW_ABI_WITH_ERRORS = [...ESCROW_ABI, ...ESCROW_ERRORS_ABI] as const; + +/** + * Walk a viem error chain looking for a decoded custom-error name, then map + * known names to a stable `invalidReason` via `ESCROW_ERROR_TO_INVALID_REASON`. + * Anything unmapped returns `ErrSimulationFailed` so the wire never leaks raw + * selectors. + * + * @param err - The error thrown by `readContract` / `simulateContract`. + * @returns A stable wire-level `invalidReason` string. + */ +function decodeRevertReason(err: unknown): string { + if (err instanceof BaseError) { + const revert = err.walk( + (e): e is ContractFunctionRevertedError => e instanceof ContractFunctionRevertedError, + ); + if (revert instanceof ContractFunctionRevertedError) { + const errorName = revert.data?.errorName; + if (errorName && errorName in ESCROW_ERROR_TO_INVALID_REASON) { + return ESCROW_ERROR_TO_INVALID_REASON[errorName]; + } + } + } + return ErrSimulationFailed; +} + +/** + * Unpack the per-method inputs the escrow needs at settle time: the token + * collector address (canonical, per method) and the `collectorData` blob the + * collector parses. EIP-3009 collectors take the raw ReceiveWithAuthorization + * signature directly. Permit2 collectors take the signature ABI-encoded as + * `bytes` (the collector itself reconstructs the PermitTransferFrom struct + * from PaymentInfo, using the deterministic nonce + payer). + * + * @param wirePayload - The verified wire payload (EIP-3009 or Permit2 shape). + * @param assetTransferMethod - Which envelope the payload uses. + * @returns `preApprovalExpiry`, `amount`, `tokenCollector`, and `collectorData` ready for the escrow call. + */ +function unpackForSettle( + wirePayload: AuthCapturePayload, + assetTransferMethod: "eip3009" | "permit2", +): { + preApprovalExpiry: number; + amount: bigint; + tokenCollector: `0x${string}`; + collectorData: `0x${string}`; +} { + if (assetTransferMethod === "eip3009") { + const p = wirePayload as Eip3009Payload; + return { + preApprovalExpiry: Number(p.authorization.validBefore), + amount: BigInt(p.authorization.value), + tokenCollector: EIP3009_TOKEN_COLLECTOR_ADDRESS, + collectorData: p.signature, + }; + } + const p = wirePayload as Permit2Payload; + // Permit2 collector expects the raw 65-byte signature; the collector itself + // reconstructs the PermitTransferFrom struct from PaymentInfo (deterministic + // nonce + payer). Don't ABI-wrap — Permit2 checks `signature.length == 65` + // directly and rejects a wrapped blob with `InvalidSignatureLength()`. + return { + preApprovalExpiry: Number(p.permit2Authorization.deadline), + amount: BigInt(p.permit2Authorization.permitted.amount), + tokenCollector: PERMIT2_TOKEN_COLLECTOR_ADDRESS, + collectorData: p.signature, + }; +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/index.ts b/typescript/packages/mechanisms/evm/src/auth-capture/index.ts new file mode 100644 index 0000000000..6f63646a25 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/index.ts @@ -0,0 +1 @@ +export { AuthCaptureEvmScheme } from "./client/scheme"; diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/nonce.ts b/typescript/packages/mechanisms/evm/src/auth-capture/nonce.ts new file mode 100644 index 0000000000..9fa5c4790c --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/nonce.ts @@ -0,0 +1,304 @@ +/** + * Nonce computation, salt generation, and signing helpers. + */ + +import { encodeAbiParameters, getAddress, keccak256, toHex, zeroAddress } from "viem"; +import type { ClientEvmSigner } from "../signer"; +import { PERMIT2_ADDRESS } from "../constants"; +import { + AUTH_CAPTURE_ESCROW_ADDRESS, + PERMIT2_TRANSFER_FROM_TYPES, + RECEIVE_AUTHORIZATION_TYPES, +} from "./constants"; +import type { AuthCaptureExtra, Eip3009Payload, PaymentInfoStruct, Permit2Payload } from "./types"; + +/** + * PaymentInfo typehash — must match AuthCaptureEscrow.PAYMENT_INFO_TYPEHASH. + */ +const PAYMENT_INFO_TYPEHASH = keccak256( + new TextEncoder().encode( + "PaymentInfo(address operator,address payer,address receiver,address token,uint120 maxAmount,uint48 preApprovalExpiry,uint48 authorizationExpiry,uint48 refundExpiry,uint16 minFeeBps,uint16 maxFeeBps,address feeReceiver,uint256 salt)", + ), +); + +/** + * Compute the payer-agnostic PaymentInfo hash that auth-capture uses as both + * the ERC-3009 nonce (`bytes32`) and the Permit2 nonce (`uint256`, via the + * same 32 bytes interpreted as an integer). The payer field is zeroed before + * hashing so the facilitator can reconstruct the same hash on the verify side + * without knowing payer identity in advance. + * + * Freshness comes from `paymentInfo.salt`; generate a new salt per signing + * call via `generateSalt`. Identical extras + same salt would collide across + * payers. + * + * @param chainId - EVM chain id; binds the hash to a specific chain. + * @param paymentInfo - The reconstructed PaymentInfo struct (canonical Solidity field names). + * @returns The 32-byte hash to use as the nonce on the wire. + */ +export function computePayerAgnosticPaymentInfoHash( + chainId: number, + paymentInfo: PaymentInfoStruct, +): `0x${string}` { + const paymentInfoEncoded = encodeAbiParameters( + [ + { name: "typehash", type: "bytes32" }, + { name: "operator", type: "address" }, + { name: "payer", type: "address" }, + { name: "receiver", type: "address" }, + { name: "token", type: "address" }, + { name: "maxAmount", type: "uint120" }, + { name: "preApprovalExpiry", type: "uint48" }, + { name: "authorizationExpiry", type: "uint48" }, + { name: "refundExpiry", type: "uint48" }, + { name: "minFeeBps", type: "uint16" }, + { name: "maxFeeBps", type: "uint16" }, + { name: "feeReceiver", type: "address" }, + { name: "salt", type: "uint256" }, + ], + [ + PAYMENT_INFO_TYPEHASH, + paymentInfo.operator, + zeroAddress, + paymentInfo.receiver, + paymentInfo.token, + BigInt(paymentInfo.maxAmount), + paymentInfo.preApprovalExpiry, + paymentInfo.authorizationExpiry, + paymentInfo.refundExpiry, + paymentInfo.minFeeBps, + paymentInfo.maxFeeBps, + paymentInfo.feeReceiver, + BigInt(paymentInfo.salt), + ], + ); + const paymentInfoHash = keccak256(paymentInfoEncoded); + + const outerEncoded = encodeAbiParameters( + [ + { name: "chainId", type: "uint256" }, + { name: "escrow", type: "address" }, + { name: "paymentInfoHash", type: "bytes32" }, + ], + [BigInt(chainId), AUTH_CAPTURE_ESCROW_ADDRESS, paymentInfoHash], + ); + + return keccak256(outerEncoded); +} + +/** + * Sign an ERC-3009 `ReceiveWithAuthorization` over the supplied authorization + * fields. The EIP-712 domain is bound to the **token contract** (not the + * escrow), so the token's `name` and `version` come from `extra` because they + * vary per asset (e.g. `"USDC"` on Sepolia vs `"USD Coin"` on mainnet). + * + * @param signer - Client signer with `signTypedData`. + * @param authorization - The ERC-3009 authorization to sign. + * @param extra - Carries the token EIP-712 domain `name` + `version`. + * @param tokenAddress - Address of the token contract (verifyingContract in the domain). + * @param chainId - EVM chain id (chainId in the domain). + * @returns The 65-byte ECDSA signature (or EIP-1271 / EIP-6492 envelope, depending on the signer). + */ +export async function signERC3009( + signer: ClientEvmSigner, + authorization: Eip3009Payload["authorization"], + extra: AuthCaptureExtra, + tokenAddress: `0x${string}`, + chainId: number, +): Promise<`0x${string}`> { + const domain = { + name: extra.name, + version: extra.version, + chainId, + verifyingContract: getAddress(tokenAddress), + }; + + const message = { + from: getAddress(authorization.from), + to: getAddress(authorization.to), + value: BigInt(authorization.value), + validAfter: BigInt(authorization.validAfter), + validBefore: BigInt(authorization.validBefore), + nonce: authorization.nonce, + }; + + return signer.signTypedData({ + domain, + types: RECEIVE_AUTHORIZATION_TYPES, + primaryType: "ReceiveWithAuthorization", + message, + }); +} + +/** + * Verify an ERC-3009 `ReceiveWithAuthorization` signature against the supplied + * authorization fields. Mirrors `signERC3009`: the EIP-712 domain is bound to + * the **token contract**, with `name`/`version` from `extra`. Wraps errors + * (smart-wallet `isValidSignature` reverts, EIP-6492 issues) and returns false + * rather than throwing. + * + * @param signer - Facilitator-side verifier with `verifyTypedData`. + * @param signer.verifyTypedData - Method that recovers and validates the typed-data signature. + * @param authorization - The ERC-3009 authorization to verify. + * @param signature - The signature blob from the payer. + * @param extra - Carries the token EIP-712 domain `name`, `version`, and the chain id. + * @param tokenAddress - Address of the token contract (verifyingContract in the domain). + * @returns True if the signature recovers to `authorization.from`; false on any error. + */ +export async function verifyERC3009Signature( + signer: { + verifyTypedData: (_args: { + address: `0x${string}`; + domain: Record; + types: Record; + primaryType: string; + message: Record; + signature: `0x${string}`; + }) => Promise; + }, + authorization: Eip3009Payload["authorization"], + signature: `0x${string}`, + extra: AuthCaptureExtra & { chainId: number }, + tokenAddress: `0x${string}`, +): Promise { + const domain = { + name: extra.name, + version: extra.version, + chainId: extra.chainId, + verifyingContract: getAddress(tokenAddress), + }; + + const message = { + from: getAddress(authorization.from), + to: getAddress(authorization.to), + value: BigInt(authorization.value), + validAfter: BigInt(authorization.validAfter), + validBefore: BigInt(authorization.validBefore), + nonce: authorization.nonce, + }; + + try { + return await signer.verifyTypedData({ + address: getAddress(authorization.from), + domain, + types: RECEIVE_AUTHORIZATION_TYPES, + primaryType: "ReceiveWithAuthorization", + message, + signature, + }); + } catch { + return false; + } +} + +/** + * Sign a Permit2 `PermitTransferFrom` over the supplied permit fields. Domain + * is bound to the canonical Permit2 contract. No witness struct is needed — + * the deterministic nonce (the payer-agnostic PaymentInfo hash, packed into + * uint256) cryptographically binds all payment parameters including receiver, + * amount, and deadlines. + * + * @param signer - Client signer with `signTypedData`. + * @param permit - The Permit2 PermitTransferFrom message to sign. + * @param chainId - EVM chain id (chainId in the Permit2 domain). + * @returns The 65-byte ECDSA signature (or EIP-1271 / EIP-6492 envelope, depending on the signer). + */ +export async function signPermit2( + signer: ClientEvmSigner, + permit: Permit2Payload["permit2Authorization"], + chainId: number, +): Promise<`0x${string}`> { + const domain = { + name: "Permit2", + chainId, + verifyingContract: PERMIT2_ADDRESS, + }; + + const message = { + permitted: { + token: getAddress(permit.permitted.token), + amount: BigInt(permit.permitted.amount), + }, + spender: getAddress(permit.spender), + nonce: BigInt(permit.nonce), + deadline: BigInt(permit.deadline), + }; + + return signer.signTypedData({ + domain, + types: PERMIT2_TRANSFER_FROM_TYPES, + primaryType: "PermitTransferFrom", + message, + }); +} + +/** + * Verify a Permit2 `PermitTransferFrom` signature against the supplied permit + * fields. Mirrors `signPermit2`: domain bound to the canonical Permit2 + * contract. Wraps errors and returns false rather than throwing. + * + * @param signer - Facilitator-side verifier with `verifyTypedData`. + * @param signer.verifyTypedData - Method that recovers and validates the typed-data signature. + * @param permit - The Permit2 PermitTransferFrom message to verify. + * @param signature - The signature blob from the payer. + * @param chainId - EVM chain id (chainId in the Permit2 domain). + * @returns True if the signature recovers to `permit.from`; false on any error. + */ +export async function verifyPermit2Signature( + signer: { + verifyTypedData: (_args: { + address: `0x${string}`; + domain: Record; + types: Record; + primaryType: string; + message: Record; + signature: `0x${string}`; + }) => Promise; + }, + permit: Permit2Payload["permit2Authorization"], + signature: `0x${string}`, + chainId: number, +): Promise { + const domain = { + name: "Permit2", + chainId, + verifyingContract: PERMIT2_ADDRESS, + }; + + const message = { + permitted: { + token: getAddress(permit.permitted.token), + amount: BigInt(permit.permitted.amount), + }, + spender: getAddress(permit.spender), + nonce: BigInt(permit.nonce), + deadline: BigInt(permit.deadline), + }; + + try { + return await signer.verifyTypedData({ + address: getAddress(permit.from), + domain, + types: PERMIT2_TRANSFER_FROM_TYPES, + primaryType: "PermitTransferFrom", + message, + signature, + }); + } catch { + return false; + } +} + +/** + * Generate a fresh cryptographically-random 32-byte salt. MUST be called once + * per signing request — never reuse across requests. Freshness is required + * because the nonce derivation zeroes the payer field; identical extras with + * the same salt would collide across payers. + * + * @returns A new 32-byte salt as a `0x`-prefixed hex string. + */ +export function generateSalt(): `0x${string}` { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return toHex(bytes); +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/server/index.ts b/typescript/packages/mechanisms/evm/src/auth-capture/server/index.ts new file mode 100644 index 0000000000..224777b216 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/server/index.ts @@ -0,0 +1 @@ +export { AuthCaptureEvmScheme } from "./scheme"; diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/server/scheme.ts b/typescript/packages/mechanisms/evm/src/auth-capture/server/scheme.ts new file mode 100644 index 0000000000..83812d3809 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/server/scheme.ts @@ -0,0 +1,290 @@ +/** + * AuthCapture Scheme - Server + * Handles price parsing and requirement enhancement for resource servers. + * + * Implements x402's SchemeNetworkServer interface so it can be registered + * on an x402ResourceServer via server.register('eip155:84532', new AuthCaptureEvmScheme()). + */ + +import type { + AssetAmount, + MoneyParser, + Network, + PaymentRequirements, + Price, + SchemeNetworkServer, +} from "@x402/core/types"; +import { convertToTokenAmount, numberToDecimalString } from "@x402/core/utils"; +import { getDefaultAsset } from "../../shared/defaultAssets"; +import { AUTH_CAPTURE_SCHEME } from "../constants"; + +/** + * Validate a relative-offset extras key and resolve it to an absolute Unix + * second. Returns `undefined` when the key is absent. Throws on a present- + * but-invalid value so the merchant gets a clear error at the layer they + * configured it, rather than a downstream facilitator rejection with a + * cryptic reason. + * + * @param extras - Merged `extra` map being assembled for publication. + * @param key - The relative-offset key to read (e.g. `"captureDeadlineSeconds"`). + * @param now - Unix-second clock value used for the conversion. + * @returns Absolute Unix-second deadline, or `undefined` if the key wasn't set. + * @throws If `extras[key]` is present but not a finite positive number. + */ +function resolveOffsetToDeadline( + extras: Record, + key: string, + now: number, +): number | undefined { + const raw = extras[key]; + if (raw === undefined) return undefined; + if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) { + throw new Error( + `extra.${key} must be a positive finite number of seconds-from-now (got ${String(raw)})`, + ); + } + return now + raw; +} + +/** + * Field-specific hint appended to the missing-field error message for + * fields whose configuration path is non-obvious. Keeps the merchant from + * having to dig through docs to find out where the value comes from. + */ +const AUTH_CAPTURE_MERCHANT_FIELD_HINTS: Record = { + captureDeadline: + " Set extra.captureDeadlineSeconds (relative, recommended) or extra.captureDeadline (absolute).", + refundDeadline: + " Set extra.refundDeadlineSeconds (relative, recommended) or extra.refundDeadline (absolute).", +}; + +/** + * Assert that the merged `extra` carries every field that comes from the + * merchant's route config, so a missing or wrongly-typed value surfaces as + * a server-side error the merchant will see in their own logs rather than + * as a downstream `invalid_auth_capture_extra` rejection from the facilitator. + * + * Asymmetric on purpose, matching upstream `batch-settlement`'s split: this + * asserter covers fields the merchant directly sets in `accepts.extra` + * (`captureAuthorizer`, deadlines, `feeRecipient`, fee bands), and skips + * fields that `parsePrice` auto-populates from `getDefaultAsset` (`name`, + * `version`). Those are handled separately: + * - For decimal pricing they're populated automatically and never missing. + * - For custom-AssetAmount pricing the merchant must set them on + * `AssetAmount.extra`; if they forget, the facilitator's + * `isAuthCaptureExtra` guard still rejects at verify time with + * `invalid_auth_capture_extra`. That's the right escalation point because + * the merchant has taken explicit control of the asset domain. + * + * @param extra - The merged `extra` map about to be returned by `enhancePaymentRequirements`. + * @throws With a message naming the first missing or wrongly-typed merchant field, plus a path-to-fix hint where relevant. + */ +function assertAuthCaptureMerchantExtraComplete(extra: Record): void { + const required: Array<[string, "string" | "number"]> = [ + ["captureAuthorizer", "string"], + ["captureDeadline", "number"], + ["refundDeadline", "number"], + ["feeRecipient", "string"], + ["minFeeBps", "number"], + ["maxFeeBps", "number"], + ]; + for (const [key, expectedType] of required) { + if (typeof extra[key] !== expectedType) { + const hint = AUTH_CAPTURE_MERCHANT_FIELD_HINTS[key] ?? ""; + throw new Error(`AuthCapture requires extra.${key} (${expectedType}).${hint}`); + } + } +} + +/** + * Server-side implementation of the auth-capture scheme: maps merchant-friendly + * prices (`"$0.01"`, decimal numbers, or pre-built `AssetAmount`) to the + * stablecoin asset + base-unit amount needed in `PaymentRequirements`, resolves + * merchant-supplied `*DeadlineSeconds` offsets into per-request absolute + * deadlines, and merges facilitator-advertised `extra` fields into the + * published requirements. Implements `SchemeNetworkServer`. + */ +export class AuthCaptureEvmScheme implements SchemeNetworkServer { + readonly scheme = AUTH_CAPTURE_SCHEME; + private moneyParsers: MoneyParser[] = []; + + /** + * Add a custom money parser to the chain. Parsers run in registration order; + * the first one to return a non-null `AssetAmount` wins. If every parser + * returns null, the default network-stablecoin conversion is used. + * + * @param parser - Function that maps a decimal amount to an `AssetAmount`, or `null` to defer. + * @returns This server scheme instance, for fluent chaining. + */ + registerMoneyParser(parser: MoneyParser): AuthCaptureEvmScheme { + this.moneyParsers.push(parser); + return this; + } + + /** + * Translate a merchant-supplied `Price` into a fully-resolved `AssetAmount`. + * Pass-through for `AssetAmount` inputs (with required `asset` validation); + * otherwise normalizes the input to a decimal, then runs the registered + * money parser chain, falling back to the default stablecoin for the network. + * + * @param price - `"$0.01"` / `0.01` / `{ asset, amount }`. + * @param network - CAIP-2 network identifier used for default-asset lookup. + * @returns The resolved `AssetAmount` containing token address and base units. + */ + async parsePrice(price: Price, network: Network): Promise { + if (typeof price === "object" && price !== null && "amount" in price) { + if (!price.asset) { + throw new Error(`Asset address must be specified for AssetAmount on network ${network}`); + } + return { + amount: price.amount, + asset: price.asset, + extra: price.extra || {}, + }; + } + + const numericAmount = this.parseMoneyToDecimal(price); + + for (const parser of this.moneyParsers) { + const result = await parser(numericAmount, network); + if (result !== null) { + return result; + } + } + + return this.defaultMoneyConversion(numericAmount, network); + } + + /** + * Merge facilitator-advertised `extra` (from `/supported`) into the + * merchant's payment requirements and resolve relative deadline offsets + * into per-request absolute deadlines. + * + * auth-capture's wire format commits the payer to absolute Unix-second + * `captureDeadline` and `refundDeadline` values in the on-chain + * `PaymentInfo`. Merchants almost always think in policy terms + * ("capture within 30 days, refund within 60 days"), so the server-side + * convention is: + * + * - Merchant sets `extra.captureDeadlineSeconds` / `extra.refundDeadlineSeconds` + * (seconds-from-now relative offsets, matching x402's `maxTimeoutSeconds` + * suffix convention). These are server-side inputs only. + * - `enhancePaymentRequirements` runs per request, computes + * `now + offset`, and publishes absolute `captureDeadline` / + * `refundDeadline` (the values the wire-format spec defines). The + * `*Seconds` keys are stripped from the published `extra`. + * - Merchants who already have absolute timestamps (e.g., tied to an + * external commitment) can set `extra.captureDeadline` / `refundDeadline` + * directly; those values win over offset-derived ones. + * - After offset conversion the merged `extra` is validated against the + * merchant-set subset of `isAuthCaptureExtra`: `captureAuthorizer`, + * `captureDeadline`, `refundDeadline`, `feeRecipient`, `minFeeBps`, + * `maxFeeBps`. Missing or wrongly-typed fields throw here so the + * merchant sees the error in their own logs rather than as a 402 + * `invalid_auth_capture_extra` to a payer. `name` / `version` are + * excluded because `parsePrice` auto-populates them from + * `getDefaultAsset` for decimal pricing; the only path that bypasses + * that is a custom `AssetAmount` where the merchant has explicitly + * taken control of the asset domain, and the facilitator-side + * `isAuthCaptureExtra` rejection at verify time is the right + * escalation point for that case. Matches the asymmetric split + * `batch-settlement` uses (fail-fast on merchant-set fields, leave + * scheme-auto-populated fields to the wire). + * + * @param requirements - The merchant-authored payment requirements. + * @param supportedKind - The facilitator's advertised support entry for this scheme/network. + * @param supportedKind.x402Version - Protocol version the facilitator advertises. + * @param supportedKind.scheme - Scheme identifier (`"auth-capture"`). + * @param supportedKind.network - CAIP-2 network identifier. + * @param supportedKind.extra - Facilitator-injected `extra` fields (lowest priority on collision). + * @param _ - Unused list of facilitator extensions (interface compatibility). + * @returns Enhanced `PaymentRequirements` with merged `extra` and resolved deadlines. + */ + async enhancePaymentRequirements( + requirements: PaymentRequirements, + supportedKind: { + x402Version: number; + scheme: string; + network: Network; + extra?: Record; + }, + _: string[], + ): Promise { + const merged: Record = { + ...supportedKind.extra, + ...requirements.extra, + }; + + const now = Math.floor(Date.now() / 1000); + const captureFromOffset = resolveOffsetToDeadline(merged, "captureDeadlineSeconds", now); + const refundFromOffset = resolveOffsetToDeadline(merged, "refundDeadlineSeconds", now); + + // Strip the server-side-only offset inputs; the wire format only carries absolute deadlines. + delete merged.captureDeadlineSeconds; + delete merged.refundDeadlineSeconds; + + // Absolute values (if the merchant supplied them directly) win over offset-derived ones. + if (captureFromOffset !== undefined && typeof merged.captureDeadline !== "number") { + merged.captureDeadline = captureFromOffset; + } + if (refundFromOffset !== undefined && typeof merged.refundDeadline !== "number") { + merged.refundDeadline = refundFromOffset; + } + + assertAuthCaptureMerchantExtraComplete(merged); + + return { ...requirements, extra: merged }; + } + + /** + * Normalize a `Price` (string or number) to a decimal `number`. Strips `$` + * and `,` formatting characters from strings before parsing. + * + * @param money - Decimal money expressed as a number or formatted string. + * @returns The parsed decimal amount. + * @throws If the string does not parse as a number. + */ + private parseMoneyToDecimal(money: string | number): number { + if (typeof money === "number") { + return money; + } + const cleaned = String(money).replace(/[$,]/g, "").trim(); + const amount = parseFloat(cleaned); + if (isNaN(amount)) { + throw new Error(`Cannot parse price: ${money}`); + } + return amount; + } + + /** + * Fall-through converter: resolves a decimal amount against the default + * stablecoin registered for the network in `getDefaultAsset` (shared with + * the `exact` and `upto` schemes via `../../shared/defaultAssets`). + * The EIP-712 token-domain fields (`name` / `version`) are included for + * tokens used via ERC-3009 or EIP-2612 paths, and `assetTransferMethod` is + * propagated for chains whose default token does not support ERC-3009. + * + * @param amount - Decimal amount in the token's display units. + * @param network - CAIP-2 network identifier. + * @returns Resolved `AssetAmount` with the network's default stablecoin. + * @throws If no default stablecoin is configured for `network`. + */ + private defaultMoneyConversion(amount: number, network: Network): AssetAmount { + const assetInfo = getDefaultAsset(network); + const tokenAmount = convertToTokenAmount(numberToDecimalString(amount), assetInfo.decimals); + const includeEip712Domain = !assetInfo.assetTransferMethod || assetInfo.supportsEip2612; + return { + asset: assetInfo.address, + amount: tokenAmount, + extra: { + ...(includeEip712Domain && { + name: assetInfo.name, + version: assetInfo.version, + }), + ...(assetInfo.assetTransferMethod && { + assetTransferMethod: assetInfo.assetTransferMethod, + }), + }, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/types.ts b/typescript/packages/mechanisms/evm/src/auth-capture/types.ts new file mode 100644 index 0000000000..8df0e312c2 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/types.ts @@ -0,0 +1,172 @@ +/** + * auth-capture wire-format types. + * + * Spec-level field names (captureAuthorizer, captureDeadline, refundDeadline, + * feeRecipient) live here at the extra/wire layer. The on-chain PaymentInfo + * struct keeps the canonical Solidity field names (operator, authorizationExpiry, + * refundExpiry, feeReceiver) so the EIP-712 typehash stays byte-identical with + * the AuthCaptureEscrow contract. + * + * Salt is NOT in extra. It is generated client-side per signing call and rides + * on the payload alongside the signature. + */ + +import type { AssetTransferMethod } from "../types"; + +// AuthCaptureExtra — fields in PaymentRequirements.extra. +// +// Fee-policy fields (minFeeBps, maxFeeBps, feeRecipient) are all required. +// No implicit defaults: a merchant who wants no minimum fee writes +// `minFeeBps: 0` explicitly. This forces fee policy to be a conscious choice +// on the wire and avoids "did they mean 0 or did they forget?" ambiguity. +export interface AuthCaptureExtra { + // Required + // The only address allowed to call authorize/capture/void/refund/charge on + // AuthCaptureEscrow (each of those is gated by onlySender(paymentInfo.operator)) + // — i.e., it must be msg.sender of the "Authorize" call. In x402's + // facilitator-submits flow that means either the facilitator's EOA, or any + // smart contract that ultimately calls escrow (arbiter with dispute logic, + // multisig, etc.). Independent of assetTransferMethod — applies to both + // EIP-3009 and Permit2. + captureAuthorizer: `0x${string}`; // formerly `operator` in commerce-payments + captureDeadline: number; // absolute Unix seconds; capture must occur before this + refundDeadline: number; // absolute Unix seconds; refunds allowed until this + feeRecipient: `0x${string}`; // address that receives the fee portion (renamed from feeReceiver) + minFeeBps: number; // floor on the captureAuthorizer's fee; 0 = no minimum + maxFeeBps: number; // cap on the captureAuthorizer's fee + name: string; // EIP-712 token-domain name (e.g., "USDC") + version: string; // EIP-712 token-domain version (e.g., "2") + // Optional + autoCapture?: boolean; // default: false. true → facilitator calls charge(), false → authorize() + assetTransferMethod?: AssetTransferMethod; // default: 'eip3009' +} + +/** + * Type guard for AuthCaptureExtra. Checks the structural shape an auth-capture + * scheme requires inside `PaymentRequirements.extra`: every spec-mandated + * required field present with the right primitive type. + * + * @param value - Candidate object from `requirements.extra`. + * @returns True if `value` has every required AuthCaptureExtra field. + */ +export function isAuthCaptureExtra(value: unknown): value is AuthCaptureExtra { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + typeof v.captureAuthorizer === "string" && + typeof v.captureDeadline === "number" && + typeof v.refundDeadline === "number" && + typeof v.feeRecipient === "string" && + typeof v.minFeeBps === "number" && + typeof v.maxFeeBps === "number" && + typeof v.name === "string" && + typeof v.version === "string" + ); +} + +// EIP-3009 payload — ReceiveWithAuthorization to the canonical EIP-3009 token collector. +export interface Eip3009Payload { + authorization: { + from: `0x${string}`; + to: `0x${string}`; // EIP3009_TOKEN_COLLECTOR_ADDRESS + value: string; + validAfter: string; + validBefore: string; // = preApprovalExpiry + nonce: `0x${string}`; // = payer-agnostic PaymentInfo hash + }; + signature: `0x${string}`; + salt: `0x${string}`; // bytes32, fresh per request, used to reconstruct PaymentInfo +} + +/** + * Type guard for an EIP-3009-shaped auth-capture payload. Checks for an + * `authorization` object plus the required `signature` and `salt` fields; + * field-level validation happens later in `verify()`. + * + * @param value - Candidate payment payload from the wire. + * @returns True if `value` has the EIP-3009 envelope shape. + */ +export function isEip3009Payload(value: unknown): value is Eip3009Payload { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + return ( + "authorization" in v && + typeof v.authorization === "object" && + v.authorization !== null && + typeof v.signature === "string" && + typeof v.salt === "string" + ); +} + +// Permit2 payload — PermitTransferFrom to the canonical Permit2 token collector. +export interface Permit2Payload { + permit2Authorization: { + from: `0x${string}`; + permitted: { + token: `0x${string}`; + amount: string; + }; + spender: `0x${string}`; // PERMIT2_TOKEN_COLLECTOR_ADDRESS + nonce: string; // uint256 string, = uint256(payer-agnostic PaymentInfo hash) + deadline: string; // = preApprovalExpiry + }; + signature: `0x${string}`; + salt: `0x${string}`; // bytes32, fresh per request, used to reconstruct PaymentInfo +} + +/** + * Type guard for a Permit2-shaped auth-capture payload. Checks for the + * `permit2Authorization` envelope (with `from`, `spender`, `nonce`, + * `deadline`, `permitted`) plus the required `signature` and `salt` fields. + * + * @param value - Candidate payment payload from the wire. + * @returns True if `value` has the Permit2 envelope shape. + */ +export function isPermit2Payload(value: unknown): value is Permit2Payload { + if (typeof value !== "object" || value === null) return false; + const v = value as Record; + if (typeof v.signature !== "string" || typeof v.salt !== "string") return false; + if (typeof v.permit2Authorization !== "object" || v.permit2Authorization === null) return false; + const a = v.permit2Authorization as Record; + return ( + typeof a.from === "string" && + typeof a.spender === "string" && + typeof a.nonce === "string" && + typeof a.deadline === "string" && + typeof a.permitted === "object" && + a.permitted !== null + ); +} + +// Discriminated union of all auth-capture payload shapes. +export type AuthCapturePayload = Eip3009Payload | Permit2Payload; + +/** + * Type guard for any auth-capture payload. Returns true if `value` matches + * either the EIP-3009 envelope or the Permit2 envelope. + * + * @param value - Candidate payment payload from the wire. + * @returns True if `value` is a valid auth-capture envelope of either shape. + */ +export function isAuthCapturePayload(value: unknown): value is AuthCapturePayload { + return isEip3009Payload(value) || isPermit2Payload(value); +} + +/** + * On-chain PaymentInfo struct (canonical Solidity names — DO NOT RENAME). + * Reconstructed by the facilitator from extra + payload.salt + payer + receiver/asset/amount. + */ +export interface PaymentInfoStruct { + operator: `0x${string}`; // = extra.captureAuthorizer + payer: `0x${string}`; + receiver: `0x${string}`; // = requirements.payTo + token: `0x${string}`; // = requirements.asset + maxAmount: string; // = requirements.amount + preApprovalExpiry: number; + authorizationExpiry: number; // = extra.captureDeadline + refundExpiry: number; // = extra.refundDeadline + minFeeBps: number; + maxFeeBps: number; + feeReceiver: `0x${string}`; // = extra.feeRecipient + salt: `0x${string}`; // = payload.salt +} diff --git a/typescript/packages/mechanisms/evm/src/auth-capture/utils.ts b/typescript/packages/mechanisms/evm/src/auth-capture/utils.ts new file mode 100644 index 0000000000..b2c9eb9f9a --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/auth-capture/utils.ts @@ -0,0 +1,17 @@ +/** + * Parse chainId from CAIP-2 network identifier + * + * @param network - CAIP-2 network identifier (e.g., 'eip155:84532') + * @returns The chain ID as a number + */ +export function parseChainId(network: string): number { + const parts = network.split(":"); + if (parts.length !== 2 || parts[0] !== "eip155") { + throw new Error(`Invalid network format: ${network}. Expected 'eip155:'`); + } + const chainId = parseInt(parts[1], 10); + if (isNaN(chainId)) { + throw new Error(`Invalid chainId in network: ${network}`); + } + return chainId; +} diff --git a/typescript/packages/mechanisms/evm/src/index.ts b/typescript/packages/mechanisms/evm/src/index.ts index da0b194cc2..2f990233d5 100644 --- a/typescript/packages/mechanisms/evm/src/index.ts +++ b/typescript/packages/mechanisms/evm/src/index.ts @@ -97,3 +97,24 @@ export { // Default-asset registry (network → token metadata) export { DEFAULT_STABLECOINS } from "./shared/defaultAssets"; + +// AuthCapture scheme +export { AuthCaptureEvmScheme } from "./auth-capture"; + +// AuthCapture types +export type { + AuthCaptureExtra, + AuthCapturePayload, + Eip3009Payload as AuthCaptureEip3009Payload, + PaymentInfoStruct as AuthCapturePaymentInfo, + Permit2Payload as AuthCapturePermit2Payload, +} from "./auth-capture/types"; +export { isAuthCaptureExtra, isAuthCapturePayload } from "./auth-capture/types"; + +// AuthCapture constants +export { + AUTH_CAPTURE_ESCROW_ADDRESS, + AUTH_CAPTURE_SCHEME, + EIP3009_TOKEN_COLLECTOR_ADDRESS, + PERMIT2_TOKEN_COLLECTOR_ADDRESS, +} from "./auth-capture/constants"; diff --git a/typescript/packages/mechanisms/evm/src/signer.ts b/typescript/packages/mechanisms/evm/src/signer.ts index 4ec807bdd6..40bfec0a0b 100644 --- a/typescript/packages/mechanisms/evm/src/signer.ts +++ b/typescript/packages/mechanisms/evm/src/signer.ts @@ -76,6 +76,10 @@ export type FacilitatorEvmSigner = { abi: readonly unknown[]; functionName: string; args?: readonly unknown[]; + /** Optional `from` address for the underlying `eth_call`. Lets schemes + * whose contracts gate on `msg.sender` (e.g., authCapture escrow's + * `onlySender(operator)`) simulate from the correct caller. */ + account?: `0x${string}`; }): Promise; verifyTypedData(args: { address: `0x${string}`; diff --git a/typescript/packages/mechanisms/evm/test/integrations/auth-capture-evm.test.ts b/typescript/packages/mechanisms/evm/test/integrations/auth-capture-evm.test.ts new file mode 100644 index 0000000000..8500fa7cff --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/integrations/auth-capture-evm.test.ts @@ -0,0 +1,314 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { x402Client } from "@x402/core/client"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { x402ResourceServer, FacilitatorClient } from "@x402/core/server"; +import { + Network, + PaymentPayload, + PaymentRequirements, + VerifyResponse, + SettleResponse, + SupportedResponse, +} from "@x402/core/types"; +import { toClientEvmSigner, toFacilitatorEvmSigner } from "../../src"; +import { AuthCaptureEvmScheme as AuthCaptureEvmClient } from "../../src/auth-capture/client/scheme"; +import { AuthCaptureEvmScheme as AuthCaptureEvmServer } from "../../src/auth-capture/server/scheme"; +import { AuthCaptureEvmScheme as AuthCaptureEvmFacilitator } from "../../src/auth-capture/facilitator/scheme"; +import type { AuthCaptureExtra } from "../../src/auth-capture/types"; +import { privateKeyToAccount } from "viem/accounts"; +import { createPublicClient, createWalletClient, http } from "viem"; +import { baseSepolia } from "viem/chains"; + +const CLIENT_PRIVATE_KEY = process.env.CLIENT_PRIVATE_KEY as `0x${string}` | undefined; +const FACILITATOR_PRIVATE_KEY = process.env.FACILITATOR_PRIVATE_KEY as `0x${string}` | undefined; + +const HAS_KEYS = Boolean(CLIENT_PRIVATE_KEY && FACILITATOR_PRIVATE_KEY); +const describeOnChain = HAS_KEYS ? describe : describe.skip; + +if (!HAS_KEYS) { + console.warn( + "[auth-capture-evm.test.ts] Skipping on-chain tests: CLIENT_PRIVATE_KEY and FACILITATOR_PRIVATE_KEY env vars are required.", + ); +} + +const NETWORK: Network = "eip155:84532"; +const ASSET_USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + +/** + * Wraps an x402Facilitator instance for use as a FacilitatorClient. + */ +class EvmFacilitatorClient implements FacilitatorClient { + /** + * @param facilitator - The x402 facilitator to wrap. + */ + constructor(private readonly facilitator: x402Facilitator) {} + + /** + * @param paymentPayload - Payment payload to verify. + * @param paymentRequirements - Payment requirements to verify against. + * @returns Verification response from the wrapped facilitator. + */ + verify( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.verify(paymentPayload, paymentRequirements); + } + + /** + * @param paymentPayload - Payment payload to settle. + * @param paymentRequirements - Payment requirements for settlement. + * @returns Settlement response from the wrapped facilitator. + */ + settle( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.settle(paymentPayload, paymentRequirements); + } + + /** + * @returns Supported payment kinds reported by the wrapped facilitator. + */ + getSupported(): Promise { + return Promise.resolve(this.facilitator.getSupported()); + } +} + +/** + * Builds payment requirements for the auth-capture scheme on Base Sepolia. + * + * @param payTo - Receiver address. + * @param amount - Amount in smallest token units (USDC has 6 decimals). + * @param captureAuthorizer - Address allowed to authorize/capture/void on escrow: facilitator's submitter EOA, or a smart contract that ultimately calls escrow as msg.sender. + * @param overrides - Optional `extra` overrides (e.g., autoCapture, assetTransferMethod). + * @returns Configured {@link PaymentRequirements}. + */ +function buildAuthCaptureRequirements( + payTo: `0x${string}`, + amount: string, + captureAuthorizer: `0x${string}`, + overrides: Partial = {}, +): PaymentRequirements { + const now = Math.floor(Date.now() / 1000); + const extra: AuthCaptureExtra = { + captureAuthorizer, + captureDeadline: now + 3600, + refundDeadline: now + 7200, + feeRecipient: captureAuthorizer, + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + ...overrides, + }; + return { + scheme: "auth-capture", + network: NETWORK, + asset: ASSET_USDC_BASE_SEPOLIA, + amount, + payTo, + maxTimeoutSeconds: 3600, + extra: extra as unknown as Record, + }; +} + +/** + * Wires up client + server + facilitator for live Base Sepolia testing. + * + * @returns Pipeline components and addresses. + */ +function buildPipeline(): { + client: x402Client; + server: x402ResourceServer; + receiverAddress: `0x${string}`; + clientAddress: `0x${string}`; + facilitatorAddress: `0x${string}`; + publicClient: ReturnType; +} { + const clientAccount = privateKeyToAccount(CLIENT_PRIVATE_KEY!); + const facilitatorAccount = privateKeyToAccount(FACILITATOR_PRIVATE_KEY!); + + const publicClient = createPublicClient({ chain: baseSepolia, transport: http() }); + const facilitatorWalletClient = createWalletClient({ + account: facilitatorAccount, + chain: baseSepolia, + transport: http(), + }); + + const facilitatorSigner = toFacilitatorEvmSigner({ + address: facilitatorAccount.address, + readContract: args => publicClient.readContract({ ...args, args: args.args || [] } as never), + verifyTypedData: args => publicClient.verifyTypedData(args as never), + writeContract: args => + facilitatorWalletClient.writeContract({ ...args, args: args.args || [] } as never), + sendTransaction: args => facilitatorWalletClient.sendTransaction(args), + waitForTransactionReceipt: args => publicClient.waitForTransactionReceipt(args), + getCode: args => publicClient.getCode(args), + }); + + const facilitator = new x402Facilitator().register( + NETWORK, + new AuthCaptureEvmFacilitator(facilitatorSigner), + ); + const facilitatorClient = new EvmFacilitatorClient(facilitator); + + const clientSigner = toClientEvmSigner(clientAccount, publicClient); + const client = new x402Client().register(NETWORK, new AuthCaptureEvmClient(clientSigner)); + + const server = new x402ResourceServer(facilitatorClient); + server.register(NETWORK, new AuthCaptureEvmServer()); + + return { + client, + server, + receiverAddress: facilitatorAccount.address, + clientAddress: clientAccount.address, + facilitatorAddress: facilitatorAccount.address, + publicClient, + }; +} + +describe("AuthCapture EVM Integration Tests", () => { + describeOnChain("x402Client / x402ResourceServer / x402Facilitator - direct API", () => { + let client: x402Client; + let server: x402ResourceServer; + let receiverAddress: `0x${string}`; + let clientAddress: `0x${string}`; + let facilitatorAddress: `0x${string}`; + + beforeEach(async () => { + const pipeline = buildPipeline(); + client = pipeline.client; + server = pipeline.server; + receiverAddress = pipeline.receiverAddress; + clientAddress = pipeline.clientAddress; + facilitatorAddress = pipeline.facilitatorAddress; + await server.initialize(); + }); + + it( + "EIP-3009 + autoCapture: false — verifies and authorizes escrow", + { timeout: 60000 }, + async () => { + const accepts = [ + buildAuthCaptureRequirements(receiverAddress, "1000", facilitatorAddress, { + assetTransferMethod: "eip3009", + autoCapture: false, + }), + ]; + const resource = { + url: "https://example.com/api", + description: "auth-capture test resource", + mimeType: "application/json", + }; + + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const payload = await client.createPaymentPayload(paymentRequired); + + expect(payload.x402Version).toBe(2); + expect(payload.accepted.scheme).toBe("auth-capture"); + + const accepted = server.findMatchingRequirements(accepts, payload); + expect(accepted).toBeDefined(); + + const verifyResponse = await server.verifyPayment(payload, accepted!); + expect(verifyResponse.isValid, JSON.stringify(verifyResponse)).toBe(true); + expect(verifyResponse.payer?.toLowerCase()).toBe(clientAddress.toLowerCase()); + + const settleResponse = await server.settlePayment(payload, accepted!); + expect(settleResponse.success, JSON.stringify(settleResponse)).toBe(true); + expect(settleResponse.network).toBe(NETWORK); + expect(settleResponse.transaction).toBeDefined(); + expect(settleResponse.payer?.toLowerCase()).toBe(clientAddress.toLowerCase()); + }, + ); + + it( + "EIP-3009 + autoCapture: true — verifies and charges (single-shot transfer)", + { timeout: 60000 }, + async () => { + const accepts = [ + buildAuthCaptureRequirements(receiverAddress, "1000", facilitatorAddress, { + assetTransferMethod: "eip3009", + autoCapture: true, + }), + ]; + const resource = { + url: "https://example.com/api", + description: "auth-capture charge test", + mimeType: "application/json", + }; + + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const payload = await client.createPaymentPayload(paymentRequired); + const accepted = server.findMatchingRequirements(accepts, payload); + + const verifyResponse = await server.verifyPayment(payload, accepted!); + expect(verifyResponse.isValid, JSON.stringify(verifyResponse)).toBe(true); + + const settleResponse = await server.settlePayment(payload, accepted!); + expect(settleResponse.success, JSON.stringify(settleResponse)).toBe(true); + expect(settleResponse.transaction).toBeDefined(); + }, + ); + + it( + "Permit2 + autoCapture: false — verifies and authorizes (requires Permit2 pre-approval)", + { timeout: 60000 }, + async () => { + const accepts = [ + buildAuthCaptureRequirements(receiverAddress, "1000", facilitatorAddress, { + assetTransferMethod: "permit2", + autoCapture: false, + }), + ]; + const resource = { + url: "https://example.com/api", + description: "auth-capture Permit2 test", + mimeType: "application/json", + }; + + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const payload = await client.createPaymentPayload(paymentRequired); + const accepted = server.findMatchingRequirements(accepts, payload); + + const verifyResponse = await server.verifyPayment(payload, accepted!); + expect(verifyResponse.isValid, JSON.stringify(verifyResponse)).toBe(true); + + const settleResponse = await server.settlePayment(payload, accepted!); + expect(settleResponse.success, JSON.stringify(settleResponse)).toBe(true); + expect(settleResponse.transaction).toBeDefined(); + }, + ); + + it( + "Permit2 + autoCapture: true — verifies and charges (requires Permit2 pre-approval)", + { timeout: 60000 }, + async () => { + const accepts = [ + buildAuthCaptureRequirements(receiverAddress, "1000", facilitatorAddress, { + assetTransferMethod: "permit2", + autoCapture: true, + }), + ]; + const resource = { + url: "https://example.com/api", + description: "auth-capture Permit2 charge test", + mimeType: "application/json", + }; + + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const payload = await client.createPaymentPayload(paymentRequired); + const accepted = server.findMatchingRequirements(accepts, payload); + + const verifyResponse = await server.verifyPayment(payload, accepted!); + expect(verifyResponse.isValid, JSON.stringify(verifyResponse)).toBe(true); + + const settleResponse = await server.settlePayment(payload, accepted!); + expect(settleResponse.success, JSON.stringify(settleResponse)).toBe(true); + expect(settleResponse.transaction).toBeDefined(); + }, + ); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/client.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/client.test.ts new file mode 100644 index 0000000000..4da25e36a7 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/client.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { AuthCaptureEvmScheme } from "../../../src/auth-capture/client/index"; +import { + EIP3009_TOKEN_COLLECTOR_ADDRESS, + PERMIT2_TOKEN_COLLECTOR_ADDRESS, +} from "../../../src/auth-capture/constants"; +import { PERMIT2_ADDRESS } from "../../../src/constants"; +import { isEip3009Payload, isPermit2Payload } from "../../../src/auth-capture/types"; +import type { Eip3009Payload, Permit2Payload } from "../../../src/auth-capture/types"; + +const FUTURE = Math.floor(Date.now() / 1000) + 86400; + +describe("AuthCaptureEvmScheme", () => { + const createMockSigner = () => ({ + address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as const, + signTypedData: vi.fn().mockResolvedValue("0xdeadbeef" as `0x${string}`), + }); + + let mockSigner: ReturnType; + + beforeEach(() => { + mockSigner = createMockSigner(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const mockRequirements = { + scheme: "auth-capture", + network: "eip155:84532", + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x1234567890123456789012345678901234567890", + maxTimeoutSeconds: 3600, + extra: { + captureAuthorizer: "0xcccccccccccccccccccccccccccccccccccccccc" as `0x${string}`, + captureDeadline: FUTURE, + refundDeadline: FUTURE + 86400, + feeRecipient: "0x4444444444444444444444444444444444444444" as `0x${string}`, + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + }, + }; + + describe("constructor and properties", () => { + it('should have scheme set to "auth-capture"', () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + expect(scheme.scheme).toBe("auth-capture"); + }); + }); + + describe("createPaymentPayload — EIP-3009 (default)", () => { + it("should create a valid EIP-3009 payload for x402Version 2", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.createPaymentPayload(2, mockRequirements); + + expect(result.x402Version).toBe(2); + expect(isEip3009Payload(result.payload)).toBe(true); + const payload = result.payload as unknown as Eip3009Payload; + expect(payload.authorization).toBeDefined(); + expect(payload.signature).toBe("0xdeadbeef"); + expect(payload.salt).toMatch(/^0x[a-fA-F0-9]{64}$/); + }); + + it("should throw for unsupported x402Version", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await expect(scheme.createPaymentPayload(1, mockRequirements)).rejects.toThrow( + "Unsupported x402Version: 1. Only version 2 is supported.", + ); + }); + + it("should throw for x402Version 0", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await expect(scheme.createPaymentPayload(0, mockRequirements)).rejects.toThrow( + "Unsupported x402Version: 0", + ); + }); + + it("should throw when EIP-712 name is missing", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const requirementsNoName = { + ...mockRequirements, + extra: { ...mockRequirements.extra, name: "" }, + }; + await expect(scheme.createPaymentPayload(2, requirementsNoName)).rejects.toThrow( + "EIP-712 domain parameter 'name' is required", + ); + }); + + it("should throw when EIP-712 version is missing", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const requirementsNoVersion = { + ...mockRequirements, + extra: { ...mockRequirements.extra, version: "" }, + }; + await expect(scheme.createPaymentPayload(2, requirementsNoVersion)).rejects.toThrow( + "EIP-712 domain parameter 'version' is required", + ); + }); + + it("should throw when captureAuthorizer is missing", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const bad = { + ...mockRequirements, + extra: { ...mockRequirements.extra, captureAuthorizer: "" as `0x${string}` }, + }; + await expect(scheme.createPaymentPayload(2, bad)).rejects.toThrow( + "'captureAuthorizer' is required", + ); + }); + + it("should throw when feeRecipient is missing", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const bad = { + ...mockRequirements, + extra: { ...mockRequirements.extra, feeRecipient: "" as `0x${string}` }, + }; + await expect(scheme.createPaymentPayload(2, bad)).rejects.toThrow( + "'feeRecipient' is required", + ); + }); + + it("should set authorization.from to signer address", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.createPaymentPayload(2, mockRequirements); + const payload = result.payload as unknown as Eip3009Payload; + expect(payload.authorization.from).toBe(mockSigner.address); + }); + + it("should set authorization.to to the canonical EIP-3009 token collector", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.createPaymentPayload(2, mockRequirements); + const payload = result.payload as unknown as Eip3009Payload; + expect(payload.authorization.to).toBe(EIP3009_TOKEN_COLLECTOR_ADDRESS); + }); + + it("should set authorization.value to requirements amount", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.createPaymentPayload(2, mockRequirements); + const payload = result.payload as unknown as Eip3009Payload; + expect(payload.authorization.value).toBe("1000000"); + }); + + it("should derive validBefore from now + maxTimeoutSeconds", async () => { + const fakeNowMs = 1700000000000; + vi.spyOn(Date, "now").mockReturnValue(fakeNowMs); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.createPaymentPayload(2, { + ...mockRequirements, + maxTimeoutSeconds: 600, + }); + const payload = result.payload as unknown as Eip3009Payload; + expect(payload.authorization.validBefore).toBe("1700000600"); + }); + + it("should generate a fresh salt on each call", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const a = (await scheme.createPaymentPayload(2, mockRequirements)) + .payload as unknown as Eip3009Payload; + const b = (await scheme.createPaymentPayload(2, mockRequirements)) + .payload as unknown as Eip3009Payload; + expect(a.salt).not.toBe(b.salt); + }); + + it("should throw for invalid network format", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const badNetworkRequirements = { ...mockRequirements, network: "solana:mainnet" }; + await expect(scheme.createPaymentPayload(2, badNetworkRequirements)).rejects.toThrow( + "Invalid network format", + ); + }); + + it("should call signTypedData on signer", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.createPaymentPayload(2, mockRequirements); + expect(mockSigner.signTypedData).toHaveBeenCalledOnce(); + }); + + it("should sign with EIP-712 domain bound to the asset (verifyingContract = requirements.asset)", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.createPaymentPayload(2, mockRequirements); + const args = mockSigner.signTypedData.mock.calls[0][0]; + expect(args.primaryType).toBe("ReceiveWithAuthorization"); + expect(args.domain.name).toBe("USDC"); + expect(args.domain.version).toBe("2"); + expect(args.domain.chainId).toBe(84532); + // Critical: verifyingContract is the token, NOT the collector + expect(args.domain.verifyingContract.toLowerCase()).toBe( + mockRequirements.asset.toLowerCase(), + ); + }); + }); + + describe("createPaymentPayload — Permit2", () => { + it("should create a valid Permit2 payload when assetTransferMethod is permit2", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.createPaymentPayload(2, { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }); + + expect(isPermit2Payload(result.payload)).toBe(true); + const payload = result.payload as unknown as Permit2Payload; + expect(payload.permit2Authorization.from).toBe(mockSigner.address); + expect(payload.permit2Authorization.spender).toBe(PERMIT2_TOKEN_COLLECTOR_ADDRESS); + expect(payload.permit2Authorization.permitted.token).toBe(mockRequirements.asset); + expect(payload.permit2Authorization.permitted.amount).toBe(mockRequirements.amount); + expect(payload.salt).toMatch(/^0x[a-fA-F0-9]{64}$/); + }); + + it("should compute a uint256-string Permit2 nonce", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.createPaymentPayload(2, { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }); + const payload = result.payload as unknown as Permit2Payload; + // uint256 stringified — should parse as a valid bigint + expect(() => BigInt(payload.permit2Authorization.nonce)).not.toThrow(); + expect(payload.permit2Authorization.nonce.length).toBeGreaterThan(0); + }); + + it("should sign with EIP-712 domain bound to canonical Permit2 (NOT the token)", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.createPaymentPayload(2, { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }); + const args = mockSigner.signTypedData.mock.calls[0][0]; + expect(args.primaryType).toBe("PermitTransferFrom"); + expect(args.domain.name).toBe("Permit2"); + expect(args.domain.chainId).toBe(84532); + // Critical: verifyingContract is the canonical Permit2, NOT the token, NOT the collector + expect(args.domain.verifyingContract).toBe(PERMIT2_ADDRESS); + }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts new file mode 100644 index 0000000000..e3d1e34742 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/facilitator.test.ts @@ -0,0 +1,668 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + ContractFunctionExecutionError, + ContractFunctionRevertedError, + encodeErrorResult, + hexToBigInt, +} from "viem"; +import { AuthCaptureEvmScheme } from "../../../src/auth-capture/facilitator/scheme"; +import { ESCROW_ABI, ESCROW_ERRORS_ABI } from "../../../src/auth-capture/abi"; +import { + AUTH_CAPTURE_ESCROW_ADDRESS, + EIP3009_TOKEN_COLLECTOR_ADDRESS, + PERMIT2_TOKEN_COLLECTOR_ADDRESS, +} from "../../../src/auth-capture/constants"; +import { computePayerAgnosticPaymentInfoHash } from "../../../src/auth-capture/nonce"; +import type { PaymentInfoStruct } from "../../../src/auth-capture/types"; + +describe("AuthCaptureEvmScheme", () => { + const createMockSigner = () => ({ + getAddresses: () => ["0x1234567890123456789012345678901234567890"] as readonly `0x${string}`[], + readContract: vi.fn().mockResolvedValue(BigInt("1000000000")), + writeContract: vi.fn().mockResolvedValue("0xabcdef1234567890" as `0x${string}`), + verifyTypedData: vi.fn().mockResolvedValue(true), + sendTransaction: vi.fn(), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), + getCode: vi.fn().mockResolvedValue("0x"), + }); + + let mockSigner: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockSigner = createMockSigner(); + }); + + const futureSeconds = Math.floor(Date.now() / 1000) + 3600; + const captureDeadline = futureSeconds + 86400; + const refundDeadline = captureDeadline + 86400; + + const PAYER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as `0x${string}`; + const ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; + const PAY_TO = "0xdddddddddddddddddddddddddddddddddddddddd" as `0x${string}`; + const CAPTURE_AUTHORIZER = "0xcccccccccccccccccccccccccccccccccccccccc" as `0x${string}`; + const FEE_RECIPIENT = "0x4444444444444444444444444444444444444444" as `0x${string}`; + const SALT = + "0x0000000000000000000000000000000000000000000000000000000000000abc" as `0x${string}`; + + const mockRequirements = { + scheme: "auth-capture", + network: "eip155:84532", + amount: "1000000", + asset: ASSET, + payTo: PAY_TO, + maxTimeoutSeconds: 60, + extra: { + captureAuthorizer: CAPTURE_AUTHORIZER, + captureDeadline, + refundDeadline, + feeRecipient: FEE_RECIPIENT, + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + }, + }; + + // Build a PaymentInfoStruct that matches what the facilitator will reconstruct. + function buildPaymentInfo(): PaymentInfoStruct { + return { + operator: CAPTURE_AUTHORIZER, + payer: PAYER, + receiver: PAY_TO, + token: ASSET, + maxAmount: "1000000", + preApprovalExpiry: futureSeconds, + authorizationExpiry: captureDeadline, + refundExpiry: refundDeadline, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: FEE_RECIPIENT, + salt: SALT, + }; + } + + function buildEip3009Payload() { + const paymentInfo = buildPaymentInfo(); + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + return { + x402Version: 2, + scheme: "auth-capture", + resource: { url: "https://example.com/weather", method: "GET" }, + accepted: { ...mockRequirements }, + payload: { + authorization: { + from: PAYER, + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(futureSeconds), + nonce, + }, + signature: "0xabcd" as `0x${string}`, + salt: SALT, + }, + }; + } + + function buildPermit2Payload() { + const paymentInfo = buildPaymentInfo(); + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + return { + x402Version: 2, + scheme: "auth-capture", + resource: { url: "https://example.com/weather", method: "GET" }, + accepted: { ...mockRequirements }, + payload: { + permit2Authorization: { + from: PAYER, + permitted: { token: ASSET, amount: "1000000" }, + spender: PERMIT2_TOKEN_COLLECTOR_ADDRESS, + nonce: hexToBigInt(nonce).toString(), + deadline: String(futureSeconds), + }, + signature: "0xabcd" as `0x${string}`, + salt: SALT, + }, + }; + } + + describe("settle — autoCapture routing", () => { + it("should default to authorize when autoCapture is absent", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "authorize" }), + ); + }); + + it("should call charge when autoCapture is true", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, autoCapture: true }, + }; + await scheme.settle(buildEip3009Payload(), reqs); + + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "charge" }), + ); + }); + + it("should call authorize when autoCapture is false", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, autoCapture: false }, + }; + await scheme.settle(buildEip3009Payload(), reqs); + + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "authorize" }), + ); + }); + }); + + describe("settle — target address", () => { + it("should target the canonical AuthCaptureEscrow address when captureAuthorizer is an EOA", async () => { + mockSigner.getCode.mockResolvedValue("0x"); + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + + expect(mockSigner.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ address: AUTH_CAPTURE_ESCROW_ADDRESS }), + ); + }); + + it("should route authorize × eip3009 × contract via the captureAuthorizer with the literal escrow ABI and 4 args", async () => { + mockSigner.getCode.mockResolvedValue("0x6080604052"); + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.address).toBe(CAPTURE_AUTHORIZER); + expect(call.functionName).toBe("authorize"); + expect(call.abi).toBe(ESCROW_ABI); + expect(call.args).toHaveLength(4); + expect(call.args[2]).toBe(EIP3009_TOKEN_COLLECTOR_ADDRESS); + }); + + it("should route charge × eip3009 × contract via the captureAuthorizer with the 6-arg ABI", async () => { + mockSigner.getCode.mockResolvedValue("0x6080604052"); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, autoCapture: true }, + }; + await scheme.settle(buildEip3009Payload(), reqs); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.address).toBe(CAPTURE_AUTHORIZER); + expect(call.functionName).toBe("charge"); + expect(call.abi).toBe(ESCROW_ABI); + expect(call.args).toHaveLength(6); + expect(call.args[2]).toBe(EIP3009_TOKEN_COLLECTOR_ADDRESS); + // 6-arg charge tail: [..., feeBps, feeReceiver] — exact values mirror + // the EOA-path 'charge fee args' test so the contract path is + // independently complete, not just transitive on shared args-build code. + expect(call.args[4]).toBe(0); + expect(call.args[5]).toBe(FEE_RECIPIENT); + }); + + it("should route authorize × permit2 × contract via the captureAuthorizer with the permit2 collector", async () => { + mockSigner.getCode.mockResolvedValue("0x6080604052"); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + await scheme.settle(buildPermit2Payload(), reqs); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.address).toBe(CAPTURE_AUTHORIZER); + expect(call.functionName).toBe("authorize"); + expect(call.abi).toBe(ESCROW_ABI); + expect(call.args).toHaveLength(4); + expect(call.args[2]).toBe(PERMIT2_TOKEN_COLLECTOR_ADDRESS); + }); + + it("should route charge × permit2 × contract via the captureAuthorizer with 6 args + permit2 collector", async () => { + mockSigner.getCode.mockResolvedValue("0x6080604052"); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + assetTransferMethod: "permit2" as const, + autoCapture: true, + }, + }; + await scheme.settle(buildPermit2Payload(), reqs); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.address).toBe(CAPTURE_AUTHORIZER); + expect(call.functionName).toBe("charge"); + expect(call.abi).toBe(ESCROW_ABI); + expect(call.args).toHaveLength(6); + expect(call.args[2]).toBe(PERMIT2_TOKEN_COLLECTOR_ADDRESS); + }); + + it("should route simulateSettle through the captureAuthorizer contract with ESCROW_ABI + errors", async () => { + mockSigner.getCode.mockResolvedValue("0x6080604052"); + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.verify(buildEip3009Payload(), mockRequirements); + + const simulateCall = mockSigner.readContract.mock.calls.find( + c => c[0].functionName === "authorize" || c[0].functionName === "charge", + ); + expect(simulateCall).toBeDefined(); + const call = simulateCall![0]; + expect(call.address).toBe(CAPTURE_AUTHORIZER); + expect(call.abi).toHaveLength(ESCROW_ABI.length + ESCROW_ERRORS_ABI.length); + }); + + it("should pass EIP3009_TOKEN_COLLECTOR as the tokenCollector arg for eip3009", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.args[2]).toBe(EIP3009_TOKEN_COLLECTOR_ADDRESS); + }); + + it("should pass PERMIT2_TOKEN_COLLECTOR as the tokenCollector arg for permit2", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + await scheme.settle(buildPermit2Payload(), reqs); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.args[2]).toBe(PERMIT2_TOKEN_COLLECTOR_ADDRESS); + }); + }); + + describe("verify — invariants", () => { + it("should reject when extra is missing required fields", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const bad = { + ...mockRequirements, + extra: { name: "USDC", version: "2" } as unknown as typeof mockRequirements.extra, + }; + const result = await scheme.verify(buildEip3009Payload(), bad); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_extra"); + }); + + it("should reject when refundDeadline is not after captureDeadline", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const bad = { + ...mockRequirements, + extra: { ...mockRequirements.extra, refundDeadline: captureDeadline - 1 }, + }; + const result = await scheme.verify(buildEip3009Payload(), bad); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_deadline_ordering"); + }); + + it("should reject when payload method does not match assetTransferMethod", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + const result = await scheme.verify(buildEip3009Payload(), reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("payload_method_mismatch"); + }); + + it("should reject when EIP-3009 payload.to is not the canonical collector", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + payload.payload.authorization.to = + "0x9999999999999999999999999999999999999999" as `0x${string}`; + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("token_collector_mismatch"); + }); + + it("should reject when Permit2 payload.spender is not the canonical collector", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + const payload = buildPermit2Payload(); + payload.payload.permit2Authorization.spender = + "0x9999999999999999999999999999999999999999" as `0x${string}`; + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("token_collector_mismatch"); + }); + + it("should reject when Permit2 token does not match requirements.asset", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + const payload = buildPermit2Payload(); + payload.payload.permit2Authorization.permitted.token = + "0x9999999999999999999999999999999999999999" as `0x${string}`; + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("token_mismatch"); + }); + + it("should reject when authorization.value does not match requirements.amount", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + payload.payload.authorization.value = "999999"; + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("amount_mismatch"); + }); + + it("should reject when EIP-3009 validBefore is in the past", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + payload.payload.authorization.validBefore = String(Math.floor(Date.now() / 1000) - 60); + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("authorization_expired"); + }); + + it("should reject when EIP-3009 validAfter is in the future", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + payload.payload.authorization.validAfter = String(Math.floor(Date.now() / 1000) + 3600); + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("authorization_not_yet_valid"); + }); + + it("should reject unsupported assetTransferMethod", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + assetTransferMethod: "allowance" as unknown as "eip3009", + }, + }; + const result = await scheme.verify(buildEip3009Payload(), reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("unsupported_asset_transfer_method"); + }); + + it("should reject when payload.accepted.network differs from requirements.network", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + payload.accepted = { ...payload.accepted, network: "eip155:8453" }; + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("network_mismatch"); + }); + + it("should reject invalid signature", async () => { + mockSigner.verifyTypedData.mockResolvedValueOnce(false); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_auth_capture_signature"); + }); + + it("should reject when simulation reverts and balance is sufficient", async () => { + mockSigner.readContract.mockReset(); + // First call: simulateSettle (escrow.authorize) → revert + // Second call: balanceOf for the actionable-error fallback → sufficient + mockSigner.readContract + .mockRejectedValueOnce(new Error("execution reverted")) + .mockResolvedValueOnce(BigInt("1000000000")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("simulation_failed"); + }); + + it("should surface insufficient_balance when simulation fails and balance is short", async () => { + mockSigner.readContract.mockReset(); + mockSigner.readContract + .mockRejectedValueOnce(new Error("execution reverted")) + .mockResolvedValueOnce(BigInt("1")); // balance < amount + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("insufficient_balance"); + }); + + it("should reject when preApprovalExpiry exceeds captureDeadline", async () => { + // maxTimeoutSeconds = 60s, but captureDeadline only 5s in the future → + // preApprovalExpiry (now + 60) > captureDeadline. Mirrors the on-chain + // _validatePayment ordering check. + const scheme = new AuthCaptureEvmScheme(mockSigner); + const tightCaptureDeadline = Math.floor(Date.now() / 1000) + 30; + const reqs = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + captureDeadline: tightCaptureDeadline, + refundDeadline: tightCaptureDeadline + 86400, + }, + }; + // Build payload with a fresh preApprovalExpiry that exceeds captureDeadline + const futureSecondsLocal = Math.floor(Date.now() / 1000) + 3600; + const paymentInfo: PaymentInfoStruct = { + operator: CAPTURE_AUTHORIZER, + payer: PAYER, + receiver: PAY_TO, + token: ASSET, + maxAmount: "1000000", + preApprovalExpiry: futureSecondsLocal, + authorizationExpiry: tightCaptureDeadline, + refundExpiry: tightCaptureDeadline + 86400, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: FEE_RECIPIENT, + salt: SALT, + }; + const nonce = computePayerAgnosticPaymentInfoHash(84532, paymentInfo); + const payload = { + x402Version: 2, + scheme: "auth-capture", + resource: { url: "https://example.com", method: "GET" }, + accepted: { ...reqs }, + payload: { + authorization: { + from: PAYER, + to: EIP3009_TOKEN_COLLECTOR_ADDRESS, + value: "1000000", + validAfter: "0", + validBefore: String(futureSecondsLocal), + nonce, + }, + signature: "0xabcd" as `0x${string}`, + salt: SALT, + }, + }; + const result = await scheme.verify(payload, reqs); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_deadline_ordering"); + }); + }); + + describe("verify — nonce binding (regression for payer-agnostic-hash design)", () => { + it("should reject when salt is mutated after signing", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const payload = buildEip3009Payload(); + // Tamper with salt — wire nonce was computed against SALT, not this new value + payload.payload.salt = + "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" as `0x${string}`; + const result = await scheme.verify(payload, mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("nonce_mismatch"); + }); + + it("should reject when extra.captureAuthorizer is mutated after signing", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const tampered = { + ...mockRequirements, + extra: { + ...mockRequirements.extra, + captureAuthorizer: "0x9999999999999999999999999999999999999999" as `0x${string}`, + }, + }; + const result = await scheme.verify(buildEip3009Payload(), tampered); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("nonce_mismatch"); + }); + + it("should reject when requirements.amount is mutated after signing (Permit2)", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + amount: "999999", + extra: { ...mockRequirements.extra, assetTransferMethod: "permit2" as const }, + }; + // amount_mismatch fires before nonce_mismatch — that's the expected order. + // Either way, the tampering is rejected. + const result = await scheme.verify(buildPermit2Payload(), reqs); + expect(result.isValid).toBe(false); + expect(["amount_mismatch", "nonce_mismatch"]).toContain(result.invalidReason); + }); + }); + + describe("verify — typed simulation revert decoding", () => { + /** + * Build a viem ContractFunctionExecutionError that wraps a real + * ContractFunctionRevertedError encoded from the named custom error. + * Mirrors what viem produces when the chain reverts with a known error + * declared in the call's ABI. + */ + function buildRevertError(errorName: string): Error { + const errorAbi = [{ type: "error" as const, name: errorName, inputs: [] }]; + const data = encodeErrorResult({ abi: errorAbi, errorName }); + const inner = new ContractFunctionRevertedError({ + abi: errorAbi, + data, + functionName: "authorize", + }); + return new ContractFunctionExecutionError(inner, { + abi: errorAbi, + functionName: "authorize", + args: [], + }); + } + + it("should decode AfterPreApprovalExpiry → authorization_expired", async () => { + mockSigner.readContract.mockReset(); + mockSigner.readContract + .mockRejectedValueOnce(buildRevertError("AfterPreApprovalExpiry")) + .mockResolvedValueOnce(BigInt("1000000000")); // balanceOf — sufficient + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("authorization_expired"); + }); + + it("should decode PaymentAlreadyCollected → payment_already_collected", async () => { + mockSigner.readContract.mockReset(); + mockSigner.readContract + .mockRejectedValueOnce(buildRevertError("PaymentAlreadyCollected")) + .mockResolvedValueOnce(BigInt("1000000000")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("payment_already_collected"); + }); + + it("should decode FeeBpsOutOfRange → fee_bps_out_of_range", async () => { + mockSigner.readContract.mockReset(); + mockSigner.readContract + .mockRejectedValueOnce(buildRevertError("FeeBpsOutOfRange")) + .mockResolvedValueOnce(BigInt("1000000000")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("fee_bps_out_of_range"); + }); + + it("should decode InvalidFeeReceiver → invalid_fee_receiver", async () => { + mockSigner.readContract.mockReset(); + mockSigner.readContract + .mockRejectedValueOnce(buildRevertError("InvalidFeeReceiver")) + .mockResolvedValueOnce(BigInt("1000000000")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_fee_receiver"); + }); + + it("should decode TokenCollectionFailed → token_collection_failed", async () => { + mockSigner.readContract.mockReset(); + mockSigner.readContract + .mockRejectedValueOnce(buildRevertError("TokenCollectionFailed")) + .mockResolvedValueOnce(BigInt("1000000000")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("token_collection_failed"); + }); + + it("should fall through unknown reverts to generic simulation_failed", async () => { + mockSigner.readContract.mockReset(); + mockSigner.readContract + .mockRejectedValueOnce(buildRevertError("SomeUnmappedError")) + .mockResolvedValueOnce(BigInt("1000000000")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("simulation_failed"); + }); + + it("should fall through plain Error (not BaseError) to simulation_failed", async () => { + mockSigner.readContract.mockReset(); + mockSigner.readContract + .mockRejectedValueOnce(new Error("RPC went sideways")) + .mockResolvedValueOnce(BigInt("1000000000")); + const scheme = new AuthCaptureEvmScheme(mockSigner); + const result = await scheme.verify(buildEip3009Payload(), mockRequirements); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("simulation_failed"); + }); + }); + + describe("settle — charge fee args (ABI 6-arg correctness)", () => { + it("should pass feeBps and feeReceiver as args[4] and args[5] for charge", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + const reqs = { + ...mockRequirements, + extra: { ...mockRequirements.extra, autoCapture: true }, + }; + await scheme.settle(buildEip3009Payload(), reqs); + + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.functionName).toBe("charge"); + expect(call.args.length).toBe(6); + // Default minFeeBps is 0 when extra.minFeeBps is omitted (matches buildPaymentInfo). + expect(call.args[4]).toBe(0); + expect(call.args[5]).toBe(FEE_RECIPIENT); + }); + + it("should pass 4 args for authorize (no feeBps/feeReceiver)", async () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + await scheme.settle(buildEip3009Payload(), mockRequirements); + const call = mockSigner.writeContract.mock.calls[0][0]; + expect(call.functionName).toBe("authorize"); + expect(call.args.length).toBe(4); + }); + }); + + describe("getExtra", () => { + it("should return undefined — escrow + tokenCollector are constants, not advertised", () => { + const scheme = new AuthCaptureEvmScheme(mockSigner); + expect(scheme.getExtra("eip155:8453")).toBeUndefined(); + }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/nonce.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/nonce.test.ts new file mode 100644 index 0000000000..b76efb4cbf --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/nonce.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { zeroAddress } from "viem"; +import { computePayerAgnosticPaymentInfoHash, generateSalt } from "../../../src/auth-capture/nonce"; +import type { PaymentInfoStruct } from "../../../src/auth-capture/types"; + +describe("nonce utilities", () => { + describe("computePayerAgnosticPaymentInfoHash", () => { + const mockPaymentInfo: PaymentInfoStruct = { + operator: "0x1111111111111111111111111111111111111111", + payer: "0xPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPPP".toLowerCase() as `0x${string}`, + receiver: "0x2222222222222222222222222222222222222222", + token: "0x3333333333333333333333333333333333333333", + maxAmount: "1000000", + preApprovalExpiry: 281474976710655, + authorizationExpiry: 281474976710655, + refundExpiry: 281474976710655, + minFeeBps: 0, + maxFeeBps: 100, + feeReceiver: "0x4444444444444444444444444444444444444444", + salt: "0x0000000000000000000000000000000000000000000000000000000000000001", + }; + + it("should produce a 32-byte hex string", () => { + const nonce = computePayerAgnosticPaymentInfoHash(84532, mockPaymentInfo); + expect(nonce).toMatch(/^0x[a-fA-F0-9]{64}$/); + }); + + it("should produce deterministic results for same inputs", () => { + const nonce1 = computePayerAgnosticPaymentInfoHash(84532, mockPaymentInfo); + const nonce2 = computePayerAgnosticPaymentInfoHash(84532, mockPaymentInfo); + expect(nonce1).toBe(nonce2); + }); + + it("should produce different results for different chainIds", () => { + const nonce1 = computePayerAgnosticPaymentInfoHash(84532, mockPaymentInfo); + const nonce2 = computePayerAgnosticPaymentInfoHash(8453, mockPaymentInfo); + expect(nonce1).not.toBe(nonce2); + }); + + it("should produce different results for different payment info", () => { + const nonce1 = computePayerAgnosticPaymentInfoHash(84532, mockPaymentInfo); + const nonce2 = computePayerAgnosticPaymentInfoHash(84532, { + ...mockPaymentInfo, + maxAmount: "2000000", + }); + expect(nonce1).not.toBe(nonce2); + }); + + it("should produce different results for different salts (freshness check)", () => { + const nonce1 = computePayerAgnosticPaymentInfoHash(84532, mockPaymentInfo); + const nonce2 = computePayerAgnosticPaymentInfoHash(84532, { + ...mockPaymentInfo, + salt: "0x0000000000000000000000000000000000000000000000000000000000000002", + }); + expect(nonce1).not.toBe(nonce2); + }); + + it("should be payer-agnostic — different payers produce identical nonces", () => { + const nonceA = computePayerAgnosticPaymentInfoHash(84532, { + ...mockPaymentInfo, + payer: "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".toLowerCase() as `0x${string}`, + }); + const nonceB = computePayerAgnosticPaymentInfoHash(84532, { + ...mockPaymentInfo, + payer: "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB".toLowerCase() as `0x${string}`, + }); + const nonceZero = computePayerAgnosticPaymentInfoHash(84532, { + ...mockPaymentInfo, + payer: zeroAddress, + }); + expect(nonceA).toBe(nonceB); + expect(nonceA).toBe(nonceZero); + }); + }); + + describe("generateSalt", () => { + it("should produce a 32-byte hex string", () => { + const salt = generateSalt(); + expect(salt).toMatch(/^0x[a-fA-F0-9]{64}$/); + }); + + it("should produce unique values on each call", () => { + const salt1 = generateSalt(); + const salt2 = generateSalt(); + const salt3 = generateSalt(); + expect(salt1).not.toBe(salt2); + expect(salt2).not.toBe(salt3); + expect(salt1).not.toBe(salt3); + }); + + it("should produce valid hex characters only", () => { + const salt = generateSalt(); + const hexPart = salt.slice(2); + expect(hexPart).toMatch(/^[0-9a-f]+$/); + }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/server.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/server.test.ts new file mode 100644 index 0000000000..fe137948db --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/server.test.ts @@ -0,0 +1,637 @@ +import { describe, it, expect } from "vitest"; +import { AuthCaptureEvmScheme } from "../../../src/auth-capture/server/index"; + +const BASE_SEPOLIA_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + +describe("AuthCaptureEvmScheme", () => { + describe("parsePrice", () => { + it("should parse dollar amounts with default decimals (6 for USDC)", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$1.00", "eip155:84532"); + + expect(result.amount).toBe("1000000"); + expect(result.asset).toBe(BASE_SEPOLIA_USDC); + expect(result.extra).toEqual({ name: "USDC", version: "2" }); + }); + + it("should parse amounts without dollar sign", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("0.50", "eip155:84532"); + + expect(result.amount).toBe("500000"); + expect(result.extra).toEqual({ name: "USDC", version: "2" }); + }); + + it("should parse small amounts correctly", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$0.01", "eip155:84532"); + + expect(result.amount).toBe("10000"); + }); + + it("should parse large amounts correctly", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$1000.00", "eip155:84532"); + + expect(result.amount).toBe("1000000000"); + }); + + it("should handle amounts with commas", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$1,000.50", "eip155:84532"); + + expect(result.amount).toBe("1000500000"); + }); + + it("should handle zero amounts", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$0.00", "eip155:84532"); + + expect(result.amount).toBe("0"); + }); + + it("should accept numeric price", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice(0.01, "eip155:84532"); + + expect(result.amount).toBe("10000"); + expect(result.asset).toBe(BASE_SEPOLIA_USDC); + expect(result.extra).toEqual({ name: "USDC", version: "2" }); + }); + + it("should return extra with name and version for Base mainnet", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$1.00", "eip155:8453"); + + expect(result.extra).toEqual({ name: "USD Coin", version: "2" }); + }); + + it("should pass through AssetAmount objects with extra", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice( + { asset: "0xCustomToken", amount: "42000", extra: { name: "Custom", version: "1" } }, + "eip155:84532", + ); + + expect(result.amount).toBe("42000"); + expect(result.asset).toBe("0xCustomToken"); + expect(result.extra).toEqual({ name: "Custom", version: "1" }); + }); + + it("should pass through AssetAmount objects without extra", async () => { + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice( + { asset: "0xCustomToken", amount: "42000" }, + "eip155:84532", + ); + + expect(result.amount).toBe("42000"); + expect(result.asset).toBe("0xCustomToken"); + expect(result.extra).toEqual({}); + }); + + it("should throw when AssetAmount has no asset", async () => { + const scheme = new AuthCaptureEvmScheme(); + await expect( + scheme.parsePrice({ asset: "", amount: "42000" }, "eip155:84532"), + ).rejects.toThrow("Asset address must be specified"); + }); + + it("should throw for unsupported network", async () => { + const scheme = new AuthCaptureEvmScheme(); + await expect(scheme.parsePrice("$1.00", "eip155:99999")).rejects.toThrow( + "No default asset configured for network", + ); + }); + + it("should propagate assetTransferMethod from default-asset table for permit2 chains", async () => { + // Mezo testnet defaults to mUSD which uses permit2 and supports EIP-2612, + // so name/version remain (for the EIP-2612 sig) and assetTransferMethod is propagated. + const scheme = new AuthCaptureEvmScheme(); + const result = await scheme.parsePrice("$1.00", "eip155:31611"); + + expect(result.asset).toBe("0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503"); + expect(result.extra).toMatchObject({ + assetTransferMethod: "permit2", + name: "Mezo USD", + version: "1", + }); + }); + }); + + describe("registerMoneyParser", () => { + it("should use custom parser when it returns a result", async () => { + const scheme = new AuthCaptureEvmScheme(); + scheme.registerMoneyParser(async (amount, _network) => ({ + asset: "0xCustomToken", + amount: String(amount * 1e18), + extra: { name: "Custom", version: "1" }, + })); + + const result = await scheme.parsePrice("$1.00", "eip155:84532"); + + expect(result.asset).toBe("0xCustomToken"); + expect(result.amount).toBe(String(1e18)); + expect(result.extra).toEqual({ name: "Custom", version: "1" }); + }); + + it("should fall through to default when custom parser returns null", async () => { + const scheme = new AuthCaptureEvmScheme(); + scheme.registerMoneyParser(async () => null); + + const result = await scheme.parsePrice("$1.00", "eip155:84532"); + + expect(result.asset).toBe(BASE_SEPOLIA_USDC); + expect(result.amount).toBe("1000000"); + expect(result.extra).toEqual({ name: "USDC", version: "2" }); + }); + + it("should try parsers in registration order", async () => { + const scheme = new AuthCaptureEvmScheme(); + scheme.registerMoneyParser(async () => null); + scheme.registerMoneyParser(async (amount, _network) => ({ + asset: "0xSecondParser", + amount: String(amount * 100), + extra: {}, + })); + + const result = await scheme.parsePrice("$1.00", "eip155:84532"); + + expect(result.asset).toBe("0xSecondParser"); + expect(result.amount).toBe("100"); + }); + }); + + describe("enhancePaymentRequirements", () => { + // A complete `extra` carrying every field `isAuthCaptureExtra` requires. + // Use this in tests that aren't exercising the fail-fast validation path, + // so the assertion against missing-field rejection doesn't fire and the + // test can focus on whatever behavior it's actually trying to cover. + // Passing `undefined` for a key removes it (vs. spreading, which would + // leave a `key: undefined` entry that overrides supportedKind on merge). + const completeExtra = (overrides: Record = {}) => { + const out: Record = { + captureAuthorizer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + captureDeadlineSeconds: 30 * 86400, + refundDeadlineSeconds: 60 * 86400, + feeRecipient: "0x0000000000000000000000000000000000000000", + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + }; + for (const [k, v] of Object.entries(overrides)) { + if (v === undefined) delete out[k]; + else out[k] = v; + } + return out; + }; + + const baseRequirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0x1234567890123456789012345678901234567890", + maxTimeoutSeconds: 300, + extra: completeExtra(), + }; + + it("should merge extra fields from supportedKind", async () => { + const scheme = new AuthCaptureEvmScheme(); + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { + fromSupported1: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + fromSupported2: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }, + }; + + const result = await scheme.enhancePaymentRequirements(baseRequirements, supportedKind, []); + + expect(result.extra).toMatchObject({ + fromSupported1: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + fromSupported2: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }); + }); + + it("should preserve existing extra fields from requirements", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ customField: "custom-value" }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { fromSupported: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + }; + + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + expect(result.extra).toMatchObject({ + fromSupported: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + customField: "custom-value", + }); + }); + + it("should let requirements extra override supportedKind extra", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ sharedKey: "0xcccccccccccccccccccccccccccccccccccccccc" }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { sharedKey: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + }; + + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + expect(result.extra?.sharedKey).toBe("0xcccccccccccccccccccccccccccccccccccccccc"); + }); + + it("should preserve all original requirement fields", async () => { + const scheme = new AuthCaptureEvmScheme(); + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + const result = await scheme.enhancePaymentRequirements(baseRequirements, supportedKind, []); + + expect(result.scheme).toBe("auth-capture"); + expect(result.network).toBe("eip155:84532"); + expect(result.amount).toBe("1000000"); + expect(result.asset).toBe(BASE_SEPOLIA_USDC); + expect(result.payTo).toBe("0x1234567890123456789012345678901234567890"); + }); + + it("should convert captureDeadlineSeconds and refundDeadlineSeconds to absolute deadlines, stripping the offset keys", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: 600, + refundDeadlineSeconds: 1200, + }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + const before = Math.floor(Date.now() / 1000); + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + const after = Math.floor(Date.now() / 1000); + + const captureDeadline = result.extra?.captureDeadline as number; + const refundDeadline = result.extra?.refundDeadline as number; + + expect(captureDeadline).toBeGreaterThanOrEqual(before + 600); + expect(captureDeadline).toBeLessThanOrEqual(after + 600); + expect(refundDeadline).toBeGreaterThanOrEqual(before + 1200); + expect(refundDeadline).toBeLessThanOrEqual(after + 1200); + + expect(result.extra).not.toHaveProperty("captureDeadlineSeconds"); + expect(result.extra).not.toHaveProperty("refundDeadlineSeconds"); + }); + + it("should process the capture/refund pair independently when one half is absolute and the other is an offset", async () => { + // Asymmetric mix: capture is pinned to an absolute timestamp (e.g., a delivery commit), + // refund is a relative window. Each half is converted on its own. If the merchant pairs + // an absolute capture in the far future with a tiny refund offset (or vice-versa), the + // resulting `(captureDeadline, refundDeadline)` can violate the spec's ordering invariant; + // the facilitator rejects with `invalid_deadline_ordering` at verify time, covered by + // facilitator.test.ts at "should reject when refundDeadline is not after captureDeadline". + const scheme = new AuthCaptureEvmScheme(); + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + // Case 1: absolute capture + relative refund. + const reqs1 = { + ...baseRequirements, + extra: completeExtra({ + captureDeadline: 1700000000, + captureDeadlineSeconds: undefined, + refundDeadlineSeconds: 60, + }), + }; + const before1 = Math.floor(Date.now() / 1000); + const out1 = await scheme.enhancePaymentRequirements(reqs1, supportedKind, []); + const after1 = Math.floor(Date.now() / 1000); + + expect(out1.extra?.captureDeadline).toBe(1700000000); + expect(out1.extra?.refundDeadline).toBeGreaterThanOrEqual(before1 + 60); + expect(out1.extra?.refundDeadline).toBeLessThanOrEqual(after1 + 60); + expect(out1.extra).not.toHaveProperty("captureDeadlineSeconds"); + expect(out1.extra).not.toHaveProperty("refundDeadlineSeconds"); + + // Case 2: relative capture + absolute refund. + const reqs2 = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: 60, + refundDeadlineSeconds: undefined, + refundDeadline: 1800000000, + }), + }; + const before2 = Math.floor(Date.now() / 1000); + const out2 = await scheme.enhancePaymentRequirements(reqs2, supportedKind, []); + const after2 = Math.floor(Date.now() / 1000); + + expect(out2.extra?.refundDeadline).toBe(1800000000); + expect(out2.extra?.captureDeadline).toBeGreaterThanOrEqual(before2 + 60); + expect(out2.extra?.captureDeadline).toBeLessThanOrEqual(after2 + 60); + expect(out2.extra).not.toHaveProperty("captureDeadlineSeconds"); + expect(out2.extra).not.toHaveProperty("refundDeadlineSeconds"); + }); + + it("should let absolute captureDeadline / refundDeadline win over offsets", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: 600, + refundDeadlineSeconds: 1200, + captureDeadline: 1700000000, + refundDeadline: 1800000000, + }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + expect(result.extra?.captureDeadline).toBe(1700000000); + expect(result.extra?.refundDeadline).toBe(1800000000); + expect(result.extra).not.toHaveProperty("captureDeadlineSeconds"); + expect(result.extra).not.toHaveProperty("refundDeadlineSeconds"); + }); + + it("should produce distinct deadlines across two calls separated in time", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: 1, + refundDeadlineSeconds: 2, + }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + const first = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + await new Promise(resolve => setTimeout(resolve, 1100)); + const second = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + + expect(second.extra?.captureDeadline).toBeGreaterThan(first.extra?.captureDeadline as number); + expect(second.extra?.refundDeadline).toBeGreaterThan(first.extra?.refundDeadline as number); + }); + + it("should throw on non-positive captureDeadlineSeconds", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureDeadlineSeconds: 0 }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureDeadlineSeconds/); + }); + + it("should throw on non-positive refundDeadlineSeconds", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ refundDeadlineSeconds: -1 }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/refundDeadlineSeconds/); + }); + + it("should throw on non-finite offset", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureDeadlineSeconds: Number.NaN }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureDeadlineSeconds/); + }); + + it("should throw on non-number offset", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureDeadlineSeconds: "30d" }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureDeadlineSeconds/); + }); + + it("should accept offsets from supportedKind.extra (facilitator-injected) when not set in requirements", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + // Drop the offsets from requirements so supportedKind's offsets are what gets used. + extra: completeExtra({ + captureDeadlineSeconds: undefined, + refundDeadlineSeconds: undefined, + }), + }; + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + extra: { + captureDeadlineSeconds: 600, + refundDeadlineSeconds: 1200, + }, + }; + + const before = Math.floor(Date.now() / 1000); + const result = await scheme.enhancePaymentRequirements(requirements, supportedKind, []); + const after = Math.floor(Date.now() / 1000); + + const captureDeadline = result.extra?.captureDeadline as number; + const refundDeadline = result.extra?.refundDeadline as number; + + expect(captureDeadline).toBeGreaterThanOrEqual(before + 600); + expect(captureDeadline).toBeLessThanOrEqual(after + 600); + expect(refundDeadline).toBeGreaterThanOrEqual(before + 1200); + expect(refundDeadline).toBeLessThanOrEqual(after + 1200); + }); + }); + + describe("enhancePaymentRequirements - fail-fast field validation", () => { + const completeExtra = (overrides: Record = {}) => { + const out: Record = { + captureAuthorizer: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + captureDeadlineSeconds: 30 * 86400, + refundDeadlineSeconds: 60 * 86400, + feeRecipient: "0x0000000000000000000000000000000000000000", + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + }; + for (const [k, v] of Object.entries(overrides)) { + if (v === undefined) delete out[k]; + else out[k] = v; + } + return out; + }; + + const baseRequirements = { + scheme: "auth-capture", + network: "eip155:84532" as const, + amount: "1000000", + asset: BASE_SEPOLIA_USDC, + payTo: "0x1234567890123456789012345678901234567890", + maxTimeoutSeconds: 300, + }; + + const supportedKind = { + x402Version: 2, + scheme: "auth-capture", + network: "eip155:84532" as const, + }; + + for (const field of ["captureAuthorizer", "feeRecipient", "minFeeBps", "maxFeeBps"] as const) { + it(`should throw when extra.${field} is missing`, async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ [field]: undefined }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(new RegExp(`extra\\.${field}`)); + }); + } + + it("should throw when neither captureDeadlineSeconds nor captureDeadline is provided", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + captureDeadlineSeconds: undefined, + }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/extra\.captureDeadline/); + }); + + it("should throw when neither refundDeadlineSeconds nor refundDeadline is provided", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ + refundDeadlineSeconds: undefined, + }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/extra\.refundDeadline/); + }); + + it("should throw when captureAuthorizer is the wrong type", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureAuthorizer: 42 }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureAuthorizer/); + }); + + it("should include the path-to-fix hint in the deadline error message", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ captureDeadlineSeconds: undefined }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).rejects.toThrow(/captureDeadlineSeconds.*captureDeadline/); + }); + + it("should not fail-fast on missing name (auto-populated by parsePrice for decimal pricing; wire-side rejection for AssetAmount path)", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ name: undefined }), + }; + // Merchant uses decimal pricing → name is auto-populated by parsePrice → no need to throw here. + // Merchant uses a custom AssetAmount and forgets name → facilitator catches with invalid_auth_capture_extra. + // Either way enhance does not throw on missing name. + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).resolves.not.toThrow(); + }); + + it("should not fail-fast on missing version (same rationale as name)", async () => { + const scheme = new AuthCaptureEvmScheme(); + const requirements = { + ...baseRequirements, + extra: completeExtra({ version: undefined }), + }; + await expect( + scheme.enhancePaymentRequirements(requirements, supportedKind, []), + ).resolves.not.toThrow(); + }); + }); + + describe("scheme property", () => { + it('should have scheme set to "auth-capture"', () => { + const scheme = new AuthCaptureEvmScheme(); + expect(scheme.scheme).toBe("auth-capture"); + }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts b/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts new file mode 100644 index 0000000000..1b74d8e985 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/auth-capture/types.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from "vitest"; +import { + isAuthCaptureExtra, + isAuthCapturePayload, + isEip3009Payload, + isPermit2Payload, +} from "../../../src/auth-capture/types"; + +describe("type guards", () => { + const FUTURE = Math.floor(Date.now() / 1000) + 86400; + + const validExtra = { + captureAuthorizer: "0xcccccccccccccccccccccccccccccccccccccccc", + captureDeadline: FUTURE, + refundDeadline: FUTURE + 86400, + feeRecipient: "0x4444444444444444444444444444444444444444", + minFeeBps: 0, + maxFeeBps: 100, + name: "USDC", + version: "2", + }; + + const validEip3009 = { + authorization: { + from: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + to: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + value: "1000000", + validAfter: "0", + validBefore: String(FUTURE), + nonce: "0x1234567890123456789012345678901234567890123456789012345678901234", + }, + signature: "0xabcd", + salt: "0x0000000000000000000000000000000000000000000000000000000000000abc", + }; + + const validPermit2 = { + permit2Authorization: { + from: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + permitted: { token: "0xeeee", amount: "1000000" }, + spender: "0xdddd", + nonce: "12345", + deadline: String(FUTURE), + }, + signature: "0xabcd", + salt: "0x0000000000000000000000000000000000000000000000000000000000000abc", + }; + + describe("isAuthCaptureExtra", () => { + it("accepts a valid extra object", () => { + expect(isAuthCaptureExtra(validExtra)).toBe(true); + }); + + it("rejects null and undefined", () => { + expect(isAuthCaptureExtra(null)).toBe(false); + expect(isAuthCaptureExtra(undefined)).toBe(false); + }); + + it("rejects non-objects", () => { + expect(isAuthCaptureExtra("string")).toBe(false); + expect(isAuthCaptureExtra(42)).toBe(false); + expect(isAuthCaptureExtra(true)).toBe(false); + }); + + it("rejects when captureAuthorizer is missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { captureAuthorizer: _ca, ...rest } = validExtra; + expect(isAuthCaptureExtra(rest)).toBe(false); + }); + + it("rejects when captureDeadline is not a number", () => { + expect(isAuthCaptureExtra({ ...validExtra, captureDeadline: "soon" })).toBe(false); + }); + + it("rejects when feeRecipient is not a string", () => { + expect(isAuthCaptureExtra({ ...validExtra, feeRecipient: 42 })).toBe(false); + }); + + it("rejects when name/version are missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { name: _n, ...rest } = validExtra; + expect(isAuthCaptureExtra(rest)).toBe(false); + }); + + it("rejects when minFeeBps is missing (required per spec, no implicit default)", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { minFeeBps: _m, ...rest } = validExtra; + expect(isAuthCaptureExtra(rest)).toBe(false); + }); + + it("rejects when maxFeeBps is missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { maxFeeBps: _m, ...rest } = validExtra; + expect(isAuthCaptureExtra(rest)).toBe(false); + }); + + it("rejects the old commerce-era extra shape", () => { + const oldShape = { + escrowAddress: "0xeee", + operatorAddress: "0xccc", + tokenCollector: "0xbbb", + name: "USDC", + version: "2", + }; + expect(isAuthCaptureExtra(oldShape)).toBe(false); + }); + }); + + describe("isEip3009Payload", () => { + it("accepts a valid EIP-3009 payload", () => { + expect(isEip3009Payload(validEip3009)).toBe(true); + }); + + it("rejects when authorization is missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { authorization: _a, ...rest } = validEip3009; + expect(isEip3009Payload(rest)).toBe(false); + }); + + it("rejects when signature is missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { signature: _s, ...rest } = validEip3009; + expect(isEip3009Payload(rest)).toBe(false); + }); + + it("rejects when salt is missing (regression: salt is required on payload)", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { salt: _salt, ...rest } = validEip3009; + expect(isEip3009Payload(rest)).toBe(false); + }); + + it("rejects a Permit2 payload (no authorization field)", () => { + expect(isEip3009Payload(validPermit2)).toBe(false); + }); + + it("rejects null", () => { + expect(isEip3009Payload(null)).toBe(false); + }); + }); + + describe("isPermit2Payload", () => { + it("accepts a valid Permit2 payload", () => { + expect(isPermit2Payload(validPermit2)).toBe(true); + }); + + it("rejects when permit2Authorization is missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { permit2Authorization: _p, ...rest } = validPermit2; + expect(isPermit2Payload(rest)).toBe(false); + }); + + it("rejects when salt is missing", () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { salt: _s, ...rest } = validPermit2; + expect(isPermit2Payload(rest)).toBe(false); + }); + + it("rejects when permit2Authorization.from is not a string", () => { + expect( + isPermit2Payload({ + ...validPermit2, + permit2Authorization: { ...validPermit2.permit2Authorization, from: 42 }, + }), + ).toBe(false); + }); + + it("rejects an EIP-3009 payload", () => { + expect(isPermit2Payload(validEip3009)).toBe(false); + }); + }); + + describe("isAuthCapturePayload (discriminated union)", () => { + it("accepts both EIP-3009 and Permit2 payloads", () => { + expect(isAuthCapturePayload(validEip3009)).toBe(true); + expect(isAuthCapturePayload(validPermit2)).toBe(true); + }); + + it("rejects shapes that match neither variant", () => { + expect(isAuthCapturePayload({ signature: "0xabcd", salt: "0x00" })).toBe(false); + expect(isAuthCapturePayload({ authorization: {} })).toBe(false); + expect(isAuthCapturePayload(null)).toBe(false); + }); + }); +}); diff --git a/typescript/packages/mechanisms/evm/tsup.config.ts b/typescript/packages/mechanisms/evm/tsup.config.ts index d8d066d877..c15d55db82 100644 --- a/typescript/packages/mechanisms/evm/tsup.config.ts +++ b/typescript/packages/mechanisms/evm/tsup.config.ts @@ -18,6 +18,9 @@ const baseConfig = { "batch-settlement/server/file-storage": "src/batch-settlement/server/fileStorage.ts", "batch-settlement/server/redis-storage": "src/batch-settlement/server/redisStorage.ts", "batch-settlement/facilitator/index": "src/batch-settlement/facilitator/index.ts", + "auth-capture/client/index": "src/auth-capture/client/index.ts", + "auth-capture/server/index": "src/auth-capture/server/index.ts", + "auth-capture/facilitator/index": "src/auth-capture/facilitator/index.ts", }, dts: { resolve: true,