diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index efc9ae6ec3..f829b88f40 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,18 +3,27 @@ # How this file is read by GitHub: # - Last matching pattern wins. Order matters. # - A trailing slash means "this directory and everything under it". +# - `**` matches across path segments; `*` matches within a segment. # - Paths with no rule require no specific reviewer (any maintainer can approve). # We deliberately leave cross-network e2e and most examples unowned. # # Teams: -# @x402-foundation/core — Core protocol, shared infra, CI, docs/specs -# @x402-foundation/site — x402.org website -# @x402-foundation/java — Java SDK -# @x402-foundation/evm — EVM network maintainers -# @x402-foundation/svm — SVM (Solana) network maintainers -# @x402-foundation/stellar — Stellar network maintainers -# @x402-foundation/aptos — Aptos network maintainers -# @x402-foundation/avm — AVM (Avalanche) network maintainers +# @x402-foundation/core — Core protocol, shared infra, CI, docs/specs +# @x402-foundation/site — x402.org facilitator +# @x402-foundation/java — Java SDK +# @x402-foundation/evm — EVM network maintainers +# @x402-foundation/svm — SVM (Solana) network maintainers +# @x402-foundation/stellar — Stellar network maintainers +# @x402-foundation/aptos — Aptos network maintainers +# @x402-foundation/avm — AVM (Avalanche) network maintainers +# @x402-foundation/cardano — Cardano network maintainers +# @x402-foundation/casper — Casper network maintainers +# @x402-foundation/concordium — Concordium network maintainers +# @x402-foundation/hedera — Hedera network maintainers +# @x402-foundation/keeta — Keeta network maintainers +# @x402-foundation/near — NEAR network maintainers +# @x402-foundation/tvm — TVM (TON) network maintainers +# @x402-foundation/xrpl — XRPL network maintainers # ───────────────────────────────────────────────────────────────────────────── # 1. Repo-wide meta @@ -71,7 +80,6 @@ # ───────────────────────────────────────────────────────────────────────────── # 6. Site (x402.org) -# Network partner subdirs overridden in section 12. # ───────────────────────────────────────────────────────────────────────────── /typescript/site/ @x402-foundation/site @@ -117,73 +125,100 @@ # ───────────────────────────────────────────────────────────────────────────── /contracts/ @x402-foundation/core -# Note on e2e: intentionally NOT owned in bulk. Most of e2e (echo, gin, hono, -# fastapi, fastify, flask, nethttp, mcp-*, axios, fetch, requests, httpx, -# the facilitators, scripts, templates, mock-facilitator) is cross-network -# infrastructure — anyone closest to the change reviews. Only the per-network -# Next.js API routes have owners (section 12). +# Note on e2e: intentionally NOT owned. e2e is cross-network test infrastructure. +# Network ownership lives in specs and SDK paths below. # ╔═══════════════════════════════════════════════════════════════════════════╗ # ║ 12. Network overrides — placed last so they win last-match precedence. ║ # ╚═══════════════════════════════════════════════════════════════════════════╝ # ── EVM ─────────────────────────────────────────────────────────────────────── -/contracts/evm/ @x402-foundation/evm -/specs/schemes/exact/scheme_exact_evm.md @x402-foundation/evm -/specs/schemes/upto/scheme_upto_evm.md @x402-foundation/evm -/specs/extensions/eip2612_gas_sponsoring.md @x402-foundation/evm -/specs/extensions/erc20_gas_sponsoring.md @x402-foundation/evm -/typescript/packages/mechanisms/evm/ @x402-foundation/evm -/typescript/packages/extensions/src/eip2612-gas-sponsoring/ @x402-foundation/evm -/typescript/packages/extensions/src/erc20-approval-gas-sponsoring/ @x402-foundation/evm -/typescript/packages/http/paywall/src/evm/ @x402-foundation/evm -/go/mechanisms/evm/ @x402-foundation/evm -/go/signers/evm/ @x402-foundation/evm -/go/extensions/eip2612gassponsor/ @x402-foundation/evm -/go/extensions/erc20approvalgassponsor/ @x402-foundation/evm -/go/http/evm_paywall_template.go @x402-foundation/evm -/python/x402/mechanisms/evm/ @x402-foundation/evm -/python/x402/extensions/eip2612_gas_sponsoring/ @x402-foundation/evm -/python/x402/extensions/erc20_approval_gas_sponsoring/ @x402-foundation/evm -/python/x402/http/paywall/evm_paywall_template.py @x402-foundation/evm -/python/x402/tests/unit/mechanisms/evm/ @x402-foundation/evm -/e2e/servers/next/app/api/exact/evm/ @x402-foundation/evm -/e2e/servers/next/app/api/upto/evm/ @x402-foundation/evm -/.github/workflows/publish_npm_scoped_x402_evm.yml @x402-foundation/evm +/contracts/evm/ @x402-foundation/evm +/specs/schemes/**/scheme_*_evm.md @x402-foundation/evm +/specs/extensions/eip2612_gas_sponsoring.md @x402-foundation/evm +/specs/extensions/erc20_gas_sponsoring.md @x402-foundation/evm +/typescript/packages/mechanisms/evm/ @x402-foundation/evm +/typescript/packages/extensions/src/eip2612-gas-sponsoring/ @x402-foundation/evm +/typescript/packages/extensions/src/erc20-approval-gas-sponsoring/ @x402-foundation/evm +/typescript/packages/http/paywall/src/evm/ @x402-foundation/evm +/go/mechanisms/evm/ @x402-foundation/evm +/go/signers/evm/ @x402-foundation/evm +/go/extensions/eip2612gassponsor/ @x402-foundation/evm +/go/extensions/erc20approvalgassponsor/ @x402-foundation/evm +/go/http/evm_paywall_template.go @x402-foundation/evm +/python/x402/mechanisms/evm/ @x402-foundation/evm +/python/x402/extensions/eip2612_gas_sponsoring/ @x402-foundation/evm +/python/x402/extensions/erc20_approval_gas_sponsoring/ @x402-foundation/evm +/python/x402/http/paywall/evm_paywall_template.py @x402-foundation/evm +/python/x402/tests/unit/mechanisms/evm/ @x402-foundation/evm +/.github/workflows/publish_npm_scoped_x402_evm.yml @x402-foundation/evm # ── SVM (Solana) ────────────────────────────────────────────────────────────── -/specs/schemes/exact/scheme_exact_svm.md @x402-foundation/svm -/typescript/packages/mechanisms/svm/ @x402-foundation/svm -/typescript/packages/http/paywall/src/svm/ @x402-foundation/svm -/typescript/site/app/ecosystem/partners-data/solana-pay-x402/ @x402-foundation/svm -/go/mechanisms/svm/ @x402-foundation/svm -/go/signers/svm/ @x402-foundation/svm -/go/http/svm_paywall_template.go @x402-foundation/svm -/python/x402/mechanisms/svm/ @x402-foundation/svm -/python/x402/http/paywall/svm_paywall_template.py @x402-foundation/svm -/python/x402/tests/unit/mechanisms/svm/ @x402-foundation/svm -/e2e/servers/next/app/api/exact/svm/ @x402-foundation/svm -/.github/workflows/publish_npm_scoped_x402_svm.yml @x402-foundation/svm +/specs/schemes/**/scheme_*_svm.md @x402-foundation/svm +/typescript/packages/mechanisms/svm/ @x402-foundation/svm +/typescript/packages/http/paywall/src/svm/ @x402-foundation/svm +/go/mechanisms/svm/ @x402-foundation/svm +/go/signers/svm/ @x402-foundation/svm +/go/http/svm_paywall_template.go @x402-foundation/svm +/python/x402/mechanisms/svm/ @x402-foundation/svm +/python/x402/http/paywall/svm_paywall_template.py @x402-foundation/svm +/python/x402/tests/unit/mechanisms/svm/ @x402-foundation/svm +/.github/workflows/publish_npm_scoped_x402_svm.yml @x402-foundation/svm # ── Stellar ─────────────────────────────────────────────────────────────────── -/specs/schemes/exact/scheme_exact_stellar.md @x402-foundation/stellar -/typescript/packages/mechanisms/stellar/ @x402-foundation/stellar -/typescript/site/app/ecosystem/partners-data/stellar/ @x402-foundation/stellar -/typescript/site/app/ecosystem/partners-data/built-on-stellar/ @x402-foundation/stellar -/e2e/servers/next/app/api/exact/stellar/ @x402-foundation/stellar -/.github/workflows/publish_npm_scoped_x402_stellar.yml @x402-foundation/stellar +/specs/schemes/**/scheme_*_stellar.md @x402-foundation/stellar +/typescript/packages/mechanisms/stellar/ @x402-foundation/stellar +/.github/workflows/publish_npm_scoped_x402_stellar.yml @x402-foundation/stellar # ── Aptos ───────────────────────────────────────────────────────────────────── -/specs/schemes/exact/scheme_exact_aptos.md @x402-foundation/aptos -/typescript/packages/mechanisms/aptos/ @x402-foundation/aptos -/e2e/servers/next/app/api/exact/aptos/ @x402-foundation/aptos -/.github/workflows/publish_npm_scoped_x402_aptos.yml @x402-foundation/aptos - -# ── AVM (Avalanche) — placeholders for upcoming network port ───────────────── -/typescript/packages/mechanisms/avm/ @x402-foundation/avm -/typescript/packages/http/paywall/src/avm/ @x402-foundation/avm -/go/mechanisms/avm/ @x402-foundation/avm -/go/signers/avm/ @x402-foundation/avm -/python/x402/mechanisms/avm/ @x402-foundation/avm -/e2e/servers/next/app/api/exact/avm/ @x402-foundation/avm -/.github/workflows/publish_npm_scoped_x402_avm.yml @x402-foundation/avm +/specs/schemes/**/scheme_*_aptos.md @x402-foundation/aptos +/typescript/packages/mechanisms/aptos/ @x402-foundation/aptos +/.github/workflows/publish_npm_scoped_x402_aptos.yml @x402-foundation/aptos + +# ── AVM (Avalanche) ─────────────────────────────────────────────────────────── +/typescript/packages/mechanisms/avm/ @x402-foundation/avm +/typescript/packages/http/paywall/src/avm/ @x402-foundation/avm +/python/x402/http/paywall/avm_paywall_template.py @x402-foundation/avm +/.github/workflows/publish_npm_scoped_x402_avm.yml @x402-foundation/avm + +# ── Cardano ─────────────────────────────────────────────────────────────────── +/specs/schemes/**/scheme_*_cardano.md @x402-foundation/cardano +/typescript/packages/mechanisms/cardano/ @x402-foundation/cardano +/.github/workflows/publish_npm_scoped_x402_cardano.yml @x402-foundation/cardano + +# ── Casper ──────────────────────────────────────────────────────────────────── +/specs/schemes/**/scheme_*_casper.md @x402-foundation/casper +/typescript/packages/mechanisms/casper/ @x402-foundation/casper +/.github/workflows/publish_npm_scoped_x402_casper.yml @x402-foundation/casper + +# ── Concordium ──────────────────────────────────────────────────────────────── +/specs/schemes/**/scheme_*_concordium.md @x402-foundation/concordium +/typescript/packages/mechanisms/concordium/ @x402-foundation/concordium +/.github/workflows/publish_npm_scoped_x402_concordium.yml @x402-foundation/concordium + +# ── Hedera ──────────────────────────────────────────────────────────────────── +/specs/schemes/**/scheme_*_hedera.md @x402-foundation/hedera +/typescript/packages/mechanisms/hedera/ @x402-foundation/hedera +/.github/workflows/publish_npm_scoped_x402_hedera.yml @x402-foundation/hedera + +# ── Keeta ───────────────────────────────────────────────────────────────────── +/specs/schemes/**/scheme_*_keeta.md @x402-foundation/keeta +/typescript/packages/mechanisms/keeta/ @x402-foundation/keeta +/.github/workflows/publish_npm_scoped_x402_keeta.yml @x402-foundation/keeta + +# ── NEAR ────────────────────────────────────────────────────────────────────── +/specs/schemes/**/scheme_*_near.md @x402-foundation/near +/typescript/packages/mechanisms/near/ @x402-foundation/near +/.github/workflows/publish_npm_scoped_x402_near.yml @x402-foundation/near + +# ── TVM (TON; spec slug is `ton`, package slug is `tvm`) ────────────────────── +/specs/schemes/**/scheme_*_ton.md @x402-foundation/tvm +/typescript/packages/mechanisms/tvm/ @x402-foundation/tvm +/python/x402/mechanisms/tvm/ @x402-foundation/tvm +/python/x402/tests/unit/mechanisms/tvm/ @x402-foundation/tvm +/.github/workflows/publish_npm_scoped_x402_tvm.yml @x402-foundation/tvm + +# ── XRPL ────────────────────────────────────────────────────────────────────── +/specs/schemes/**/scheme_*_xrpl.md @x402-foundation/xrpl +/typescript/packages/mechanisms/xrpl/ @x402-foundation/xrpl +/.github/workflows/publish_npm_scoped_x402_xrpl.yml @x402-foundation/xrpl diff --git a/.github/workflows/publish_npm_scoped_x402_all.yml b/.github/workflows/publish_npm_scoped_x402_all.yml index 2497da762e..6122729318 100644 --- a/.github/workflows/publish_npm_scoped_x402_all.yml +++ b/.github/workflows/publish_npm_scoped_x402_all.yml @@ -72,6 +72,7 @@ jobs: publish_package "packages/mechanisms/avm" publish_package "packages/mechanisms/aptos" publish_package "packages/mechanisms/cardano" + publish_package "packages/mechanisms/casper" publish_package "packages/mechanisms/stellar" publish_package "packages/mechanisms/hedera" publish_package "packages/mechanisms/keeta" diff --git a/.github/workflows/publish_npm_scoped_x402_casper.yml b/.github/workflows/publish_npm_scoped_x402_casper.yml new file mode 100644 index 0000000000..8024e538ab --- /dev/null +++ b/.github/workflows/publish_npm_scoped_x402_casper.yml @@ -0,0 +1,48 @@ +name: Publish @x402/casper package to NPM + +on: + workflow_dispatch: + +jobs: + publish-npm-x402-casper: + if: github.repository == 'x402-foundation/x402' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: npm + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + with: + version: 11.1.1 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Update npm for OIDC trusted publishing + run: npm install -g npm@11.14.1 --ignore-scripts + + - name: Configure npm for trusted publishing + run: npm config delete always-auth 2>/dev/null || true + + - name: Install and build + working-directory: ./typescript + run: | + pnpm install --frozen-lockfile --ignore-scripts + pnpm -r --filter=@x402/core --filter=@x402/casper run build + + - name: Publish @x402/casper package + working-directory: ./typescript/packages/mechanisms/casper + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + PACKAGE_VERSION=$(node -p "require('./package.json').version") + + echo "Package: $PACKAGE_NAME@$PACKAGE_VERSION" + + echo "Publishing to NPM (main branch)" + pnpm publish --provenance --access public diff --git a/README.md b/README.md index 71afc26297..6b36c42bfb 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ app.use( ```shell # All available reference sdks npm install @x402/core \ - @x402/evm @x402/svm @x402/avm @x402/aptos @x402/stellar @x402/tvm @x402/hedera @x402/keeta \ + @x402/evm @x402/svm @x402/avm @x402/aptos @x402/casper @x402/stellar @x402/tvm @x402/hedera @x402/keeta \ @x402/axios @x402/fastify @x402/fetch @x402/express @x402/hono @x402/next @x402/paywall @x402/extensions @x402/mcp ``` diff --git a/docs/advanced-concepts/lifecycle-hooks.mdx b/docs/advanced-concepts/lifecycle-hooks.mdx index d53eb4108b..2ac538f796 100644 --- a/docs/advanced-concepts/lifecycle-hooks.mdx +++ b/docs/advanced-concepts/lifecycle-hooks.mdx @@ -467,28 +467,58 @@ Register hooks on the MCP client for payment lifecycle events specific to tool c * **onBeforePayment** — Runs after payment approval but before the payment payload is created. * **onAfterPayment** — Runs after the payment payload is submitted and the tool result is received. -```typescript -import { x402MCPClient } from "@x402/mcp"; - -const client = new x402MCPClient(mcpClient, paymentClient); - -client - .onPaymentRequired(async ({ toolName, paymentRequired }) => { - if (blocklist.has(toolName)) { - return { abort: true }; - } - // Return undefined to proceed with normal payment flow - }) - .onBeforePayment(async ({ toolName, paymentRequired }) => { - console.log(`Creating payment for tool: ${toolName}`); - }) - .onAfterPayment(async ({ toolName, paymentPayload, result, settleResponse }) => { - await auditLog.record({ - tool: toolName, - transaction: settleResponse?.transaction, - }); - }); -``` + + + ```typescript + import { x402MCPClient } from "@x402/mcp"; + + const client = new x402MCPClient(mcpClient, paymentClient); + + client + .onPaymentRequired(async ({ toolName, paymentRequired }) => { + if (blocklist.has(toolName)) { + return { abort: true }; + } + // Return undefined to proceed with normal payment flow + }) + .onBeforePayment(async ({ toolName, paymentRequired }) => { + console.log(`Creating payment for tool: ${toolName}`); + }) + .onAfterPayment(async ({ toolName, paymentPayload, result, settleResponse }) => { + await auditLog.record({ + tool: toolName, + transaction: settleResponse?.transaction, + }); + }); + ``` + + + ```go + import x402mcp "github.com/x402-foundation/x402/go/v2/mcp" + + client := x402mcp.NewX402MCPClient(session, paymentClient, x402mcp.Options{}) + + client. + OnPaymentRequired(func(ctx x402mcp.PaymentRequiredContext) (*x402mcp.PaymentRequiredHookResult, error) { + if blocklist[ctx.ToolName] { + return &x402mcp.PaymentRequiredHookResult{Abort: true}, nil + } + // Return nil to proceed with normal payment flow + return nil, nil + }). + OnBeforePayment(func(ctx x402mcp.PaymentRequiredContext) error { + log.Printf("Creating payment for tool: %s", ctx.ToolName) + return nil + }). + OnAfterPayment(func(ctx x402mcp.AfterPaymentContext) error { + if ctx.SettleResponse != nil { + return auditLog.Record(ctx.ToolName, ctx.SettleResponse.Transaction) + } + return nil + }) + ``` + + ### x402MCPServer payment wrapper (MCP server) @@ -498,26 +528,60 @@ Register hooks in the `PaymentWrapperConfig.hooks` object when creating a paymen * **onAfterExecution** — Runs after the tool handler returns, before settlement. * **onAfterSettlement** — Runs after successful payment settlement. -```typescript -import { createPaymentWrapper } from "@x402/mcp"; + + + ```typescript + import { createPaymentWrapper } from "@x402/mcp"; + + const paid = createPaymentWrapper(resourceServer, { + accepts, + hooks: { + onBeforeExecution: async ({ toolName, paymentPayload }) => { + if (await isRateLimited(paymentPayload.payer)) { + return false; // Abort execution + } + }, + onAfterExecution: async ({ toolName, result }) => { + console.log(`Tool ${toolName} executed successfully`); + }, + onAfterSettlement: async ({ settlement }) => { + await sendReceipt({ transaction: settlement.transaction }); + }, + }, + }); + ``` + + + ```go + import x402mcp "github.com/x402-foundation/x402/go/v2/mcp" -const paid = createPaymentWrapper(resourceServer, { - accepts, - hooks: { - onBeforeExecution: async ({ toolName, paymentPayload }) => { - if (await isRateLimited(paymentPayload.payer)) { - return false; // Abort execution + beforeExec := x402mcp.BeforeExecutionHook(func(ctx x402mcp.ServerHookContext) (bool, error) { + if isRateLimited(ctx.PaymentPayload.Payer) { + return false, nil // Abort execution } - }, - onAfterExecution: async ({ toolName, result }) => { - console.log(`Tool ${toolName} executed successfully`); - }, - onAfterSettlement: async ({ settlement }) => { - await sendReceipt({ transaction: settlement.transaction }); - }, - }, -}); -``` + return true, nil + }) + + afterExec := x402mcp.AfterExecutionHook(func(ctx x402mcp.AfterExecutionContext) error { + log.Printf("Tool %s executed successfully", ctx.ToolName) + return nil + }) + + afterSettle := x402mcp.AfterSettlementHook(func(ctx x402mcp.SettlementContext) error { + return sendReceipt(ctx.Settlement.Transaction) + }) + + wrapper := x402mcp.NewPaymentWrapper(resourceServer, x402mcp.PaymentWrapperConfig{ + Accepts: accepts, + Hooks: &x402mcp.PaymentWrapperHooks{ + OnBeforeExecution: &beforeExec, + OnAfterExecution: &afterExec, + OnAfterSettlement: &afterSettle, + }, + }) + ``` + + ## Hook Chaining diff --git a/docs/guides/mcp-server-with-x402.md b/docs/guides/mcp-server-with-x402.mdx similarity index 90% rename from docs/guides/mcp-server-with-x402.md rename to docs/guides/mcp-server-with-x402.mdx index 240ca91f5f..35dd1d21f2 100644 --- a/docs/guides/mcp-server-with-x402.md +++ b/docs/guides/mcp-server-with-x402.mdx @@ -237,6 +237,55 @@ const client = createx402MCPClient({ }); ``` +#### Capping tool-call timeouts + +By default, the x402 MCP client caps derived tool-call timeouts at **10 minutes** (600 seconds). The cap applies to both the initial probe call and the paid retry. You can lower or raise this limit using `maxRequestTimeoutSeconds` (TypeScript/Python) or `MaxRequestTimeout` (Go): + + + + ```typescript + import { createx402MCPClient } from "@x402/mcp"; + + const client = createx402MCPClient({ + name: "my-agent", + version: "1.0.0", + schemes: [{ network: "eip155:84532", client: new ExactEvmScheme(account) }], + // Cap tool-call timeouts at 2 minutes (default: 600s) + maxRequestTimeoutSeconds: 120, + }); + ``` + + Per-call overrides are also supported via `callTool`'s `options.timeout` (milliseconds), which takes precedence over the cap. + + + ```python + from x402.mcp import wrap_mcp_client_with_payment + + x402_mcp = wrap_mcp_client_with_payment( + mcp_client, + payment_client, + max_request_timeout_seconds=120, # default: 600 + ) + ``` + + Pass `read_timeout_seconds` to individual `call_tool` calls to override the cap for a single request. + + + ```go + import ( + "time" + x402mcp "github.com/x402-foundation/x402/go/v2/mcp" + ) + + client := x402mcp.NewX402MCPClient(session, paymentClient, x402mcp.Options{ + MaxRequestTimeout: 2 * time.Minute, // default: 10 minutes + }) + ``` + + + +The timeout is derived from the payment requirement's `maxTimeoutSeconds` field, capped by `maxRequestTimeoutSeconds`. If the accept's `maxTimeoutSeconds` exceeds the cap, the cap wins. + #### Using the onPaymentRequested hook For per-call logic (e.g. checking tool name or prompting the user), use `onPaymentRequested`: diff --git a/docs/schemes/exact.mdx b/docs/schemes/exact.mdx index fd45a30a3c..154d898cd7 100644 --- a/docs/schemes/exact.mdx +++ b/docs/schemes/exact.mdx @@ -293,6 +293,83 @@ The `authorization` flow remains the default. Clients prefer `authorization` whe +### Casper Setup + +The Casper implementation uses CEP-3009 (`transfer_with_authorization`) — Casper's adaptation of EIP-3009 for CEP-18 tokens. The client signs an off-chain EIP-712 authorization; the facilitator relays it on-chain and pays the gas. + +Install the Casper package: + +```bash +npm install @x402/casper +``` + +**Server** + +```typescript +import { x402ResourceServer } from "@x402/core/server"; +import { ExactCasperScheme } from "@x402/casper/exact/server"; + +const resourceServer = new x402ResourceServer(facilitatorClient) + .register("casper:casper-test", new ExactCasperScheme()); + +// In your route config — price must be specified as an explicit asset amount: +{ + "GET /data": { + accepts: [ + { + scheme: "exact", + price: { + amount: "1500000000", // atomic units + asset: "0cb6f94834c60510d532b0ae077b18b4100874a4c867396d61c2b13c790ead52", // contract_package_hash + extra: { name: "csprUSD", version: "1" }, + }, + network: "casper:casper-test", + payTo: "007a9f9948cb7b258d18f3c5e85780372971b5b40096e724c9e596c284a01445fa", + }, + ], + description: "Data endpoint", + mimeType: "application/json", + }, +} +``` + +**Client** + +```typescript +import { createClientCasperSigner } from "@x402/casper"; +import { ExactCasperScheme } from "@x402/casper/exact/client"; +import { x402Client } from "@x402/core/client"; + +// Default algorithm is ED25519; pass 2 for secp256k1 +const casperSigner = await createClientCasperSigner(process.env.CASPER_PRIVATE_KEY!); + +const client = new x402Client() + .register("casper:*", new ExactCasperScheme(casperSigner)); +``` + +**Facilitator** + +```typescript +import { createFacilitatorCasperSigner } from "@x402/casper"; +import { ExactCasperScheme } from "@x402/casper/exact/facilitator"; +import { x402Facilitator } from "@x402/core/facilitator"; + +const casperSigner = await createFacilitatorCasperSigner( + process.env.CASPER_PRIVATE_KEY!, + 1, // 1 = ED25519, 2 = secp256k1 + { + rpcUrlConfig: { "casper:casper-test": process.env.CASPER_RPC_URL! }, + // Optional: enable speculative execution for preflight validation + // speculativeRpcUrlConfig: { "casper:casper-test": process.env.CASPER_SPECULATIVE_RPC_URL! }, + }, +); + +const facilitator = new x402Facilitator() + .register("casper:casper-test", new ExactCasperScheme(casperSigner)); +``` + +The `asset` field is the 32-byte hex `contract_package_hash` of the CEP-18 token. The `extra.name` and `extra.version` fields are required — they are used to construct the CEP-3009 EIP-712 domain separator. + ### XRPL Setup The XRPL implementation uses payer-signed `Payment` transactions. The payer pays the XRPL transaction fee; facilitator-sponsored fees are not supported. @@ -467,7 +544,7 @@ For testnet funds, get test ADA from the [Cardano testnets faucet](https://docs. ### Network Implementations -The `exact` scheme has network specifications for EVM, SVM, AVM, Stellar, Aptos, Hedera, TON, Cardano, Keeta, Sui, Concordium, NEAR, and XRPL. +The `exact` scheme has network specifications for EVM, SVM, AVM, Stellar, Aptos, Casper, Hedera, TON, Cardano, Keeta, Sui, Concordium, NEAR, and XRPL. ### SVM Smart Wallet Support @@ -581,6 +658,7 @@ Permit2 may require a one-time approval. The gas sponsoring extensions can let t * [`exact` Keeta spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_keeta.md) * [`exact` Concordium spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_concordium.md) * [`exact` Cardano spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_cardano.md) +* [`exact` Casper spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_casper.md) * [`exact` Sui spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_sui.md) * [`exact` NEAR spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_near.md) * [`exact` XRPL spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_xrpl.md) diff --git a/docs/sdk-features.md b/docs/sdk-features.md index defc606dbf..bf58253843 100644 --- a/docs/sdk-features.md +++ b/docs/sdk-features.md @@ -32,6 +32,7 @@ This page tracks which features are implemented in each SDK (TypeScript, Go, Pyt | avm (Algorand) | ✅ | ❌ | ❌ | | stellar | ✅ | ❌ | ❌ | | aptos | ✅ | ❌ | ❌ | +| casper | ✅ | ❌ | ❌ | | hedera | ✅ | ❌ | ❌ | | keeta | ✅ | ❌ | ❌ | | near | ✅ | ❌ | ❌ | @@ -49,6 +50,7 @@ This page tracks which features are implemented in each SDK (TypeScript, Go, Pyt | exact | avm | - | ✅ | ❌ | ❌ | | exact | stellar | - | ✅ | ❌ | ❌ | | exact | aptos | - | ✅ | ❌ | ❌ | +| exact | casper | CEP-3009 | ✅ | ❌ | ❌ | | exact | hedera | - | ✅ | ❌ | ❌ | | exact | tvm | - | ✅ | ❌ | ✅ | | exact | keeta | - | ✅ | ❌ | ❌ | @@ -144,17 +146,17 @@ This page tracks which features are implemented in each SDK (TypeScript, Go, Pyt | Hook | TypeScript | Go | Python | |------|------------|-----|--------| -| onPaymentRequired | ✅ | ❌ | ❌ | -| onBeforePayment | ✅ | ❌ | ❌ | -| onAfterPayment | ✅ | ❌ | ❌ | +| onPaymentRequired | ✅ | ✅ | ❌ | +| onBeforePayment | ✅ | ✅ | ❌ | +| onAfterPayment | ✅ | ✅ | ❌ | ### MCP Server Hooks (payment wrapper) | Hook | TypeScript | Go | Python | |------|------------|-----|--------| -| onBeforeExecution | ✅ | ❌ | ✅ | -| onAfterExecution | ✅ | ❌ | ✅ | -| onAfterSettlement | ✅ | ❌ | ✅ | +| onBeforeExecution | ✅ | ✅ | ✅ | +| onAfterExecution | ✅ | ✅ | ✅ | +| onAfterSettlement | ✅ | ✅ | ✅ | ## HTTP Server Features diff --git a/e2e/.env-local b/e2e/.env-local index 812ffaee9a..e5e4421667 100644 --- a/e2e/.env-local +++ b/e2e/.env-local @@ -22,6 +22,8 @@ CLIENT_EVM_PRIVATE_KEY= CLIENT_SVM_PRIVATE_KEY= CLIENT_AVM_PRIVATE_KEY= CLIENT_APTOS_PRIVATE_KEY= +CLIENT_CASPER_PRIVATE_KEY= +CLIENT_CASPER_PRIVATE_KEY_ALGORITHM= CLIENT_CCD_PRIVATE_KEY= CLIENT_CCD_ADDRESS= CLIENT_HEDERA_ACCOUNT_ID= @@ -39,6 +41,8 @@ FACILITATOR_EVM_PRIVATE_KEY= FACILITATOR_SVM_PRIVATE_KEY= FACILITATOR_AVM_PRIVATE_KEY= FACILITATOR_APTOS_PRIVATE_KEY= +FACILITATOR_CASPER_PRIVATE_KEY= +FACILITATOR_CASPER_PRIVATE_KEY_ALGORITHM= FACILITATOR_CCD_PRIVATE_KEY= FACILITATOR_CCD_ADDRESS= FACILITATOR_HEDERA_ACCOUNT_ID= diff --git a/e2e/README.md b/e2e/README.md index 89544807d9..20548a00a2 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -203,8 +203,10 @@ CLIENT_EVM_PRIVATE_KEY=0x... # EVM private key for client payments CLIENT_SVM_PRIVATE_KEY=... # Solana private key for client payments CLIENT_AVM_PRIVATE_KEY=... # Algorand private key for client payments CLIENT_APTOS_PRIVATE_KEY=... # Aptos private key for client payments (hex string) -CLIENT_CCD_PRIVATE_KEY=... # Concordium private key for client payments -CLIENT_CCD_ADDRESS=... # Concordium account address for client payments +CLIENT_CASPER_PRIVATE_KEY=... # Casper private key for client payments +CLIENT_CASPER_PRIVATE_KEY_ALGORITHM=ed25519 # Optional: ed25519 (default) or secp256k1 +CLIENT_CCD_PRIVATE_KEY=... # Concordium private key for client payments +CLIENT_CCD_ADDRESS=... # Concordium account address for client payments CLIENT_HEDERA_ACCOUNT_ID=0.0.... # Hedera account id for client payments CLIENT_HEDERA_PRIVATE_KEY=0x... # Hedera ECDSA private key for client payments CLIENT_KEETA_MNEMONIC=... # Keeta mnemonic for client payments @@ -220,6 +222,7 @@ SERVER_EVM_ADDRESS=0x... # Where servers receive EVM payments SERVER_SVM_ADDRESS=... # Where servers receive Solana payments SERVER_AVM_ADDRESS=... # Where servers receive Algorand payments SERVER_APTOS_ADDRESS=0x... # Where servers receive Aptos payments +SERVER_CASPER_ADDRESS=00... # Where servers receive Casper payments SERVER_CCD_ADDRESS=... # Where servers receive Concordium payments SERVER_HEDERA_ADDRESS=0.0.... # Where servers receive Hedera payments SERVER_KEETA_ADDRESS=keeta_... # Where servers receive Keeta payments @@ -233,6 +236,8 @@ FACILITATOR_EVM_PRIVATE_KEY=0x... # EVM private key for facilitator FACILITATOR_SVM_PRIVATE_KEY=... # Solana private key for facilitator FACILITATOR_AVM_PRIVATE_KEY=... # Algorand private key for facilitator FACILITATOR_APTOS_PRIVATE_KEY=... # Aptos private key for facilitator (hex string) +FACILITATOR_CASPER_PRIVATE_KEY=... # Casper private key for facilitator gas payments +FACILITATOR_CASPER_PRIVATE_KEY_ALGORITHM=ed25519 # Optional: ed25519 (default) or secp256k1 FACILITATOR_CCD_PRIVATE_KEY=... # Concordium private key for facilitator FACILITATOR_CCD_ADDRESS=... # Concordium account address for facilitator FACILITATOR_HEDERA_ACCOUNT_ID=0.0... # Hedera fee payer account id for facilitator @@ -245,6 +250,16 @@ FACILITATOR_NEAR_PRIVATE_KEY=ed25519:... # NEAR relayer private key FACILITATOR_CARDANO_MNEMONIC=... # Optional: the Cardano facilitator only broadcasts, so it runs provider-only without a mnemonic # XRPL needs no facilitator wallet — the facilitator is keyless (payer signs and pays fees) +# Casper CEP-18 support +CASPER_ASSET=... # CEP-18 contract package hash (64 hex chars, no 0x/hash-) +CASPER_TOKEN_NAME=... # CEP-3009 EIP-712 token name +CASPER_TOKEN_VERSION=... # CEP-3009 EIP-712 token version +CASPER_AMOUNT=1 # Optional; defaults to 1 + +# Casper network override +CASPER_TESTNET_RPC_URL=https://node.testnet.casper.network/rpc # Optional; defaults by network +CASPER_SPECULATIVE_RPC_URL=https://node-specexec.testnet.casper.network/rpc # Optional + BLOCKFROST_PROJECT_ID=preprod... # Blockfrost preprod project id (get one at blockfrost.io) CARDANO_TESTNET_RPC_URL=... # Optional Blockfrost base URL override (default https://cardano-preprod.blockfrost.io/api/v0) CARDANO_L1_CONFIRMATIONS= # Optional (-1..20). Unset = 1 confirmation; -1 = mempool (faster local runs) @@ -359,6 +374,16 @@ You need **three separate NEAR testnet accounts** for e2e tests — client (paye 2. Fund the **facilitator (relayer)** account with testnet NEAR for gas from the [NEAR faucet](https://near-faucet.io/). The relayer submits the NEP-366 `SignedDelegate` and sponsors gas, so the payer spends zero gas. 3. Give the **client (payer)** the payment token. The default asset is **wNEAR** (`wrap.testnet`, a NEP-141): wrap NEAR via `wrap.testnet` `near_deposit`. Both payer and merchant must be `storage_deposit`-registered on the token contract. +#### Casper Testnet + +Create or reuse dedicated Casper testnet accounts or wallet keys for the client, +server payee, and facilitator. Fund any account that submits Casper transactions +with testnet CSPR from the [CSPR.live testnet faucet](https://testnet.cspr.live/tools/faucet); +CSPR is required for gas on Casper Testnet. + +Use [testnet.cspr.trade](https://testnet.cspr.trade) to get wrapped CSPR (WCSPR) or csprUSD for +the client payments. + #### Cardano Preprod 1. Create a preprod wallet (CIP-30 wallet or `PrivateKey.generateMnemonic()` from `@evolution-sdk/evolution`) and set `CLIENT_CARDANO_MNEMONIC` plus `SERVER_CARDANO_ADDRESS` (the client's `addr_test1...` works). diff --git a/e2e/clients/typescript/client.ts b/e2e/clients/typescript/client.ts index 5aa851cb4a..1a698c53cb 100644 --- a/e2e/clients/typescript/client.ts +++ b/e2e/clients/typescript/client.ts @@ -14,6 +14,8 @@ import { UptoSvmScheme } from "@x402/svm/upto/client"; import { ExactSvmSchemeV1 } from "@x402/svm/v1"; import { ExactAptosScheme } from "@x402/aptos/exact/client"; import { Account, Ed25519PrivateKey, PrivateKey, PrivateKeyVariants } from "@aptos-labs/ts-sdk"; +import { ExactCasperScheme } from "@x402/casper/exact/client"; +import { ClientCasperSigner, createClientCasperSigner } from "@x402/casper"; import { createClientHederaSigner, PrivateKey as HederaPrivateKey } from "@x402/hedera"; import { ExactHederaScheme } from "@x402/hedera/exact/client"; import { ExactKeetaScheme } from "@x402/keeta/exact/client"; @@ -179,6 +181,14 @@ export async function createE2EClient(): Promise { aptosAccount = Account.fromPrivateKey({ privateKey: aptosPrivateKey }); } + let casperClientSigner: ClientCasperSigner | undefined; + if (process.env.CLIENT_CASPER_PRIVATE_KEY) { + casperClientSigner = await createClientCasperSigner( + process.env.CLIENT_CASPER_PRIVATE_KEY, + process.env.CLIENT_CASPER_PRIVATE_KEY_ALGORITHM === "secp256k1" ? 2 : 1, // Default to ED25519 if not specified + ); + } + let hederaClientSigner: ReturnType | undefined; if (process.env.CLIENT_HEDERA_ACCOUNT_ID && process.env.CLIENT_HEDERA_PRIVATE_KEY) { hederaClientSigner = createClientHederaSigner( @@ -215,16 +225,16 @@ export async function createE2EClient(): Promise { const tvmProvider = (process.env.TVM_PROVIDER || TVM_PROVIDER_TONCENTER).toLowerCase(); const tvmScheme = tvmPrivateKey ? new ExactTvmScheme( - toClientTvmSigner(parseTvmKeyPair(tvmPrivateKey), { - network: tvmNetwork, - provider: tvmProvider, - apiKey: - tvmProvider === TVM_PROVIDER_TONAPI - ? process.env.TVM_TONAPI_API_KEY - : process.env.TVM_TONCENTER_API_KEY, - providerBaseUrl: process.env.TVM_RPC_URL, - }), - ) + toClientTvmSigner(parseTvmKeyPair(tvmPrivateKey), { + network: tvmNetwork, + provider: tvmProvider, + apiKey: + tvmProvider === TVM_PROVIDER_TONAPI + ? process.env.TVM_TONAPI_API_KEY + : process.env.TVM_TONCENTER_API_KEY, + providerBaseUrl: process.env.TVM_RPC_URL, + }), + ) : undefined; if (ccdPrivateKey && ccdAddress) { @@ -245,6 +255,12 @@ export async function createE2EClient(): Promise { client: new ExactAptosScheme(aptosAccount), }); } + if (casperClientSigner) { + schemes.push({ + network: networkCaip2Pattern("casper"), + client: new ExactCasperScheme(casperClientSigner), + }); + } if (hederaClientSigner) { schemes.push({ network: networkCaip2Pattern("hedera"), diff --git a/e2e/clients/typescript/package.json b/e2e/clients/typescript/package.json index 9477902f8f..e1d0adb9ec 100644 --- a/e2e/clients/typescript/package.json +++ b/e2e/clients/typescript/package.json @@ -25,6 +25,7 @@ "@x402/avm": "workspace:*", "@x402/axios": "workspace:*", "@x402/cardano": "workspace:*", + "@x402/casper": "workspace:*", "@x402/concordium": "workspace:*", "@x402/core": "workspace:*", "@x402/evm": "workspace:*", @@ -47,4 +48,4 @@ "tsx": "^4.21.0", "typescript": "^5.7.3" } -} +} \ No newline at end of file diff --git a/e2e/config/mechanisms_casper.json b/e2e/config/mechanisms_casper.json new file mode 100644 index 0000000000..4ef0abcd86 --- /dev/null +++ b/e2e/config/mechanisms_casper.json @@ -0,0 +1,102 @@ +{ + "env": { + "SERVER_CASPER_ADDRESS": { + "required": true, + "roles": [ + "server" + ] + }, + "SERVER_CASPER_AMOUNT": { + "required": false, + "roles": [ + "server" + ] + }, + "SERVER_CASPER_ASSET": { + "required": false, + "roles": [ + "server" + ] + }, + "SERVER_CASPER_TOKEN_NAME": { + "required": false, + "roles": [ + "server" + ] + }, + "SERVER_CASPER_TOKEN_DECIMALS": { + "required": false, + "roles": [ + "server" + ] + }, + "SERVER_CASPER_TOKEN_VERSION": { + "required": false, + "roles": [ + "server" + ] + }, + "CLIENT_CASPER_PRIVATE_KEY": { + "required": true, + "roles": [ + "client" + ] + }, + "CLIENT_CASPER_PRIVATE_KEY_ALGORITHM": { + "required": false, + "roles": [ + "client" + ] + }, + "FACILITATOR_CASPER_PRIVATE_KEY": { + "required": true, + "roles": [ + "facilitator" + ] + }, + "FACILITATOR_CASPER_PRIVATE_KEY_ALGORITHM": { + "required": false, + "roles": [ + "facilitator" + ] + } + }, + "testnet": { + "name": "Casper Testnet", + "caip2": "casper:casper-test", + "rpcUrlDefault": "https://node.testnet.casper.network/rpc" + }, + "mainnet": { + "name": "Casper", + "caip2": "casper:casper", + "rpcUrlDefault": "https://node.mainnet.casper.network/rpc" + }, + "routes": { + "/exact/casper": { + "scheme": "exact", + "sdks": [ + "typescript" + ], + "price": { + "amount": "10000000", + "amountEnv": "SERVER_CASPER_AMOUNT", + "asset": "0cb6f94834c60510d532b0ae077b18b4100874a4c867396d61c2b13c790ead52", + "assetEnv": "SERVER_CASPER_ASSET", + "extraEnv": { + "name": { + "env": "SERVER_CASPER_TOKEN_NAME" + }, + "decimals": { + "env": "SERVER_CASPER_TOKEN_DECIMALS" + }, + "version": { + "env": "SERVER_CASPER_TOKEN_VERSION" + } + } + }, + "extensions": [ + "bazaar" + ] + } + } +} \ No newline at end of file diff --git a/e2e/facilitators/typescript/index.ts b/e2e/facilitators/typescript/index.ts index f8be580ff3..ccdc039fe7 100644 --- a/e2e/facilitators/typescript/index.ts +++ b/e2e/facilitators/typescript/index.ts @@ -21,6 +21,8 @@ import { base58 } from "@scure/base"; import { createKeyPairSignerFromBytes } from "@solana/kit"; import { toFacilitatorAptosSigner } from "@x402/aptos"; import { ExactAptosScheme } from "@x402/aptos/exact/facilitator"; +import { FacilitatorCasperSigner, createFacilitatorCasperSigner } from "@x402/casper"; +import { ExactCasperScheme } from "@x402/casper/exact/facilitator"; import { toFacilitatorAvmSigner } from "@x402/avm"; import { ExactAvmScheme } from "@x402/avm/exact/facilitator"; import { x402Facilitator } from "@x402/core/facilitator"; @@ -116,6 +118,7 @@ const KEETA_NETWORK = resolveNetworkCaip2("keeta"); const STELLAR_NETWORK = resolveNetworkCaip2("stellar"); const TVM_NETWORK = resolveNetworkCaip2("tvm"); const NEAR_NETWORK = resolveNetworkCaip2("near"); +const CASPER_NETWORK = resolveNetworkCaip2("casper"); const NEAR_RPC_URL = process.env.NEAR_RPC_URL; const XRPL_NETWORK = resolveNetworkCaip2("xrpl"); const XRPL_RPC_URL = process.env.XRPL_RPC_URL; @@ -128,6 +131,7 @@ const EVM_RPC_URL = process.env.EVM_RPC_URL; const SVM_RPC_URL = process.env.SVM_RPC_URL; const AVM_RPC_URL = process.env.AVM_RPC_URL; const APTOS_RPC_URL = process.env.APTOS_RPC_URL; +const CASPER_RPC_URL = process.env.CASPER_RPC_URL; const HEDERA_RPC_URL = process.env.HEDERA_RPC_URL; const STELLAR_RPC_URL = process.env.STELLAR_RPC_URL; const TVM_PROVIDER = (process.env.TVM_PROVIDER || TVM_PROVIDER_TONCENTER).toLowerCase(); @@ -146,6 +150,7 @@ function getEvmChain(network: string): Chain { console.log(`🌐 EVM Network: ${EVM_NETWORK}`); console.log(`🌐 SVM Network: ${SVM_NETWORK}`); console.log(`🌐 Aptos Network: ${APTOS_NETWORK}`); +console.log(`🌐 Casper Network: ${CASPER_NETWORK}`); console.log(`🌐 AVM Network: ${AVM_NETWORK}`); console.log(`🌐 Hedera Network: ${HEDERA_NETWORK}`); console.log(`🌐 Keeta Network: ${KEETA_NETWORK}`); @@ -159,6 +164,7 @@ if (SVM_RPC_URL) console.log(`🌐 SVM RPC URL: ${SVM_RPC_URL}`); if (AVM_RPC_URL) console.log(`🌐 AVM RPC URL: ${AVM_RPC_URL}`); if (APTOS_RPC_URL) console.log(`🌐 Aptos RPC URL: ${APTOS_RPC_URL}`); if (HEDERA_RPC_URL) console.log(`🌐 Hedera Node URL: ${HEDERA_RPC_URL}`); +if (CASPER_RPC_URL) console.log(`🌐 Casper RPC URL: ${CASPER_RPC_URL}`); if (STELLAR_RPC_URL) console.log(`🌐 Stellar RPC URL: ${STELLAR_RPC_URL}`); console.log(`🌐 TVM Provider: ${TVM_PROVIDER}`); @@ -166,6 +172,7 @@ const hasFacilitatorCredential = [ process.env.FACILITATOR_EVM_PRIVATE_KEY, process.env.FACILITATOR_SVM_PRIVATE_KEY, process.env.FACILITATOR_APTOS_PRIVATE_KEY, + process.env.FACILITATOR_CASPER_PRIVATE_KEY && process.env.FACILITATOR_CASPER_PRIVATE_KEY_ALGORITHM, process.env.FACILITATOR_AVM_PRIVATE_KEY, process.env.FACILITATOR_HEDERA_ACCOUNT_ID && process.env.FACILITATOR_HEDERA_PRIVATE_KEY, process.env.FACILITATOR_KEETA_MNEMONIC, @@ -185,8 +192,8 @@ if (!hasFacilitatorCredential) { // Initialize the EVM account from private key when configured const evmAccount = process.env.FACILITATOR_EVM_PRIVATE_KEY ? privateKeyToAccount(process.env.FACILITATOR_EVM_PRIVATE_KEY as `0x${string}`, { - nonceManager, - }) + nonceManager, + }) : undefined; if (evmAccount) { console.info(`EVM Facilitator account: ${evmAccount.address}`); @@ -196,12 +203,12 @@ if (evmAccount) { // delegate to this address or supply their own (SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY). const authorizerSigner: AuthorizerSigner | undefined = evmAccount ? { - address: evmAccount.address, - signTypedData: (params) => - evmAccount.signTypedData( - params as Parameters[0], - ), - } + address: evmAccount.address, + signTypedData: (params) => + evmAccount.signTypedData( + params as Parameters[0], + ), + } : undefined; if (authorizerSigner) { console.info(`EVM Receiver Authorizer: ${authorizerSigner.address}`); @@ -210,8 +217,8 @@ if (authorizerSigner) { // Initialize the SVM account from private key when configured const svmAccount = process.env.FACILITATOR_SVM_PRIVATE_KEY ? await createKeyPairSignerFromBytes( - base58.decode(process.env.FACILITATOR_SVM_PRIVATE_KEY as string), - ) + base58.decode(process.env.FACILITATOR_SVM_PRIVATE_KEY as string), + ) : undefined; if (svmAccount) { console.info(`SVM Facilitator account: ${svmAccount.address}`); @@ -238,6 +245,21 @@ if (process.env.FACILITATOR_AVM_PRIVATE_KEY) { console.info(`AVM Facilitator account: ${avmSigner.getAddresses()[0]}`); } +let casperFacilitatorSigner: FacilitatorCasperSigner | undefined; +if (process.env.FACILITATOR_CASPER_PRIVATE_KEY) { + casperFacilitatorSigner = await createFacilitatorCasperSigner( + process.env.FACILITATOR_CASPER_PRIVATE_KEY, + process.env.FACILITATOR_CASPER_PRIVATE_KEY_ALGORITHM === "secp256k1" ? 2 : 1, // Default to ED25519 if not specified); + { + rpcUrlConfig: CASPER_RPC_URL ? { [`${CASPER_NETWORK}`]: CASPER_RPC_URL } : undefined, + speculativeRpcUrlConfig: process.env.CASPER_SPECULATIVE_RPC_URL + ? { [`${CASPER_NETWORK}`]: process.env.CASPER_SPECULATIVE_RPC_URL } + : undefined, + }, + ); + console.info(`Casper Facilitator account: ${casperFacilitatorSigner.getAddresses(CASPER_NETWORK as Network)[0]}`); +} + // Initialize the Hedera signer from account + private key (optional) let hederaSigner: ReturnType | undefined; if (process.env.FACILITATOR_HEDERA_ACCOUNT_ID && process.env.FACILITATOR_HEDERA_PRIVATE_KEY) { @@ -345,60 +367,60 @@ if (process.env.FACILITATOR_CCD_PRIVATE_KEY && process.env.FACILITATOR_CCD_ADDRE const evmChain = getEvmChain(EVM_NETWORK); const viemClient = evmAccount ? createWalletClient({ - account: evmAccount, - chain: evmChain, - transport: http(EVM_RPC_URL), - }).extend(publicActions) + account: evmAccount, + chain: evmChain, + transport: http(EVM_RPC_URL), + }).extend(publicActions) : undefined; const evmSigner = evmAccount && viemClient ? toFacilitatorEvmSigner({ - address: evmAccount.address, - readContract: (args: { - address: `0x${string}`; - abi: readonly unknown[]; - functionName: string; - args?: readonly unknown[]; - }) => - viemClient.readContract({ - ...args, - args: args.args || [], - }), - verifyTypedData: (args: { - address: `0x${string}`; - domain: Record; - types: Record; - primaryType: string; - message: Record; - signature: `0x${string}`; - }) => viemClient.verifyTypedData(args as any), - writeContract: (args: { - address: `0x${string}`; - abi: readonly unknown[]; - functionName: string; - args: readonly unknown[]; - gas?: bigint; - }) => - viemClient.writeContract({ - ...args, - args: args.args || [], - gas: args.gas, - }), - sendTransaction: (args: { to: `0x${string}`; data: `0x${string}` }) => - viemClient.sendTransaction(args), - waitForTransactionReceipt: (args: { hash: `0x${string}` }) => - viemClient.waitForTransactionReceipt(args), - getCode: (args: { address: `0x${string}` }) => viemClient.getCode(args), - }) + address: evmAccount.address, + readContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + }) => + viemClient.readContract({ + ...args, + args: args.args || [], + }), + verifyTypedData: (args: { + address: `0x${string}`; + domain: Record; + types: Record; + primaryType: string; + message: Record; + signature: `0x${string}`; + }) => viemClient.verifyTypedData(args as any), + writeContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args: readonly unknown[]; + gas?: bigint; + }) => + viemClient.writeContract({ + ...args, + args: args.args || [], + gas: args.gas, + }), + sendTransaction: (args: { to: `0x${string}`; data: `0x${string}` }) => + viemClient.sendTransaction(args), + waitForTransactionReceipt: (args: { hash: `0x${string}` }) => + viemClient.waitForTransactionReceipt(args), + getCode: (args: { address: `0x${string}` }) => viemClient.getCode(args), + }) : undefined; // Facilitator can now handle all Solana networks with automatic RPC creation // Pass custom RPC URL if provided const svmSigner = svmAccount ? toFacilitatorSvmSigner( - svmAccount, - SVM_RPC_URL ? { defaultRpcUrl: SVM_RPC_URL } : undefined, - ) + svmAccount, + SVM_RPC_URL ? { defaultRpcUrl: SVM_RPC_URL } : undefined, + ) : undefined; // Facilitator can handle all Aptos networks with automatic RPC creation @@ -597,6 +619,12 @@ if (aptosSigner) { new ExactAptosScheme(aptosSigner), ); } +if (casperFacilitatorSigner) { + facilitator.register( + CASPER_NETWORK as Network, + new ExactCasperScheme(casperFacilitatorSigner), + ); +} if (hederaSigner) { facilitator.register( HEDERA_NETWORK as Network, @@ -980,6 +1008,7 @@ app.get("/health", (req, res) => { svmNetwork: SVM_NETWORK, avmNetwork: avmSigner ? AVM_NETWORK : "(not configured)", aptosNetwork: aptosAccount ? APTOS_NETWORK : "(not configured)", + casperNetwork: casperFacilitatorSigner ? CASPER_NETWORK : "(not configured)", hederaNetwork: hederaSigner ? HEDERA_NETWORK : "(not configured)", keetaNetwork: process.env.FACILITATOR_KEETA_MNEMONIC ? KEETA_NETWORK : "(not configured)", stellarNetwork: stellarSigner ? STELLAR_NETWORK : "(not configured)", @@ -1020,6 +1049,7 @@ let server = app.listen(parseInt(PORT), () => { ║ SVM Network: ${SVM_NETWORK} ║ ║ AVM Network: ${AVM_NETWORK} ║ ║ Aptos Network: ${APTOS_NETWORK} ║ +║ Casper Network: ${CASPER_NETWORK} ║ ║ Hedera Network: ${HEDERA_NETWORK} ║ ║ Keeta Network: ${KEETA_NETWORK} ║ ║ NEAR Network: ${NEAR_NETWORK} ║ diff --git a/e2e/facilitators/typescript/package.json b/e2e/facilitators/typescript/package.json index c64bd3e277..3737767d5e 100644 --- a/e2e/facilitators/typescript/package.json +++ b/e2e/facilitators/typescript/package.json @@ -20,6 +20,7 @@ "@x402/aptos": "workspace:*", "@x402/avm": "workspace:*", "@x402/cardano": "workspace:*", + "@x402/casper": "workspace:*", "@x402/concordium": "workspace:*", "@x402/core": "workspace:*", "@x402/evm": "workspace:*", @@ -43,4 +44,4 @@ "tsx": "^4.21.0", "typescript": "^5.7.3" } -} +} \ No newline at end of file diff --git a/e2e/facilitators/typescript/tsconfig.json b/e2e/facilitators/typescript/tsconfig.json index e85a5d7c24..fc0e5250ed 100644 --- a/e2e/facilitators/typescript/tsconfig.json +++ b/e2e/facilitators/typescript/tsconfig.json @@ -3,9 +3,7 @@ "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", - "lib": [ - "ES2022" - ], + "lib": ["ES2022"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, @@ -16,11 +14,6 @@ "sourceMap": true, "outDir": "./dist" }, - "include": [ - "*.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/e2e/package.json b/e2e/package.json index 1c07adbb35..288c8e88e1 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -33,6 +33,8 @@ "@x402/aptos": "workspace:*", "@x402/avm": "workspace:*", "@x402/cardano": "workspace:*", + "@x402/casper": "workspace:*", + "casper-js-sdk": "5.1.1", "@x402/concordium": "workspace:*", "@x402/core": "workspace:*", "@x402/evm": "workspace:*", @@ -57,4 +59,4 @@ "engines": { "node": ">=22.0.0" } -} +} \ No newline at end of file diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index 0bcefa903f..bd1f10924c 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: '@x402/cardano': specifier: workspace:* version: link:../typescript/packages/mechanisms/cardano + '@x402/casper': + specifier: workspace:* + version: link:../typescript/packages/mechanisms/casper '@x402/concordium': specifier: workspace:* version: link:../typescript/packages/mechanisms/concordium @@ -73,6 +76,9 @@ importers: '@x402/tvm': specifier: workspace:* version: link:../typescript/packages/mechanisms/tvm + casper-js-sdk: + specifier: 5.1.1 + version: 5.1.1 dotenv: specifier: ^17.0.1 version: 17.0.1 @@ -1486,6 +1492,70 @@ importers: specifier: ^3.2.7 version: 3.2.7(@types/debug@4.1.12)(@types/node@22.16.0)(jiti@1.21.7)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + ../typescript/packages/mechanisms/casper: + dependencies: + '@casper-ecosystem/casper-eip-712': + specifier: 1.2.1 + version: 1.2.1 + '@x402/core': + specifier: workspace:~ + version: link:../../core + casper-js-sdk: + specifier: 5.1.1 + version: 5.1.1 + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.38.0 + '@types/node': + specifier: ^22.13.4 + version: 22.16.0 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.38.0(jiti@1.21.7))(typescript@5.8.3))(eslint@9.38.0(jiti@1.21.7))(typescript@5.8.3) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.48.0(eslint@9.38.0(jiti@1.21.7))(typescript@5.8.3) + '@vitest/coverage-v8': + specifier: ^3.2.7 + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.16.0)(jiti@1.21.7)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + eslint: + specifier: ^9.24.0 + version: 9.38.0(jiti@1.21.7) + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.38.0(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.38.0(jiti@1.21.7)) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.38.0(jiti@1.21.7)) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.38.0(jiti@1.21.7))(prettier@3.5.2) + eslint-plugin-sonarjs: + specifier: ^3.0.7 + version: 3.0.7(eslint@9.38.0(jiti@1.21.7)) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsup: + specifier: ^8.4.0 + version: 8.5.0(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.8.3)(yaml@2.8.1) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.8.3 + vite: + specifier: ^6.2.6 + version: 6.4.1(@types/node@22.16.0)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.8.3)(vite@6.4.1(@types/node@22.16.0)(jiti@1.21.7)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.12)(@types/node@22.16.0)(jiti@1.21.7)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + ../typescript/packages/mechanisms/concordium: dependencies: '@concordium/web-sdk': @@ -2130,6 +2200,9 @@ importers: '@x402/cardano': specifier: workspace:* version: link:../../../typescript/packages/mechanisms/cardano + '@x402/casper': + specifier: workspace:* + version: link:../../../typescript/packages/mechanisms/casper '@x402/concordium': specifier: workspace:* version: link:../../../typescript/packages/mechanisms/concordium @@ -2215,6 +2288,9 @@ importers: '@x402/cardano': specifier: workspace:* version: link:../../../typescript/packages/mechanisms/cardano + '@x402/casper': + specifier: workspace:* + version: link:../../../typescript/packages/mechanisms/casper '@x402/concordium': specifier: workspace:* version: link:../../../typescript/packages/mechanisms/concordium @@ -2569,6 +2645,9 @@ importers: '@x402/cardano': specifier: workspace:* version: link:../../../typescript/packages/mechanisms/cardano + '@x402/casper': + specifier: workspace:* + version: link:../../../typescript/packages/mechanisms/casper '@x402/concordium': specifier: workspace:* version: link:../../../typescript/packages/mechanisms/concordium @@ -2651,6 +2730,9 @@ importers: '@x402/cardano': specifier: workspace:* version: link:../../../../../typescript/packages/mechanisms/cardano + '@x402/casper': + specifier: workspace:* + version: link:../../../../../typescript/packages/mechanisms/casper '@x402/concordium': specifier: workspace:* version: link:../../../../../typescript/packages/mechanisms/concordium @@ -3320,6 +3402,12 @@ packages: peerDependencies: algosdk: ^3.0.0 + '@casper-ecosystem/casper-eip-712@1.2.1': + resolution: {integrity: sha512-qhrpaISByIoOnbELH7hhLMVBinIP1vOc4Z+6ZI5aItcc1O3SrDXNu0fdKm5qRSLLb5p94JxaS+jGegiIAwiKDA==} + + '@casperlabs/ts-results@3.3.5': + resolution: {integrity: sha512-ymSQqqb4mOSet592li02u1Gd28LoOFJUm6R3jkdNQ+nqsnbHvN+izBigtP4aYmNwh6gFyCwDgjYporEJgDT4eA==} + '@coinbase/cdp-sdk@1.38.4': resolution: {integrity: sha512-xVvfluaGt0NxNmjElP3C1yI6KnsGwihabdXj+qNtjjsSmd/Ha2V3gAiCyNrrYOqIORn4mpC2jgu2fUFEDaMpaw==} @@ -3828,6 +3916,18 @@ packages: resolution: {integrity: sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA==} engines: {node: '>=14'} + '@ethersproject/bignumber@5.8.0': + resolution: {integrity: sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==} + + '@ethersproject/bytes@5.8.0': + resolution: {integrity: sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==} + + '@ethersproject/constants@5.8.0': + resolution: {integrity: sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==} + + '@ethersproject/logger@5.8.0': + resolution: {integrity: sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==} + '@evanhahn/lottie-web-light@5.8.1': resolution: {integrity: sha512-U0G1tt3/UEYnyCNNslWPi1dB7X1xQ9aoSip+B3GTKO/Bns8yz/p39vBkRSN9d25nkbHuCsbjky2coQftj5YVKw==} @@ -4732,6 +4832,9 @@ packages: resolution: {integrity: sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==} engines: {node: '>= 20.19.0'} + '@noble/ed25519@1.7.5': + resolution: {integrity: sha512-xuS0nwRMQBvSxDa7UxMb61xTiH3MxTgUfhyPUALVIe0FlOAz4sjELwyDRyUvqeEYfRSG9qNjFIycqLZppg4RSA==} + '@noble/ed25519@2.3.0': resolution: {integrity: sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==} @@ -4773,6 +4876,9 @@ packages: resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} engines: {node: '>= 20.19.0'} + '@noble/secp256k1@1.7.2': + resolution: {integrity: sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -7533,6 +7639,9 @@ packages: asn1.js@4.10.1: resolution: {integrity: sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + asn1js@3.0.6: resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} engines: {node: '>=12.0.0'} @@ -7586,6 +7695,9 @@ packages: axios@1.18.0: resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -7680,6 +7792,9 @@ packages: bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} + bn.js@5.2.5: + resolution: {integrity: sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==} + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -7833,6 +7948,10 @@ packages: caniuse-lite@1.0.30001751: resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==} + casper-js-sdk@5.1.1: + resolution: {integrity: sha512-KeKaBzvi+nK/1etL4D1nCPT8L7h+oBa3KXlB3ImifGlkDJwgBphva7Ep7e7fnhr9b2gFCWgDnJ0suTDl6ymbYQ==} + engines: {node: '>=18'} + cbor2@1.12.0: resolution: {integrity: sha512-3Cco8XQhi27DogSp9Ri6LYNZLi/TBY/JVnDe+mj06NkBjW/ZYOtekaEU4wZ4xcRMNrFkDv8KNtOAqHyDfz3lYg==} engines: {node: '>=18.7'} @@ -8661,6 +8780,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource@2.0.2: + resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} + engines: {node: '>=12.0.0'} + eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} @@ -9125,6 +9248,9 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + humanize-duration@3.34.1: + resolution: {integrity: sha512-YIiigjQ+O31rvcyDJPK1ptTZtvSErjBtmHy93VhbwxB/VwMG0FszKs2IB667tFxmOhNapePQQxa24luQRDkKFQ==} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -11464,6 +11590,9 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + typedjson@1.8.0: + resolution: {integrity: sha512-taVJVGebQDagEmVc3Cu6vVVLkWLnxqPcTrkVgbpAsI02ZDDrnHy5zvt1JVqXv4/yztBgZAX1oR07+bkiusGJLQ==} + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -12914,6 +13043,15 @@ snapshots: - bufferutil - utf-8-validate + '@casper-ecosystem/casper-eip-712@1.2.1': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + '@casperlabs/ts-results@3.3.5': + dependencies: + tslib: 2.8.1 + '@coinbase/cdp-sdk@1.38.4(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana-program/system': 0.8.1(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) @@ -12986,7 +13124,7 @@ snapshots: '@coinbase/wallet-sdk@3.9.3': dependencies: - bn.js: 5.2.3 + bn.js: 5.2.5 buffer: 6.0.3 clsx: 1.2.1 eth-block-tracker: 7.1.0 @@ -13414,6 +13552,22 @@ snapshots: ethereum-cryptography: 2.2.1 micro-ftch: 0.3.1 + '@ethersproject/bignumber@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + bn.js: 5.2.3 + + '@ethersproject/bytes@5.8.0': + dependencies: + '@ethersproject/logger': 5.8.0 + + '@ethersproject/constants@5.8.0': + dependencies: + '@ethersproject/bignumber': 5.8.0 + + '@ethersproject/logger@5.8.0': {} + '@evanhahn/lottie-web-light@5.8.1': {} '@evolution-sdk/evolution@0.5.12(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(bufferutil@4.0.9)(utf-8-validate@5.0.10)': @@ -14407,6 +14561,8 @@ snapshots: dependencies: '@noble/hashes': 2.3.0 + '@noble/ed25519@1.7.5': {} + '@noble/ed25519@2.3.0': {} '@noble/ed25519@3.0.1': {} @@ -14429,6 +14585,8 @@ snapshots: '@noble/hashes@2.3.0': {} + '@noble/secp256k1@1.7.2': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -19737,6 +19895,13 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.2 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + asn1js@3.0.6: dependencies: pvtsutils: 1.3.6 @@ -19805,6 +19970,16 @@ snapshots: - debug - supports-color + axios@1.20.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + axobject-query@4.1.0: {} babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.4): @@ -19887,6 +20062,8 @@ snapshots: bn.js@5.2.3: {} + bn.js@5.2.5: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -20091,6 +20268,26 @@ snapshots: caniuse-lite@1.0.30001751: {} + casper-js-sdk@5.1.1: + dependencies: + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@noble/curves': 1.9.7 + '@noble/ed25519': 1.7.5 + '@noble/hashes': 1.8.0 + '@noble/secp256k1': 1.7.2 + asn1.js: 5.4.1 + axios: 1.20.0 + bn.js: 5.2.5 + eventsource: 2.0.2 + humanize-duration: 3.34.1 + ts-results: '@casperlabs/ts-results@3.3.5' + typedjson: 1.8.0 + transitivePeerDependencies: + - debug + - supports-color + cbor2@1.12.0: {} chai@5.3.3: @@ -21189,6 +21386,8 @@ snapshots: eventsource-parser@3.0.6: {} + eventsource@2.0.2: {} + eventsource@3.0.7: dependencies: eventsource-parser: 3.0.6 @@ -21828,6 +22027,8 @@ snapshots: transitivePeerDependencies: - supports-color + humanize-duration@3.34.1: {} + humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -24695,6 +24896,10 @@ snapshots: dependencies: is-typedarray: 1.0.0 + typedjson@1.8.0: + dependencies: + tslib: 2.8.1 + typescript@5.8.3: {} typia@9.5.0(@types/node@22.16.0)(typescript@5.8.3): diff --git a/e2e/scripts/print-wallet-status.ts b/e2e/scripts/print-wallet-status.ts index 610fc571eb..32d702887e 100644 --- a/e2e/scripts/print-wallet-status.ts +++ b/e2e/scripts/print-wallet-status.ts @@ -29,6 +29,14 @@ import { toClientCardanoSigner, toFacilitatorCardanoSigner, } from "@x402/cardano"; +import { + createClientCasperSigner, + createFacilitatorCasperSigner, + dictionaryKeyForAddress, + getActiveContractForToken, + getDefaultAsset as getCasperDefaultAsset, + readDictionaryU256OrDefault, +} from "@x402/casper"; import { getDefaultAsset as getAvmDefaultAsset, toClientAvmSigner } from "@x402/avm"; import { getConcordiumGrpcUrl, @@ -58,6 +66,7 @@ import { TVM_PROVIDER_TONAPI, TVM_PROVIDER_TONCENTER, } from "@x402/tvm"; +import { HttpHandler, KeyAlgorithm, RpcClient } from "casper-js-sdk"; import { config } from "dotenv"; import { createPublicClient, formatEther, formatUnits, http, parseAbi } from "viem"; import { privateKeyToAccount } from "viem/accounts"; @@ -708,6 +717,115 @@ async function reportCardano(mode: NetworkMode): Promise { }; } +const CSPR_DECIMALS = 9; +const CASPER_BALANCES_DICTIONARY = "balances"; + +function casperKeyAlgorithm(algorithmEnvKey: string): KeyAlgorithm { + return env(algorithmEnvKey) === "secp256k1" ? KeyAlgorithm.SECP256K1 : KeyAlgorithm.ED25519; +} + +function normalizeCasperAssetPackageHash(asset: string): string { + return asset.trim().replace(/^(hash-|contract-)/, ""); +} + +async function casperCsprBalance(rpcUrl: string, publicKeyHex: string): Promise { + return withBalance("CSPR", async () => { + const rootResult = await rpcJson(rpcUrl, "chain_get_state_root_hash", []); + if (!isRecord(rootResult)) { + throw new Error("unexpected Casper state root response"); + } + const stateRootHash = String(rootResult.state_root_hash ?? ""); + if (!stateRootHash) { + throw new Error("Casper state root hash missing from RPC response"); + } + const balanceResult = await nearRpcJson(rpcUrl, "state_get_balance", { + state_root_hash: stateRootHash, + purse_identifier: { main_purse_under_public_key: publicKeyHex }, + }); + if (!isRecord(balanceResult)) { + throw new Error("unexpected Casper balance response"); + } + const motes = BigInt(String(balanceResult.balance_value ?? balanceResult.balance ?? 0)); + return formatAmount(motes, CSPR_DECIMALS); + }); +} + +async function casperTokenBalance( + rpcUrl: string, + assetPackageHash: string, + accountAddress: string, + decimals: number, + symbol: string, +): Promise { + return withBalance(symbol, async () => { + const rpcClient = new RpcClient(new HttpHandler(rpcUrl)); + const contract = await getActiveContractForToken( + rpcClient, + normalizeCasperAssetPackageHash(assetPackageHash), + ); + const raw = await readDictionaryU256OrDefault( + rpcClient, + contract.contractHash, + CASPER_BALANCES_DICTIONARY, + dictionaryKeyForAddress(accountAddress), + ); + return formatAmount(raw, decimals); + }); +} + +async function reportCasper(mode: NetworkMode): Promise { + const net = getNetworkForProtocol(mode, "casper"); + const rpcUrl = net.rpcUrl; + const rpcUrlConfig = { [net.caip2]: rpcUrl }; + + const facilitatorSigner = await createFacilitatorCasperSigner( + requireEnv("FACILITATOR_CASPER_PRIVATE_KEY"), + casperKeyAlgorithm("FACILITATOR_CASPER_PRIVATE_KEY_ALGORITHM"), + { rpcUrlConfig }, + ); + const clientSigner = await createClientCasperSigner( + requireEnv("CLIENT_CASPER_PRIVATE_KEY"), + casperKeyAlgorithm("CLIENT_CASPER_PRIVATE_KEY_ALGORITHM"), + ); + + const facilitatorAddress = facilitatorSigner.getAddresses(net.caip2)[0] ?? "(unknown)"; + const clientAddress = clientSigner.accountAddress(); + const server = requireEnv(serverAddressKey("casper")); + + const paymentAsset = + env("SERVER_CASPER_ASSET") ?? env("CASPER_ASSET") ?? getCasperDefaultAsset(net.caip2).asset; + const paymentMeta = + env("SERVER_CASPER_ASSET") || env("CASPER_ASSET") + ? { + symbol: env("CASPER_TOKEN_NAME") ?? paymentAsset.slice(0, 12), + decimals: Number(env("SERVER_CASPER_TOKEN_DECIMALS") ?? env("CASPER_TOKEN_DECIMALS") ?? 6), + } + : getCasperDefaultAsset(net.caip2); + + const native = await casperCsprBalance( + rpcUrl, + facilitatorSigner.getPublicKeyHex(net.caip2), + ); + const payment = await casperTokenBalance( + rpcUrl, + paymentAsset, + clientAddress, + paymentMeta.decimals, + paymentMeta.symbol, + ); + + return { + family: "casper", + networkName: net.name, + caip2: net.caip2, + rows: [ + { role: "facilitator", address: facilitatorAddress, balance: native }, + { role: "client", address: clientAddress, balance: payment }, + { role: "server", address: server }, + ], + }; +} + async function reportNear(mode: NetworkMode): Promise { const net = getNetworkForProtocol(mode, "near"); const facilitator = requireEnv("FACILITATOR_NEAR_ACCOUNT_ID"); @@ -811,6 +929,7 @@ const HANDLERS: Record Promise> = { near: reportNear, xrpl: reportXrpl, cardano: reportCardano, + casper: reportCasper, }; async function reportFamily(family: ProtocolFamily, mode: NetworkMode): Promise { diff --git a/e2e/servers/typescript/config.ts b/e2e/servers/typescript/config.ts index ba86f992b2..2d58a9ade8 100644 --- a/e2e/servers/typescript/config.ts +++ b/e2e/servers/typescript/config.ts @@ -7,6 +7,7 @@ import { UptoSvmScheme } from "@x402/svm/upto/server"; import { base58 } from "@scure/base"; import { createKeyPairSignerFromBytes } from "@solana/kit"; import { ExactAptosScheme } from "@x402/aptos/exact/server"; +import { ExactCasperScheme } from "@x402/casper/exact/server"; import { ExactHederaScheme } from "@x402/hedera/exact/server"; import { ExactKeetaScheme } from "@x402/keeta/exact/server"; import { ExactStellarScheme } from "@x402/stellar/exact/server"; @@ -122,6 +123,9 @@ async function registerFamilySchemes( case "aptos": server.register(pattern, new ExactAptosScheme()); return; + case "casper": + server.register(pattern, new ExactCasperScheme()); + return; case "hedera": server.register(pattern, new ExactHederaScheme()); return; diff --git a/e2e/servers/typescript/http/next/package.json b/e2e/servers/typescript/http/next/package.json index 7854c3c973..b04adb1b34 100644 --- a/e2e/servers/typescript/http/next/package.json +++ b/e2e/servers/typescript/http/next/package.json @@ -11,7 +11,8 @@ "dependencies": { "@x402/aptos": "workspace:*", "@x402/avm": "workspace:*", - "@x402/cardano": "workspace:*", + "@x402/cardano": "workspace:*", + "@x402/casper": "workspace:*", "@x402/concordium": "workspace:*", "@x402/core": "workspace:*", "@x402/evm": "workspace:*", diff --git a/e2e/servers/typescript/package.json b/e2e/servers/typescript/package.json index c094ce1bfe..6eebb5231c 100644 --- a/e2e/servers/typescript/package.json +++ b/e2e/servers/typescript/package.json @@ -12,7 +12,8 @@ "@modelcontextprotocol/sdk": "^1.15.1", "@x402/aptos": "workspace:*", "@x402/avm": "workspace:*", - "@x402/cardano": "workspace:*", + "@x402/casper": "workspace:*", + "@x402/cardano": "workspace:*", "@x402/concordium": "workspace:*", "@x402/core": "workspace:*", "@x402/evm": "workspace:*", diff --git a/examples/go/clients/batch-settlement/README.md b/examples/go/clients/batch-settlement/README.md index 11c1c012e8..8a2302c3ec 100644 --- a/examples/go/clients/batch-settlement/README.md +++ b/examples/go/clients/batch-settlement/README.md @@ -24,7 +24,19 @@ Use this when: ## Deposit policy -The default per-request deposit is `payment amount × DEPOSIT_MULTIPLIER` (default `5`). For app-specific deposit decisions (caps, dynamic adjustments, opting out), pass a `DepositStrategy` callback to `BatchSettlementEvmSchemeOptions`: +The client deposits `extra.minDeposit` when the server announced a valid hint, otherwise `amount × DEPOSIT_MULTIPLIER` (default `5`, minimum `3`). + +`x402Client` spend controls still cap each request's `amount` (default `$1` on USDC). That same atomic cap is the escrow ceiling: + +`maxDeposit = maxAmountPerPayment × depositMultiplier` + +So the default `$1` cap and multiplier `5` lock at most `$5`. `DisableSpendControls()` (or any uncapped asset) leaves the deposit uncapped too. + +Use `DepositStrategy` only for app-specific decisions: + +- **empty result** — use the SDK default (`DepositAmount` in context). +- **`Skip: true`** — skip this deposit attempt. +- **base-unit `Amount`** — custom amount; must be **≥ `MinimumDepositAmount`**, and still respects `maxDeposit` when a spend cap is set. ```go cfg := &batchedclient.BatchSettlementEvmSchemeOptions{ @@ -51,7 +63,7 @@ cfg := &batchedclient.BatchSettlementEvmSchemeOptions{ | `RESOURCE_SERVER_URL` | no | Server base URL (default `http://localhost:4021`) | | `ENDPOINT_PATH` | no | Path on the server (default `/weather`) | | `CHANNEL_SALT` | no | 32-byte hex salt; change to open a fresh channel (default `0x00…00`) | -| `DEPOSIT_MULTIPLIER` | no | Per-request deposit is payment amount × this multiplier (must be integer ≥ 3; default `5`) | +| `DEPOSIT_MULTIPLIER` | no | Deposit target is `amount ×` this multiplier when `extra.minDeposit` is absent; lock ceiling is `spendCap ×` this multiplier (integer **≥ 3**; default `5`) | | `STORAGE_DIR` | no | If set, persists session state under `${STORAGE_DIR}/client/` | | `NUMBER_OF_REQUESTS` | no | How many paid requests to issue (default `3`) | | `REFUND_AFTER_REQUESTS` | no | If `"true"`, request a cooperative refund after the request loop completes | diff --git a/examples/go/clients/batch-settlement/main.go b/examples/go/clients/batch-settlement/main.go index 21244fda18..eb02714637 100644 --- a/examples/go/clients/batch-settlement/main.go +++ b/examples/go/clients/batch-settlement/main.go @@ -85,6 +85,11 @@ func main() { x402Client := x402.Newx402Client() x402Client.Register("eip155:*", scheme) + // Per-request cap on PaymentRequirements.amount (default "$1" if omitted). + // Deposit ceiling is this cap × depositMultiplier ($5 at the defaults). + x402Client.SetSpendControls(x402.SpendControls{ + MaxAmountPerPayment: "$1", + }) httpClient := x402http.WrapHTTPClientWithPayment(http.DefaultClient, x402http.Newx402HTTPClient(x402Client)) diff --git a/examples/go/servers/batch-settlement/README.md b/examples/go/servers/batch-settlement/README.md index 22807c17c3..b76390554e 100644 --- a/examples/go/servers/batch-settlement/README.md +++ b/examples/go/servers/batch-settlement/README.md @@ -2,7 +2,7 @@ Demo resource server using the batch-settlement scheme: a client opens a payment channel with a single deposit; subsequent paid requests update an off-chain voucher. The `ChannelManager` periodically claims and settles onchain. -The route demonstrates **dynamic pricing**: the client authorizes up to `$0.01` per request, and the handler bills a random fraction of that via `Settlement-Overrides`. +The route demonstrates **dynamic pricing**: the client authorizes up to `$0.01` per request, and the handler bills a random fraction of that via `Settlement-Overrides`. Every 402 also includes `extra.minDeposit` (SDK default `10 × amount`) so clients can size the channel deposit. Override per route with `accepts.extra.minDeposit` (`"$0.10"` on the default asset, or an atomic string). The server announces the hint only; it does not reject smaller deposits unless you set `EnforceMinDeposit: true`. ## Run diff --git a/examples/go/servers/batch-settlement/main.go b/examples/go/servers/batch-settlement/main.go index 699d8a9e93..c8e50beca8 100644 --- a/examples/go/servers/batch-settlement/main.go +++ b/examples/go/servers/batch-settlement/main.go @@ -54,7 +54,8 @@ func main() { storageDir := os.Getenv("STORAGE_DIR") cfg := &batchedserver.BatchSettlementEvmSchemeServerConfig{ - WithdrawDelay: withdrawDelay, + WithdrawDelay: withdrawDelay, + EnforceMinDeposit: false, } if receiverAuthKey != "" { signer, err := newReceiverAuthorizerSigner(receiverAuthKey) @@ -125,6 +126,8 @@ func main() { Price: maxPrice, Network: network, PayTo: evmAddress, + // Optional: override the default 10× deposit hint (Money strings require the network default asset). + // Extra: map[string]interface{}{"minDeposit": "$0.10"}, }, }, Description: "Weather data", diff --git a/examples/python/clients/batch-settlement/README.md b/examples/python/clients/batch-settlement/README.md index d7800cbf0f..d0c060cd0a 100644 --- a/examples/python/clients/batch-settlement/README.md +++ b/examples/python/clients/batch-settlement/README.md @@ -12,6 +12,22 @@ at the end to claw back any unused channel balance. uv sync --reinstall-package x402 ``` +## Deposit policy + +The client deposits `extra.minDeposit` when the server announced a valid hint, otherwise `amount × DEPOSIT_MULTIPLIER` (default `5`, minimum `3`). + +`x402Client` spend controls still cap each request's `amount` (default `$1` on USDC). That same atomic cap is the escrow ceiling: + +`maxDeposit = maxAmountPerPayment × depositMultiplier` + +So the default `$1` cap and multiplier `5` lock at most `$5`. `spend_controls=False` (or any uncapped asset) leaves the deposit uncapped too. + +Use `deposit_strategy` only for app-specific decisions: + +- **`None`** — use the SDK default (`deposit_amount` in context). +- **`False`** — skip this deposit attempt. +- **Base-unit string or `int`** — custom amount; must be **≥ `minimum_deposit_amount`**, and still respects `max_deposit` when a spend cap is set. + ## Environment | Variable | Required | Description | @@ -22,7 +38,7 @@ uv sync --reinstall-package x402 | `EVM_VOUCHER_SIGNER_PRIVATE_KEY` | no | Dedicated voucher-signing key (`payerAuthorizer`). Defaults to `EVM_PRIVATE_KEY`. | | `EVM_RPC_URL` | no | EVM JSON-RPC endpoint. Defaults to `https://sepolia.base.org`. | | `CHANNEL_SALT` | no | 32-byte hex salt for channel ID derivation. Defaults to all-zeros. | -| `DEPOSIT_MULTIPLIER` | no | Deposit = multiplier × request price (default `5`). | +| `DEPOSIT_MULTIPLIER` | no | Deposit target is `amount ×` this multiplier when `extra.minDeposit` is absent; lock ceiling is `spendCap ×` this multiplier (integer **≥ 3**; default `5`). | | `STORAGE_DIR` | no | Directory for persistent file-backed channel storage. Defaults to in-memory. | | `NUMBER_OF_REQUESTS` | no | Number of paid requests to issue (default `3`). | | `REFUND_AFTER_REQUESTS` | no | Set to `true` to issue a cooperative refund at the end. | diff --git a/examples/python/clients/batch-settlement/main.py b/examples/python/clients/batch-settlement/main.py index 3488b76dae..9932bace08 100644 --- a/examples/python/clients/batch-settlement/main.py +++ b/examples/python/clients/batch-settlement/main.py @@ -15,7 +15,7 @@ EVM_VOUCHER_SIGNER_PRIVATE_KEY Optional. Dedicated voucher-signing key (payerAuthorizer). EVM_RPC_URL Optional. Defaults to https://sepolia.base.org. CHANNEL_SALT Optional. 32-byte hex salt for channel ID derivation. - DEPOSIT_MULTIPLIER Optional. Deposit = multiplier * request_price (default 5). + DEPOSIT_MULTIPLIER Optional. Deposit target is amount × this multiplier when extra.minDeposit is absent; lock ceiling is spendCap × this multiplier (integer ≥ 3; default 5). STORAGE_DIR Optional. Directory for persistent file-backed channel storage. NUMBER_OF_REQUESTS Optional. Number of paid requests to issue (default 3). REFUND_AFTER_REQUESTS Optional. Set to "true" to issue a cooperative refund at the end. @@ -94,6 +94,9 @@ async def main() -> None: ), ) client = x402Client().register("eip155:*", batch_scheme) + # Per-request cap on PaymentRequirements.amount (default "$1" if omitted). + # Deposit ceiling is this cap × deposit_multiplier ($5 at the defaults). + client.set_spend_controls({"max_amount_per_payment": "$1"}) http_client = x402HTTPClient(client) url = f"{RESOURCE_SERVER_URL}{ENDPOINT_PATH}" diff --git a/examples/python/servers/batch-settlement/README.md b/examples/python/servers/batch-settlement/README.md index 0e63b75fde..52a1456f0b 100644 --- a/examples/python/servers/batch-settlement/README.md +++ b/examples/python/servers/batch-settlement/README.md @@ -37,5 +37,5 @@ uv run python main.py ## Endpoints -- `GET /weather` — protected; usage-based-priced at a random 1–100% of `$0.01`. +- `GET /weather` — protected; usage-based-priced at a random 1–100% of `$0.01`. Every 402 also includes `extra.minDeposit` (SDK default `10 × amount`) so clients can size the channel deposit. Override per route with `accepts.extra.minDeposit` (`"$0.10"` on the default asset, or an atomic string). The server announces the hint only; it does not reject smaller deposits unless you set `enforce_min_deposit=True`. - `GET /health` — unprotected. diff --git a/examples/python/servers/batch-settlement/main.py b/examples/python/servers/batch-settlement/main.py index b367501986..a5f2e7ac0d 100644 --- a/examples/python/servers/batch-settlement/main.py +++ b/examples/python/servers/batch-settlement/main.py @@ -66,6 +66,7 @@ receiver_authorizer_signer=( LocalAuthorizerSigner(RECEIVER_AUTH_KEY) if RECEIVER_AUTH_KEY else None ), + enforce_min_deposit=False, storage=FileChannelStorage(STORAGE_DIR) if STORAGE_DIR else None, ) scheme = BatchSettlementEvmScheme(EVM_ADDRESS, scheme_config) @@ -113,6 +114,8 @@ async def lifespan(_app: FastAPI): "payTo": EVM_ADDRESS, "price": MAX_PRICE, "network": EVM_NETWORK, + # Optional: override the default 10× deposit hint (Money strings require the network default asset). + # "extra": {"minDeposit": "$0.10"}, }, }, } diff --git a/examples/typescript/clients/advanced/.env-local b/examples/typescript/clients/advanced/.env-local index eda7ab3a46..d1d093a9a5 100644 --- a/examples/typescript/clients/advanced/.env-local +++ b/examples/typescript/clients/advanced/.env-local @@ -4,6 +4,8 @@ BLOCKFROST_PREPROD_URL= BLOCKFROST_PROJECT_ID= EVM_PRIVATE_KEY= APTOS_PRIVATE_KEY= +CASPER_PRIVATE_KEY= +CASPER_PRIVATE_KEY_ALGORITHM= KEETA_MNEMONIC= SVM_PRIVATE_KEY= STELLAR_PRIVATE_KEY= diff --git a/examples/typescript/clients/advanced/README.md b/examples/typescript/clients/advanced/README.md index 5d6a6e1588..748fa7cf2b 100644 --- a/examples/typescript/clients/advanced/README.md +++ b/examples/typescript/clients/advanced/README.md @@ -42,6 +42,8 @@ and fill required environment variables: - `CARDANO_NETWORK` - Cardano network (optional, defaults to `cardano:preprod`) - `BLOCKFROST_PREPROD_URL` / `BLOCKFROST_PROJECT_ID` - Blockfrost endpoint + project id (required when `CARDANO_MNEMONIC` is set) - `APTOS_PRIVATE_KEY` - Aptos Ed25519 private key for Aptos payments (optional; `all-networks`) +- `CASPER_PRIVATE_KEY` - Casper private key for Casper payments (optional; `all-networks`) +- `CASPER_PRIVATE_KEY_ALGORITHM` - Casper private key algorithm, either `ed25519` (default) or `secp256k1` (optional; `all-networks`) - `CCD_PRIVATE_KEY` - Concordium Ed25519 private key for Concordium payments (optional; `all-networks`) - `CCD_ADDRESS` - Concordium account address for Concordium payments (optional; `all-networks`) - `EVM_PRIVATE_KEY` - Ethereum private key for EVM payments @@ -121,6 +123,12 @@ To create and fund an XRPL Testnet payer account: 2. Keep the [base reserve](https://xrpl.org/docs/concepts/accounts/reserves) funded (currently 1 XRP; the faucet funding is more than enough). The `all-networks` example pays in XRP drops, so no further setup is needed. 3. For issued-currency (IOU) payments, the payer needs a sufficient issued-currency balance, and the receiving account must hold a [trust line](https://xrpl.org/docs/concepts/tokens/fungible-tokens) to the issuer. +#### Casper Testnet + +Create or reuse a dedicated Casper testnet account or wallet key, then fund any account that submits Casper transactions with testnet CSPR from the [CSPR.live testnet faucet](https://testnet.cspr.live/tools/faucet). CSPR is required for gas on Casper Testnet. + +Use [testnet.cspr.trade](https://testnet.cspr.trade) to get wrapped CSPR (WCSPR) or csprUSD for the client payments. + ## Available Examples Each example demonstrates a specific advanced pattern: diff --git a/examples/typescript/clients/advanced/all_networks.ts b/examples/typescript/clients/advanced/all_networks.ts index 6b19cc0a60..85ff66f2d2 100644 --- a/examples/typescript/clients/advanced/all_networks.ts +++ b/examples/typescript/clients/advanced/all_networks.ts @@ -22,6 +22,8 @@ import { toClientAvmSigner } from "@x402/avm"; import { ExactAvmScheme } from "@x402/avm/exact/client"; import { toClientCardanoSigner } from "@x402/cardano"; import { ExactCardanoScheme } from "@x402/cardano/exact/client"; +import { createClientCasperSigner } from "@x402/casper"; +import { ExactCasperScheme } from "@x402/casper/exact/client"; import { ExactConcordiumScheme } from "@x402/concordium/exact/client"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { UptoEvmScheme } from "@x402/evm/upto/client"; @@ -60,6 +62,7 @@ const cardanoNetwork = process.env.CARDANO_NETWORK || "cardano:preprod"; const blockfrostBaseUrl = process.env.BLOCKFROST_PREPROD_URL; const blockfrostProjectId = process.env.BLOCKFROST_PROJECT_ID; const aptosPrivateKey = process.env.APTOS_PRIVATE_KEY as string | undefined; +const casperPrivateKey = process.env.CASPER_PRIVATE_KEY as string | undefined; const ccdPrivateKey = process.env.CCD_PRIVATE_KEY as string | undefined; const ccdAddress = process.env.CCD_ADDRESS as string | undefined; const evmPrivateKey = process.env.EVM_PRIVATE_KEY as `0x${string}` | undefined; @@ -116,6 +119,7 @@ async function main(): Promise { !avmPrivateKey && !cardanoMnemonic && !aptosPrivateKey && + !casperPrivateKey && !(ccdPrivateKey && ccdAddress) && !evmPrivateKey && !keetaMnemonic && @@ -127,7 +131,7 @@ async function main(): Promise { !xrplSeed ) { console.error( - "❌ At least one of AVM_PRIVATE_KEY, APTOS_PRIVATE_KEY, CARDANO_MNEMONIC, CCD_PRIVATE_KEY + CCD_ADDRESS, EVM_PRIVATE_KEY, KEETA_MNEMONIC, NEAR_ACCOUNT_ID + NEAR_PRIVATE_KEY, SVM_PRIVATE_KEY, STELLAR_PRIVATE_KEY, HEDERA_ACCOUNT_ID + HEDERA_PRIVATE_KEY, TVM_PRIVATE_KEY, or XRPL_SEED is required", + "❌ At least one of AVM_PRIVATE_KEY, APTOS_PRIVATE_KEY, CARDANO_MNEMONIC, CASPER_PRIVATE_KEY, CCD_PRIVATE_KEY + CCD_ADDRESS, EVM_PRIVATE_KEY, KEETA_MNEMONIC, NEAR_ACCOUNT_ID + NEAR_PRIVATE_KEY, SVM_PRIVATE_KEY, STELLAR_PRIVATE_KEY, HEDERA_ACCOUNT_ID + HEDERA_PRIVATE_KEY, TVM_PRIVATE_KEY, or XRPL_SEED is required", ); process.exit(1); } @@ -174,6 +178,16 @@ async function main(): Promise { console.log(`Initialized Cardano signer on ${cardanoNetwork}`); } + // Register Casper scheme if private key is provided + if (casperPrivateKey) { + const casperSigner = await createClientCasperSigner( + casperPrivateKey, + process.env.CASPER_PRIVATE_KEY_ALGORITHM === "secp256k1" ? 2 : 1, // Default to ED25519 if not specified + ); + client.register("casper:*", new ExactCasperScheme(casperSigner)); + console.log(`Initialized Casper account: ${casperSigner.accountAddress()}`); + } + // Register Concordium scheme if private key and address are provided if (ccdPrivateKey && ccdAddress) { const signer = { diff --git a/examples/typescript/clients/advanced/package.json b/examples/typescript/clients/advanced/package.json index 192323d6d2..b674ab8d26 100644 --- a/examples/typescript/clients/advanced/package.json +++ b/examples/typescript/clients/advanced/package.json @@ -22,6 +22,7 @@ "@x402/aptos": "workspace:*", "@x402/avm": "workspace:*", "@x402/cardano": "workspace:*", + "@x402/casper": "workspace:*", "@x402/concordium": "workspace:*", "@x402/evm": "workspace:*", "@x402/keeta": "workspace:*", diff --git a/examples/typescript/facilitator/advanced/.env-local b/examples/typescript/facilitator/advanced/.env-local index c09da874d8..c9ac8113e1 100644 --- a/examples/typescript/facilitator/advanced/.env-local +++ b/examples/typescript/facilitator/advanced/.env-local @@ -7,6 +7,10 @@ BLOCKFROST_PROJECT_ID= EVM_PRIVATE_KEY= APTOS_PRIVATE_KEY= APTOS_RPC_URL= +CASPER_PRIVATE_KEY= +CASPER_PRIVATE_KEY_ALGORITHM= +CASPER_NETWORK= +CASPER_RPC_URL= SVM_PRIVATE_KEY= HEDERA_ACCOUNT_ID= HEDERA_PRIVATE_KEY= diff --git a/examples/typescript/facilitator/advanced/README.md b/examples/typescript/facilitator/advanced/README.md index ed6c2dc58f..d3ef47be6a 100644 --- a/examples/typescript/facilitator/advanced/README.md +++ b/examples/typescript/facilitator/advanced/README.md @@ -13,6 +13,7 @@ Express.js facilitator service demonstrating advanced x402 patterns including al - Keeta mnemonic (seed phrase) and wallet with Testnet KTA for transaction fees (create wallet on [Keeta Testnet Wallet](https://wallet.test.keeta.com/) and fund via [Keeta Testnet Faucet](https://faucet.test.keeta.com/)) - Cardano: a Blockfrost project id (preprod/preview) for chain queries and submission, plus an optional facilitator mnemonic — the facilitator only broadcasts the client's signed transaction, so it needs **no funds** - No XRPL account or key: the XRPL facilitator is keyless (the payer signs and pays transaction fees); set `XRPL_NETWORK` to enable it (optional) +- Casper private key with testnet CSPR for transaction fees (fund via the [CSPR.live testnet faucet](https://testnet.cspr.live/tools/faucet)) ## Setup @@ -26,6 +27,10 @@ and fill required environment variables: - `APTOS_PRIVATE_KEY` - Aptos Ed25519 private key for fee payer (optional; `all-networks`) - `APTOS_RPC_URL` - Aptos RPC URL (optional; `all-networks`) +- `CASPER_PRIVATE_KEY` - Casper private key as 64-character hex (optional; `all-networks`) +- `CASPER_PRIVATE_KEY_ALGORITHM` - Casper private key algorithm, either `ed25519` (default) or `secp256k1` (optional; `all-networks`) +- `CASPER_NETWORK` - Casper network CAIP-2 (optional; defaults to `casper:casper-test`) +- `CASPER_RPC_URL` - Casper JSON-RPC URL (optional; defaults to the SDK testnet RPC for known networks) - `CCD_FACILITATOR_PRIVATE_KEY` - Concordium Ed25519 private key for sponsor signing (optional; `all-networks`) - `CCD_FACILITATOR_ADDRESS` - Concordium sponsor account address (optional; `all-networks`) - `CCD_NETWORK` - Concordium network CAIP-2 (optional; defaults to `ccd:4221332d34e1694168c2a0c0b3fd0f27`) @@ -66,6 +71,16 @@ For testing on Aptos testnet, you can obtain test tokens from these faucets: - **Test APT**: https://aptos.dev/network/faucet or through an account on [geomi.dev](https://geomi.dev/manage/faucet) - **Test USDC**: https://faucet.circle.com/ +#### Casper Testnet + +Create or reuse a dedicated Casper testnet account or wallet key, then fund any +account that submits Casper transactions with testnet CSPR from the +[CSPR.live testnet faucet](https://testnet.cspr.live/tools/faucet). CSPR is +required for gas on Casper Testnet. + +Use [testnet.cspr.trade](https://testnet.cspr.trade) to get wrapped CSPR (WCSPR) or csprUSD for +the client payments. + ## Available Examples Each example demonstrates a specific advanced pattern: @@ -279,6 +294,8 @@ Networks use [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/cai - `aptos:2` — Aptos Testnet - `aptos:1` — Aptos Mainnet +- `casper:casper-test` - Casper Testnet +- `casper:casper` - Casper Mainnet - `eip155:84532` — Base Sepolia - `eip155:8453` — Base Mainnet - `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` — Solana Devnet diff --git a/examples/typescript/facilitator/advanced/all_networks.ts b/examples/typescript/facilitator/advanced/all_networks.ts index dd0a568e11..797a652c12 100644 --- a/examples/typescript/facilitator/advanced/all_networks.ts +++ b/examples/typescript/facilitator/advanced/all_networks.ts @@ -21,6 +21,8 @@ import { toFacilitatorAvmSigner } from "@x402/avm"; import { ExactAvmScheme } from "@x402/avm/exact/facilitator"; import { toFacilitatorCardanoSigner } from "@x402/cardano"; import { ExactCardanoScheme } from "@x402/cardano/exact/facilitator"; +import { createFacilitatorCasperSigner } from "@x402/casper"; +import { ExactCasperScheme } from "@x402/casper/exact/facilitator"; import { ExactConcordiumScheme } from "@x402/concordium/exact/facilitator"; import { CONCORDIUM_TESTNET_CAIP2, @@ -91,11 +93,14 @@ const PORT = process.env.PORT || "4022"; // Configuration - optional per network (alphabetic order) const avmPrivateKey = process.env.AVM_PRIVATE_KEY as string | undefined; const cardanoMnemonic = process.env.CARDANO_MNEMONIC as string | undefined; -const cardanoNetwork = (process.env.CARDANO_NETWORK || "cardano:preprod") as Network; +const cardanoNetwork = (process.env.CARDANO_NETWORK || + "cardano:preprod") as Network; const blockfrostBaseUrl = process.env.BLOCKFROST_PREPROD_URL; const blockfrostProjectId = process.env.BLOCKFROST_PROJECT_ID; const aptosPrivateKey = process.env.APTOS_PRIVATE_KEY as string | undefined; const aptosRpcUrl = process.env.APTOS_RPC_URL as string | undefined; +const casperPrivateKey = process.env.CASPER_PRIVATE_KEY as string | undefined; +const casperRpcUrl = process.env.CASPER_RPC_URL as string | undefined; const ccdFacilitatorPrivateKey = process.env.CCD_FACILITATOR_PRIVATE_KEY as | string | undefined; @@ -127,6 +132,7 @@ if ( !avmPrivateKey && !cardanoMnemonic && !aptosPrivateKey && + !casperPrivateKey && !(ccdFacilitatorPrivateKey && ccdFacilitatorAddress) && !evmPrivateKey && !keetaMnemonic && @@ -137,7 +143,7 @@ if ( !(hederaAccountId && hederaPrivateKey) ) { console.error( - "❌ At least one of AVM_PRIVATE_KEY, APTOS_PRIVATE_KEY, CARDANO_MNEMONIC, CCD_FACILITATOR_PRIVATE_KEY + CCD_FACILITATOR_ADDRESS, EVM_PRIVATE_KEY, KEETA_MNEMONIC, NEAR_RELAYER_ACCOUNT_ID + NEAR_RELAYER_PRIVATE_KEY, SVM_PRIVATE_KEY, STELLAR_PRIVATE_KEY, TVM_PRIVATE_KEY, or HEDERA_ACCOUNT_ID + HEDERA_PRIVATE_KEY is required", + "❌ At least one of AVM_PRIVATE_KEY, APTOS_PRIVATE_KEY, CARDANO_MNEMONIC, CASPER_PRIVATE_KEY, CCD_FACILITATOR_PRIVATE_KEY + CCD_FACILITATOR_ADDRESS, EVM_PRIVATE_KEY, KEETA_MNEMONIC, NEAR_RELAYER_ACCOUNT_ID + NEAR_RELAYER_PRIVATE_KEY, SVM_PRIVATE_KEY, STELLAR_PRIVATE_KEY, TVM_PRIVATE_KEY, or HEDERA_ACCOUNT_ID + HEDERA_PRIVATE_KEY is required", ); process.exit(1); } @@ -146,6 +152,8 @@ if ( const AVM_NETWORK = "algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDe"; // Algorand Testnet const CARDANO_NETWORK = cardanoNetwork; // Cardano Preprod Testnet (default) const APTOS_NETWORK = (process.env.APTOS_NETWORK || "aptos:2") as Network; // Aptos Testnet +const CASPER_NETWORK = (process.env.CASPER_NETWORK || + "casper:casper-test") as Network; // Casper Testnet const CCD_NETWORK = CONCORDIUM_TESTNET_CAIP2; // Concordium Testnet const EVM_NETWORK = "eip155:84532"; // Base Sepolia const HEDERA_NETWORK = "hedera:testnet"; // Hedera Testnet @@ -206,16 +214,25 @@ if (aptosPrivateKey) { // Register Cardano scheme if a mnemonic and Blockfrost connection are provided if (cardanoMnemonic) { if (!blockfrostBaseUrl || !blockfrostProjectId) { - console.error("❌ CARDANO_MNEMONIC requires BLOCKFROST_PREPROD_URL and BLOCKFROST_PROJECT_ID"); + console.error( + "❌ CARDANO_MNEMONIC requires BLOCKFROST_PREPROD_URL and BLOCKFROST_PROJECT_ID", + ); process.exit(1); } const cardanoSigner = toFacilitatorCardanoSigner({ mnemonic: cardanoMnemonic, network: CARDANO_NETWORK, - provider: { blockfrost: { baseUrl: blockfrostBaseUrl, projectId: blockfrostProjectId } }, + provider: { + blockfrost: { + baseUrl: blockfrostBaseUrl, + projectId: blockfrostProjectId, + }, + }, awaitConfirmation: false, }); - console.info(`Cardano Facilitator account: ${cardanoSigner.getAddresses()[0]}`); + console.info( + `Cardano Facilitator account: ${cardanoSigner.getAddresses()[0]}`, + ); facilitator.register( CARDANO_NETWORK, new ExactCardanoScheme(cardanoSigner, { @@ -224,6 +241,26 @@ if (cardanoMnemonic) { ); } +// Register Casper scheme if private key is provided. +if (casperPrivateKey) { + const casperSigner = await createFacilitatorCasperSigner( + casperPrivateKey, + process.env.CASPER_PRIVATE_KEY_ALGORITHM === "secp256k1" ? 2 : 1, // Default to ED25519 if not specified, + { + rpcUrlConfig: casperRpcUrl + ? { [CASPER_NETWORK]: casperRpcUrl } + : undefined, + speculativeRpcUrlConfig: process.env.CASPER_SPECULATIVE_RPC_URL + ? { [`${CASPER_NETWORK}`]: process.env.CASPER_SPECULATIVE_RPC_URL } + : undefined, + }, + ); + facilitator.register(CASPER_NETWORK, new ExactCasperScheme(casperSigner)); + console.info( + `Casper Facilitator account: ${casperSigner.getAddresses(CASPER_NETWORK)[0]}`, + ); +} + // Register Concordium scheme if private key + address are provided (recommended). // This matches how every other mechanism reads a private key from an env var. if (ccdFacilitatorPrivateKey && ccdFacilitatorAddress) { diff --git a/examples/typescript/facilitator/advanced/package.json b/examples/typescript/facilitator/advanced/package.json index d43b369b62..9cea858691 100644 --- a/examples/typescript/facilitator/advanced/package.json +++ b/examples/typescript/facilitator/advanced/package.json @@ -22,6 +22,7 @@ "@x402/aptos": "workspace:*", "@x402/avm": "workspace:*", "@x402/cardano": "workspace:*", + "@x402/casper": "workspace:*", "@x402/concordium": "workspace:*", "@x402/core": "workspace:*", "@x402/evm": "workspace:*", diff --git a/examples/typescript/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index 8d6dc7a498..7d7db7a87a 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -15,7 +15,7 @@ importers: version: 1.0.1 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) turbo: specifier: ^2.5.0 version: 2.5.6 @@ -43,7 +43,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -79,7 +79,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/extensions: dependencies: @@ -125,7 +125,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -161,7 +161,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/http/axios: dependencies: @@ -183,7 +183,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) axios: specifier: ^1.7.9 version: 1.13.2 @@ -222,7 +222,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/http/express: dependencies: @@ -253,7 +253,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -292,7 +292,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/http/fastify: dependencies: @@ -320,7 +320,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -359,7 +359,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/http/fetch: dependencies: @@ -381,7 +381,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -417,7 +417,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/http/hono: dependencies: @@ -445,7 +445,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -484,7 +484,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/http/next: dependencies: @@ -515,7 +515,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -551,7 +551,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/http/paywall: dependencies: @@ -645,7 +645,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) '@x402/avm': specifier: workspace:~ version: link:../../mechanisms/avm @@ -702,7 +702,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mcp: dependencies: @@ -730,7 +730,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) '@x402/evm': specifier: workspace:~ version: link:../mechanisms/evm @@ -775,7 +775,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/aptos: dependencies: @@ -800,7 +800,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -836,7 +836,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/avm: dependencies: @@ -861,7 +861,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -897,7 +897,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/cardano: dependencies: @@ -928,7 +928,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -964,7 +964,71 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + + ../../typescript/packages/mechanisms/casper: + dependencies: + '@casper-ecosystem/casper-eip-712': + specifier: 1.2.1 + version: 1.2.1 + '@x402/core': + specifier: workspace:~ + version: link:../../core + casper-js-sdk: + specifier: 5.1.1 + version: 5.1.1 + 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) + '@vitest/coverage-v8': + specifier: ^3.2.7 + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + 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) + eslint-plugin-sonarjs: + specifier: ^3.0.7 + version: 3.0.7(eslint@9.33.0(jiti@2.6.1)) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsup: + specifier: ^8.4.0 + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vite: + specifier: ^6.2.6 + version: 6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/concordium: dependencies: @@ -989,7 +1053,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -1025,7 +1089,7 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/evm: dependencies: @@ -1053,7 +1117,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -1089,7 +1153,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/hedera: dependencies: @@ -1117,7 +1181,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -1153,7 +1217,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/keeta: dependencies: @@ -1181,7 +1245,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -1217,7 +1281,7 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/near: dependencies: @@ -1260,7 +1324,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -1296,7 +1360,7 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/stellar: dependencies: @@ -1321,7 +1385,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -1357,7 +1421,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/svm: dependencies: @@ -1397,7 +1461,7 @@ importers: version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.2) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -1433,7 +1497,7 @@ importers: version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/tvm: dependencies: @@ -1461,7 +1525,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -1497,7 +1561,7 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) ../../typescript/packages/mechanisms/xrpl: dependencies: @@ -1522,7 +1586,7 @@ importers: version: 8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^3.2.7 - version: 3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) eslint: specifier: ^9.24.0 version: 9.33.0(jiti@2.6.1) @@ -1558,7 +1622,7 @@ importers: version: 5.1.4(typescript@5.9.3)(vite@6.3.5(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) vitest: specifier: ^3.2.7 - version: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) clients/advanced: dependencies: @@ -1589,6 +1653,9 @@ importers: '@x402/cardano': specifier: workspace:* version: link:../../../../typescript/packages/mechanisms/cardano + '@x402/casper': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/casper '@x402/concordium': specifier: workspace:* version: link:../../../../typescript/packages/mechanisms/concordium @@ -2334,6 +2401,9 @@ importers: '@x402/cardano': specifier: workspace:* version: link:../../../../typescript/packages/mechanisms/cardano + '@x402/casper': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/casper '@x402/concordium': specifier: workspace:* version: link:../../../../typescript/packages/mechanisms/concordium @@ -2692,7 +2762,7 @@ importers: version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) wagmi: specifier: ^2.14.11 - version: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) + version: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) devDependencies: '@eslint/js': specifier: ^9.24.0 @@ -2903,6 +2973,9 @@ importers: '@x402/cardano': specifier: workspace:* version: link:../../../../typescript/packages/mechanisms/cardano + '@x402/casper': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/casper '@x402/concordium': specifier: workspace:* version: link:../../../../typescript/packages/mechanisms/concordium @@ -2978,7 +3051,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3039,7 +3112,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3103,7 +3176,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3164,7 +3237,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3326,7 +3399,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3384,7 +3457,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3448,7 +3521,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3634,7 +3707,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3695,7 +3768,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3817,7 +3890,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3887,7 +3960,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -4034,6 +4107,12 @@ packages: peerDependencies: algosdk: ^3.0.0 + '@casper-ecosystem/casper-eip-712@1.2.1': + resolution: {integrity: sha512-qhrpaISByIoOnbELH7hhLMVBinIP1vOc4Z+6ZI5aItcc1O3SrDXNu0fdKm5qRSLLb5p94JxaS+jGegiIAwiKDA==} + + '@casperlabs/ts-results@3.3.5': + resolution: {integrity: sha512-ymSQqqb4mOSet592li02u1Gd28LoOFJUm6R3jkdNQ+nqsnbHvN+izBigtP4aYmNwh6gFyCwDgjYporEJgDT4eA==} + '@coinbase/cdp-sdk@1.39.0': resolution: {integrity: sha512-3RbeIGVHBlDUxRKSLSnrvpsxo7W4IIqaOHyCscX1rPL/iprL0Nh6AaL/JogC4OkHwquu7SkKWjjNDb+a5wG8gw==} @@ -4547,6 +4626,18 @@ packages: resolution: {integrity: sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA==} engines: {node: '>=14'} + '@ethersproject/bignumber@5.8.0': + resolution: {integrity: sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==} + + '@ethersproject/bytes@5.8.0': + resolution: {integrity: sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==} + + '@ethersproject/constants@5.8.0': + resolution: {integrity: sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==} + + '@ethersproject/logger@5.8.0': + resolution: {integrity: sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==} + '@evanhahn/lottie-web-light@5.8.1': resolution: {integrity: sha512-U0G1tt3/UEYnyCNNslWPi1dB7X1xQ9aoSip+B3GTKO/Bns8yz/p39vBkRSN9d25nkbHuCsbjky2coQftj5YVKw==} @@ -4971,6 +5062,10 @@ packages: resolution: {integrity: sha512-5yb2gMI1BDm0JybZezeoX/3XhPDOtTbcFvpTXM9kxsoZjPZFh4XciqRbpD6N86HYZqWDhEaKUDuOyR0sQHEjMA==} engines: {node: '>=12.0.0'} + '@metamask/safe-event-emitter@3.1.3': + resolution: {integrity: sha512-Pnl74ukgoOkSItBRmZ9nnhdBbRtBI1ZvpeYjKNgxjVzDWa/AtruKZc+7LjfZ0iO8iYqh7Ts3bPs2Gu7M2hSf7g==} + engines: {node: '>=12.0.0'} + '@metamask/sdk-analytics@0.0.5': resolution: {integrity: sha512-fDah+keS1RjSUlC8GmYXvx6Y26s3Ax1U9hGpWb6GSY5SAdmTSIqp2CvYy6yW0WgLhnYhW+6xERuD0eVqV63QIQ==} deprecated: No longer maintained, superseded by @metamask/connect-analytics @@ -5211,6 +5306,9 @@ packages: resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} engines: {node: '>= 20.19.0'} + '@noble/ed25519@1.7.5': + resolution: {integrity: sha512-xuS0nwRMQBvSxDa7UxMb61xTiH3MxTgUfhyPUALVIe0FlOAz4sjELwyDRyUvqeEYfRSG9qNjFIycqLZppg4RSA==} + '@noble/ed25519@2.3.0': resolution: {integrity: sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==} @@ -5241,6 +5339,9 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} + '@noble/secp256k1@1.7.2': + resolution: {integrity: sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -7044,6 +7145,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -7062,8 +7166,8 @@ packages: '@types/express@5.0.3': resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==} - '@types/http-cache-semantics@4.0.4': - resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -7110,6 +7214,12 @@ packages: '@types/node@22.17.2': resolution: {integrity: sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/node@22.20.2': + resolution: {integrity: sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==} + '@types/node@22.7.5': resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} @@ -7849,6 +7959,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + asn1js@3.0.6: resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} engines: {node: '>=12.0.0'} @@ -7920,12 +8033,12 @@ packages: axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} - axios@1.16.1: - resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} - axios@1.18.0: resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -7997,6 +8110,9 @@ packages: bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} + bn.js@5.2.5: + resolution: {integrity: sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==} + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -8123,6 +8239,10 @@ packages: caniuse-lite@1.0.30001735: resolution: {integrity: sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==} + casper-js-sdk@5.1.1: + resolution: {integrity: sha512-KeKaBzvi+nK/1etL4D1nCPT8L7h+oBa3KXlB3ImifGlkDJwgBphva7Ep7e7fnhr9b2gFCWgDnJ0suTDl6ymbYQ==} + engines: {node: '>=18'} + cbor2@1.12.0: resolution: {integrity: sha512-3Cco8XQhi27DogSp9Ri6LYNZLi/TBY/JVnDe+mj06NkBjW/ZYOtekaEU4wZ4xcRMNrFkDv8KNtOAqHyDfz3lYg==} engines: {node: '>=18.7'} @@ -8424,6 +8544,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decamelize@1.2.0: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} @@ -8848,6 +8977,9 @@ packages: eventemitter3@5.0.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + eventid@2.0.1: resolution: {integrity: sha512-sPNTqiMokAvV048P2c9+foqVJzk49o6d4e0D/sq5jog3pw+4kBgyR0gaM1FM7Mx6Kzd9dztesh9oYz1LWWOpzw==} engines: {node: '>=10'} @@ -8860,6 +8992,10 @@ packages: resolution: {integrity: sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==} engines: {node: '>=20.0.0'} + eventsource@2.0.2: + resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} + engines: {node: '>=12.0.0'} + eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} @@ -9317,6 +9453,9 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + humanize-duration@3.34.1: + resolution: {integrity: sha512-YIiigjQ+O31rvcyDJPK1ptTZtvSErjBtmHy93VhbwxB/VwMG0FszKs2IB667tFxmOhNapePQQxa24luQRDkKFQ==} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -10639,8 +10778,13 @@ packages: preact@10.24.2: resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} - preact@10.27.1: - resolution: {integrity: sha512-V79raXEWch/rbqoNc7nT9E4ep7lu+mI3+sBmfRD4i1M73R3WLYcCtdI0ibxGVf4eQL8ZIz2nFacqEC+rmnOORQ==} + preact@10.29.8: + resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==} + peerDependencies: + preact-render-to-string: '>=5' + peerDependenciesMeta: + preact-render-to-string: + optional: true prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} @@ -10710,6 +10854,9 @@ packages: pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + pumpify@2.0.1: resolution: {integrity: sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==} @@ -11048,6 +11195,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -11427,8 +11579,8 @@ packages: tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} - to-buffer@1.2.1: - resolution: {integrity: sha512-tB82LpAIWjhLYbqjx3X4zEeHN6M8CiuOEy2JY8SEQVdYRe3CCHOFaqrBW1doLDrfpWhplcW7BL+bO3/6S3pcDQ==} + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} to-regex-range@5.0.1: @@ -11609,6 +11761,9 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + typedjson@1.8.0: + resolution: {integrity: sha512-taVJVGebQDagEmVc3Cu6vVVLkWLnxqPcTrkVgbpAsI02ZDDrnHy5zvt1JVqXv4/yztBgZAX1oR07+bkiusGJLQ==} + typescript-eslint@8.48.0: resolution: {integrity: sha512-fcKOvQD9GUn3Xw63EgiDqhvWJ5jsyZUaekl3KVpGsDJnN46WJTe3jWxtQP9lMZm1LJNkFLlTaWAxK2vUQR+cqw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -12491,6 +12646,15 @@ snapshots: - bufferutil - utf-8-validate + '@casper-ecosystem/casper-eip-712@1.2.1': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + '@casperlabs/ts-results@3.3.5': + dependencies: + tslib: 2.8.1 + '@coinbase/cdp-sdk@1.39.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana-program/system': 0.8.1(@solana/kit@3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) @@ -12498,8 +12662,8 @@ snapshots: '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.9.2)(utf-8-validate@5.0.10) abitype: 1.0.6(typescript@5.9.2)(zod@3.25.76) - axios: 1.16.1 - axios-retry: 4.5.0(axios@1.16.1) + axios: 1.18.0 + axios-retry: 4.5.0(axios@1.18.0) jose: 6.0.12 md5: 2.3.0 uncrypto: 0.1.3 @@ -12522,8 +12686,8 @@ snapshots: '@solana/kit': 3.0.3(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/web3.js': 1.98.4(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10) abitype: 1.0.6(typescript@5.9.3)(zod@3.25.76) - axios: 1.16.1 - axios-retry: 4.5.0(axios@1.16.1) + axios: 1.18.0 + axios-retry: 4.5.0(axios@1.18.0) jose: 6.0.12 md5: 2.3.0 uncrypto: 0.1.3 @@ -12541,16 +12705,17 @@ snapshots: '@coinbase/wallet-sdk@3.9.3': dependencies: - bn.js: 5.2.3 + bn.js: 5.2.5 buffer: 6.0.3 clsx: 1.2.1 eth-block-tracker: 7.1.0 eth-json-rpc-filters: 6.0.1 - eventemitter3: 5.0.1 + eventemitter3: 5.0.4 keccak: 3.0.4 - preact: 10.27.1 + preact: 10.29.8 sha.js: 2.4.12 transitivePeerDependencies: + - preact-render-to-string - supports-color '@coinbase/wallet-sdk@4.3.6(@types/react@19.1.10)(bufferutil@4.0.9)(react@19.1.1)(typescript@5.9.2)(use-sync-external-store@1.4.0(react@19.1.1))(utf-8-validate@5.0.10)(zod@3.25.76)': @@ -12971,6 +13136,22 @@ snapshots: ethereum-cryptography: 2.2.1 micro-ftch: 0.3.1 + '@ethersproject/bignumber@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + bn.js: 5.2.3 + + '@ethersproject/bytes@5.8.0': + dependencies: + '@ethersproject/logger': 5.8.0 + + '@ethersproject/constants@5.8.0': + dependencies: + '@ethersproject/bignumber': 5.8.0 + + '@ethersproject/logger@5.8.0': {} + '@evanhahn/lottie-web-light@5.8.1': {} '@evolution-sdk/evolution@0.5.11(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(bufferutil@4.0.9)(utf-8-validate@5.0.10)': @@ -13367,7 +13548,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.17.2 + '@types/node': 22.20.1 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -13447,7 +13628,7 @@ snapshots: '@metamask/eth-json-rpc-provider@1.0.1': dependencies: '@metamask/json-rpc-engine': 7.3.3 - '@metamask/safe-event-emitter': 3.1.2 + '@metamask/safe-event-emitter': 3.1.3 '@metamask/utils': 5.0.2 transitivePeerDependencies: - supports-color @@ -13455,7 +13636,7 @@ snapshots: '@metamask/json-rpc-engine@7.3.3': dependencies: '@metamask/rpc-errors': 6.4.0 - '@metamask/safe-event-emitter': 3.1.2 + '@metamask/safe-event-emitter': 3.1.3 '@metamask/utils': 8.5.0 transitivePeerDependencies: - supports-color @@ -13521,6 +13702,8 @@ snapshots: '@metamask/safe-event-emitter@3.1.2': {} + '@metamask/safe-event-emitter@3.1.3': {} + '@metamask/sdk-analytics@0.0.5': dependencies: openapi-fetch: 0.13.8 @@ -13639,9 +13822,9 @@ snapshots: '@metamask/utils@5.0.2': dependencies: '@ethereumjs/tx': 4.2.0 - '@types/debug': 4.1.12 - debug: 4.4.1 - semver: 7.7.4 + '@types/debug': 4.1.13 + debug: 4.4.3 + semver: 7.8.5 superstruct: 1.0.4 transitivePeerDependencies: - supports-color @@ -13836,6 +14019,8 @@ snapshots: dependencies: '@noble/hashes': 2.2.0 + '@noble/ed25519@1.7.5': {} + '@noble/ed25519@2.3.0': {} '@noble/ed25519@3.1.0': {} @@ -13852,6 +14037,8 @@ snapshots: '@noble/hashes@2.2.0': {} + '@noble/secp256k1@1.7.2': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -14006,12 +14193,12 @@ snapshots: '@react-native/community-cli-plugin@0.85.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: '@react-native/dev-middleware': 0.85.2(bufferutil@4.0.9)(utf-8-validate@5.0.10) - debug: 4.4.1 + debug: 4.4.3 invariant: 2.2.4 metro: 0.84.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) metro-config: 0.84.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) metro-core: 0.84.3 - semver: 7.7.4 + semver: 7.8.5 transitivePeerDependencies: - bufferutil - supports-color @@ -14022,7 +14209,7 @@ snapshots: '@react-native/debugger-shell@0.85.2': dependencies: cross-spawn: 7.0.6 - debug: 4.4.1 + debug: 4.4.3 fb-dotslash: 0.5.8 transitivePeerDependencies: - supports-color @@ -14035,7 +14222,7 @@ snapshots: chrome-launcher: 0.15.2 chromium-edge-launcher: 0.3.0 connect: 3.7.0 - debug: 4.4.1 + debug: 4.4.3 invariant: 2.2.4 nullthrows: 1.1.1 open: 7.4.2 @@ -17866,7 +18053,7 @@ snapshots: '@solana/buffer-layout': 4.0.1 '@solana/codecs-numbers': 2.3.0(typescript@5.9.2) agentkeepalive: 4.6.0 - bn.js: 5.2.2 + bn.js: 5.2.3 borsh: 0.7.0 bs58: 4.0.1 buffer: 6.0.3 @@ -17889,7 +18076,7 @@ snapshots: '@solana/buffer-layout': 4.0.1 '@solana/codecs-numbers': 2.3.0(typescript@5.9.3) agentkeepalive: 4.6.0 - bn.js: 5.2.2 + bn.js: 5.2.3 borsh: 0.7.0 bs58: 4.0.1 buffer: 6.0.3 @@ -18076,9 +18263,9 @@ snapshots: '@types/cacheable-request@6.0.3': dependencies: - '@types/http-cache-semantics': 4.0.4 + '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 22.17.2 + '@types/node': 22.20.2 '@types/responselike': 1.0.3 '@types/caseless@0.12.5': @@ -18096,6 +18283,10 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -18127,7 +18318,7 @@ snapshots: '@types/express-serve-static-core': 5.0.7 '@types/serve-static': 1.15.8 - '@types/http-cache-semantics@4.0.4': {} + '@types/http-cache-semantics@4.2.0': {} '@types/http-errors@2.0.5': {} @@ -18147,7 +18338,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 22.17.2 + '@types/node': 22.20.2 '@types/long@4.0.2': optional: true @@ -18175,6 +18366,14 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/node@22.20.2': + dependencies: + undici-types: 6.21.0 + '@types/node@22.7.5': dependencies: undici-types: 6.19.8 @@ -18209,7 +18408,7 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 22.17.2 + '@types/node': 22.20.2 '@types/send@0.17.5': dependencies: @@ -18648,7 +18847,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitest/coverage-v8@3.2.7(vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1))': + '@vitest/coverage-v8@3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -18663,7 +18862,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -18745,6 +18944,7 @@ snapshots: - encoding - immer - ioredis + - preact-render-to-string - react - supports-color - uploadthing @@ -18795,6 +18995,7 @@ snapshots: - fastestsmallesttextencoderdecoder - immer - ioredis + - preact-render-to-string - react - react-native - supports-color @@ -18805,7 +19006,7 @@ snapshots: - ws - zod - '@wagmi/connectors@6.2.0(d27b5019f10ead2ad2c8a66fcf3e72ab)': + '@wagmi/connectors@6.2.0(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13)': dependencies: '@base-org/account': 2.4.0(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@4.1.13) @@ -18816,7 +19017,7 @@ snapshots: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.35(6efb213377217713e5f239ae3971a013) + porto: 0.2.35(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13)) viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) optionalDependencies: typescript: 5.9.3 @@ -18848,6 +19049,7 @@ snapshots: - fastestsmallesttextencoderdecoder - immer - ioredis + - preact-render-to-string - react - react-native - supports-color @@ -20311,6 +20513,13 @@ snapshots: asap@2.0.6: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + asn1js@3.0.6: dependencies: pvtsutils: 1.3.6 @@ -20357,9 +20566,9 @@ snapshots: axe-core@4.10.3: {} - axios-retry@4.5.0(axios@1.16.1): + axios-retry@4.5.0(axios@1.18.0): dependencies: - axios: 1.16.1 + axios: 1.18.0 is-retry-allowed: 2.2.0 axios@1.13.2: @@ -20370,7 +20579,7 @@ snapshots: transitivePeerDependencies: - debug - axios@1.16.1: + axios@1.18.0: dependencies: follow-redirects: 1.16.0 form-data: 4.0.6 @@ -20380,7 +20589,7 @@ snapshots: - debug - supports-color - axios@1.18.0: + axios@1.20.0: dependencies: follow-redirects: 1.16.0 form-data: 4.0.6 @@ -20444,6 +20653,8 @@ snapshots: bn.js@5.2.3: {} + bn.js@5.2.5: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -20601,6 +20812,26 @@ snapshots: caniuse-lite@1.0.30001735: {} + casper-js-sdk@5.1.1: + dependencies: + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@noble/curves': 1.9.7 + '@noble/ed25519': 1.7.5 + '@noble/hashes': 1.8.0 + '@noble/secp256k1': 1.7.2 + asn1.js: 5.4.1 + axios: 1.20.0 + bn.js: 5.2.5 + eventsource: 2.0.2 + humanize-duration: 3.34.1 + ts-results: '@casperlabs/ts-results@3.3.5' + typedjson: 1.8.0 + transitivePeerDependencies: + - debug + - supports-color + cbor2@1.12.0: {} chai@5.3.1: @@ -20630,7 +20861,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 22.17.2 + '@types/node': 22.20.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -20639,7 +20870,7 @@ snapshots: chromium-edge-launcher@0.3.0: dependencies: - '@types/node': 22.17.2 + '@types/node': 22.20.1 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -20856,6 +21087,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decamelize@1.2.0: {} decimal.js@10.6.0: {} @@ -21587,7 +21822,7 @@ snapshots: eth-block-tracker@7.1.0: dependencies: '@metamask/eth-json-rpc-provider': 1.0.1 - '@metamask/safe-event-emitter': 3.1.2 + '@metamask/safe-event-emitter': 3.1.3 '@metamask/utils': 5.0.2 json-rpc-random-id: 1.0.1 pify: 3.0.0 @@ -21596,7 +21831,7 @@ snapshots: eth-json-rpc-filters@6.0.1: dependencies: - '@metamask/safe-event-emitter': 3.1.2 + '@metamask/safe-event-emitter': 3.1.3 async-mutex: 0.2.6 eth-query: 2.1.2 json-rpc-engine: 6.1.0 @@ -21637,6 +21872,8 @@ snapshots: eventemitter3@5.0.1: {} + eventemitter3@5.0.4: {} + eventid@2.0.1: dependencies: uuid: 8.3.2 @@ -21646,6 +21883,8 @@ snapshots: eventsource-parser@3.0.5: {} + eventsource@2.0.2: {} + eventsource@3.0.7: dependencies: eventsource-parser: 3.0.5 @@ -22033,7 +22272,7 @@ snapshots: get-stream@5.2.0: dependencies: - pump: 3.0.3 + pump: 3.0.4 get-symbol-description@1.1.0: dependencies: @@ -22266,6 +22505,8 @@ snapshots: transitivePeerDependencies: - supports-color + humanize-duration@3.34.1: {} + humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -22565,7 +22806,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.17.2 + '@types/node': 22.20.1 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -22582,7 +22823,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 22.17.2 + '@types/node': 22.20.1 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -22959,7 +23200,7 @@ snapshots: metro-file-map@0.84.3: dependencies: - debug: 4.4.1 + debug: 4.4.3 fb-watchman: 2.0.2 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -23054,7 +23295,7 @@ snapshots: chalk: 4.1.2 ci-info: 2.0.0 connect: 3.7.0 - debug: 4.4.1 + debug: 4.4.3 error-stack-parser: 2.1.4 flow-enums-runtime: 0.0.6 graceful-fs: 4.2.11 @@ -23802,7 +24043,7 @@ snapshots: - immer - use-sync-external-store - porto@0.2.35(6efb213377217713e5f239ae3971a013): + porto@0.2.35(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13)): dependencies: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) hono: 4.10.7 @@ -23815,9 +24056,8 @@ snapshots: optionalDependencies: '@tanstack/react-query': 5.90.11(react@19.2.6) react: 19.2.6 - react-native: 0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10) typescript: 5.9.3 - wagmi: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) + wagmi: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) transitivePeerDependencies: - '@types/react' - immer @@ -23827,6 +24067,14 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.6.1 + postcss: 8.5.6 + tsx: 4.21.0 + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1): dependencies: lilconfig: 3.1.3 @@ -23850,7 +24098,7 @@ snapshots: preact@10.24.2: {} - preact@10.27.1: {} + preact@10.29.8: {} prelude-ls@1.2.1: {} @@ -23915,7 +24163,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 22.17.2 + '@types/node': 22.20.1 long: 5.3.2 protobufjs@8.2.0: @@ -23939,6 +24187,11 @@ snapshots: end-of-stream: 1.4.5 once: 1.4.0 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + pumpify@2.0.1: dependencies: duplexify: 4.1.3 @@ -24081,7 +24334,7 @@ snapshots: react-refresh: 0.14.2 regenerator-runtime: 0.13.11 scheduler: 0.27.0 - semver: 7.7.4 + semver: 7.8.5 stacktrace-parser: 0.1.11 tinyglobby: 0.2.15 whatwg-fetch: 3.6.20 @@ -24127,7 +24380,7 @@ snapshots: react-refresh: 0.14.2 regenerator-runtime: 0.13.11 scheduler: 0.27.0 - semver: 7.7.4 + semver: 7.8.5 stacktrace-parser: 0.1.11 tinyglobby: 0.2.15 whatwg-fetch: 3.6.20 @@ -24416,6 +24669,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + send@0.19.0: dependencies: debug: 2.6.9 @@ -24502,7 +24757,7 @@ snapshots: dependencies: inherits: 2.0.4 safe-buffer: 5.2.1 - to-buffer: 1.2.1 + to-buffer: 1.2.2 sharp@0.34.5: dependencies: @@ -24880,7 +25135,7 @@ snapshots: tmpl@1.0.5: {} - to-buffer@1.2.1: + to-buffer@1.2.2: dependencies: isarray: 2.0.5 safe-buffer: 5.2.1 @@ -24987,6 +25242,34 @@ snapshots: - tsx - yaml + tsup@8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.9) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.1 + esbuild: 0.25.9 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0) + resolve-from: 5.0.0 + rollup: 4.46.4 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsup@8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.9) @@ -25111,6 +25394,10 @@ snapshots: dependencies: is-typedarray: 1.0.0 + typedjson@1.8.0: + dependencies: + tslib: 2.8.1 + typescript-eslint@8.48.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 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) @@ -25460,7 +25747,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.1 - vitest@3.2.7(@types/debug@4.1.12)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.17.2)(jiti@2.6.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.7 @@ -25486,7 +25773,7 @@ snapshots: vite-node: 3.2.4(@types/node@22.17.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 '@types/node': 22.17.2 jsdom: 26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) transitivePeerDependencies: @@ -25549,6 +25836,7 @@ snapshots: - fastestsmallesttextencoderdecoder - immer - ioredis + - preact-render-to-string - react-native - supports-color - uploadthing @@ -25556,10 +25844,10 @@ snapshots: - ws - zod - wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13): + wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13): dependencies: '@tanstack/react-query': 5.90.11(react@19.2.6) - '@wagmi/connectors': 6.2.0(d27b5019f10ead2ad2c8a66fcf3e72ab) + '@wagmi/connectors': 6.2.0(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) react: 19.2.6 use-sync-external-store: 1.4.0(react@19.2.6) @@ -25594,6 +25882,7 @@ snapshots: - fastestsmallesttextencoderdecoder - immer - ioredis + - preact-render-to-string - react-native - supports-color - uploadthing diff --git a/examples/typescript/servers/advanced/README.md b/examples/typescript/servers/advanced/README.md index ce952a0e55..48b1be9df6 100644 --- a/examples/typescript/servers/advanced/README.md +++ b/examples/typescript/servers/advanced/README.md @@ -72,6 +72,12 @@ and fill required environment variables: - `CARDANO_ADDRESS` - Cardano `addr_test1...` address to receive payments (optional for `all-networks`) - `CARDANO_L1_CONFIRMATIONS` - Optional confirmation policy (`-1..20`; unset = 1 confirmation) - `APTOS_ADDRESS` - Aptos account address to receive payments (optional for `all-networks`) +- `CASPER_ADDRESS` - Casper address to receive payments (optional for `all-networks`) +- `CASPER_NETWORK` - Casper network CAIP-2 (optional; defaults to `casper:casper-test`) +- `CASPER_ASSET` - CEP-18 contract package hash (optional for `all-networks`) +- `CASPER_TOKEN_NAME` - CEP-3009 EIP-712 token name (optional for `all-networks`) +- `CASPER_TOKEN_VERSION` - CEP-3009 EIP-712 token version (optional for `all-networks`) +- `CASPER_AMOUNT` - Casper CEP-18 amount (optional; defaults to `1500000000`) - `CCD_ADDRESS` - Concordium account address to receive payments (optional for `all-networks`) - `EVM_ADDRESS` - Ethereum address to receive payments - `SVM_ADDRESS` - Solana address to receive payments (optional for `all-networks`) @@ -142,6 +148,12 @@ The receiving account must exist on the ledger, i.e. hold the [base reserve](htt 1. Use the [XRPL Testnet faucet](https://xrpl.org/resources/dev-tools/xrp-faucets) to generate a funded account, and copy its classic address (starts with `r`) into `XRPL_ADDRESS`. 2. The `all-networks` example prices in XRP drops, so no further setup is needed. To receive issued-currency (IOU) payments instead, the receiving account must hold a [trust line](https://xrpl.org/docs/concepts/tokens/fungible-tokens) to the issuer. +#### Casper Testnet + +Create or reuse a dedicated Casper testnet account or wallet key, then fund any account that submits Casper transactions with testnet CSPR from the [CSPR.live testnet faucet](https://testnet.cspr.live/tools/faucet). CSPR is required for gas on Casper Testnet. + +Use [testnet.cspr.trade](https://testnet.cspr.trade) to get wrapped CSPR (WCSPR) or csprUSD for the client payments. + ## Available Examples Each example demonstrates a specific advanced pattern: diff --git a/examples/typescript/servers/advanced/all_networks.ts b/examples/typescript/servers/advanced/all_networks.ts index 80b76ef5e7..8e4ce8a50d 100644 --- a/examples/typescript/servers/advanced/all_networks.ts +++ b/examples/typescript/servers/advanced/all_networks.ts @@ -16,6 +16,7 @@ import { ExactAptosScheme } from "@x402/aptos/exact/server"; import { ExactAvmScheme } from "@x402/avm/exact/server"; import { ALGORAND_TESTNET_CAIP2 } from "@x402/avm"; import { ExactCardanoScheme } from "@x402/cardano/exact/server"; +import { ExactCasperScheme } from "@x402/casper/exact/server"; import { ExactConcordiumScheme } from "@x402/concordium/exact/server"; import { ExactEvmScheme } from "@x402/evm/exact/server"; import { ExactHederaScheme } from "@x402/hedera/exact/server"; @@ -37,6 +38,7 @@ config(); const avmAddress = process.env.AVM_ADDRESS as string | undefined; const cardanoAddress = process.env.CARDANO_ADDRESS as string | undefined; const aptosAddress = process.env.APTOS_ADDRESS as string | undefined; +const casperAddress = process.env.CASPER_ADDRESS as string | undefined; const ccdAddress = process.env.CCD_ADDRESS as string | undefined; const evmAddress = process.env.EVM_ADDRESS as `0x${string}` | undefined; const hederaAddress = process.env.HEDERA_ACCOUNT_ID as string | undefined; @@ -52,6 +54,7 @@ if ( !avmAddress && !cardanoAddress && !aptosAddress && + !casperAddress && !ccdAddress && !evmAddress && !svmAddress && @@ -63,7 +66,7 @@ if ( !xrplAddress ) { console.error( - "❌ At least one of AVM_ADDRESS, APTOS_ADDRESS, CARDANO_ADDRESS, CCD_ADDRESS, EVM_ADDRESS, KEETA_ADDRESS, NEAR_ADDRESS, SVM_ADDRESS, STELLAR_ADDRESS, HEDERA_ACCOUNT_ID, TVM_ADDRESS, or XRPL_ADDRESS is required", + "❌ At least one of AVM_ADDRESS, APTOS_ADDRESS, CARDANO_ADDRESS, CASPER_ADDRESS, CCD_ADDRESS, EVM_ADDRESS, KEETA_ADDRESS, NEAR_ADDRESS, SVM_ADDRESS, STELLAR_ADDRESS, HEDERA_ACCOUNT_ID, TVM_ADDRESS, or XRPL_ADDRESS is required", ); process.exit(1); } @@ -78,6 +81,12 @@ if (!facilitatorUrl) { const AVM_NETWORK = (process.env.AVM_NETWORK || ALGORAND_TESTNET_CAIP2) as Network; // Algorand Testnet const CARDANO_NETWORK = "cardano:preprod" as const; // Cardano Preprod Testnet const APTOS_NETWORK = (process.env.APTOS_NETWORK || APTOS_TESTNET_CAIP2) as Network; // Aptos Testnet +const CASPER_NETWORK = (process.env.CASPER_NETWORK || "casper:casper-test") as Network; // Casper Testnet +const CASPER_AMOUNT = (process.env.CASPER_AMOUNT || "900000") as string; // Casper CEP-18 amount (e.g., "1500000000" for 1.5 WCSPR) +const CASPER_ASSET = (process.env.CASPER_ASSET || + "0cb6f94834c60510d532b0ae077b18b4100874a4c867396d61c2b13c790ead52") as string; // Defaults to Casper WCSPR CEP-18 +const CASPER_TOKEN_NAME = (process.env.CASPER_TOKEN_NAME || "csprUSD") as string; // Casper CEP-18 token name (e.g., "csprUSD") +const CASPER_TOKEN_VERSION = (process.env.CASPER_TOKEN_VERSION || "1") as string; // Casper CEP-18 token version const CCD_NETWORK = "ccd:4221332d34e1694168c2a0c0b3fd0f27" as const; // Concordium Testnet const EVM_NETWORK = "eip155:84532" as const; // Base Sepolia const HEDERA_NETWORK = "hedera:testnet" as const; // Hedera Testnet @@ -129,6 +138,24 @@ if (cardanoAddress) { ...(cardanoExtra ? { extra: cardanoExtra } : {}), }); } + +if (casperAddress) { + accepts.push({ + scheme: "exact", + price: { + amount: CASPER_AMOUNT, + asset: CASPER_ASSET, + extra: { + name: CASPER_TOKEN_NAME, + version: CASPER_TOKEN_VERSION, + decimals: 6, + }, + }, + network: CASPER_NETWORK, + payTo: casperAddress, + }); +} + if (ccdAddress) { accepts.push({ scheme: "exact", @@ -225,6 +252,9 @@ if (aptosAddress) { if (cardanoAddress) { server.register(CARDANO_NETWORK, new ExactCardanoScheme()); } +if (casperAddress) { + server.register(CASPER_NETWORK, new ExactCasperScheme()); +} if (ccdAddress) { server.register(CCD_NETWORK, new ExactConcordiumScheme()); } @@ -298,6 +328,9 @@ app.listen(port, () => { if (cardanoAddress) { console.log(` Cardano: ${cardanoAddress} on ${CARDANO_NETWORK}`); } + if (casperAddress) { + console.log(` Casper: ${casperAddress} on ${CASPER_NETWORK}`); + } if (ccdAddress) { console.log(` CCD: ${ccdAddress} on ${CCD_NETWORK}`); } diff --git a/examples/typescript/servers/advanced/package.json b/examples/typescript/servers/advanced/package.json index 54f9d2d5d4..790711dbdb 100644 --- a/examples/typescript/servers/advanced/package.json +++ b/examples/typescript/servers/advanced/package.json @@ -23,6 +23,7 @@ "@x402/aptos": "workspace:*", "@x402/avm": "workspace:*", "@x402/cardano": "workspace:*", + "@x402/casper": "workspace:*", "@x402/concordium": "workspace:*", "@x402/core": "workspace:*", "@x402/express": "workspace:*", diff --git a/go/.changes/unreleased/added-20260908-batch-settlement-mindeposit.yaml b/go/.changes/unreleased/added-20260908-batch-settlement-mindeposit.yaml new file mode 100644 index 0000000000..9ed0256cd6 --- /dev/null +++ b/go/.changes/unreleased/added-20260908-batch-settlement-mindeposit.yaml @@ -0,0 +1,3 @@ +kind: added +body: Pass the resolved atomic spendControls cap to every scheme on PaymentPayloadContext.MaxAmountPerPayment (omitted when uncapped) so capital-locking schemes can reuse client policy without re-resolving it. Batch-settlement EVM servers always announce extra.minDeposit (default 10 × amount, optional per-route override via accepts.extra.minDeposit). Clients size deposits from the hint when valid, clamped to that cap × DepositMultiplier when a spend cap is set. Uncapped payments leave deposits uncapped. Older 402s fall back to DepositMultiplier for sizing. Servers may opt in to SDK enforcement via EnforceMinDeposit (default off; facilitator never enforces). +time: 2026-09-08T09:00:00Z diff --git a/go/.changes/unreleased/added-20260912-celo-usdt-default-asset.yaml b/go/.changes/unreleased/added-20260912-celo-usdt-default-asset.yaml deleted file mode 100644 index e6c10ee14f..0000000000 --- a/go/.changes/unreleased/added-20260912-celo-usdt-default-asset.yaml +++ /dev/null @@ -1,3 +0,0 @@ -kind: added -body: Add Celo mainnet USDT and USAT (EIP-3009) as default assets for eip155:42220 so "$0.10 USDT" and "$0.10 USAT" resolve on Celo; bare "$0.10" still resolves to USDC. -time: 2026-09-12T12:00:00Z diff --git a/go/.changes/unreleased/changed-20260908-http-facilitator-client-timeout.yaml b/go/.changes/unreleased/changed-20260908-http-facilitator-client-timeout.yaml deleted file mode 100644 index f45b4bd75c..0000000000 --- a/go/.changes/unreleased/changed-20260908-http-facilitator-client-timeout.yaml +++ /dev/null @@ -1,3 +0,0 @@ -kind: changed -body: Raise HTTPFacilitatorClient default per-request timeout from 30s to 90s so long settle() calls can finish. -time: 2026-09-08T07:00:00Z diff --git a/go/.changes/unreleased/changed-20260909-upto-delegated-auth-store.yaml b/go/.changes/unreleased/changed-20260909-upto-delegated-auth-store.yaml deleted file mode 100644 index fc031e5733..0000000000 --- a/go/.changes/unreleased/changed-20260909-upto-delegated-auth-store.yaml +++ /dev/null @@ -1,3 +0,0 @@ -kind: changed -body: Split SVM upto delegated-auth store Get failures from unauthenticated so hosts can map infra errors separately. -time: 2026-09-09T12:00:00Z diff --git a/go/.changes/unreleased/changed-20260915-unify-payload-context.yaml b/go/.changes/unreleased/changed-20260915-unify-payload-context.yaml new file mode 100644 index 0000000000..288ceccd1b --- /dev/null +++ b/go/.changes/unreleased/changed-20260915-unify-payload-context.yaml @@ -0,0 +1,3 @@ +kind: changed +body: Pass PaymentPayloadContext (server extensions and resolved spend cap) on SchemeNetworkClient and SchemeNetworkClientV1 CreatePaymentPayload. Remove the optional CreatePaymentPayloadWithExtensions / ExtensionAwareClient split so every scheme uses one payload API. +time: 2026-09-15T09:00:00Z diff --git a/go/.changes/unreleased/fixed-20260910-route-template-fixed-point-decode.yaml b/go/.changes/unreleased/fixed-20260910-route-template-fixed-point-decode.yaml deleted file mode 100644 index 5b80e19506..0000000000 --- a/go/.changes/unreleased/fixed-20260910-route-template-fixed-point-decode.yaml +++ /dev/null @@ -1,3 +0,0 @@ -kind: fixed -body: Decode routeTemplate to a fixed point (bounded pass budget) before traversal and scheme-injection checks so double- and deeper-encoded payloads cannot bypass isValidRouteTemplate. -time: 2026-09-10T07:22:00Z diff --git a/go/.changes/v2.26.0.md b/go/.changes/v2.26.0.md new file mode 100644 index 0000000000..18e8f8237c --- /dev/null +++ b/go/.changes/v2.26.0.md @@ -0,0 +1,13 @@ +## v2.26.0 - 2026-09-15 +### Added +- Add Celo mainnet USDT and USAT (EIP-3009) as default assets for eip155:42220 so "$0.10 USDT" and "$0.10 USAT" resolve on Celo; bare "$0.10" still resolves to USDC. ([#3457](https://github.com/x402-foundation/x402/pull/3457)) - Thanks [@GigaHierz](https://github.com/GigaHierz)! +### Changed +- Raise HTTPFacilitatorClient default per-request timeout from 30s to 90s so long settle() calls can finish. ([#3408](https://github.com/x402-foundation/x402/pull/3408)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- Split SVM upto delegated-auth store Get failures from unauthenticated so hosts can map infra errors separately. ([#3431](https://github.com/x402-foundation/x402/pull/3431)) - Thanks [@phdargen](https://github.com/phdargen)! +- Size MCP paid tool-call timeouts from the accept's maxTimeoutSeconds (default 300s) and use a 300s ceiling for the initial 402 probe. ([#3442](https://github.com/x402-foundation/x402/pull/3442)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- Cap MCP tool-call timeouts with Options.MaxRequestTimeout (default 10m) while still honoring accept maxTimeoutSeconds up to that ceiling; v1 paid retries now apply the same timeout helper. ([#3481](https://github.com/x402-foundation/x402/pull/3481)) - Thanks [@phdargen](https://github.com/phdargen)! +### Fixed +- Decode routeTemplate to a fixed point (bounded pass budget) before traversal and scheme-injection checks so double- and deeper-encoded payloads cannot bypass isValidRouteTemplate. ([#3441](https://github.com/x402-foundation/x402/pull/3441)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- MCP client dispatches HandlePaymentResponse after paid tool calls so batch-settlement can persist channel state. ([#3482](https://github.com/x402-foundation/x402/pull/3482)) - Thanks [@phdargen](https://github.com/phdargen)! +- MCP server surfaces VerifyError.InvalidReason on paid-tool 402s so batch-settlement recovery can match the protocol error code. ([#3482](https://github.com/x402-foundation/x402/pull/3482)) - Thanks [@phdargen](https://github.com/phdargen)! +- MCP 402 responses snapshot payment accepts before scheme enrichers so batch-settlement recovery retries still match. ([#3482](https://github.com/x402-foundation/x402/pull/3482)) - Thanks [@phdargen](https://github.com/phdargen)! \ No newline at end of file diff --git a/go/CHANGELOG.md b/go/CHANGELOG.md index 4cc9d03c37..6521bd65a5 100644 --- a/go/CHANGELOG.md +++ b/go/CHANGELOG.md @@ -1,3 +1,17 @@ +## v2.26.0 - 2026-09-15 +### Added +- Add Celo mainnet USDT and USAT (EIP-3009) as default assets for eip155:42220 so "$0.10 USDT" and "$0.10 USAT" resolve on Celo; bare "$0.10" still resolves to USDC. ([#3457](https://github.com/x402-foundation/x402/pull/3457)) - Thanks [@GigaHierz](https://github.com/GigaHierz)! +### Changed +- Raise HTTPFacilitatorClient default per-request timeout from 30s to 90s so long settle() calls can finish. ([#3408](https://github.com/x402-foundation/x402/pull/3408)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- Split SVM upto delegated-auth store Get failures from unauthenticated so hosts can map infra errors separately. ([#3431](https://github.com/x402-foundation/x402/pull/3431)) - Thanks [@phdargen](https://github.com/phdargen)! +- Size MCP paid tool-call timeouts from the accept's maxTimeoutSeconds (default 300s) and use a 300s ceiling for the initial 402 probe. ([#3442](https://github.com/x402-foundation/x402/pull/3442)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- Cap MCP tool-call timeouts with Options.MaxRequestTimeout (default 10m) while still honoring accept maxTimeoutSeconds up to that ceiling; v1 paid retries now apply the same timeout helper. ([#3481](https://github.com/x402-foundation/x402/pull/3481)) - Thanks [@phdargen](https://github.com/phdargen)! +### Fixed +- Decode routeTemplate to a fixed point (bounded pass budget) before traversal and scheme-injection checks so double- and deeper-encoded payloads cannot bypass isValidRouteTemplate. ([#3441](https://github.com/x402-foundation/x402/pull/3441)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- MCP client dispatches HandlePaymentResponse after paid tool calls so batch-settlement can persist channel state. ([#3482](https://github.com/x402-foundation/x402/pull/3482)) - Thanks [@phdargen](https://github.com/phdargen)! +- MCP server surfaces VerifyError.InvalidReason on paid-tool 402s so batch-settlement recovery can match the protocol error code. ([#3482](https://github.com/x402-foundation/x402/pull/3482)) - Thanks [@phdargen](https://github.com/phdargen)! +- MCP 402 responses snapshot payment accepts before scheme enrichers so batch-settlement recovery retries still match. ([#3482](https://github.com/x402-foundation/x402/pull/3482)) - Thanks [@phdargen](https://github.com/phdargen)! + ## v2.25.0 - 2026-09-04 ### Added - Add an optional facilitator-delegated receiver-authorizer mode to SVM `upto`. The facilitator may advertise `receiverAuthorizer` in `/supported`; a server that omits `ReceiverAuthorizerSigner` delegates voucher signing, and the facilitator signs after correlating the claim settle to the same authenticated caller that opened the channel. ([#3347](https://github.com/x402-foundation/x402/pull/3347)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! diff --git a/go/client.go b/go/client.go index 271ebe3f1b..8c7e02823e 100644 --- a/go/client.go +++ b/go/client.go @@ -383,6 +383,88 @@ func (c *x402Client) SelectPaymentRequirements(requirements []types.PaymentRequi var atomicAmountPattern = regexp.MustCompile(`^\d+$`) +func isAtomicAmount(amount string) bool { + return atomicAmountPattern.MatchString(amount) +} + +func findSpendControlAssetEntry( + controls SpendControls, + network Network, + asset string, + defaultAsset *DefaultAsset, +) *SpendControlAsset { + if controls.AllowAnyAsset { + return nil + } + for i := range controls.AllowedAssets { + entry := &controls.AllowedAssets[i] + if !MatchesNetwork(entry.Network, network) { + continue + } + if strings.EqualFold(entry.Asset, asset) { + return entry + } + if defaultAsset != nil && strings.EqualFold(defaultAsset.Symbol, entry.Asset) { + return entry + } + } + return nil +} + +func resolveAtomicSpendCap( + controls SpendControls, + network Network, + asset string, + defaultAsset *DefaultAsset, +) (string, error) { + assetEntry := findSpendControlAssetEntry(controls, network, asset, defaultAsset) + if assetEntry != nil && assetEntry.MaxAmountPerPayment != "" { + if !isAtomicAmount(assetEntry.MaxAmountPerPayment) { + return "", fmt.Errorf( + "spendControls.allowedAssets[].maxAmountPerPayment must be an integer atomic amount, not a dollar value; got %q", + assetEntry.MaxAmountPerPayment, + ) + } + return assetEntry.MaxAmountPerPayment, nil + } + + if defaultAsset == nil || controls.DisableMaxAmountPerPayment { + return "", nil + } + + usdLimit := DefaultMaxAmountPerPayment + if controls.MaxAmountPerPayment != "" { + usdLimit = controls.MaxAmountPerPayment + } + parsed, err := ParseMoneyString(usdLimit) + if err != nil { + return "", err + } + return ConvertToTokenAmount(parsed, defaultAsset.Decimals) +} + +func (c *x402Client) resolvePaymentPayloadContext( + client any, + network Network, + asset string, + extensions map[string]interface{}, +) (PaymentPayloadContext, error) { + payloadCtx := PaymentPayloadContext{Extensions: extensions} + if !c.spendControlsEnabled { + return payloadCtx, nil + } + var defaultAsset *DefaultAsset + if finder, ok := client.(DefaultAssetFinder); ok { + defaultAsset = finder.FindDefaultAsset(asset, network) + } + cap, err := resolveAtomicSpendCap(c.spendControls, network, asset, defaultAsset) + if err != nil { + return PaymentPayloadContext{}, err + } + payloadCtx.MaxAmountPerPayment = cap + return payloadCtx, nil +} + // applySpendControls filters by spend controls (default-asset allowlist → opt-in assets → caps). // Keeps any accept that fits so a mixed offer can still pay the affordable option. func (c *x402Client) applySpendControls(x402Version int, requirements []PaymentRequirementsView) ([]PaymentRequirementsView, error) { @@ -394,13 +476,6 @@ func (c *x402Client) applySpendControls(x402Version int, requirements []PaymentR rawAmountOf := func(requirement PaymentRequirementsView) string { return requirement.GetAmount() } - amountOf := func(requirement PaymentRequirementsView) *big.Int { - n, ok := new(big.Int).SetString(rawAmountOf(requirement), 10) - if !ok { - return big.NewInt(0) - } - return n - } schemeFor := func(requirement PaymentRequirementsView) any { network := Network(requirement.GetNetwork()) scheme := requirement.GetScheme() @@ -416,27 +491,13 @@ func (c *x402Client) applySpendControls(x402Version int, requirements []PaymentR } return finder.FindDefaultAsset(requirement.GetAsset(), Network(requirement.GetNetwork())) } - matchesAssetEntry := func(entry SpendControlAsset, requirement PaymentRequirementsView) bool { - if !MatchesNetwork(entry.Network, Network(requirement.GetNetwork())) { - return false - } - if strings.EqualFold(entry.Asset, requirement.GetAsset()) { - return true - } - defaultAsset := defaultAssetFor(requirement) - return defaultAsset != nil && strings.EqualFold(defaultAsset.Symbol, entry.Asset) - } - var assetEntries []SpendControlAsset - if !controls.AllowAnyAsset { - assetEntries = controls.AllowedAssets - } findAssetEntry := func(requirement PaymentRequirementsView) *SpendControlAsset { - for i := range assetEntries { - if matchesAssetEntry(assetEntries[i], requirement) { - return &assetEntries[i] - } - } - return nil + return findSpendControlAssetEntry( + controls, + Network(requirement.GetNetwork()), + requirement.GetAsset(), + defaultAssetFor(requirement), + ) } filtered := requirements @@ -468,40 +529,32 @@ func (c *x402Client) applySpendControls(x402Version int, requirements []PaymentR capped := beforeAmountCaps[:0] for _, requirement := range beforeAmountCaps { - assetEntry := findAssetEntry(requirement) - if assetEntry != nil && assetEntry.MaxAmountPerPayment != "" { - if !atomicAmountPattern.MatchString(assetEntry.MaxAmountPerPayment) { - return nil, fmt.Errorf( - "spendControls.allowedAssets[].maxAmountPerPayment must be an integer atomic amount, not a dollar value; got %q", - assetEntry.MaxAmountPerPayment, - ) - } - if !atomicAmountPattern.MatchString(rawAmountOf(requirement)) { - rejectedByAssetCap = true - continue - } - capN, _ := new(big.Int).SetString(assetEntry.MaxAmountPerPayment, 10) - if amountOf(requirement).Cmp(capN) <= 0 { - capped = append(capped, requirement) - } else { - rejectedByAssetCap = true - } - continue - } - defaultAsset := defaultAssetFor(requirement) - if defaultAsset == nil { - capped = append(capped, requirement) - continue + assetEntry := findAssetEntry(requirement) + cap, err := resolveAtomicSpendCap( + controls, + Network(requirement.GetNetwork()), + requirement.GetAsset(), + defaultAsset, + ) + if err != nil { + return nil, err } - - if usdLimitDisabled { + if cap == "" { capped = append(capped, requirement) continue } rawAmount := rawAmountOf(requirement) - if !atomicAmountPattern.MatchString(rawAmount) { + if !isAtomicAmount(rawAmount) { + if assetEntry != nil && assetEntry.MaxAmountPerPayment != "" { + rejectedByAssetCap = true + continue + } + if usdLimitDisabled { + capped = append(capped, requirement) + continue + } valueScaled, err := ConvertToTokenAmount(rawAmount, 18) if err != nil { return nil, err @@ -518,24 +571,21 @@ func (c *x402Client) applySpendControls(x402Version int, requirements []PaymentR capN, _ := new(big.Int).SetString(capScaled, 10) if valueN.Cmp(capN) <= 0 { capped = append(capped, requirement) - } else { + } else if defaultAsset != nil { rejectedUsdSymbol = defaultAsset.Symbol } continue } - parsed, err := ParseMoneyString(usdLimit) - if err != nil { - return nil, err - } - maxAtomic, err := ConvertToTokenAmount(parsed, defaultAsset.Decimals) - if err != nil { - return nil, err - } - maxN, _ := new(big.Int).SetString(maxAtomic, 10) - if amountOf(requirement).Cmp(maxN) <= 0 { + amountN, _ := new(big.Int).SetString(rawAmount, 10) + capN, _ := new(big.Int).SetString(cap, 10) + if amountN.Cmp(capN) <= 0 { capped = append(capped, requirement) - } else { + continue + } + if assetEntry != nil && assetEntry.MaxAmountPerPayment != "" { + rejectedByAssetCap = true + } else if defaultAsset != nil { rejectedUsdSymbol = defaultAsset.Symbol } } @@ -623,7 +673,11 @@ func (c *x402Client) CreatePaymentPayloadV1( } } - payload, err := client.CreatePaymentPayload(ctx, requirements) + payloadCtx, err := c.resolvePaymentPayloadContext(client, network, requirements.Asset, nil) + var payload types.PaymentPayloadV1 + if err == nil { + payload, err = client.CreatePaymentPayload(ctx, requirements, payloadCtx) + } if err != nil { for _, hook := range c.onPaymentCreationFailureHooks { result, hookErr := hook(PaymentCreationFailureContext{ @@ -700,14 +754,10 @@ func (c *x402Client) CreatePaymentPayload( } } - // Get partial payload from mechanism. - // If the scheme supports extensions (e.g., EIP-2612), pass them for enrichment. + payloadCtx, err := c.resolvePaymentPayloadContext(client, network, requirements.Asset, extensions) var partial types.PaymentPayload - var err error - if extAware, ok := client.(ExtensionAwareClient); ok && extensions != nil { - partial, err = extAware.CreatePaymentPayloadWithExtensions(ctx, requirements, extensions) - } else { - partial, err = client.CreatePaymentPayload(ctx, requirements) + if err == nil { + partial, err = client.CreatePaymentPayload(ctx, requirements, payloadCtx) } if err != nil { for _, hook := range c.onPaymentCreationFailureHooks { diff --git a/go/client_test.go b/go/client_test.go index 7ddf582413..9f7bd51675 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -32,7 +32,7 @@ func (m *mockSchemeNetworkClientV1) FindDefaultAsset(asset string, network Netwo return &DefaultAsset{Asset: asset, Decimals: 6, Symbol: "MOCK"} } -func (m *mockSchemeNetworkClientV1) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirementsV1) (types.PaymentPayloadV1, error) { +func (m *mockSchemeNetworkClientV1) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirementsV1, _ PaymentPayloadContext) (types.PaymentPayloadV1, error) { return types.PaymentPayloadV1{ X402Version: 1, Scheme: m.scheme, @@ -46,9 +46,15 @@ func (m *mockSchemeNetworkClientV1) CreatePaymentPayload(ctx context.Context, re // Mock V2 client for testing type mockSchemeNetworkClientV2 struct { - scheme string - findDefaultAsset func(asset string, network Network) *DefaultAsset - noFindDefaultAsset bool + scheme string + findDefaultAsset func(asset string, network Network) *DefaultAsset + noFindDefaultAsset bool + createPaymentPayloadCalls []mockCreatePaymentPayloadCall +} + +type mockCreatePaymentPayloadCall struct { + requirements types.PaymentRequirements + context PaymentPayloadContext } func (m *mockSchemeNetworkClientV2) Scheme() string { @@ -65,7 +71,11 @@ func (m *mockSchemeNetworkClientV2) FindDefaultAsset(asset string, network Netwo return &DefaultAsset{Asset: asset, Decimals: 6, Symbol: "MOCK"} } -func (m *mockSchemeNetworkClientV2) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements) (types.PaymentPayload, error) { +func (m *mockSchemeNetworkClientV2) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements, payloadCtx PaymentPayloadContext) (types.PaymentPayload, error) { + m.createPaymentPayloadCalls = append(m.createPaymentPayloadCalls, mockCreatePaymentPayloadCall{ + requirements: requirements, + context: payloadCtx, + }) return types.PaymentPayload{ X402Version: 2, Payload: map[string]interface{}{ @@ -527,6 +537,7 @@ func (m *mockFailableV1) Scheme() string { return "mock" } func (m *mockFailableV1) CreatePaymentPayload( _ context.Context, _ types.PaymentRequirementsV1, + _ PaymentPayloadContext, ) (types.PaymentPayloadV1, error) { if m.fail { return types.PaymentPayloadV1{}, fmt.Errorf("fail") @@ -540,6 +551,7 @@ func (m *mockFailableV2) Scheme() string { return "mock" } func (m *mockFailableV2) CreatePaymentPayload( _ context.Context, _ types.PaymentRequirements, + _ PaymentPayloadContext, ) (types.PaymentPayload, error) { if m.fail { return types.PaymentPayload{}, fmt.Errorf("fail") @@ -1150,3 +1162,78 @@ func TestSpendControls(t *testing.T) { require.Contains(t, err.Error(), "maxAmountPerPayment") }) } + +func TestCreatePaymentPayloadSpendCapContext(t *testing.T) { + network := Network("eip155:8453") + usdc := DefaultAsset{ + Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Decimals: 6, + Symbol: "USDC", + } + + clientWithDefaultAsset := func(entry DefaultAsset, controls *SpendControls, disabled bool) (*x402Client, *mockSchemeNetworkClientV2) { + mockClient := &mockSchemeNetworkClientV2{scheme: "exact"} + mockClient.findDefaultAsset = func(asset string, _ Network) *DefaultAsset { + if strings.EqualFold(asset, entry.Asset) { + copied := entry + return &copied + } + return nil + } + client := Newx402Client() + client.Register(network, mockClient) + if disabled { + client.DisableSpendControls() + } else if controls != nil { + client.SetSpendControls(*controls) + } + return client, mockClient + } + + req := func(amount string) types.PaymentRequirements { + return types.PaymentRequirements{ + Scheme: "exact", + Network: string(network), + Asset: usdc.Asset, + Amount: amount, + PayTo: "0xpay", + } + } + + t.Run("passes the resolved atomic spend cap on payment payload context", func(t *testing.T) { + client, mockClient := clientWithDefaultAsset(usdc, nil, false) + _, err := client.CreatePaymentPayload(context.Background(), req("1000"), nil, nil) + require.NoError(t, err) + require.Equal(t, "1000000", mockClient.createPaymentPayloadCalls[0].context.MaxAmountPerPayment) + }) + + t.Run("omits the spend cap on context when spend controls are disabled", func(t *testing.T) { + client, mockClient := clientWithDefaultAsset(usdc, nil, true) + _, err := client.CreatePaymentPayload(context.Background(), req("5000000"), nil, nil) + require.NoError(t, err) + require.Empty(t, mockClient.createPaymentPayloadCalls[0].context.MaxAmountPerPayment) + }) + + t.Run("omits the spend cap on context when the USD cap is disabled", func(t *testing.T) { + client, mockClient := clientWithDefaultAsset(usdc, &SpendControls{DisableMaxAmountPerPayment: true}, false) + _, err := client.CreatePaymentPayload(context.Background(), req("5000000"), nil, nil) + require.NoError(t, err) + require.Empty(t, mockClient.createPaymentPayloadCalls[0].context.MaxAmountPerPayment) + }) + + t.Run("passes a custom Money USD cap on context in atomic units", func(t *testing.T) { + client, mockClient := clientWithDefaultAsset(usdc, &SpendControls{MaxAmountPerPayment: "$5"}, false) + _, err := client.CreatePaymentPayload(context.Background(), req("1000"), nil, nil) + require.NoError(t, err) + require.Equal(t, "5000000", mockClient.createPaymentPayloadCalls[0].context.MaxAmountPerPayment) + }) + + t.Run("passes an allowedAssets atomic cap on context", func(t *testing.T) { + client, mockClient := clientWithDefaultAsset(usdc, &SpendControls{ + AllowedAssets: []SpendControlAsset{{Asset: usdc.Asset, Network: network, MaxAmountPerPayment: "500000"}}, + }, false) + _, err := client.CreatePaymentPayload(context.Background(), req("100"), nil, nil) + require.NoError(t, err) + require.Equal(t, "500000", mockClient.createPaymentPayloadCalls[0].context.MaxAmountPerPayment) + }) +} diff --git a/go/constants.go b/go/constants.go index 947e0bace3..7404494e91 100644 --- a/go/constants.go +++ b/go/constants.go @@ -3,7 +3,7 @@ package x402 // Version constants const ( // Version is the SDK version - Version = "2.25.0" + Version = "2.26.0" // ProtocolVersion is the current x402 protocol version ProtocolVersion = 2 diff --git a/go/http/client_test.go b/go/http/client_test.go index 70db28659b..9c19d0ce18 100644 --- a/go/http/client_test.go +++ b/go/http/client_test.go @@ -1175,7 +1175,7 @@ func (m *mockSchemeClient) Scheme() string { return m.scheme } -func (m *mockSchemeClient) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements) (types.PaymentPayload, error) { +func (m *mockSchemeClient) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements, _ x402.PaymentPayloadContext) (types.PaymentPayload, error) { return types.PaymentPayload{ X402Version: 2, Payload: map[string]interface{}{"mock": "payload"}, @@ -1195,7 +1195,7 @@ type hookSchemeClient struct { func (m *hookSchemeClient) Scheme() string { return m.scheme } -func (m *hookSchemeClient) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements) (types.PaymentPayload, error) { +func (m *hookSchemeClient) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements, _ x402.PaymentPayloadContext) (types.PaymentPayload, error) { m.createPayloadCnt++ return types.PaymentPayload{ X402Version: 2, diff --git a/go/interfaces.go b/go/interfaces.go index 664c8bfb46..000cb63991 100644 --- a/go/interfaces.go +++ b/go/interfaces.go @@ -28,7 +28,7 @@ type MoneyParser func(amount string, network Network) (*AssetAmount, error) // SchemeNetworkClientV1 is implemented by client-side V1 payment mechanisms type SchemeNetworkClientV1 interface { Scheme() string - CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirementsV1) (types.PaymentPayloadV1, error) + CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirementsV1, payloadCtx PaymentPayloadContext) (types.PaymentPayloadV1, error) } // SchemeNetworkFacilitatorV1 is implemented by facilitator-side V1 payment mechanisms @@ -86,16 +86,14 @@ type SchemeNetworkFacilitatorV1 interface { // SchemeNetworkClient is implemented by client-side payment mechanisms (V2) type SchemeNetworkClient interface { Scheme() string - CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements) (types.PaymentPayload, error) + CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements, payloadCtx PaymentPayloadContext) (types.PaymentPayload, error) } -// ExtensionAwareClient is an optional interface for schemes that can handle extensions. -// When a scheme implements this, x402Client will call CreatePaymentPayloadWithExtensions -// instead of CreatePaymentPayload, passing the server-declared extensions so the scheme -// can enrich the payload (e.g., EIP-2612 gas sponsoring). -type ExtensionAwareClient interface { - SchemeNetworkClient - CreatePaymentPayloadWithExtensions(ctx context.Context, requirements types.PaymentRequirements, extensions map[string]interface{}) (types.PaymentPayload, error) +// PaymentPayloadContext is passed to scheme CreatePaymentPayload. +// MaxAmountPerPayment is the resolved atomic spend cap; omitted when uncapped. +type PaymentPayloadContext struct { + Extensions map[string]interface{} + MaxAmountPerPayment string } // DefaultAssetFinder is an optional reverse lookup for USD spend caps. @@ -131,8 +129,8 @@ type PaymentResponseResult struct { // PaymentResponseHandler is an optional interface that SchemeNetworkClient // implementations satisfy to reconcile local state after a paid response. -// The transport (PaymentRoundTripper) invokes this hook automatically — user -// code does not need to update local channel state manually. +// HTTP (PaymentRoundTripper) and MCP (X402MCPClient) invoke this hook +// automatically — user code does not need to update local channel state manually. // // Mirrors the TS schemeHooks.onPaymentResponse field on SchemeClientHooks. type PaymentResponseHandler interface { diff --git a/go/mcp/client.go b/go/mcp/client.go index ba95f37aea..3ebc2664eb 100644 --- a/go/mcp/client.go +++ b/go/mcp/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" x402 "github.com/x402-foundation/x402/go/v2" @@ -89,7 +90,10 @@ func (c *X402MCPClient) CallTool(ctx context.Context, name string, args map[stri Arguments: args, } - result, err := c.caller.CallTool(ctx, params) + probeCtx, probeCancel := withTimeoutIfNone(ctx, c.probeTimeout()) + defer probeCancel() + + result, err := c.caller.CallTool(probeCtx, params) if err != nil { return nil, fmt.Errorf("tool call failed: %w", err) } @@ -200,7 +204,11 @@ func (c *X402MCPClient) callToolWithPayload(ctx context.Context, name string, ar Meta: mcp.Meta{MCP_PAYMENT_META_KEY: payload}, } - result, err := c.caller.CallTool(ctx, params) + timeoutSeconds := payload.Accepted.MaxTimeoutSeconds + paidCtx, paidCancel := withTimeoutIfNone(ctx, c.paidTimeout(timeoutSeconds)) + defer paidCancel() + + result, err := c.caller.CallTool(paidCtx, params) if err != nil { return nil, fmt.Errorf("paid tool call failed: %w", err) } @@ -218,9 +226,92 @@ func (c *X402MCPClient) callToolWithPayload(ctx context.Context, name string, ar }) } + var paymentRequired *types.PaymentRequired + if paymentResponse == nil && result.IsError { + paymentRequired = extractPaymentRequired(result) + } + + recovered, err := c.handlePaidToolPaymentResponse(ctx, payload, paymentResponse, paymentRequired) + if err != nil { + return nil, err + } + + if recovered && paymentRequired != nil { + freshPayload, err := c.paymentClient.CreatePaymentPayload( + ctx, + payload.Accepted, + paymentRequired.Resource, + paymentRequired.Extensions, + ) + if err != nil { + return buildMCPToolCallResultFromSDK(result, true), nil + } + + retryParams := &mcp.CallToolParams{ + Name: name, + Arguments: args, + Meta: mcp.Meta{MCP_PAYMENT_META_KEY: freshPayload}, + } + retryTimeout := freshPayload.Accepted.MaxTimeoutSeconds + if retryTimeout == 0 { + retryTimeout = 300 + } + retryCtx, retryCancel := withTimeoutIfNone(ctx, time.Duration(retryTimeout)*time.Second) + defer retryCancel() + + retryResult, err := c.caller.CallTool(retryCtx, retryParams) + if err != nil { + return nil, fmt.Errorf("paid tool call failed: %w", err) + } + + retryPaymentResponse := extractPaymentResponseFromSDK(retryResult) + if c.onAfterPay != nil && retryPaymentResponse != nil { + mcpResult := callToolResultToMCPToolResult(retryResult) + _ = c.onAfterPay(AfterPaymentContext{ + ToolName: name, + PaymentPayload: freshPayload, + Result: mcpResult, + SettleResponse: retryPaymentResponse, + }) + } + + var retryPaymentRequired *types.PaymentRequired + if retryPaymentResponse == nil && retryResult.IsError { + retryPaymentRequired = extractPaymentRequired(retryResult) + } + if _, err := c.handlePaidToolPaymentResponse(ctx, freshPayload, retryPaymentResponse, retryPaymentRequired); err != nil { + return nil, err + } + + return buildMCPToolCallResultFromSDK(retryResult, true), nil + } + return buildMCPToolCallResultFromSDK(result, true), nil } +func (c *X402MCPClient) handlePaidToolPaymentResponse( + ctx context.Context, + payload types.PaymentPayload, + settleResponse *x402.SettleResponse, + paymentRequired *types.PaymentRequired, +) (bool, error) { + if settleResponse == nil && paymentRequired == nil { + return false, nil + } + + prCtx := x402.PaymentResponseContext{ + PaymentPayload: payload, + Requirements: payload.Accepted, + SettleResponse: settleResponse, + PaymentRequired: paymentRequired, + } + result, err := c.paymentClient.HandlePaymentResponse(ctx, prCtx) + if err != nil { + return false, err + } + return result.Recovered, nil +} + // callToolWithV1Payment handles the x402 v1 payment flow (legacy). v1 PaymentRequired // has no `accepted` wrapper and uses maxAmountRequired / legacy network names; the v1 // scheme registered via RegisterV1 signs it. Hooks receive a v2-shaped view for approval. @@ -288,7 +379,10 @@ func (c *X402MCPClient) callToolWithV1Payment( return nil, fmt.Errorf("failed to create v1 payment: %w", err) } - return c.callToolWithPayloadV1(ctx, name, args, payload) + paidCtx, paidCancel := withTimeoutIfNone(ctx, c.paidTimeout(selected.MaxTimeoutSeconds)) + defer paidCancel() + + return c.callToolWithPayloadV1(paidCtx, name, args, payload) } // callToolWithPayloadV1 retries a tool call with a v1 payment attached in _meta. @@ -656,3 +750,64 @@ func paymentRequiredV1ToView(pr *types.PaymentRequiredV1) types.PaymentRequired } return types.PaymentRequired{X402Version: 1, Error: pr.Error, Accepts: accepts} } + +// withTimeoutIfNone adds a deadline only when ctx has none. +func withTimeoutIfNone(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + if _, ok := ctx.Deadline(); ok { + return ctx, func() {} + } + return context.WithTimeout(ctx, timeout) +} + +const ( + defaultProbeTimeoutSeconds = 300 + defaultAcceptTimeoutSeconds = 300 + defaultMaxRequestTimeout = 10 * time.Minute + maxTimerSafeDuration = time.Duration(1<<31-1) * time.Millisecond +) + +func (c *X402MCPClient) maxRequestTimeout() time.Duration { + if c.options.MaxRequestTimeout <= 0 { + return defaultMaxRequestTimeout + } + return c.options.MaxRequestTimeout +} + +func (c *X402MCPClient) capSeconds() int { + sec := int(c.maxRequestTimeout() / time.Second) + if sec <= 0 { + sec = int(defaultMaxRequestTimeout / time.Second) + } + return sec +} + +func clampTimerSafeDuration(d time.Duration) time.Duration { + if d > maxTimerSafeDuration { + return maxTimerSafeDuration + } + if d < 0 { + return 0 + } + return d +} + +func (c *X402MCPClient) probeTimeout() time.Duration { + capSec := c.capSeconds() + probeSec := defaultProbeTimeoutSeconds + if probeSec > capSec { + probeSec = capSec + } + return clampTimerSafeDuration(time.Duration(probeSec) * time.Second) +} + +func (c *X402MCPClient) paidTimeout(maxTimeoutSeconds int) time.Duration { + acceptSec := defaultAcceptTimeoutSeconds + if maxTimeoutSeconds > 0 { + acceptSec = maxTimeoutSeconds + } + capSec := c.capSeconds() + if acceptSec > capSec { + acceptSec = capSec + } + return clampTimerSafeDuration(time.Duration(acceptSec) * time.Second) +} diff --git a/go/mcp/client_test.go b/go/mcp/client_test.go index e3b8e5746e..cc500a2e9c 100644 --- a/go/mcp/client_test.go +++ b/go/mcp/client_test.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" "testing" + "time" "github.com/modelcontextprotocol/go-sdk/mcp" x402 "github.com/x402-foundation/x402/go/v2" @@ -20,9 +21,18 @@ type mockMCPCaller struct { callToolResults []MCPToolResult // For multi-call scenarios callToolErrors []error // For multi-call scenarios callCount int + timeouts []time.Duration // remaining until ctx deadline at each CallTool; 0 if none + hasDeadline []bool } func (m *mockMCPCaller) CallTool(ctx context.Context, params *mcp.CallToolParams) (*mcp.CallToolResult, error) { + if d, ok := ctx.Deadline(); ok { + m.hasDeadline = append(m.hasDeadline, true) + m.timeouts = append(m.timeouts, time.Until(d)) + } else { + m.hasDeadline = append(m.hasDeadline, false) + m.timeouts = append(m.timeouts, 0) + } var mcpResult MCPToolResult if len(m.callToolResults) > 0 { idx := m.callCount @@ -195,7 +205,7 @@ func (m *mockSchemeNetworkClient) FindDefaultAsset(asset string, network x402.Ne return &x402.DefaultAsset{Asset: asset, Decimals: 6, Symbol: "MOCK"} } -func (m *mockSchemeNetworkClient) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements) (types.PaymentPayload, error) { +func (m *mockSchemeNetworkClient) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements, _ x402.PaymentPayloadContext) (types.PaymentPayload, error) { return types.PaymentPayload{ X402Version: 2, Accepted: requirements, @@ -1094,6 +1104,447 @@ func (m *mockSchemeNetworkClientV1) FindDefaultAsset(asset string, network x402. return &x402.DefaultAsset{Asset: asset, Decimals: 6, Symbol: "USDC"} } -func (m *mockSchemeNetworkClientV1) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirementsV1) (types.PaymentPayloadV1, error) { +func (m *mockSchemeNetworkClientV1) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirementsV1, _ x402.PaymentPayloadContext) (types.PaymentPayloadV1, error) { return types.PaymentPayloadV1{X402Version: 1, Scheme: m.scheme, Network: requirements.Network, Payload: map[string]interface{}{"signature": "0xmock"}}, nil } + +func assertApproxTimeout(t *testing.T, got, want time.Duration) { + t.Helper() + delta := got - want + if delta < 0 { + delta = -delta + } + if delta > 2*time.Second { + t.Errorf("timeout remaining %v, want ~%v", got, want) + } +} + +func TestX402MCPClient_CallTool_ProbeTimeoutDefault300s(t *testing.T) { + mockCaller := &mockMCPCaller{ + callToolResult: MCPToolResult{ + Content: []MCPContentItem{{Type: "text", Text: "pong"}}, + }, + } + client := NewX402MCPClient(mockCaller, x402.Newx402Client(), Options{}) + + if _, err := client.CallTool(context.Background(), "ping", map[string]interface{}{}); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(mockCaller.hasDeadline) != 1 || !mockCaller.hasDeadline[0] { + t.Fatal("expected probe CallTool ctx to have a deadline") + } + assertApproxTimeout(t, mockCaller.timeouts[0], 300*time.Second) +} + +func TestX402MCPClient_CallTool_PaidTimeoutFromAcceptMaxTimeoutSeconds(t *testing.T) { + paymentRequired := types.PaymentRequired{ + X402Version: 2, + Accepts: []types.PaymentRequirements{{ + Scheme: "exact", + Network: "eip155:84532", + Amount: "1000", + Asset: "USDC", + PayTo: "0xrecipient", + MaxTimeoutSeconds: 120, + }}, + } + structuredBytes, _ := json.Marshal(paymentRequired) + var structuredContent map[string]interface{} + if err := json.Unmarshal(structuredBytes, &structuredContent); err != nil { + t.Fatalf("Failed to unmarshal structured content: %v", err) + } + + mockCaller := &mockMCPCaller{ + callToolResults: []MCPToolResult{ + {IsError: true, StructuredContent: structuredContent}, + { + Content: []MCPContentItem{{Type: "text", Text: "success"}}, + Meta: map[string]interface{}{ + MCP_PAYMENT_RESPONSE_META_KEY: map[string]interface{}{ + "success": true, "transaction": "0xtxhash", "network": "eip155:84532", + }, + }, + }, + }, + } + paymentClient := x402.Newx402Client() + paymentClient.Register("eip155:84532", &mockSchemeNetworkClient{scheme: "exact"}) + client := NewX402MCPClient(mockCaller, paymentClient, Options{AutoPayment: BoolPtr(true)}) + + if _, err := client.CallTool(context.Background(), "paid_tool", map[string]interface{}{}); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(mockCaller.hasDeadline) != 2 { + t.Fatalf("expected 2 CallTool ctxs, got %d", len(mockCaller.hasDeadline)) + } + if !mockCaller.hasDeadline[0] || !mockCaller.hasDeadline[1] { + t.Fatal("expected probe and paid CallTool ctxs to have deadlines") + } + assertApproxTimeout(t, mockCaller.timeouts[0], 300*time.Second) + assertApproxTimeout(t, mockCaller.timeouts[1], 120*time.Second) +} + +func TestX402MCPClient_CallToolWithPayment_TimeoutFromAccept(t *testing.T) { + mockCaller := &mockMCPCaller{ + callToolResult: MCPToolResult{ + Content: []MCPContentItem{{Type: "text", Text: "success"}}, + }, + } + client := NewX402MCPClient(mockCaller, x402.Newx402Client(), Options{}) + payload := types.PaymentPayload{ + X402Version: 2, + Accepted: types.PaymentRequirements{MaxTimeoutSeconds: 90}, + Payload: map[string]interface{}{"signature": "0x123"}, + } + + if _, err := client.CallToolWithPayment(context.Background(), "paid_tool", map[string]interface{}{}, payload); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(mockCaller.hasDeadline) != 1 || !mockCaller.hasDeadline[0] { + t.Fatal("expected paid CallTool ctx to have a deadline") + } + assertApproxTimeout(t, mockCaller.timeouts[0], 90*time.Second) +} + +func TestX402MCPClient_CallToolWithPayment_DefaultTimeoutWhenAcceptOmitsMaxTimeoutSeconds(t *testing.T) { + mockCaller := &mockMCPCaller{ + callToolResult: MCPToolResult{ + Content: []MCPContentItem{{Type: "text", Text: "success"}}, + }, + } + client := NewX402MCPClient(mockCaller, x402.Newx402Client(), Options{}) + payload := types.PaymentPayload{ + X402Version: 2, + Payload: map[string]interface{}{"signature": "0x123"}, + } + + if _, err := client.CallToolWithPayment(context.Background(), "paid_tool", map[string]interface{}{}, payload); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(mockCaller.hasDeadline) != 1 || !mockCaller.hasDeadline[0] { + t.Fatal("expected paid CallTool ctx to have a deadline") + } + assertApproxTimeout(t, mockCaller.timeouts[0], 300*time.Second) +} + +func TestX402MCPClient_CallTool_RespectsCallerDeadline(t *testing.T) { + mockCaller := &mockMCPCaller{ + callToolResult: MCPToolResult{ + Content: []MCPContentItem{{Type: "text", Text: "pong"}}, + }, + } + client := NewX402MCPClient(mockCaller, x402.Newx402Client(), Options{}) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if _, err := client.CallTool(ctx, "ping", map[string]interface{}{}); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(mockCaller.hasDeadline) != 1 || !mockCaller.hasDeadline[0] { + t.Fatal("expected CallTool ctx to keep the caller deadline") + } + assertApproxTimeout(t, mockCaller.timeouts[0], 15*time.Second) +} + +func TestX402MCPClient_CallTool_PaidTimeoutClampsHugeAcceptToDefaultCap(t *testing.T) { + paymentRequired := types.PaymentRequired{ + X402Version: 2, + Accepts: []types.PaymentRequirements{{ + Scheme: "exact", + Network: "eip155:84532", + Amount: "1000", + Asset: "USDC", + PayTo: "0xrecipient", + MaxTimeoutSeconds: 1_000_000, + }}, + } + mockCaller := &mockMCPCaller{ + callToolResults: []MCPToolResult{ + mcp402Result(t, paymentRequired), + mcpPaidResult(), + }, + } + paymentClient := x402.Newx402Client() + paymentClient.Register("eip155:84532", &mockSchemeNetworkClient{scheme: "exact"}) + client := NewX402MCPClient(mockCaller, paymentClient, Options{AutoPayment: BoolPtr(true)}) + + if _, err := client.CallTool(context.Background(), "paid_tool", map[string]interface{}{}); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + assertApproxTimeout(t, mockCaller.timeouts[1], defaultMaxRequestTimeout) +} + +func TestX402MCPClient_CallTool_PaidTimeoutHonoursRaisedMaxRequestTimeout(t *testing.T) { + paymentRequired := types.PaymentRequired{ + X402Version: 2, + Accepts: []types.PaymentRequirements{{ + Scheme: "exact", + Network: "eip155:84532", + Amount: "1000", + Asset: "USDC", + PayTo: "0xrecipient", + MaxTimeoutSeconds: 900, + }}, + } + mockCaller := &mockMCPCaller{ + callToolResults: []MCPToolResult{ + mcp402Result(t, paymentRequired), + mcpPaidResult(), + }, + } + paymentClient := x402.Newx402Client() + paymentClient.Register("eip155:84532", &mockSchemeNetworkClient{scheme: "exact"}) + client := NewX402MCPClient(mockCaller, paymentClient, Options{ + AutoPayment: BoolPtr(true), + MaxRequestTimeout: 15 * time.Minute, + }) + + if _, err := client.CallTool(context.Background(), "paid_tool", map[string]interface{}{}); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + assertApproxTimeout(t, mockCaller.timeouts[1], 900*time.Second) +} + +func TestX402MCPClient_V1PaidCallGetsTimeoutFromAccept(t *testing.T) { + mockCaller := &mockMCPCaller{ + callToolResults: []MCPToolResult{ + { + IsError: true, + StructuredContent: map[string]interface{}{ + "x402Version": float64(1), + "accepts": []interface{}{ + map[string]interface{}{ + "scheme": "exact", + "network": "base", + "maxAmountRequired": "10000", + "payTo": "0xrecipient", + "maxTimeoutSeconds": float64(120), + "asset": "0xdef", + }, + }, + }, + }, + mcpPaidResult(), + }, + } + paymentClient := x402.Newx402Client() + paymentClient.RegisterV1("base", &mockSchemeNetworkClientV1{scheme: "exact"}) + client := NewX402MCPClient(mockCaller, paymentClient, Options{AutoPayment: BoolPtr(true)}) + + if _, err := client.CallTool(context.Background(), "paid_tool", map[string]interface{}{}); err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(mockCaller.timeouts) < 2 { + t.Fatalf("expected probe and paid calls, got %d", len(mockCaller.timeouts)) + } + assertApproxTimeout(t, mockCaller.timeouts[0], 300*time.Second) + assertApproxTimeout(t, mockCaller.timeouts[1], 120*time.Second) +} + +// hookSchemeMCPClient implements SchemeNetworkClient and PaymentResponseHandler for MCP hook dispatch tests. +type hookSchemeMCPClient struct { + scheme string + settleCalls int + correctiveCalls int + signalRecover bool + settleErr error + createPayloadCnt int +} + +func (m *hookSchemeMCPClient) Scheme() string { return m.scheme } + +func (m *hookSchemeMCPClient) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements, _ x402.PaymentPayloadContext) (types.PaymentPayload, error) { + m.createPayloadCnt++ + return types.PaymentPayload{ + X402Version: 2, + Accepted: requirements, + Payload: map[string]interface{}{"voucher": m.createPayloadCnt}, + }, nil +} + +func (m *hookSchemeMCPClient) OnPaymentResponse(ctx context.Context, prCtx x402.PaymentResponseContext) (x402.PaymentResponseResult, error) { + if prCtx.SettleResponse != nil { + m.settleCalls++ + if m.settleErr != nil { + return x402.PaymentResponseResult{}, m.settleErr + } + return x402.PaymentResponseResult{}, nil + } + if prCtx.PaymentRequired != nil { + m.correctiveCalls++ + return x402.PaymentResponseResult{Recovered: m.signalRecover}, nil + } + return x402.PaymentResponseResult{}, nil +} + +func testPaymentRequirements() types.PaymentRequirements { + return types.PaymentRequirements{ + Scheme: "test-scheme", + Network: "eip155:1", + Asset: "USDC", + Amount: "100", + PayTo: "0xrecipient", + MaxTimeoutSeconds: 300, + } +} + +func paymentRequiredMCPToolResult(t *testing.T, pr types.PaymentRequired) MCPToolResult { + t.Helper() + structuredBytes, err := json.Marshal(pr) + if err != nil { + t.Fatalf("marshal PaymentRequired: %v", err) + } + var structuredContent map[string]interface{} + if err := json.Unmarshal(structuredBytes, &structuredContent); err != nil { + t.Fatalf("unmarshal structured content: %v", err) + } + return MCPToolResult{ + IsError: true, + StructuredContent: structuredContent, + Content: []MCPContentItem{{Type: "text", Text: string(structuredBytes)}}, + } +} + +func TestX402MCPClient_CallToolWithPayment_DispatchesOnPaymentResponseOnSuccess(t *testing.T) { + scheme := &hookSchemeMCPClient{scheme: "test-scheme"} + paymentClient := x402.Newx402Client().DisableSpendControls() + paymentClient.Register("eip155:1", scheme) + + mockMCPCaller := &mockMCPCaller{ + callToolResult: MCPToolResult{ + Content: []MCPContentItem{{Type: "text", Text: "ok"}}, + Meta: map[string]interface{}{ + MCP_PAYMENT_RESPONSE_META_KEY: map[string]interface{}{ + "success": true, + "transaction": "0xtx", + "network": "eip155:1", + }, + }, + }, + } + client := NewX402MCPClient(mockMCPCaller, paymentClient, Options{}) + req := testPaymentRequirements() + payload := types.PaymentPayload{X402Version: 2, Accepted: req, Payload: map[string]interface{}{"deposit": true}} + + _, err := client.CallToolWithPayment(context.Background(), "paid_tool", map[string]interface{}{}, payload) + if err != nil { + t.Fatalf("CallToolWithPayment: %v", err) + } + if scheme.settleCalls != 1 { + t.Fatalf("expected OnPaymentResponse(settle) once, got %d", scheme.settleCalls) + } + if scheme.correctiveCalls != 0 { + t.Fatalf("did not expect corrective dispatch, got %d", scheme.correctiveCalls) + } +} + +func TestX402MCPClient_CallToolWithPayment_PropagatesPaymentResponseHookErrors(t *testing.T) { + hookErr := errors.New("payment response hook failed") + scheme := &hookSchemeMCPClient{scheme: "test-scheme", settleErr: hookErr} + paymentClient := x402.Newx402Client().DisableSpendControls() + paymentClient.Register("eip155:1", scheme) + + mockMCPCaller := &mockMCPCaller{ + callToolResult: MCPToolResult{ + Content: []MCPContentItem{{Type: "text", Text: "ok"}}, + Meta: map[string]interface{}{ + MCP_PAYMENT_RESPONSE_META_KEY: map[string]interface{}{ + "success": true, + "transaction": "0xtx", + "network": "eip155:1", + }, + }, + }, + } + client := NewX402MCPClient(mockMCPCaller, paymentClient, Options{}) + req := testPaymentRequirements() + payload := types.PaymentPayload{X402Version: 2, Accepted: req, Payload: map[string]interface{}{"sig": "0x"}} + + _, err := client.CallToolWithPayment(context.Background(), "paid_tool", map[string]interface{}{}, payload) + if !errors.Is(err, hookErr) { + t.Fatalf("CallToolWithPayment() error = %v, want %v", err, hookErr) + } +} + +func TestX402MCPClient_CallToolWithPayment_RetriesOnceWhenHookSignalsRecovered(t *testing.T) { + scheme := &hookSchemeMCPClient{scheme: "test-scheme", signalRecover: true} + paymentClient := x402.Newx402Client().DisableSpendControls() + paymentClient.Register("eip155:1", scheme) + + pr := types.PaymentRequired{ + X402Version: 2, + Accepts: []types.PaymentRequirements{testPaymentRequirements()}, + } + mockMCPCaller := &mockMCPCaller{ + callToolResults: []MCPToolResult{ + paymentRequiredMCPToolResult(t, pr), + { + Content: []MCPContentItem{{Type: "text", Text: "ok"}}, + Meta: map[string]interface{}{ + MCP_PAYMENT_RESPONSE_META_KEY: map[string]interface{}{ + "success": true, + "transaction": "0xtx", + "network": "eip155:1", + }, + }, + }, + }, + } + client := NewX402MCPClient(mockMCPCaller, paymentClient, Options{}) + req := testPaymentRequirements() + payload := types.PaymentPayload{X402Version: 2, Accepted: req, Payload: map[string]interface{}{"deposit": true}} + + result, err := client.CallToolWithPayment(context.Background(), "paid_tool", map[string]interface{}{}, payload) + if err != nil { + t.Fatalf("CallToolWithPayment: %v", err) + } + if result.IsError { + t.Fatal("expected success after recovery retry") + } + if mockMCPCaller.callCount != 2 { + t.Fatalf("expected 2 paid tool calls, got %d", mockMCPCaller.callCount) + } + if scheme.correctiveCalls != 1 { + t.Fatalf("expected one corrective dispatch, got %d", scheme.correctiveCalls) + } + if scheme.settleCalls != 1 { + t.Fatalf("expected one settle dispatch on recovery retry, got %d", scheme.settleCalls) + } + if scheme.createPayloadCnt != 1 { + t.Fatalf("expected payload rebuilt once on recovery, got %d", scheme.createPayloadCnt) + } +} + +func TestX402MCPClient_CallToolWithPayment_NoRecoveryWhenHookDeclines(t *testing.T) { + scheme := &hookSchemeMCPClient{scheme: "test-scheme", signalRecover: false} + paymentClient := x402.Newx402Client().DisableSpendControls() + paymentClient.Register("eip155:1", scheme) + + pr := types.PaymentRequired{ + X402Version: 2, + Accepts: []types.PaymentRequirements{testPaymentRequirements()}, + } + mockMCPCaller := &mockMCPCaller{ + callToolResults: []MCPToolResult{paymentRequiredMCPToolResult(t, pr)}, + } + client := NewX402MCPClient(mockMCPCaller, paymentClient, Options{}) + req := testPaymentRequirements() + payload := types.PaymentPayload{X402Version: 2, Accepted: req, Payload: map[string]interface{}{"deposit": true}} + + result, err := client.CallToolWithPayment(context.Background(), "paid_tool", map[string]interface{}{}, payload) + if err != nil { + t.Fatalf("CallToolWithPayment: %v", err) + } + if !result.IsError { + t.Fatal("expected corrective IsError result when recovery declined") + } + if mockMCPCaller.callCount != 1 { + t.Fatalf("expected 1 paid tool call, got %d", mockMCPCaller.callCount) + } + if scheme.correctiveCalls != 1 { + t.Fatalf("expected one corrective dispatch, got %d", scheme.correctiveCalls) + } + if scheme.settleCalls != 0 { + t.Fatalf("expected no settle dispatch, got %d", scheme.settleCalls) + } +} diff --git a/go/mcp/server.go b/go/mcp/server.go index 2c4c9fae00..86b52c0bd3 100644 --- a/go/mcp/server.go +++ b/go/mcp/server.go @@ -10,6 +10,7 @@ package mcp import ( "context" "encoding/json" + "errors" "fmt" "log" @@ -131,13 +132,9 @@ func (w *PaymentWrapper) Wrap(handler ToolHandler) ToolHandler { // Verify payment -- return tool error result, NOT Go error verifyResp, err := w.server.VerifyPaymentWithExtensions(ctx, payload, requirements, declaredExtensions) - if err != nil { - return w.paymentRequiredResult( - toolName, fmt.Sprintf("Payment verification error: %v", err), &payload), nil - } - if !verifyResp.IsValid { + if err != nil || verifyResp == nil || !verifyResp.IsValid { return w.paymentRequiredResult( - toolName, fmt.Sprintf("Payment verification failed: %s", verifyResp.InvalidReason), &payload), nil + toolName, paymentRequiredErrorFromVerify(err, verifyResp), &payload), nil } args := parseArgsFromRequest(request) @@ -366,19 +363,39 @@ func (w *PaymentWrapper) buildToolResourceInfo(toolName string) *types.ResourceI } } +// paymentRequiredErrorFromVerify prefers the protocol error code (VerifyError.InvalidReason +// or VerifyResponse.InvalidReason) over a free-form wrapped message so scheme enrichers +// and client recovery can match on a stable identifier. Mirrors HTTP ProcessHTTPRequest. +func paymentRequiredErrorFromVerify(err error, verifyResp *x402.VerifyResponse) string { + var ve *x402.VerifyError + if errors.As(err, &ve) && ve.InvalidReason != "" { + return ve.InvalidReason + } + if verifyResp != nil && verifyResp.InvalidReason != "" { + return verifyResp.InvalidReason + } + if err != nil { + return err.Error() + } + return "Payment verification failed" +} + // paymentRequiredResult creates an MCP error result with payment required info. // Per spec, sets both structuredContent and content[0].text with isError: true. func (w *PaymentWrapper) paymentRequiredResult(toolName, errorMsg string, payload *types.PaymentPayload) *mcp.CallToolResult { resource := w.buildToolResourceInfo(toolName) + // Enrichers may mutate Extra in place (e.g. batch-settlement channelState). + // Snapshot so wrapper config stays a stable match baseline across tool calls. + accepts := x402.SnapshotPaymentRequirementsList(w.config.Accepts) var pr types.PaymentRequired if payload != nil { pr = w.server.CreatePaymentRequiredResponseWithPayload( - w.config.Accepts, resource, errorMsg, w.config.Extensions, payload, + accepts, resource, errorMsg, w.config.Extensions, payload, ) } else { pr = w.server.CreatePaymentRequiredResponse( - w.config.Accepts, resource, errorMsg, w.config.Extensions, + accepts, resource, errorMsg, w.config.Extensions, ) } diff --git a/go/mcp/server_test.go b/go/mcp/server_test.go index db4abc1878..261fd6b6ce 100644 --- a/go/mcp/server_test.go +++ b/go/mcp/server_test.go @@ -1514,3 +1514,218 @@ func TestPaymentWrapper_CreatePaymentRequiredResponseEnricher(t *testing.T) { t.Fatalf("expected enricher mutation, got %#v", extra) } } + +func TestPaymentRequiredErrorFromVerify_PrefersInvalidReason(t *testing.T) { + ve := x402.NewVerifyError( + "invalid_batch_settlement_evm_cumulative_amount_mismatch", + "", + "Client voucher base does not match server state", + ) + if got := paymentRequiredErrorFromVerify(ve, nil); got != ve.InvalidReason { + t.Fatalf("got %q, want InvalidReason %q", got, ve.InvalidReason) + } + + resp := &x402.VerifyResponse{IsValid: false, InvalidReason: "insufficient_balance"} + if got := paymentRequiredErrorFromVerify(nil, resp); got != "insufficient_balance" { + t.Fatalf("got %q, want VerifyResponse.InvalidReason", got) + } + + if got := paymentRequiredErrorFromVerify(fmt.Errorf("network down"), nil); got != "network down" { + t.Fatalf("got %q, want wrapped non-VerifyError", got) + } +} + +func TestPaymentWrapper_VerifyAbortUsesInvalidReason(t *testing.T) { + const reason = "invalid_batch_settlement_evm_cumulative_amount_mismatch" + mockFacilitator := &mockFacilitatorClient{ + verifyFunc: func(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*x402.VerifyResponse, error) { + t.Fatal("facilitator should not run after BeforeVerify abort") + return nil, nil + }, + } + server := x402.Newx402ResourceServer( + x402.WithFacilitatorClient(mockFacilitator), + x402.WithSchemeServer("x402:cash", &mockSchemeNetworkServer{scheme: "cash"}), + ) + ctx := context.Background() + if err := server.Initialize(ctx); err != nil { + t.Fatalf("Initialize: %v", err) + } + server.OnBeforeVerify(func(c x402.VerifyContext) (*x402.BeforeHookResult, error) { + return &x402.BeforeHookResult{ + Abort: true, + Reason: reason, + Message: "Client voucher base does not match server state", + }, nil + }) + + wrapper := NewPaymentWrapper(server, PaymentWrapperConfig{ + Accepts: []types.PaymentRequirements{ + {Scheme: "cash", Network: "x402:cash", Amount: "1000", PayTo: "test-recipient"}, + }, + }) + wrapped := wrapper.Wrap(func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{}, nil + }) + + payload := types.PaymentPayload{ + X402Version: 2, + Accepted: types.PaymentRequirements{Scheme: "cash", Network: "x402:cash", Amount: "1000", PayTo: "test-recipient"}, + Payload: map[string]interface{}{"signature": "~test-payer"}, + } + result, err := wrapped(ctx, makeCallToolRequest(nil, mcp.Meta{MCP_PAYMENT_META_KEY: payload})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.IsError { + t.Fatal("expected payment required error") + } + sc, ok := result.StructuredContent.(map[string]interface{}) + if !ok { + t.Fatalf("expected structuredContent map, got %T", result.StructuredContent) + } + if sc["error"] != reason { + t.Fatalf("PaymentRequired.error = %q, want protocol InvalidReason %q", sc["error"], reason) + } +} + +func TestPaymentWrapper_FacilitatorVerifyErrorUsesInvalidReason(t *testing.T) { + const reason = "custom_failure_reason" + mockFacilitator := &mockFacilitatorClient{ + verifyFunc: func(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*x402.VerifyResponse, error) { + return nil, x402.NewVerifyError(reason, "0xpayer", "human-readable detail") + }, + } + server := x402.Newx402ResourceServer( + x402.WithFacilitatorClient(mockFacilitator), + x402.WithSchemeServer("x402:cash", &mockSchemeNetworkServer{scheme: "cash"}), + ) + ctx := context.Background() + if err := server.Initialize(ctx); err != nil { + t.Fatalf("Initialize: %v", err) + } + + wrapper := NewPaymentWrapper(server, PaymentWrapperConfig{ + Accepts: []types.PaymentRequirements{ + {Scheme: "cash", Network: "x402:cash", Amount: "1000", PayTo: "test-recipient"}, + }, + }) + wrapped := wrapper.Wrap(func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{}, nil + }) + + payload := types.PaymentPayload{ + X402Version: 2, + Accepted: types.PaymentRequirements{Scheme: "cash", Network: "x402:cash", Amount: "1000", PayTo: "test-recipient"}, + Payload: map[string]interface{}{"signature": "~test-payer"}, + } + result, err := wrapped(ctx, makeCallToolRequest(nil, mcp.Meta{MCP_PAYMENT_META_KEY: payload})) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.IsError { + t.Fatal("expected payment required error") + } + sc, ok := result.StructuredContent.(map[string]interface{}) + if !ok { + t.Fatalf("expected structuredContent map, got %T", result.StructuredContent) + } + if sc["error"] != reason { + t.Fatalf("PaymentRequired.error = %q, want InvalidReason %q", sc["error"], reason) + } +} + +// mismatchOnlyEnricher mirrors batch-settlement: it writes recovery extra only on +// a corrective verify-failure 402, never on the unpaid / match-path 402. +type mismatchOnlyEnricher struct { + mockSchemeNetworkServer + calls int +} + +func (m *mismatchOnlyEnricher) EnrichPaymentRequiredResponse(ctx x402.PaymentRequiredContext) { + if ctx.Error != "invalid_batch_settlement_evm_cumulative_amount_mismatch" || ctx.PaymentPayload == nil { + return + } + m.calls++ + for i := range ctx.Requirements { + if ctx.Requirements[i].Extra == nil { + ctx.Requirements[i].Extra = map[string]interface{}{} + } + ctx.Requirements[i].Extra["channelState"] = map[string]interface{}{ + "chargedCumulativeAmount": "2000", + } + } +} + +func TestPaymentWrapper_PaymentRequiredDoesNotMutateConfigAccepts(t *testing.T) { + const reason = "invalid_batch_settlement_evm_cumulative_amount_mismatch" + verifyCalls := 0 + abortOnce := true + mockFacilitator := &mockFacilitatorClient{ + verifyFunc: func(ctx context.Context, payloadBytes []byte, requirementsBytes []byte) (*x402.VerifyResponse, error) { + verifyCalls++ + return &x402.VerifyResponse{IsValid: true, Payer: "test-payer"}, nil + }, + } + scheme := &mismatchOnlyEnricher{mockSchemeNetworkServer: mockSchemeNetworkServer{scheme: "cash"}} + server := x402.Newx402ResourceServer( + x402.WithFacilitatorClient(mockFacilitator), + x402.WithSchemeServer("x402:cash", scheme), + ) + ctx := context.Background() + if err := server.Initialize(ctx); err != nil { + t.Fatalf("Initialize: %v", err) + } + server.OnBeforeVerify(func(c x402.VerifyContext) (*x402.BeforeHookResult, error) { + if abortOnce { + abortOnce = false + return &x402.BeforeHookResult{ + Abort: true, + Reason: reason, + Message: "Client voucher base does not match server state", + }, nil + } + return nil, nil + }) + + config := PaymentWrapperConfig{ + Accepts: []types.PaymentRequirements{ + {Scheme: "cash", Network: "x402:cash", Amount: "1000", PayTo: "test-recipient"}, + }, + } + wrapper := NewPaymentWrapper(server, config) + wrapped := wrapper.Wrap(func(ctx context.Context, request *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "ok"}}}, nil + }) + + payload := types.PaymentPayload{ + X402Version: 2, + Accepted: types.PaymentRequirements{Scheme: "cash", Network: "x402:cash", Amount: "1000", PayTo: "test-recipient"}, + Payload: map[string]interface{}{"signature": "~test-payer"}, + } + + first, err := wrapped(ctx, makeCallToolRequest(nil, mcp.Meta{MCP_PAYMENT_META_KEY: payload})) + if err != nil { + t.Fatalf("first paid call: %v", err) + } + if !first.IsError { + t.Fatal("expected corrective 402") + } + if scheme.calls != 1 { + t.Fatalf("expected mismatch enricher once, got %d", scheme.calls) + } + if config.Accepts[0].Extra != nil { + t.Fatalf("config.Accepts Extra mutated by corrective enricher: %#v", config.Accepts[0].Extra) + } + + second, err := wrapped(ctx, makeCallToolRequest(nil, mcp.Meta{MCP_PAYMENT_META_KEY: payload})) + if err != nil { + t.Fatalf("recovery retry: %v", err) + } + if second.IsError { + t.Fatalf("expected original accepts to still match after corrective 402, got %#v", second.StructuredContent) + } + if verifyCalls != 1 { + t.Fatalf("expected facilitator verify on retry, got %d", verifyCalls) + } +} diff --git a/go/mcp/types.go b/go/mcp/types.go index a8bf2ce5a6..fda2c1d2c9 100644 --- a/go/mcp/types.go +++ b/go/mcp/types.go @@ -1,6 +1,8 @@ package mcp import ( + "time" + x402 "github.com/x402-foundation/x402/go/v2" "github.com/x402-foundation/x402/go/v2/types" ) @@ -63,6 +65,9 @@ type Options struct { // OnPaymentRequested is called before creating a payment, allowing the caller // to approve or deny. Return (true, nil) to approve, (false, nil) to deny. OnPaymentRequested func(context PaymentRequiredContext) (bool, error) + + // MaxRequestTimeout caps derived waits from accept maxTimeoutSeconds (zero → 10m). + MaxRequestTimeout time.Duration } // BoolPtr returns a pointer to the given bool value. diff --git a/go/mechanisms/evm/auth-capture/client/scheme.go b/go/mechanisms/evm/auth-capture/client/scheme.go index 5ea0b109c5..177f0ccf64 100644 --- a/go/mechanisms/evm/auth-capture/client/scheme.go +++ b/go/mechanisms/evm/auth-capture/client/scheme.go @@ -44,6 +44,7 @@ func (c *AuthCaptureEvmScheme) FindDefaultAsset(asset string, network x402.Netwo func (c *AuthCaptureEvmScheme) CreatePaymentPayload( ctx context.Context, requirements types.PaymentRequirements, + _ x402.PaymentPayloadContext, ) (types.PaymentPayload, error) { extra, deployment, err := parseAuthCaptureExtra(requirements) if err != nil { diff --git a/go/mechanisms/evm/auth-capture/client/scheme_test.go b/go/mechanisms/evm/auth-capture/client/scheme_test.go index b9b525598f..821af4dfb5 100644 --- a/go/mechanisms/evm/auth-capture/client/scheme_test.go +++ b/go/mechanisms/evm/auth-capture/client/scheme_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + x402 "github.com/x402-foundation/x402/go/v2" + "github.com/x402-foundation/x402/go/v2/mechanisms/evm" authcapture "github.com/x402-foundation/x402/go/v2/mechanisms/evm/auth-capture" "github.com/x402-foundation/x402/go/v2/types" @@ -70,7 +72,7 @@ func TestCreatePaymentPayload_InvalidAuthCaptureEscrow(t *testing.T) { req := mockRequirements(map[string]interface{}{ "authCaptureEscrow": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", }) - if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "authCaptureEscrow") { + if _, err := scheme.CreatePaymentPayload(context.Background(), req, x402.PaymentPayloadContext{}); err == nil || !strings.Contains(err.Error(), "authCaptureEscrow") { t.Fatalf("expected authCaptureEscrow error, got %v", err) } } @@ -85,7 +87,7 @@ func TestCreatePaymentPayload_V1_0EscrowPin(t *testing.T) { result, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(map[string]interface{}{ "authCaptureEscrow": authcapture.AuthCaptureEscrowV1_0Address, - })) + }), x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("err: %v", err) } @@ -103,7 +105,7 @@ func TestCreatePaymentPayload_EIP3009(t *testing.T) { scheme := NewAuthCaptureEvmScheme(signer) scheme.now = func() time.Time { return time.Unix(1700000000, 0) } - result, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(nil)) + result, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(nil), x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("err: %v", err) } @@ -151,22 +153,22 @@ func TestCreatePaymentPayload_MissingExtraFields(t *testing.T) { scheme := NewAuthCaptureEvmScheme(&mockSigner{address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) req := mockRequirements(map[string]interface{}{"name": ""}) - if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "name") { + if _, err := scheme.CreatePaymentPayload(context.Background(), req, x402.PaymentPayloadContext{}); err == nil || !strings.Contains(err.Error(), "name") { t.Fatalf("expected name error, got %v", err) } req = mockRequirements(map[string]interface{}{"version": ""}) - if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "version") { + if _, err := scheme.CreatePaymentPayload(context.Background(), req, x402.PaymentPayloadContext{}); err == nil || !strings.Contains(err.Error(), "version") { t.Fatalf("expected version error, got %v", err) } req = mockRequirements(map[string]interface{}{"captureAuthorizer": ""}) - if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "captureAuthorizer") { + if _, err := scheme.CreatePaymentPayload(context.Background(), req, x402.PaymentPayloadContext{}); err == nil || !strings.Contains(err.Error(), "captureAuthorizer") { t.Fatalf("expected captureAuthorizer error, got %v", err) } req = mockRequirements(map[string]interface{}{"feeRecipient": ""}) - if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "feeRecipient") { + if _, err := scheme.CreatePaymentPayload(context.Background(), req, x402.PaymentPayloadContext{}); err == nil || !strings.Contains(err.Error(), "feeRecipient") { t.Fatalf("expected feeRecipient error, got %v", err) } } @@ -178,7 +180,7 @@ func TestCreatePaymentPayload_BoundSalt(t *testing.T) { }) result, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(map[string]interface{}{ "receiverAuthorizer": "0x1111111111111111111111111111111111111111", - })) + }), x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("err: %v", err) } @@ -200,11 +202,11 @@ func TestCreatePaymentPayload_FreshUnboundSalt(t *testing.T) { address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", sig: []byte{0x01}, }) - a, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(nil)) + a, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(nil), x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("err: %v", err) } - b, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(nil)) + b, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(nil), x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("err: %v", err) } @@ -217,7 +219,7 @@ func TestCreatePaymentPayload_BadNetwork(t *testing.T) { scheme := NewAuthCaptureEvmScheme(&mockSigner{address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}) req := mockRequirements(nil) req.Network = "solana:mainnet" - if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil || !strings.Contains(err.Error(), "solana:mainnet") { + if _, err := scheme.CreatePaymentPayload(context.Background(), req, x402.PaymentPayloadContext{}); err == nil || !strings.Contains(err.Error(), "solana:mainnet") { t.Fatalf("expected network error, got %v", err) } } @@ -230,7 +232,7 @@ func TestCreatePaymentPayload_Permit2(t *testing.T) { scheme := NewAuthCaptureEvmScheme(signer) result, err := scheme.CreatePaymentPayload(context.Background(), mockRequirements(map[string]interface{}{ "assetTransferMethod": "permit2", - })) + }), x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("err: %v", err) } diff --git a/go/mechanisms/evm/batch-settlement/README.md b/go/mechanisms/evm/batch-settlement/README.md index ba49d8c94b..e11af1aeaa 100644 --- a/go/mechanisms/evm/batch-settlement/README.md +++ b/go/mechanisms/evm/batch-settlement/README.md @@ -37,13 +37,30 @@ c.Register("eip155:*", scheme) ### Deposit Policy -Controls how much the client deposits when the channel needs funding: +When the channel needs funding or top-up, the client deposits: + +1. `extra.minDeposit` when the server announced a valid hint (`>= amount`) +2. otherwise `amount × DepositMultiplier` (default 5, minimum 3) + +`x402Client` spend controls still apply per request to `PaymentRequirements.amount`. The same resolved atomic cap is reused as the escrow ceiling: + +`maxDeposit = spendControls.maxAmountPerPayment × DepositMultiplier` + +A default `$1` USDC cap and multiplier `5` therefore locks at most `$5`. Raise `MaxAmountPerPayment` or `DepositMultiplier` for a larger lock. + +Uncapped payments leave deposits uncapped too: `DisableSpendControls()`, `DisableMaxAmountPerPayment`, or an `AllowedAssets` entry without a per-asset cap. An uncapped client accepts a server-sized deposit (`extra.minDeposit`), which is fully refundable but time-locked through `withdrawDelay`. Use `DepositStrategy` when you need a decision the multiplier cannot express. | Field | Description | |---------------------|-------------| -| `DepositMultiplier` | Per-request `amount × multiplier` is deposited (default 5). | +| `DepositMultiplier` | Sizes the deposit target when `extra.minDeposit` is absent, and the lock ceiling when a spend cap is set. Default 5, minimum 3. | | `DepositStrategy` | Optional callback that overrides the computed amount or returns `Skip: true` to send a voucher-only payload (verify will fail; the caller is opting out of auto top-up). | +The strategy can: + +- Return an empty result to use the SDK default deposit amount. +- Return `Skip: true` to skip this deposit attempt. +- Return a base-unit `Amount` to choose a custom amount. The amount must cover the next voucher and still respects `maxDeposit` when a spend cap is set. + ```go scheme := client.NewBatchSettlementEvmScheme(signer, &client.BatchSettlementEvmSchemeOptions{ DepositStrategy: func(ctx context.Context, c client.DepositStrategyContext) (client.DepositStrategyResult, error) { @@ -109,6 +126,7 @@ import ( scheme := server.NewBatchSettlementEvmScheme(receiverAddress, &server.BatchSettlementEvmSchemeServerConfig{ ReceiverAuthorizerSigner: receiverAuthorizerSigner, // optional: self-managed authorizer (recommended) WithdrawDelay: 900, // 15 min – 30 days + EnforceMinDeposit: false, // hint only; set true to reject smaller deposits Storage: server.NewFileChannelStorage(batchsettlement.FileChannelStorageOptions{ Directory: "./sessions", }), @@ -157,6 +175,44 @@ The `receiverAuthorizer` signs `ClaimBatch` and `Refund` EIP-712 messages and is Set the route `price` to the per-request maximum. To bill less than the max, use the standard x402 settlement-override mechanism for your HTTP framework — see the framework adapter's documentation. +### Minimum deposit hint + +Every 402 includes `extra.minDeposit` (atomic string). By default the SDK sets it to `10 × amount`. + +Override per route in `accepts.extra.minDeposit`: + +```go +routes := x402http.RoutesConfig{ + "GET /weather": { + Accepts: x402http.PaymentOptions{ + { + Scheme: "batch-settlement", + Price: "$0.01", + Network: "eip155:84532", + PayTo: receiverAddress, + Extra: map[string]interface{}{"minDeposit": "$0.10"}, // optional; default-asset routes only for Money strings + }, + }, + }, +} +``` + +| Route value | When | +|-------------|------| +| omitted | `10 × amount` | +| `"$0.10"` | Default asset only — converted with that asset's decimals | +| `"5000000"` | Any asset — integer atomic base units | + +The reference server only announces the hint; it does not reject smaller deposits. Opt in to SDK enforcement: + +```go +scheme := server.NewBatchSettlementEvmScheme(receiverAddress, &server.BatchSettlementEvmSchemeServerConfig{ + EnforceMinDeposit: true, // default false +}) +``` + +When enabled, deposits below the resolved hint abort verify with `invalid_batch_settlement_evm_deposit_below_min_deposit`. The facilitator never enforces this — it remains server-local policy. + ## Facilitator Usage ```go diff --git a/go/mechanisms/evm/batch-settlement/client/extensions.go b/go/mechanisms/evm/batch-settlement/client/extensions.go index fbdd05e52e..032d23a10c 100644 --- a/go/mechanisms/evm/batch-settlement/client/extensions.go +++ b/go/mechanisms/evm/batch-settlement/client/extensions.go @@ -16,43 +16,29 @@ import ( "github.com/x402-foundation/x402/go/v2/types" ) -// Compile-time assertion: BatchSettlementEvmScheme satisfies the optional -// ExtensionAwareClient interface so x402Client routes payments through the -// extension-aware path when the server's 402 advertises gas-sponsoring keys. -var _ x402.ExtensionAwareClient = (*BatchSettlementEvmScheme)(nil) +var _ x402.SchemeNetworkClient = (*BatchSettlementEvmScheme)(nil) -// CreatePaymentPayloadWithExtensions creates a batched payment payload with -// extension awareness when `paymentRequired.extensions` advertises EIP-2612 or -// ERC-20 approval gas sponsoring. +// enrichDepositWithGasSponsoring attaches EIP-2612 or ERC-20 approval gas +// sponsoring when `payloadCtx.Extensions` advertises those keys. // // Behavior matches the exact / upto schemes: // -// 1. Build the base payload (deposit-or-voucher) via the standard -// CreatePaymentPayload flow. -// 2. Skip extension enrichment for non-deposit payloads (vouchers don't +// 1. Skip extension enrichment for non-deposit payloads (vouchers don't // need a token approve). -// 3. Skip extension enrichment for non-Permit2 deposits (ERC-3009 carries +// 2. Skip extension enrichment for non-Permit2 deposits (ERC-3009 carries // its own gas-funded transfer authorization). -// 4. Try EIP-2612 first; on a successful permit signature, attach +// 3. Try EIP-2612 first; on a successful permit signature, attach // `extensions.eip2612GasSponsoring.info` and return. -// 5. Fall back to ERC-20 approval; on success, attach +// 4. Fall back to ERC-20 approval; on success, attach // `extensions.erc20ApprovalGasSponsoring.info`. -// 6. If neither extension applies (allowance already sufficient, or token +// 5. If neither extension applies (allowance already sufficient, or token // does not advertise EIP-712 domain fields), return the base payload. -// -// Implements the optional `x402.ExtensionAwareClient` interface so -// `x402Client.CreatePaymentPayload` calls this path automatically when the -// server's 402 contains extension declarations. -func (c *BatchSettlementEvmScheme) CreatePaymentPayloadWithExtensions( +func (c *BatchSettlementEvmScheme) enrichDepositWithGasSponsoring( ctx context.Context, requirements types.PaymentRequirements, - extensions map[string]interface{}, + result types.PaymentPayload, + payloadCtx x402.PaymentPayloadContext, ) (types.PaymentPayload, error) { - result, err := c.CreatePaymentPayload(ctx, requirements) - if err != nil { - return types.PaymentPayload{}, err - } - // Vouchers never need a Permit2 approval — the deposit already established // the channel balance. Voucher payloads have type="voucher" without a // `deposit` field; bail before attempting extension signing. @@ -86,6 +72,8 @@ func (c *BatchSettlementEvmScheme) CreatePaymentPayloadWithExtensions( return result, nil } + extensions := payloadCtx.Extensions + if extData, eipErr := c.trySignEip2612Permit(ctx, requirements, result, extensions); eipErr == nil && extData != nil { result.Extensions = extData return result, nil diff --git a/go/mechanisms/evm/batch-settlement/client/extensions_test.go b/go/mechanisms/evm/batch-settlement/client/extensions_test.go index e99028f514..3f59ddb300 100644 --- a/go/mechanisms/evm/batch-settlement/client/extensions_test.go +++ b/go/mechanisms/evm/batch-settlement/client/extensions_test.go @@ -5,6 +5,7 @@ import ( "math/big" "testing" + x402 "github.com/x402-foundation/x402/go/v2" "github.com/x402-foundation/x402/go/v2/extensions/eip2612gassponsor" "github.com/x402-foundation/x402/go/v2/extensions/erc20approvalgassponsor" "github.com/x402-foundation/x402/go/v2/mechanisms/evm" @@ -113,20 +114,24 @@ func bothExtensionsDeclared() map[string]interface{} { } } -// TestCreatePaymentPayloadWithExtensions_NoExtensionsDeclared confirms that +func extCtx(extensions map[string]interface{}) x402.PaymentPayloadContext { + return x402.PaymentPayloadContext{Extensions: extensions} +} + +// TestCreatePaymentPayload_NoExtensionsDeclared confirms that // when the server's 402 has no extensions, the path is identical to plain // CreatePaymentPayload — no enrichment, no extra RPC. -func TestCreatePaymentPayloadWithExtensions_NoExtensionsDeclared(t *testing.T) { +func TestCreatePaymentPayload_NoExtensionsDeclared(t *testing.T) { signer := &extReadSigner{ mockSigner: &mockSigner{address: extTestSigner, sig: []byte{0xab}}, allowance: big.NewInt(0), } scheme := batchedExtSchemeWith(signer) - out, err := scheme.CreatePaymentPayloadWithExtensions( + out, err := scheme.CreatePaymentPayload( context.Background(), extRequirementsPermit2(), - nil, + extCtx(nil), ) if err != nil { t.Fatalf("err: %v", err) @@ -136,10 +141,10 @@ func TestCreatePaymentPayloadWithExtensions_NoExtensionsDeclared(t *testing.T) { } } -// TestCreatePaymentPayloadWithExtensions_AllowanceShortCircuit confirms the +// TestCreatePaymentPayload_AllowanceShortCircuit confirms the // EIP-2612 path is skipped when the user has already approved Permit2 for at // least the deposit amount. -func TestCreatePaymentPayloadWithExtensions_AllowanceShortCircuit(t *testing.T) { +func TestCreatePaymentPayload_AllowanceShortCircuit(t *testing.T) { // Deposit defaults to amount * DefaultDepositMultiplier (5) = 500. // Allowance of 1e18 is way more than enough → no permit signed. signer := &extReadSigner{ @@ -149,10 +154,10 @@ func TestCreatePaymentPayloadWithExtensions_AllowanceShortCircuit(t *testing.T) } scheme := batchedExtSchemeWith(signer) - out, err := scheme.CreatePaymentPayloadWithExtensions( + out, err := scheme.CreatePaymentPayload( context.Background(), extRequirementsPermit2(), - eip2612OnlyDeclared(), + extCtx(eip2612OnlyDeclared()), ) if err != nil { t.Fatalf("err: %v", err) @@ -162,11 +167,11 @@ func TestCreatePaymentPayloadWithExtensions_AllowanceShortCircuit(t *testing.T) } } -// TestCreatePaymentPayloadWithExtensions_Eip2612SignedWhenAllowanceZero +// TestCreatePaymentPayload_Eip2612SignedWhenAllowanceZero // exercises the happy path: server advertises EIP-2612, user has zero // allowance, signer can sign typed data → extension is attached with the // expected DEPOSIT amount (not the per-request requirements.Amount). -func TestCreatePaymentPayloadWithExtensions_Eip2612SignedWhenAllowanceZero(t *testing.T) { +func TestCreatePaymentPayload_Eip2612SignedWhenAllowanceZero(t *testing.T) { signer := &extReadSigner{ mockSigner: &mockSigner{address: extTestSigner, sig: make([]byte, 65)}, allowance: big.NewInt(0), @@ -174,10 +179,10 @@ func TestCreatePaymentPayloadWithExtensions_Eip2612SignedWhenAllowanceZero(t *te } scheme := batchedExtSchemeWith(signer) - out, err := scheme.CreatePaymentPayloadWithExtensions( + out, err := scheme.CreatePaymentPayload( context.Background(), extRequirementsPermit2(), - eip2612OnlyDeclared(), + extCtx(eip2612OnlyDeclared()), ) if err != nil { t.Fatalf("err: %v", err) @@ -206,10 +211,10 @@ func TestCreatePaymentPayloadWithExtensions_Eip2612SignedWhenAllowanceZero(t *te } } -// TestCreatePaymentPayloadWithExtensions_Eip2612TakesPriorityOverErc20 pins +// TestCreatePaymentPayload_Eip2612TakesPriorityOverErc20 pins // priority: when both extensions are advertised, EIP-2612 is tried first; if it // succeeds, the ERC-20 approval branch is NOT exercised. -func TestCreatePaymentPayloadWithExtensions_Eip2612TakesPriorityOverErc20(t *testing.T) { +func TestCreatePaymentPayload_Eip2612TakesPriorityOverErc20(t *testing.T) { signer := &extReadSigner{ mockSigner: &mockSigner{address: extTestSigner, sig: make([]byte, 65)}, allowance: big.NewInt(0), @@ -217,10 +222,10 @@ func TestCreatePaymentPayloadWithExtensions_Eip2612TakesPriorityOverErc20(t *tes } scheme := batchedExtSchemeWith(signer) - out, err := scheme.CreatePaymentPayloadWithExtensions( + out, err := scheme.CreatePaymentPayload( context.Background(), extRequirementsPermit2(), - bothExtensionsDeclared(), + extCtx(bothExtensionsDeclared()), ) if err != nil { t.Fatalf("err: %v", err) @@ -233,12 +238,12 @@ func TestCreatePaymentPayloadWithExtensions_Eip2612TakesPriorityOverErc20(t *tes } } -// TestCreatePaymentPayloadWithExtensions_Eip2612SkippedWithoutNameVersion +// TestCreatePaymentPayload_Eip2612SkippedWithoutNameVersion // confirms that without name/version on requirements.Extra, the token's EIP-712 // domain is unknown and the client silently skips signing instead of erroring. // The downstream request will then 402 with permit2_allowance_required (the // standard Permit2 path's diagnosis). -func TestCreatePaymentPayloadWithExtensions_Eip2612SkippedWithoutNameVersion(t *testing.T) { +func TestCreatePaymentPayload_Eip2612SkippedWithoutNameVersion(t *testing.T) { signer := &extReadSigner{ mockSigner: &mockSigner{address: extTestSigner, sig: []byte{0xab}}, allowance: big.NewInt(0), @@ -249,10 +254,10 @@ func TestCreatePaymentPayloadWithExtensions_Eip2612SkippedWithoutNameVersion(t * delete(reqs.Extra, "name") delete(reqs.Extra, "version") - out, err := scheme.CreatePaymentPayloadWithExtensions( + out, err := scheme.CreatePaymentPayload( context.Background(), reqs, - eip2612OnlyDeclared(), + extCtx(eip2612OnlyDeclared()), ) if err != nil { t.Fatalf("err: %v", err) @@ -262,12 +267,12 @@ func TestCreatePaymentPayloadWithExtensions_Eip2612SkippedWithoutNameVersion(t * } } -// TestCreatePaymentPayloadWithExtensions_Eip2612DeadlineFromPermit2 +// TestCreatePaymentPayload_Eip2612DeadlineFromPermit2 // verifies the EIP-2612 deadline is taken from the just-signed Permit2 // authorization (not the fallback `now + maxTimeoutSeconds`). This keeps // both signatures on the same expiry window. The mockSigner records the // signed message, so we read the deadline from there. -func TestCreatePaymentPayloadWithExtensions_Eip2612DeadlineFromPermit2(t *testing.T) { +func TestCreatePaymentPayload_Eip2612DeadlineFromPermit2(t *testing.T) { signer := &extReadSigner{ mockSigner: &mockSigner{address: extTestSigner, sig: make([]byte, 65)}, allowance: big.NewInt(0), @@ -275,10 +280,10 @@ func TestCreatePaymentPayloadWithExtensions_Eip2612DeadlineFromPermit2(t *testin } scheme := batchedExtSchemeWith(signer) - out, err := scheme.CreatePaymentPayloadWithExtensions( + out, err := scheme.CreatePaymentPayload( context.Background(), extRequirementsPermit2(), - eip2612OnlyDeclared(), + extCtx(eip2612OnlyDeclared()), ) if err != nil { t.Fatalf("err: %v", err) diff --git a/go/mechanisms/evm/batch-settlement/client/scheme.go b/go/mechanisms/evm/batch-settlement/client/scheme.go index 08fc419ae4..9c33d212e8 100644 --- a/go/mechanisms/evm/batch-settlement/client/scheme.go +++ b/go/mechanisms/evm/batch-settlement/client/scheme.go @@ -37,6 +37,7 @@ type DepositStrategyContext struct { CurrentBalance string MinimumDepositAmount string DepositAmount string + MaxDeposit string } // DepositStrategyResult is the return value of a DepositStrategy callback. @@ -57,10 +58,10 @@ type DepositStrategy func(ctx context.Context, c DepositStrategyContext) (Deposi // BatchSettlementEvmSchemeOptions configures the batched client scheme. // -// Use `DepositStrategy` for app-specific sizing or skipping. +// Use DepositStrategy for app-specific sizing or skipping. type BatchSettlementEvmSchemeOptions struct { - // DepositMultiplier is the multiplier applied to the required amount for deposits. - // E.g., 5 means deposit 5× the per-request amount. Defaults to 5. + // DepositMultiplier sizes the deposit target when extra.minDeposit is absent, + // and the lock ceiling when a spend cap is set. Defaults to 5. DepositMultiplier int // DepositStrategy lets the caller override the computed deposit amount or // skip the deposit entirely (returning Skip=true sends a voucher-only @@ -135,9 +136,12 @@ func (c *BatchSettlementEvmScheme) FindDefaultAsset(asset string, network x402.N // // The client loads local session state, falls back to onchain recovery when // storage is empty, then chooses deposit vs voucher from the resulting context. +// Permit2 deposits may attach EIP-2612 or ERC-20 approval gas sponsoring when +// payloadCtx.Extensions advertises those keys. func (c *BatchSettlementEvmScheme) CreatePaymentPayload( ctx context.Context, requirements types.PaymentRequirements, + payloadCtx x402.PaymentPayloadContext, ) (types.PaymentPayload, error) { channelConfig, err := c.BuildChannelConfig(requirements) if err != nil { @@ -197,11 +201,21 @@ func (c *BatchSettlementEvmScheme) CreatePaymentPayload( needsTopUp := !needsInitialDeposit && newCumulative.Cmp(balance) > 0 if needsInitialDeposit || needsTopUp { - computedDeposit := c.calculateDepositAmount(requiredAmount) minimumDeposit := new(big.Int).Sub(newCumulative, balance) if minimumDeposit.Sign() < 0 { minimumDeposit = big.NewInt(0) } + maxDeposit := MaxDepositFromSpendCap(payloadCtx.MaxAmountPerPayment, c.config.DepositMultiplier) + computedDeposit, err := DepositAmountForRequest( + c.config.DepositMultiplier, + requiredAmount, + minimumDeposit, + requirements.Extra, + maxDeposit, + ) + if err != nil { + return types.PaymentPayload{}, err + } strategyCtx := DepositStrategyContext{ PaymentRequirements: requirements, ChannelConfig: channelConfig, @@ -211,19 +225,32 @@ func (c *BatchSettlementEvmScheme) CreatePaymentPayload( MaxClaimableAmount: newCumulative.String(), CurrentBalance: balance.String(), MinimumDepositAmount: minimumDeposit.String(), - DepositAmount: computedDeposit.String(), + DepositAmount: computedDeposit, + } + if maxDeposit != nil { + strategyCtx.MaxDeposit = maxDeposit.String() } resolved, err := c.resolveDepositAmount(ctx, strategyCtx) if err != nil { return types.PaymentPayload{}, err } + var payload types.PaymentPayload if resolved.skip { - return c.createVoucherPayload(ctx, channelId, channelConfig, newCumulative.String(), requirements) + payload, err = c.createVoucherPayload(ctx, channelId, channelConfig, newCumulative.String(), requirements) + } else { + payload, err = c.createDepositPayload(ctx, channelConfig, resolved.amount, newCumulative.String(), requirements) } - return c.createDepositPayload(ctx, channelConfig, resolved.amount, newCumulative.String(), requirements) + if err != nil { + return types.PaymentPayload{}, err + } + return c.enrichDepositWithGasSponsoring(ctx, requirements, payload, payloadCtx) } - return c.createVoucherPayload(ctx, channelId, channelConfig, newCumulative.String(), requirements) + payload, err := c.createVoucherPayload(ctx, channelId, channelConfig, newCumulative.String(), requirements) + if err != nil { + return types.PaymentPayload{}, err + } + return c.enrichDepositWithGasSponsoring(ctx, requirements, payload, payloadCtx) } // resolveDepositAmountResult is the internal output of resolveDepositAmount. @@ -256,12 +283,23 @@ func (c *BatchSettlementEvmScheme) resolveDepositAmount( return resolveDepositAmountResult{}, fmt.Errorf("depositStrategy must return a positive integer deposit amount, got %q", res.Amount) } minimum, _ := new(big.Int).SetString(strategyCtx.MinimumDepositAmount, 10) - if minimum != nil && amount.Cmp(minimum) < 0 { + if minimum == nil { + minimum = big.NewInt(0) + } + if amount.Cmp(minimum) < 0 { return resolveDepositAmountResult{}, fmt.Errorf( "depositStrategy returned %s, below required top-up %s", amount.String(), minimum.String()) } - return resolveDepositAmountResult{amount: amount.String()}, nil + var maxDeposit *big.Int + if strategyCtx.MaxDeposit != "" { + maxDeposit, _ = new(big.Int).SetString(strategyCtx.MaxDeposit, 10) + } + clamped, err := ApplyMaxDeposit(amount, minimum, maxDeposit) + if err != nil { + return resolveDepositAmountResult{}, err + } + return resolveDepositAmountResult{amount: clamped}, nil } // BuildChannelConfig constructs a ChannelConfig from payment requirements and scheme config. @@ -909,9 +947,89 @@ func (a *refundContextAdapter) ProcessCorrectivePaymentRequired(ctx context.Cont return a.scheme.ProcessCorrectivePaymentRequired(ctx, errorReason, accepts) } -// calculateDepositAmount returns `requiredAmount * DepositMultiplier`. Callers -// wanting a cap should use a DepositStrategy callback. -func (c *BatchSettlementEvmScheme) calculateDepositAmount(requiredAmount *big.Int) *big.Int { - multiplier := big.NewInt(int64(c.config.DepositMultiplier)) - return new(big.Int).Mul(requiredAmount, multiplier) +func isAtomicAmountString(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + +// ParseAnnouncedMinDeposit parses a server-announced extra.minDeposit when it is a +// valid deposit target (positive integer >= requestAmount). +func ParseAnnouncedMinDeposit(value interface{}, requestAmount *big.Int) *big.Int { + s, ok := value.(string) + if !ok || !isAtomicAmountString(s) { + return nil + } + parsed, ok := new(big.Int).SetString(s, 10) + if !ok || parsed.Sign() <= 0 || parsed.Cmp(requestAmount) < 0 { + return nil + } + return parsed +} + +// MaxDepositFromSpendCap derives the deposit ceiling as depositMultiplier × the +// resolved spend cap. Returns nil when the payment is uncapped. +func MaxDepositFromSpendCap(maxAmountPerPayment string, depositMultiplier int) *big.Int { + if !isAtomicAmountString(maxAmountPerPayment) { + return nil + } + cap, ok := new(big.Int).SetString(maxAmountPerPayment, 10) + if !ok || cap.Sign() <= 0 { + return nil + } + if depositMultiplier <= 0 { + depositMultiplier = DefaultDepositMultiplier + } + return new(big.Int).Mul(cap, big.NewInt(int64(depositMultiplier))) +} + +// ApplyMaxDeposit clamps a computed deposit to maxDeposit. Errors when the +// voucher gap exceeds the cap. +func ApplyMaxDeposit(deposit, needed, maxDeposit *big.Int) (string, error) { + if maxDeposit == nil { + return deposit.String(), nil + } + if needed.Cmp(maxDeposit) > 0 { + return "", fmt.Errorf( + "required deposit %s exceeds depositMultiplier × spendControls.maxAmountPerPayment (%s). Raise maxAmountPerPayment or depositMultiplier", + needed.String(), maxDeposit.String(), + ) + } + if deposit.Cmp(maxDeposit) > 0 { + return maxDeposit.String(), nil + } + return deposit.String(), nil +} + +// DepositAmountForRequest computes the deposit amount from the voucher gap, +// server hint, or deposit multiplier. +func DepositAmountForRequest( + multiplier int, + requestAmount *big.Int, + needed *big.Int, + extra map[string]interface{}, + maxDeposit *big.Int, +) (string, error) { + var announced *big.Int + if extra != nil { + announced = ParseAnnouncedMinDeposit(extra["minDeposit"], requestAmount) + } + if multiplier <= 0 { + multiplier = DefaultDepositMultiplier + } + target := new(big.Int).Mul(big.NewInt(int64(multiplier)), requestAmount) + if announced != nil { + target = announced + } + deposit := target + if needed.Cmp(target) > 0 { + deposit = needed + } + return ApplyMaxDeposit(deposit, needed, maxDeposit) } diff --git a/go/mechanisms/evm/batch-settlement/client/scheme_test.go b/go/mechanisms/evm/batch-settlement/client/scheme_test.go index 79d75c649d..d97ceb323e 100644 --- a/go/mechanisms/evm/batch-settlement/client/scheme_test.go +++ b/go/mechanisms/evm/batch-settlement/client/scheme_test.go @@ -262,24 +262,112 @@ func TestBuildChannelConfig_ExplicitPayerAuthorizer(t *testing.T) { } } -// ---------- calculateDepositAmount ---------- +// ---------- depositAmountForRequest ---------- -func TestCalculateDepositAmount_BasicMultiplier(t *testing.T) { - scheme := NewBatchSettlementEvmScheme(&mockSigner{address: "0x1"}, nil) - // Default multiplier 5 → 5 * 100 = 500. - got := scheme.calculateDepositAmount(big.NewInt(100)) - if got.Cmp(big.NewInt(500)) != 0 { - t.Fatalf("got %s", got.String()) +func TestDepositAmountForRequest_UsesAnnouncedMinDepositWhenValid(t *testing.T) { + cap := big.NewInt(1_000_000) + got, err := DepositAmountForRequest(5, big.NewInt(1000), big.NewInt(1000), map[string]interface{}{"minDeposit": "12000"}, cap) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "12000" { + t.Fatalf("got %s", got) } } -func TestCalculateDepositAmount_HonorsCustomMultiplier(t *testing.T) { - scheme := NewBatchSettlementEvmScheme(&mockSigner{address: "0x1"}, &BatchSettlementEvmSchemeOptions{ - DepositMultiplier: 100, - }) - got := scheme.calculateDepositAmount(big.NewInt(100)) - if got.Cmp(big.NewInt(10_000)) != 0 { - t.Fatalf("got %s", got.String()) +func TestDepositAmountForRequest_IgnoresInvalidMinDepositValues(t *testing.T) { + cap := big.NewInt(1_000_000) + got, err := DepositAmountForRequest(5, big.NewInt(1000), big.NewInt(1000), map[string]interface{}{"minDeposit": "abc"}, cap) + if err != nil || got != "5000" { + t.Fatalf("abc: got %s err=%v", got, err) + } + got, err = DepositAmountForRequest(5, big.NewInt(1000), big.NewInt(1000), map[string]interface{}{"minDeposit": "0"}, cap) + if err != nil || got != "5000" { + t.Fatalf("0: got %s err=%v", got, err) + } + got, err = DepositAmountForRequest(5, big.NewInt(1000), big.NewInt(1000), map[string]interface{}{"minDeposit": "500"}, cap) + if err != nil || got != "5000" { + t.Fatalf("500: got %s err=%v", got, err) + } +} + +func TestDepositAmountForRequest_FallsBackToDepositMultiplier(t *testing.T) { + cap := big.NewInt(1_000_000) + got, err := DepositAmountForRequest(7, big.NewInt(1000), big.NewInt(1000), nil, cap) + if err != nil || got != "7000" { + t.Fatalf("got %s err=%v", got, err) + } +} + +func TestDepositAmountForRequest_UsesVoucherGapWhenItExceedsTarget(t *testing.T) { + cap := big.NewInt(1_000_000) + got, err := DepositAmountForRequest(5, big.NewInt(1000), big.NewInt(8000), map[string]interface{}{"minDeposit": "5000"}, cap) + if err != nil || got != "8000" { + t.Fatalf("got %s err=%v", got, err) + } +} + +func TestDepositAmountForRequest_ClampsTargetToMaxDeposit(t *testing.T) { + got, err := DepositAmountForRequest(5, big.NewInt(1000), big.NewInt(1000), map[string]interface{}{"minDeposit": "12000"}, big.NewInt(4000)) + if err != nil || got != "4000" { + t.Fatalf("got %s err=%v", got, err) + } +} + +func TestDepositAmountForRequest_ThrowsWhenVoucherGapExceedsMaxDeposit(t *testing.T) { + _, err := DepositAmountForRequest(5, big.NewInt(1000), big.NewInt(8000), map[string]interface{}{"minDeposit": "5000"}, big.NewInt(4000)) + if err == nil || !strings.Contains(err.Error(), "exceeds depositMultiplier") { + t.Fatalf("expected exceeds error, got %v", err) + } +} + +func TestDepositAmountForRequest_SkipsCeilingWhenNoSpendCap(t *testing.T) { + got, err := DepositAmountForRequest(5, big.NewInt(1000), big.NewInt(1000), map[string]interface{}{"minDeposit": "15000"}, nil) + if err != nil || got != "15000" { + t.Fatalf("got %s err=%v", got, err) + } +} + +func TestMaxDepositFromSpendCap(t *testing.T) { + got := MaxDepositFromSpendCap("1000", 5) + if got == nil || got.Cmp(big.NewInt(5000)) != 0 { + t.Fatalf("default multiplier = %v", got) + } + got = MaxDepositFromSpendCap("1000", 7) + if got == nil || got.Cmp(big.NewInt(7000)) != 0 { + t.Fatalf("custom multiplier = %v", got) + } + if MaxDepositFromSpendCap("", 5) != nil { + t.Fatal("expected nil when no spend cap") + } +} + +func TestApplyMaxDeposit(t *testing.T) { + got, err := ApplyMaxDeposit(big.NewInt(12000), big.NewInt(1000), big.NewInt(20000)) + if err != nil || got != "12000" { + t.Fatalf("within ceiling: got %s err=%v", got, err) + } + got, err = ApplyMaxDeposit(big.NewInt(12000), big.NewInt(1000), nil) + if err != nil || got != "12000" { + t.Fatalf("uncapped: got %s err=%v", got, err) + } +} + +func TestParseAnnouncedMinDeposit(t *testing.T) { + if got := ParseAnnouncedMinDeposit("1000", big.NewInt(1000)); got == nil || got.Cmp(big.NewInt(1000)) != 0 { + t.Fatalf("equal: %v", got) + } + if got := ParseAnnouncedMinDeposit("5000", big.NewInt(1000)); got == nil || got.Cmp(big.NewInt(5000)) != 0 { + t.Fatalf("above: %v", got) + } + if ParseAnnouncedMinDeposit(nil, big.NewInt(1000)) != nil { + t.Fatal("undefined should be rejected") + } + if ParseAnnouncedMinDeposit("999", big.NewInt(1000)) != nil { + t.Fatal("below request amount should be rejected") + } + if ParseAnnouncedMinDeposit("-1", big.NewInt(1000)) != nil { + t.Fatal("negative should be rejected") } } @@ -686,7 +774,7 @@ func TestUpdateChannelFromSettle_IgnoresInflatedServerCumulative(t *testing.T) { t.Fatalf("session = %+v", got) } - result, err := scheme.CreatePaymentPayload(context.Background(), req) + result, err := scheme.CreatePaymentPayload(context.Background(), req, x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("CreatePaymentPayload: %v", err) } @@ -896,7 +984,7 @@ func TestUpdateChannelFromSettle_WritesNothingWhenExtraCumulativeNotInteger(t *t func TestCreatePaymentPayload_FirstRequestDeposit(t *testing.T) { scheme := NewBatchSettlementEvmScheme(&mockSigner{address: "0x1111111111111111111111111111111111111111", sig: []byte{0xaa}}, nil) - payload, err := scheme.CreatePaymentPayload(context.Background(), defaultRequirements()) + payload, err := scheme.CreatePaymentPayload(context.Background(), defaultRequirements(), x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("err: %v", err) } @@ -919,7 +1007,7 @@ func TestCreatePaymentPayload_VoucherWhenSessionHasFunds(t *testing.T) { channelId, _ = batchsettlement.NormalizeChannelId(channelId) _ = storage.Set(channelId, &BatchSettlementClientContext{Balance: "1000", ChargedCumulativeAmount: "100"}) - payload, err := scheme.CreatePaymentPayload(context.Background(), defaultRequirements()) + payload, err := scheme.CreatePaymentPayload(context.Background(), defaultRequirements(), x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("err: %v", err) } @@ -941,7 +1029,7 @@ func TestCreatePaymentPayload_TopsUpOnInsufficient(t *testing.T) { channelId, _ = batchsettlement.NormalizeChannelId(channelId) _ = storage.Set(channelId, &BatchSettlementClientContext{Balance: "50", ChargedCumulativeAmount: "0"}) - payload, err := scheme.CreatePaymentPayload(context.Background(), defaultRequirements()) + payload, err := scheme.CreatePaymentPayload(context.Background(), defaultRequirements(), x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("err: %v", err) } @@ -971,7 +1059,7 @@ func TestCreatePaymentPayload_DepositStrategySkipYieldsVoucher(t *testing.T) { channelId, _ = batchsettlement.NormalizeChannelId(channelId) _ = storage.Set(channelId, &BatchSettlementClientContext{Balance: "50"}) - payload, err := scheme.CreatePaymentPayload(context.Background(), defaultRequirements()) + payload, err := scheme.CreatePaymentPayload(context.Background(), defaultRequirements(), x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("err: %v", err) } @@ -980,11 +1068,120 @@ func TestCreatePaymentPayload_DepositStrategySkipYieldsVoucher(t *testing.T) { } } +func depositAmountOf(t *testing.T, payload types.PaymentPayload) string { + t.Helper() + dep, ok := payload.Payload["deposit"].(map[string]interface{}) + if !ok { + t.Fatalf("expected deposit payload, got %+v", payload.Payload) + } + amt, _ := dep["amount"].(string) + return amt +} + +func requirementsWithMinDeposit(amount, minDeposit string) types.PaymentRequirements { + req := defaultRequirements() + req.Amount = amount + req.Extra["minDeposit"] = minDeposit + return req +} + +const testSpendCap = "1000000" + +func TestCreatePaymentPayload_HonorsValidMinDepositOverMultiplier(t *testing.T) { + signer := &mockSigner{address: "0x1111111111111111111111111111111111111111", sig: []byte{0xaa}} + scheme := NewBatchSettlementEvmScheme(signer, &BatchSettlementEvmSchemeOptions{DepositMultiplier: 7}) + payload, err := scheme.CreatePaymentPayload(context.Background(), requirementsWithMinDeposit("1000", "15000"), x402.PaymentPayloadContext{MaxAmountPerPayment: testSpendCap}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got := depositAmountOf(t, payload); got != "15000" { + t.Fatalf("deposit.amount = %s", got) + } +} + +func TestCreatePaymentPayload_FallsBackWhenMinDepositBelowAmount(t *testing.T) { + signer := &mockSigner{address: "0x1111111111111111111111111111111111111111", sig: []byte{0xaa}} + scheme := NewBatchSettlementEvmScheme(signer, &BatchSettlementEvmSchemeOptions{DepositMultiplier: 5}) + payload, err := scheme.CreatePaymentPayload(context.Background(), requirementsWithMinDeposit("1000", "500"), x402.PaymentPayloadContext{MaxAmountPerPayment: testSpendCap}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got := depositAmountOf(t, payload); got != "5000" { + t.Fatalf("deposit.amount = %s", got) + } +} + +func TestCreatePaymentPayload_ClampsMinDepositToSpendCapTimesMultiplier(t *testing.T) { + signer := &mockSigner{address: "0x1111111111111111111111111111111111111111", sig: []byte{0xaa}} + scheme := NewBatchSettlementEvmScheme(signer, &BatchSettlementEvmSchemeOptions{DepositMultiplier: 5}) + payload, err := scheme.CreatePaymentPayload(context.Background(), requirementsWithMinDeposit("1000", "15000"), x402.PaymentPayloadContext{MaxAmountPerPayment: "800"}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got := depositAmountOf(t, payload); got != "4000" { + t.Fatalf("deposit.amount = %s", got) + } +} + +func TestCreatePaymentPayload_RejectsWhenVoucherGapExceedsSpendCapTimesMultiplier(t *testing.T) { + signer := &mockSigner{address: "0x1111111111111111111111111111111111111111", sig: []byte{0xaa}} + scheme := NewBatchSettlementEvmScheme(signer, nil) + _, err := scheme.CreatePaymentPayload(context.Background(), requirementsWithAmount("1000"), x402.PaymentPayloadContext{MaxAmountPerPayment: "100"}) + if err == nil || !strings.Contains(err.Error(), "exceeds depositMultiplier") { + t.Fatalf("expected exceeds error, got %v", err) + } +} + +func TestCreatePaymentPayload_AllowsDepositWhenNoSpendCap(t *testing.T) { + signer := &mockSigner{address: "0x1111111111111111111111111111111111111111", sig: []byte{0xaa}} + scheme := NewBatchSettlementEvmScheme(signer, nil) + payload, err := scheme.CreatePaymentPayload(context.Background(), requirementsWithAmount("1000"), x402.PaymentPayloadContext{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got := depositAmountOf(t, payload); got != "5000" { + t.Fatalf("deposit.amount = %s", got) + } +} + +func TestCreatePaymentPayload_DepositStrategyContextIncludesMaxDeposit(t *testing.T) { + signer := &mockSigner{address: "0x1111111111111111111111111111111111111111", sig: []byte{0xaa}} + var seen DepositStrategyContext + scheme := NewBatchSettlementEvmScheme(signer, &BatchSettlementEvmSchemeOptions{ + DepositStrategy: func(_ context.Context, c DepositStrategyContext) (DepositStrategyResult, error) { + seen = c + return DepositStrategyResult{}, nil + }, + }) + _, err := scheme.CreatePaymentPayload(context.Background(), requirementsWithAmount("1000"), x402.PaymentPayloadContext{MaxAmountPerPayment: testSpendCap}) + if err != nil { + t.Fatalf("err: %v", err) + } + if seen.MaxDeposit != "5000000" { + t.Fatalf("maxDeposit = %q", seen.MaxDeposit) + } + if seen.DepositAmount != "5000" { + t.Fatalf("depositAmount = %q", seen.DepositAmount) + } +} + +func TestCreatePaymentPayload_CustomMultiplier(t *testing.T) { + signer := &mockSigner{address: "0x1111111111111111111111111111111111111111", sig: []byte{0xaa}} + scheme := NewBatchSettlementEvmScheme(signer, &BatchSettlementEvmSchemeOptions{DepositMultiplier: 7}) + payload, err := scheme.CreatePaymentPayload(context.Background(), requirementsWithAmount("1000"), x402.PaymentPayloadContext{MaxAmountPerPayment: testSpendCap}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got := depositAmountOf(t, payload); got != "7000" { + t.Fatalf("deposit.amount = %s", got) + } +} + func TestCreatePaymentPayload_BadAmount(t *testing.T) { scheme := NewBatchSettlementEvmScheme(&mockSigner{address: "0x1"}, nil) req := defaultRequirements() req.Amount = "not-a-number" - if _, err := scheme.CreatePaymentPayload(context.Background(), req); err == nil { + if _, err := scheme.CreatePaymentPayload(context.Background(), req, x402.PaymentPayloadContext{}); err == nil { t.Fatal("expected error") } } diff --git a/go/mechanisms/evm/batch-settlement/constants.go b/go/mechanisms/evm/batch-settlement/constants.go index 750c5c3d54..605a12c71e 100644 --- a/go/mechanisms/evm/batch-settlement/constants.go +++ b/go/mechanisms/evm/batch-settlement/constants.go @@ -24,6 +24,10 @@ const ( // MaxWithdrawDelay is the maximum withdraw delay in seconds (30 days). MaxWithdrawDelay = 2_592_000 + + // DefaultServerMinDepositMultiplier is the default server SDK multiplier for + // extra.minDeposit when no floor is configured. + DefaultServerMinDepositMultiplier = 10 ) // ChannelConfigTypeString is the EIP-712 typed-data primary type string used diff --git a/go/mechanisms/evm/batch-settlement/constants_test.go b/go/mechanisms/evm/batch-settlement/constants_test.go index 9b0de685c1..adf6408d2f 100644 --- a/go/mechanisms/evm/batch-settlement/constants_test.go +++ b/go/mechanisms/evm/batch-settlement/constants_test.go @@ -99,6 +99,7 @@ func TestErrorCodes(t *testing.T) { ErrRefundNoBalance, ErrRefundAmountInvalid, ErrRefundAmountExceedsBalance, + ErrDepositBelowMinDeposit, } { if !strings.HasPrefix(code, wirePrefix) { t.Fatalf("error reason must start with %q, got %q", wirePrefix, code) diff --git a/go/mechanisms/evm/batch-settlement/errors.go b/go/mechanisms/evm/batch-settlement/errors.go index 705226aeca..01872c2027 100644 --- a/go/mechanisms/evm/batch-settlement/errors.go +++ b/go/mechanisms/evm/batch-settlement/errors.go @@ -82,4 +82,9 @@ const ( // larger than the channel's available balance. Non-recoverable; client // should retry with a smaller amount or omit `amount` for a full refund. ErrRefundAmountExceedsBalance = "invalid_batch_settlement_evm_refund_amount_exceeds_balance" + + // ErrDepositBelowMinDeposit signals that the resource server rejected a + // deposit below its local extra.minDeposit policy. Opt-in via + // EnforceMinDeposit; the facilitator never enforces this field. + ErrDepositBelowMinDeposit = "invalid_batch_settlement_evm_deposit_below_min_deposit" ) diff --git a/go/mechanisms/evm/batch-settlement/server/hooks.go b/go/mechanisms/evm/batch-settlement/server/hooks.go index e8983bf24c..dad1d9fa89 100644 --- a/go/mechanisms/evm/batch-settlement/server/hooks.go +++ b/go/mechanisms/evm/batch-settlement/server/hooks.go @@ -92,6 +92,28 @@ func (s *BatchSettlementEvmScheme) BeforeVerifyHook() x402.BeforeVerifyHook { return nil, nil } + if s.enforceMinDeposit && batchsettlement.IsDepositPayload(payload) { + hintReq := types.PaymentRequirements{ + Amount: ctx.Requirements.GetAmount(), + Asset: ctx.Requirements.GetAsset(), + Network: ctx.Requirements.GetNetwork(), + Extra: ctx.Requirements.GetExtra(), + } + minDepositStr, hintErr := s.ResolveMinDepositHint(hintReq) + if hintErr != nil { + return nil, hintErr + } + minDeposit, ok := new(big.Int).SetString(minDepositStr, 10) + depositAmount := depositAmountFromPayload(payload) + if ok && minDeposit != nil && depositAmount != nil && depositAmount.Cmp(minDeposit) < 0 { + return &x402.BeforeHookResult{ + Abort: true, + Reason: batchsettlement.ErrDepositBelowMinDeposit, + Message: "Deposit amount is below the server minimum", + }, nil + } + } + voucherFields, _ := payload["voucher"].(map[string]interface{}) if voucherFields == nil { return nil, nil @@ -1113,3 +1135,22 @@ func mapIntField(m map[string]interface{}, key string, defaultVal int) int { } return defaultVal } + +func depositAmountFromPayload(payload map[string]interface{}) *big.Int { + if payload == nil { + return nil + } + dep, ok := payload["deposit"].(map[string]interface{}) + if !ok { + return nil + } + s, ok := dep["amount"].(string) + if !ok { + return nil + } + n, ok := new(big.Int).SetString(s, 10) + if !ok { + return nil + } + return n +} diff --git a/go/mechanisms/evm/batch-settlement/server/hooks_test.go b/go/mechanisms/evm/batch-settlement/server/hooks_test.go index b726df2c6b..be13d9a09e 100644 --- a/go/mechanisms/evm/batch-settlement/server/hooks_test.go +++ b/go/mechanisms/evm/batch-settlement/server/hooks_test.go @@ -70,12 +70,16 @@ func refundPayload(channelId, maxClaimable, sig string) map[string]interface{} { } func depositPayloadFor(channelId, maxClaimable, sig string) map[string]interface{} { + return depositPayloadWithAmount(channelId, maxClaimable, sig, "1000") +} + +func depositPayloadWithAmount(channelId, maxClaimable, sig, amount string) map[string]interface{} { cfg := testConfig() return map[string]interface{}{ "type": "deposit", "channelConfig": batchsettlement.ChannelConfigToMap(cfg), "deposit": map[string]interface{}{ - "amount": "1000", + "amount": amount, "authorization": map[string]interface{}{}, }, "voucher": map[string]interface{}{ @@ -182,6 +186,88 @@ func TestBeforeVerifyHook_NoSessionNonRefundPasses(t *testing.T) { } } +func TestBeforeVerifyHook_DoesNotRejectDepositBelowMinWhenEnforcementDisabled(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + id := testChannelId(t) + reqs := stubRequirements{ + scheme: batchsettlement.SchemeBatched, + network: "eip155:8453", + amount: "1000", + extra: map[string]interface{}{"minDeposit": "10000"}, + } + res, err := s.BeforeVerifyHook()(x402.VerifyContext{ + Payload: &stubPayload{data: depositPayloadWithAmount(id, "1000", "0xsig", "5000")}, + Requirements: reqs, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if res != nil { + t.Fatalf("expected pass-through, got %+v", res) + } +} + +func TestBeforeVerifyHook_RejectsDepositBelowMinWhenEnforcementEnabled(t *testing.T) { + storage := NewInMemoryChannelStorage() + s := NewBatchSettlementEvmScheme("0xreceiver", &BatchSettlementEvmSchemeServerConfig{ + Storage: storage, + EnforceMinDeposit: true, + }) + id := testChannelId(t) + reqs := stubRequirements{ + scheme: batchsettlement.SchemeBatched, + network: "eip155:8453", + amount: "1000", + extra: map[string]interface{}{"minDeposit": "10000"}, + } + + below, err := s.BeforeVerifyHook()(x402.VerifyContext{ + Payload: &stubPayload{data: depositPayloadWithAmount(id, "1000", "0xsig", "5000")}, + Requirements: reqs, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if below == nil || !below.Abort || below.Reason != batchsettlement.ErrDepositBelowMinDeposit { + t.Fatalf("below min: got %+v", below) + } + + atMin, err := s.BeforeVerifyHook()(x402.VerifyContext{ + Payload: &stubPayload{data: depositPayloadWithAmount(id, "1000", "0xsig", "10000")}, + Requirements: reqs, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if atMin != nil { + t.Fatalf("at min expected pass-through, got %+v", atMin) + } +} + +func TestBeforeVerifyHook_EnforcesDefault10xMinDepositWhenOmitted(t *testing.T) { + storage := NewInMemoryChannelStorage() + s := NewBatchSettlementEvmScheme("0xreceiver", &BatchSettlementEvmSchemeServerConfig{ + Storage: storage, + EnforceMinDeposit: true, + }) + id := testChannelId(t) + reqs := stubRequirements{ + scheme: batchsettlement.SchemeBatched, + network: "eip155:8453", + amount: "1000", + } + res, err := s.BeforeVerifyHook()(x402.VerifyContext{ + Payload: &stubPayload{data: depositPayloadWithAmount(id, "1000", "0xsig", "5000")}, + Requirements: reqs, + }) + if err != nil { + t.Fatalf("err: %v", err) + } + if res == nil || !res.Abort || res.Reason != batchsettlement.ErrDepositBelowMinDeposit { + t.Fatalf("got %+v", res) + } +} + func TestBeforeVerifyHook_StaleCumulativeAborts(t *testing.T) { s := NewBatchSettlementEvmScheme("0xreceiver", nil) id := testChannelId(t) diff --git a/go/mechanisms/evm/batch-settlement/server/scheme.go b/go/mechanisms/evm/batch-settlement/server/scheme.go index 7d1ff327f2..d7fa817d60 100644 --- a/go/mechanisms/evm/batch-settlement/server/scheme.go +++ b/go/mechanisms/evm/batch-settlement/server/scheme.go @@ -54,6 +54,9 @@ type BatchSettlementEvmSchemeServerConfig struct { // milliseconds, that may be trusted for local voucher verification. // When zero, derived from WithdrawDelay (clamped between 30s and 5min). OnchainStateTtlMs int64 + // EnforceMinDeposit rejects deposits below the announced extra.minDeposit + // hint. Default false (hint only). The facilitator never enforces this. + EnforceMinDeposit bool } // BatchSettlementEvmScheme implements SchemeNetworkServer for batched settlement. @@ -63,6 +66,7 @@ type BatchSettlementEvmScheme struct { receiverAuthorizerSigner AuthorizerSigner withdrawDelay int onchainStateTtlMs int64 + enforceMinDeposit bool moneyParsers []x402.MoneyParser // requestContexts maps a per-payment key to state carried across verify and @@ -136,6 +140,7 @@ func NewBatchSettlementEvmScheme(receiverAddress string, config *BatchSettlement var authSigner AuthorizerSigner withdrawDelay := batchsettlement.MinWithdrawDelay var onchainStateTtlMs int64 + var enforceMinDeposit bool if config != nil { storage = config.Storage @@ -144,6 +149,7 @@ func NewBatchSettlementEvmScheme(receiverAddress string, config *BatchSettlement withdrawDelay = config.WithdrawDelay } onchainStateTtlMs = config.OnchainStateTtlMs + enforceMinDeposit = config.EnforceMinDeposit } if onchainStateTtlMs <= 0 { @@ -160,6 +166,7 @@ func NewBatchSettlementEvmScheme(receiverAddress string, config *BatchSettlement receiverAuthorizerSigner: authSigner, withdrawDelay: withdrawDelay, onchainStateTtlMs: onchainStateTtlMs, + enforceMinDeposit: enforceMinDeposit, moneyParsers: []x402.MoneyParser{}, requestContexts: make(map[string]*BatchSettlementRequestContext), } @@ -441,6 +448,11 @@ func (s *BatchSettlementEvmScheme) GetWithdrawDelay() int { return s.withdrawDelay } +// GetEnforceMinDeposit returns whether deposits below extra.minDeposit are rejected. +func (s *BatchSettlementEvmScheme) GetEnforceMinDeposit() bool { + return s.enforceMinDeposit +} + // GetReceiverAuthorizerAddress returns the receiver authorizer's address. func (s *BatchSettlementEvmScheme) GetReceiverAuthorizerAddress() string { if s.receiverAuthorizerSigner != nil { @@ -598,6 +610,12 @@ func (s *BatchSettlementEvmScheme) EnhancePaymentRequirements( requirements.Extra["withdrawDelay"] = s.withdrawDelay } + minDeposit, hintErr := s.ResolveMinDepositHint(requirements) + if hintErr != nil { + return requirements, hintErr + } + requirements.Extra["minDeposit"] = minDeposit + // Copy extensions from supportedKind if supportedKind.Extra != nil { for _, key := range extensionKeys { @@ -745,6 +763,87 @@ func (s *BatchSettlementEvmScheme) DeleteSession(channelId string) error { return s.storage.Delete(channelId) } +// ResolveMinDepositHint resolves the extra.minDeposit hint written on every 402. +func (s *BatchSettlementEvmScheme) ResolveMinDepositHint(requirements types.PaymentRequirements) (string, error) { + amount, ok := new(big.Int).SetString(requirements.Amount, 10) + if !ok { + return "", fmt.Errorf("invalid amount: %s", requirements.Amount) + } + + var routeOverride interface{} + if requirements.Extra != nil { + routeOverride = requirements.Extra["minDeposit"] + } + + var configuredMin *big.Int + if override, isString := routeOverride.(string); isString { + if isAtomicMinDeposit(override) { + parsed, err := parseAtomicMinDeposit(override) + if err != nil { + return "", err + } + configuredMin = parsed + } else { + parsed, err := s.resolveRouteMoneyMinDeposit(override, requirements) + if err != nil { + return "", err + } + configuredMin = parsed + } + } + + if configuredMin == nil { + return new(big.Int).Mul(amount, big.NewInt(int64(batchsettlement.DefaultServerMinDepositMultiplier))).String(), nil + } + + if amount.Cmp(configuredMin) > 0 { + return amount.String(), nil + } + return configuredMin.String(), nil +} + +func (s *BatchSettlementEvmScheme) resolveRouteMoneyMinDeposit(money string, requirement types.PaymentRequirements) (*big.Int, error) { + defaultAsset := evm.FindDefaultAsset(requirement.Asset, string(requirement.Network)) + if defaultAsset == nil { + return nil, fmt.Errorf( + "extra.minDeposit money values are only supported for default assets; use an integer atomic string for %s on %s", + requirement.Asset, requirement.Network, + ) + } + parsed, _, err := x402.ParseMoney(money) + if err != nil { + return nil, err + } + atomic, err := x402.ConvertToTokenAmount(parsed, defaultAsset.Decimals) + if err != nil { + return nil, err + } + return parseAtomicMinDeposit(atomic) +} + +func parseAtomicMinDeposit(amount string) (*big.Int, error) { + if !isAtomicMinDeposit(amount) { + return nil, fmt.Errorf("minDeposit must resolve to a positive integer") + } + value, ok := new(big.Int).SetString(amount, 10) + if !ok || value.Sign() <= 0 { + return nil, fmt.Errorf("minDeposit must resolve to a positive integer") + } + return value, nil +} + +func isAtomicMinDeposit(amount string) bool { + if amount == "" { + return false + } + for i := 0; i < len(amount); i++ { + if amount[i] < '0' || amount[i] > '9' { + return false + } + } + return true +} + // Helper functions func defaultMoneyConversion(amount string, network x402.Network, symbol string) (x402.AssetAmount, error) { diff --git a/go/mechanisms/evm/batch-settlement/server/scheme_test.go b/go/mechanisms/evm/batch-settlement/server/scheme_test.go index c515affa1a..9c3f1777e6 100644 --- a/go/mechanisms/evm/batch-settlement/server/scheme_test.go +++ b/go/mechanisms/evm/batch-settlement/server/scheme_test.go @@ -41,6 +41,9 @@ func TestNewBatchSettlementEvmScheme_NilConfigDefaults(t *testing.T) { if s.GetStorage() == nil { t.Fatal("expected default in-memory storage") } + if s.GetEnforceMinDeposit() { + t.Fatal("enforceMinDeposit should default to false") + } if s.Scheme() != batchsettlement.SchemeBatched { t.Fatalf("scheme = %s", s.Scheme()) } @@ -65,6 +68,13 @@ func TestNewBatchSettlementEvmScheme_OverridesApplied(t *testing.T) { } } +func TestNewBatchSettlementEvmScheme_EnforceMinDepositEnabled(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", &BatchSettlementEvmSchemeServerConfig{EnforceMinDeposit: true}) + if !s.GetEnforceMinDeposit() { + t.Fatal("expected enforceMinDeposit true") + } +} + func TestNewBatchSettlementEvmScheme_ZeroWithdrawDelayFallsBackToMin(t *testing.T) { s := NewBatchSettlementEvmScheme("0xreceiver", &BatchSettlementEvmSchemeServerConfig{WithdrawDelay: 0}) if s.GetWithdrawDelay() != batchsettlement.MinWithdrawDelay { @@ -165,6 +175,9 @@ func TestEnhancePaymentRequirements_ExplicitAsset(t *testing.T) { if out.Extra["withdrawDelay"] != 1800 { t.Fatalf("withdrawDelay = %v", out.Extra["withdrawDelay"]) } + if out.Extra["minDeposit"] != "10000" { + t.Fatalf("minDeposit = %v", out.Extra["minDeposit"]) + } } // In delegated-authorizer mode (no local ReceiverAuthorizerSigner) the server @@ -238,6 +251,102 @@ func TestEnhancePaymentRequirements_PassesThroughAssetTransferMethod(t *testing. } } +func TestEnhancePaymentRequirements_DefaultsMinDepositTo10xAmount(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + req := types.PaymentRequirements{ + Network: "eip155:8453", + Asset: "0x1234567890abcdef1234567890abcdef12345678", + Amount: "2500", + Extra: map[string]interface{}{ + "receiverAuthorizer": "0x4444444444444444444444444444444444444444", + }, + } + out, err := s.EnhancePaymentRequirements(context.Background(), req, types.SupportedKind{}, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if out.Extra["minDeposit"] != "25000" { + t.Fatalf("minDeposit = %v", out.Extra["minDeposit"]) + } +} + +func TestEnhancePaymentRequirements_ConvertsRouteMinDepositMoneyOnDefaultAssets(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + req := types.PaymentRequirements{ + Network: "eip155:8453", + Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Amount: "1000", + Extra: map[string]interface{}{ + "receiverAuthorizer": "0x4444444444444444444444444444444444444444", + "minDeposit": "$1", + }, + } + out, err := s.EnhancePaymentRequirements(context.Background(), req, types.SupportedKind{}, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if out.Extra["minDeposit"] != "1000000" { + t.Fatalf("minDeposit = %v", out.Extra["minDeposit"]) + } +} + +func TestEnhancePaymentRequirements_UsesRequestAmountWhenItExceedsRouteMoney(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + req := types.PaymentRequirements{ + Network: "eip155:8453", + Asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Amount: "2000000", + Extra: map[string]interface{}{ + "receiverAuthorizer": "0x4444444444444444444444444444444444444444", + "minDeposit": "$1", + }, + } + out, err := s.EnhancePaymentRequirements(context.Background(), req, types.SupportedKind{}, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if out.Extra["minDeposit"] != "2000000" { + t.Fatalf("minDeposit = %v", out.Extra["minDeposit"]) + } +} + +func TestEnhancePaymentRequirements_AcceptsAtomicMinDepositForAnyAsset(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + req := types.PaymentRequirements{ + Network: "eip155:8453", + Asset: "0x00000000000000000000000000000000000000aa", + Amount: "1000", + Extra: map[string]interface{}{ + "receiverAuthorizer": "0x4444444444444444444444444444444444444444", + "minDeposit": "5000000", + }, + } + out, err := s.EnhancePaymentRequirements(context.Background(), req, types.SupportedKind{}, nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if out.Extra["minDeposit"] != "5000000" { + t.Fatalf("minDeposit = %v", out.Extra["minDeposit"]) + } +} + +func TestEnhancePaymentRequirements_RejectsMinDepositMoneyForNonDefaultAssets(t *testing.T) { + s := NewBatchSettlementEvmScheme("0xreceiver", nil) + req := types.PaymentRequirements{ + Network: "eip155:8453", + Asset: "0x00000000000000000000000000000000000000aa", + Amount: "1000", + Extra: map[string]interface{}{ + "receiverAuthorizer": "0x4444444444444444444444444444444444444444", + "minDeposit": "$1", + }, + } + _, err := s.EnhancePaymentRequirements(context.Background(), req, types.SupportedKind{}, nil) + if err == nil || !strings.Contains(err.Error(), "only supported for default assets") { + t.Fatalf("expected default-asset error, got %v", err) + } +} + func TestEnhancePaymentRequirements_DecimalAmountNormalized(t *testing.T) { s := NewBatchSettlementEvmScheme("0xreceiver", nil) req := types.PaymentRequirements{ diff --git a/go/mechanisms/evm/exact/client/scheme.go b/go/mechanisms/evm/exact/client/scheme.go index d88773c9c5..34b6948320 100644 --- a/go/mechanisms/evm/exact/client/scheme.go +++ b/go/mechanisms/evm/exact/client/scheme.go @@ -47,31 +47,15 @@ func (c *ExactEvmScheme) FindDefaultAsset(asset string, network x402.Network) *x // CreatePaymentPayload creates a V2 payment payload for the exact scheme. // Routes to EIP-3009 or Permit2 based on requirements.Extra["assetTransferMethod"]. // Defaults to EIP-3009 for backward compatibility. -func (c *ExactEvmScheme) CreatePaymentPayload( - ctx context.Context, - requirements types.PaymentRequirements, -) (types.PaymentPayload, error) { - assetTransferMethod := evm.AssetTransferMethodEIP3009 // default - if requirements.Extra != nil { - if method, ok := requirements.Extra["assetTransferMethod"].(string); ok { - assetTransferMethod = evm.AssetTransferMethod(method) - } - } - if assetTransferMethod == evm.AssetTransferMethodPermit2 { - return CreatePermit2Payload(ctx, c.signer, requirements) - } - return c.createEIP3009Payload(ctx, requirements) -} - -// CreatePaymentPayloadWithExtensions creates a payment payload with extension awareness. // For Permit2 flows, if the server advertises eip2612GasSponsoring and the signer // supports ReadContract, automatically signs an EIP-2612 permit when Permit2 // allowance is insufficient. -func (c *ExactEvmScheme) CreatePaymentPayloadWithExtensions( +func (c *ExactEvmScheme) CreatePaymentPayload( ctx context.Context, requirements types.PaymentRequirements, - extensions map[string]interface{}, + payloadCtx x402.PaymentPayloadContext, ) (types.PaymentPayload, error) { + extensions := payloadCtx.Extensions assetTransferMethod := evm.AssetTransferMethodEIP3009 if requirements.Extra != nil { if method, ok := requirements.Extra["assetTransferMethod"].(string); ok { diff --git a/go/mechanisms/evm/exact/v1/client/scheme.go b/go/mechanisms/evm/exact/v1/client/scheme.go index eb5515687f..fd43ebc8ce 100644 --- a/go/mechanisms/evm/exact/v1/client/scheme.go +++ b/go/mechanisms/evm/exact/v1/client/scheme.go @@ -42,6 +42,7 @@ func (c *ExactEvmSchemeV1) FindDefaultAsset(asset string, network x402.Network) func (c *ExactEvmSchemeV1) CreatePaymentPayload( ctx context.Context, requirements types.PaymentRequirementsV1, + _ x402.PaymentPayloadContext, ) (types.PaymentPayloadV1, error) { networkStr := requirements.Network diff --git a/go/mechanisms/evm/upto/client/scheme.go b/go/mechanisms/evm/upto/client/scheme.go index ac6cf5662e..37beab785f 100644 --- a/go/mechanisms/evm/upto/client/scheme.go +++ b/go/mechanisms/evm/upto/client/scheme.go @@ -16,7 +16,7 @@ import ( "github.com/x402-foundation/x402/go/v2/types" ) -// UptoEvmScheme implements SchemeNetworkClient and ExtensionAwareClient for EVM upto payments. +// UptoEvmScheme implements SchemeNetworkClient for EVM upto payments. // Always uses Permit2 (no EIP-3009 path). type UptoEvmScheme struct { signer evm.ClientEvmSigner @@ -43,19 +43,14 @@ func (c *UptoEvmScheme) FindDefaultAsset(asset string, network x402.Network) *x4 } // CreatePaymentPayload creates a V2 payment payload for the upto scheme (always Permit2). +// If the server advertises eip2612GasSponsoring and the signer supports ReadContract, +// automatically signs an EIP-2612 permit when Permit2 allowance is insufficient. func (c *UptoEvmScheme) CreatePaymentPayload( ctx context.Context, requirements types.PaymentRequirements, + payloadCtx x402.PaymentPayloadContext, ) (types.PaymentPayload, error) { - return CreateUptoPermit2Payload(ctx, c.signer, requirements) -} - -// CreatePaymentPayloadWithExtensions creates a payment payload with extension support. -func (c *UptoEvmScheme) CreatePaymentPayloadWithExtensions( - ctx context.Context, - requirements types.PaymentRequirements, - extensions map[string]interface{}, -) (types.PaymentPayload, error) { + extensions := payloadCtx.Extensions result, err := CreateUptoPermit2Payload(ctx, c.signer, requirements) if err != nil { return types.PaymentPayload{}, err diff --git a/go/mechanisms/svm/exact/client/duplicate_tx_test.go b/go/mechanisms/svm/exact/client/duplicate_tx_test.go index 40b8514df9..d81045b794 100644 --- a/go/mechanisms/svm/exact/client/duplicate_tx_test.go +++ b/go/mechanisms/svm/exact/client/duplicate_tx_test.go @@ -13,6 +13,8 @@ import ( "testing" "unicode/utf8" + x402 "github.com/x402-foundation/x402/go/v2" + bin "github.com/gagliardetto/binary" solana "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/programs/token" @@ -203,10 +205,10 @@ func TestMintMetadataCacheAvoidsRepeatedMintRPC(t *testing.T) { } ctx := context.Background() - _, err := client.CreatePaymentPayload(ctx, requirements) + _, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) - _, err = client.CreatePaymentPayload(ctx, requirements) + _, err = client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) assert.Equal(t, int32(1), atomic.LoadInt32(&accountInfoCalls)) @@ -237,7 +239,7 @@ func TestRecentBlockhashResolution(t *testing.T) { }, } - payload, err := client.CreatePaymentPayload(context.Background(), requirements) + payload, err := client.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded, err := svm.DecodeTransaction(payload.Payload["transaction"].(string)) @@ -270,7 +272,7 @@ func TestRecentBlockhashResolution(t *testing.T) { }, } - payload, err := client.CreatePaymentPayload(context.Background(), requirements) + payload, err := client.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded, err := svm.DecodeTransaction(payload.Payload["transaction"].(string)) @@ -309,7 +311,7 @@ func TestRecentBlockhashResolution(t *testing.T) { }, } - payload, err := client.CreatePaymentPayload(context.Background(), requirements) + payload, err := client.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded, err := svm.DecodeTransaction(payload.Payload["transaction"].(string)) @@ -353,10 +355,10 @@ func TestFixedBlockhashProducesDistinctTransactions(t *testing.T) { ctx := context.Background() - payload1, err := client.CreatePaymentPayload(ctx, requirements) + payload1, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err, "First payload creation should succeed") - payload2, err := client.CreatePaymentPayload(ctx, requirements) + payload2, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err, "Second payload creation should succeed") tx1 := payload1.Payload["transaction"].(string) @@ -416,10 +418,10 @@ func TestFixedBlockhashProducesDistinctTransactions(t *testing.T) { ctx := context.Background() - payload1, err := client.CreatePaymentPayload(ctx, requirements) + payload1, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) - payload2, err := client.CreatePaymentPayload(ctx, requirements) + payload2, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) tx1 := payload1.Payload["transaction"].(string) @@ -472,7 +474,7 @@ func TestFixedBlockhashProducesDistinctTransactions(t *testing.T) { wg.Add(1) go func(idx int) { defer wg.Done() - payload, err := client.CreatePaymentPayload(ctx, requirements) + payload, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) if err != nil { t.Errorf("Concurrent request %d failed: %v", idx, err) return @@ -517,7 +519,7 @@ func TestComputeBudgetUsesSchemeDefaults(t *testing.T) { MaxTimeoutSeconds: 3600, Extra: map[string]interface{}{"feePayer": solana.NewWallet().PublicKey().String()}, } - payload, err := client.CreatePaymentPayload(context.Background(), requirements) + payload, err := client.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded, err := svm.DecodeTransaction(payload.Payload["transaction"].(string)) @@ -606,7 +608,7 @@ func TestMemoDataIsValidUTF8(t *testing.T) { ctx := context.Background() - payload, err := client.CreatePaymentPayload(ctx, requirements) + payload, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) tx1 := payload.Payload["transaction"].(string) @@ -670,7 +672,7 @@ func TestMemoInstructionHasNoSigners(t *testing.T) { Extra: map[string]interface{}{"feePayer": solana.NewWallet().PublicKey().String()}, } - payload, err := client.CreatePaymentPayload(context.Background(), requirements) + payload, err := client.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded, err := svm.DecodeTransaction(payload.Payload["transaction"].(string)) @@ -709,7 +711,7 @@ func TestSellerMemo(t *testing.T) { }, } - payload, err := client.CreatePaymentPayload(context.Background(), requirements) + payload, err := client.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded, err := svm.DecodeTransaction(payload.Payload["transaction"].(string)) @@ -754,9 +756,9 @@ func TestSellerMemo(t *testing.T) { ctx := context.Background() - payload1, err := client.CreatePaymentPayload(ctx, requirements) + payload1, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) - payload2, err := client.CreatePaymentPayload(ctx, requirements) + payload2, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded1, err := svm.DecodeTransaction(payload1.Payload["transaction"].(string)) @@ -798,7 +800,7 @@ func TestSellerMemo(t *testing.T) { }, } - _, err := client.CreatePaymentPayload(context.Background(), requirements) + _, err := client.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.Error(t, err) assert.Contains(t, err.Error(), ErrMemoExceedsMaxSize) }) @@ -830,9 +832,9 @@ func TestSellerMemo(t *testing.T) { ctx := context.Background() - payload1, err := client.CreatePaymentPayload(ctx, requirements) + payload1, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) - payload2, err := client.CreatePaymentPayload(ctx, requirements) + payload2, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded1, err := svm.DecodeTransaction(payload1.Payload["transaction"].(string)) diff --git a/go/mechanisms/svm/exact/client/scheme.go b/go/mechanisms/svm/exact/client/scheme.go index 4742d7fb32..c635d5e935 100644 --- a/go/mechanisms/svm/exact/client/scheme.go +++ b/go/mechanisms/svm/exact/client/scheme.go @@ -56,6 +56,7 @@ func (c *ExactSvmScheme) FindDefaultAsset(asset string, network x402.Network) *x func (c *ExactSvmScheme) CreatePaymentPayload( ctx context.Context, requirements types.PaymentRequirements, + _ x402.PaymentPayloadContext, ) (types.PaymentPayload, error) { // Validate network networkStr := string(requirements.Network) diff --git a/go/mechanisms/svm/exact/v1/client/mint_cache_test.go b/go/mechanisms/svm/exact/v1/client/mint_cache_test.go index 664f533c03..6dda590a27 100644 --- a/go/mechanisms/svm/exact/v1/client/mint_cache_test.go +++ b/go/mechanisms/svm/exact/v1/client/mint_cache_test.go @@ -10,6 +10,8 @@ import ( "sync/atomic" "testing" + x402 "github.com/x402-foundation/x402/go/v2" + bin "github.com/gagliardetto/binary" solana "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/programs/token" @@ -142,10 +144,10 @@ func TestMintMetadataCacheAvoidsRepeatedMintRPC(t *testing.T) { } ctx := context.Background() - _, err := client.CreatePaymentPayload(ctx, requirements) + _, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) - _, err = client.CreatePaymentPayload(ctx, requirements) + _, err = client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) assert.Equal(t, int32(1), atomic.LoadInt32(&accountInfoCalls)) diff --git a/go/mechanisms/svm/exact/v1/client/scheme.go b/go/mechanisms/svm/exact/v1/client/scheme.go index a2a0a89c94..52efad1aa0 100644 --- a/go/mechanisms/svm/exact/v1/client/scheme.go +++ b/go/mechanisms/svm/exact/v1/client/scheme.go @@ -57,6 +57,7 @@ func (c *ExactSvmSchemeV1) FindDefaultAsset(asset string, network x402.Network) func (c *ExactSvmSchemeV1) CreatePaymentPayload( ctx context.Context, requirements types.PaymentRequirementsV1, + _ x402.PaymentPayloadContext, ) (types.PaymentPayloadV1, error) { // Validate network (V1 uses simple names, normalize to CAIP-2 internally) diff --git a/go/mechanisms/svm/upto/client/scheme.go b/go/mechanisms/svm/upto/client/scheme.go index 9d162155bf..685ddb37bc 100644 --- a/go/mechanisms/svm/upto/client/scheme.go +++ b/go/mechanisms/svm/upto/client/scheme.go @@ -11,6 +11,7 @@ import ( solana "github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go/rpc" + x402 "github.com/x402-foundation/x402/go/v2" "github.com/x402-foundation/x402/go/v2/mechanisms/svm" "github.com/x402-foundation/x402/go/v2/mechanisms/svm/paymentchannels" "github.com/x402-foundation/x402/go/v2/mechanisms/svm/upto" @@ -55,6 +56,7 @@ func (c *UptoSvmScheme) Scheme() string { func (c *UptoSvmScheme) CreatePaymentPayload( ctx context.Context, requirements types.PaymentRequirements, + _ x402.PaymentPayloadContext, ) (types.PaymentPayload, error) { networkStr := string(requirements.Network) if !svm.IsValidNetwork(networkStr) { diff --git a/go/mechanisms/svm/upto/client/scheme_test.go b/go/mechanisms/svm/upto/client/scheme_test.go index afc6a9552f..7747666949 100644 --- a/go/mechanisms/svm/upto/client/scheme_test.go +++ b/go/mechanisms/svm/upto/client/scheme_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + x402 "github.com/x402-foundation/x402/go/v2" + solana "github.com/gagliardetto/solana-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -160,7 +162,7 @@ func TestCreatePaymentPayloadBuildsAVerifiableOpen(t *testing.T) { requirements := newRequirements(t, feePayer, authorizer, challengeOptions{memo: &memo}) scheme := NewUptoSvmScheme(signer) - payload, err := scheme.CreatePaymentPayload(context.Background(), requirements) + payload, err := scheme.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) assert.Equal(t, 2, payload.X402Version) @@ -216,7 +218,7 @@ func TestCreatePaymentPayloadTreatsAnEmptyMemoAsUnset(t *testing.T) { requirements := newRequirements(t, feePayer, authorizer, challengeOptions{memo: &empty}) scheme := NewUptoSvmScheme(signer) - payload, err := scheme.CreatePaymentPayload(context.Background(), requirements) + payload, err := scheme.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded, err := svm.UptoPayloadFromMap(payload.Payload) @@ -258,7 +260,7 @@ func TestCreatePaymentPayloadFallsBackToRPCHints(t *testing.T) { rpcURL := stubRPC(t, 341_000_123, solana.Hash(solana.SysVarRentPubkey)) scheme := NewUptoSvmScheme(signer, &svm.ClientConfig{RPCURL: rpcURL}) - payload, err := scheme.CreatePaymentPayload(context.Background(), requirements) + payload, err := scheme.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded, err := svm.UptoPayloadFromMap(payload.Payload) @@ -279,7 +281,7 @@ func TestCreatePaymentPayloadUsesChallengeHintsWithoutRPC(t *testing.T) { requirements := newRequirements(t, feePayer, authorizer, challengeOptions{}) scheme := NewUptoSvmScheme(signer, &svm.ClientConfig{RPCURL: unreachableRPC(t)}) - payload, err := scheme.CreatePaymentPayload(context.Background(), requirements) + payload, err := scheme.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded, err := svm.UptoPayloadFromMap(payload.Payload) @@ -300,7 +302,7 @@ func TestCreatePaymentPayloadUsesTheOpenComputeBudgetDefaults(t *testing.T) { requirements := newRequirements(t, feePayer, authorizer, challengeOptions{}) scheme := NewUptoSvmScheme(signer, &svm.ClientConfig{}) - payload, err := scheme.CreatePaymentPayload(context.Background(), requirements) + payload, err := scheme.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.NoError(t, err) decoded, err := svm.UptoPayloadFromMap(payload.Payload) @@ -398,7 +400,7 @@ func TestCreatePaymentPayloadRejectsInvalidChallenges(t *testing.T) { test.mutate(&requirements) scheme := NewUptoSvmScheme(newTestSigner(t)) - _, err := scheme.CreatePaymentPayload(context.Background(), requirements) + _, err := scheme.CreatePaymentPayload(context.Background(), requirements, x402.PaymentPayloadContext{}) require.ErrorContains(t, err, test.wantError) }) diff --git a/go/test/mocks/cash/cash.go b/go/test/mocks/cash/cash.go index bede68d83c..931c987c33 100644 --- a/go/test/mocks/cash/cash.go +++ b/go/test/mocks/cash/cash.go @@ -34,7 +34,7 @@ func (c *SchemeNetworkClient) Scheme() string { } // CreatePaymentPayload creates a V2 payment payload for the cash scheme -func (c *SchemeNetworkClient) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements) (types.PaymentPayload, error) { +func (c *SchemeNetworkClient) CreatePaymentPayload(ctx context.Context, requirements types.PaymentRequirements, _ x402.PaymentPayloadContext) (types.PaymentPayload, error) { validUntil := time.Now().Add(time.Duration(requirements.MaxTimeoutSeconds) * time.Second).Unix() return types.PaymentPayload{ diff --git a/go/test/unit/evm_client_facilitator_test.go b/go/test/unit/evm_client_facilitator_test.go index fead8be2da..fa798ca3bd 100644 --- a/go/test/unit/evm_client_facilitator_test.go +++ b/go/test/unit/evm_client_facilitator_test.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" + x402 "github.com/x402-foundation/x402/go/v2" "github.com/x402-foundation/x402/go/v2/mechanisms/evm" evmclient "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/client" evmfacilitator "github.com/x402-foundation/x402/go/v2/mechanisms/evm/exact/facilitator" @@ -238,7 +239,7 @@ func TestCreatePaymentPayloadEIP3009(t *testing.T) { }, } - payload, err := client.CreatePaymentPayload(ctx, requirements) + payload, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("Failed to create payload: %v", err) } @@ -285,7 +286,7 @@ func TestCreatePaymentPayloadEIP3009(t *testing.T) { PayTo: "0x9876543210987654321098765432109876543210", } - _, err := client.CreatePaymentPayload(ctx, requirements) + _, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) if err == nil { t.Error("Expected error for invalid network") } @@ -305,7 +306,7 @@ func TestCreatePaymentPayloadEIP3009(t *testing.T) { }, } - payload, err := client.CreatePaymentPayload(ctx, requirements) + payload, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("Failed to create payload for arbitrary network: %v", err) } @@ -335,7 +336,7 @@ func TestCreatePaymentPayloadPermit2(t *testing.T) { }, } - payload, err := client.CreatePaymentPayload(ctx, requirements) + payload, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("Failed to create payload: %v", err) } @@ -386,7 +387,7 @@ func TestCreatePaymentPayloadPermit2(t *testing.T) { MaxTimeoutSeconds: 300, } - payloadEIP3009, _ := client.CreatePaymentPayload(ctx, reqEIP3009) + payloadEIP3009, _ := client.CreatePaymentPayload(ctx, reqEIP3009, x402.PaymentPayloadContext{}) if !evm.IsEIP3009Payload(payloadEIP3009.Payload) { t.Error("Expected EIP-3009 when assetTransferMethod not specified") } @@ -404,7 +405,7 @@ func TestCreatePaymentPayloadPermit2(t *testing.T) { }, } - payloadExplicitEIP3009, _ := client.CreatePaymentPayload(ctx, reqExplicitEIP3009) + payloadExplicitEIP3009, _ := client.CreatePaymentPayload(ctx, reqExplicitEIP3009, x402.PaymentPayloadContext{}) if !evm.IsEIP3009Payload(payloadExplicitEIP3009.Payload) { t.Error("Expected EIP-3009 when assetTransferMethod is eip3009") } @@ -422,7 +423,7 @@ func TestCreatePaymentPayloadPermit2(t *testing.T) { }, } - payloadPermit2, _ := client.CreatePaymentPayload(ctx, reqPermit2) + payloadPermit2, _ := client.CreatePaymentPayload(ctx, reqPermit2, x402.PaymentPayloadContext{}) if !evm.IsPermit2Payload(payloadPermit2.Payload) { t.Error("Expected Permit2 when assetTransferMethod is permit2") } @@ -1383,10 +1384,10 @@ func TestExactEvmFacilitatorScheme(t *testing.T) { // EIP-2612 Gas Sponsoring Tests // ========================================================================= -// TestCreatePaymentPayloadWithExtensions_EIP2612 tests that the client creates +// TestCreatePaymentPayload_EIP2612 tests that the client creates // EIP-2612 extension data when the server advertises the extension and // Permit2 allowance is insufficient. -func TestCreatePaymentPayloadWithExtensions_EIP2612(t *testing.T) { +func TestCreatePaymentPayload_EIP2612(t *testing.T) { ctx := context.Background() t.Run("Creates EIP-2612 extension when server advertises and allowance is 0", func(t *testing.T) { @@ -1415,7 +1416,7 @@ func TestCreatePaymentPayloadWithExtensions_EIP2612(t *testing.T) { }, } - payload, err := client.CreatePaymentPayloadWithExtensions(ctx, requirements, extensions) + payload, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{Extensions: extensions}) if err != nil { t.Fatalf("Failed to create payload: %v", err) } @@ -1449,7 +1450,7 @@ func TestCreatePaymentPayloadWithExtensions_EIP2612(t *testing.T) { } // No extensions advertised - payload, err := client.CreatePaymentPayloadWithExtensions(ctx, requirements, nil) + payload, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{}) if err != nil { t.Fatalf("Failed to create payload: %v", err) } @@ -1484,7 +1485,7 @@ func TestCreatePaymentPayloadWithExtensions_EIP2612(t *testing.T) { }, } - payload, err := client.CreatePaymentPayloadWithExtensions(ctx, requirements, extensions) + payload, err := client.CreatePaymentPayload(ctx, requirements, x402.PaymentPayloadContext{Extensions: extensions}) if err != nil { t.Fatalf("Failed to create payload: %v", err) } diff --git a/python/x402/CHANGELOG.md b/python/x402/CHANGELOG.md index 4520d3caa7..ddfa7af341 100644 --- a/python/x402/CHANGELOG.md +++ b/python/x402/CHANGELOG.md @@ -2,6 +2,27 @@ +## [2.23.0] - 2026-09-15 + +### Fixed + +- EVM exact settle's ERC-6492 branch now reads payer deployment from the verify it already awaited rather than issuing a second eth_getCode. Both reads happen within one settle call and before any deploy transaction, so this is not the post-deploy re-read that races RPC state propagation. ([#3366](https://github.com/x402-foundation/x402/pull/3366)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- HTTP adapters now exit on permanent facilitator capability or route configuration errors during initialize, matching TypeScript and Go. Transient facilitator timeouts stay retryable. ([#3364](https://github.com/x402-foundation/x402/pull/3364)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen), [@cursoragent](https://github.com/cursoragent)! +- HTTPFacilitatorClient now defaults to a 90s per-request timeout so long settle() calls can finish. ([#3409](https://github.com/x402-foundation/x402/pull/3409)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- MCP clients cap tool-call read timeouts at `max_request_timeout_seconds` (default 600s) while honoring accept `maxTimeoutSeconds` below that ceiling. ([#3481](https://github.com/x402-foundation/x402/pull/3481)) - Thanks [@phdargen](https://github.com/phdargen)! +- MCP tool calls now derive their request timeout from the accept's maxTimeoutSeconds (default 300s) instead of the MCP SDK's 60s default, so slow-finality settlements no longer abort mid-flight. The initial 402 probe uses a 300s ceiling unless the caller passes an explicit read_timeout_seconds. ([#3443](https://github.com/x402-foundation/x402/pull/3443)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- `_is_valid_route_template` now decodes a routeTemplate to a fixed point (up to a bounded pass budget) before running its traversal/scheme-injection checks, instead of a single unquote pass. A double- or deeper-encoded payload (`%252e%252e`, `%253a%252f%252f`, ...) previously survived one decode still percent-encoded and slipped past the `..`/`://` checks, letting a malicious routeTemplate cause the facilitator to catalog a payment under an arbitrary URL. ([#3440](https://github.com/x402-foundation/x402/pull/3440)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + +### Added + +- Add Celo mainnet USDT and USAT (EIP-3009) as default assets for `eip155:42220` so `"$0.10 USDT"` and `"$0.10 USAT"` resolve on Celo; bare `"$0.10"` still resolves to USDC. ([#3457](https://github.com/x402-foundation/x402/pull/3457)) - Thanks [@GigaHierz](https://github.com/GigaHierz)! +- Pass the resolved atomic `spend_controls` cap to every scheme on `PaymentPayloadContext.max_amount_per_payment` (omitted when uncapped) so capital-locking schemes can reuse client policy without re-resolving it. Batch-settlement EVM servers always announce `extra.minDeposit` (default `10 × amount`, optional per-route override via `accepts.extra.minDeposit`). Clients size deposits from the hint when valid, clamped to that cap × `deposit_multiplier` when a spend cap is set. Uncapped payments (`spend_controls=False` or no per-asset cap) also leave deposits uncapped. Older 402s fall back to `deposit_multiplier` for sizing. Servers may opt in to SDK enforcement via `enforce_min_deposit=True` (default off; facilitator never enforces). Export `invalid_batch_settlement_evm_deposit_below_min_deposit` for custom server enforcement. ([#3480](https://github.com/x402-foundation/x402/pull/3480)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + +### Misc + +- EVM facilitators now cache a positive asset-contract check for 15 minutes instead of issuing a fresh eth_getCode on the payment token for every payment. Only positive results are cached, so a token observed mid-deployment still recovers on the next request. ([#3362](https://github.com/x402-foundation/x402/pull/3362)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + + ## [2.22.0] - 2026-09-04 ### Fixed diff --git a/python/x402/__init__.py b/python/x402/__init__.py index 890ad6c4d7..9ffc467bb4 100644 --- a/python/x402/__init__.py +++ b/python/x402/__init__.py @@ -72,6 +72,7 @@ PaymentFlowConfig, PaymentFlowName, PaymentFlowPhases, + PaymentPayloadContext, ResolvedPaymentFlow, SchemeNetworkClient, SchemeNetworkClientV1, @@ -172,7 +173,7 @@ x402ResourceServerSync, ) -__version__ = "2.22.0" +__version__ = "2.23.0" __all__ = [ # Version @@ -209,6 +210,7 @@ "SchemeNetworkServer", "SchemeNetworkFacilitator", "SchemeNetworkFacilitatorV1", + "PaymentPayloadContext", "PaymentFlowName", "PaymentFlowPhases", "PaymentFlowConfig", diff --git a/python/x402/changelog.d/3364.bugfix.md b/python/x402/changelog.d/3364.bugfix.md deleted file mode 100644 index 04e4749917..0000000000 --- a/python/x402/changelog.d/3364.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -HTTP adapters now exit on permanent facilitator capability or route configuration errors during initialize, matching TypeScript and Go. Transient facilitator timeouts stay retryable. diff --git a/python/x402/changelog.d/3366.bugfix.md b/python/x402/changelog.d/3366.bugfix.md deleted file mode 100644 index 5aa6f80728..0000000000 --- a/python/x402/changelog.d/3366.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -EVM exact settle's ERC-6492 branch now reads payer deployment from the verify it already awaited rather than issuing a second eth_getCode. Both reads happen within one settle call and before any deploy transaction, so this is not the post-deploy re-read that races RPC state propagation. diff --git a/python/x402/changelog.d/3409.bugfix.md b/python/x402/changelog.d/3409.bugfix.md deleted file mode 100644 index 304479f539..0000000000 --- a/python/x402/changelog.d/3409.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -HTTPFacilitatorClient now defaults to a 90s per-request timeout so long settle() calls can finish. diff --git a/python/x402/changelog.d/3440.bugfix.md b/python/x402/changelog.d/3440.bugfix.md deleted file mode 100644 index 517f00758a..0000000000 --- a/python/x402/changelog.d/3440.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -`_is_valid_route_template` now decodes a routeTemplate to a fixed point (up to a bounded pass budget) before running its traversal/scheme-injection checks, instead of a single unquote pass. A double- or deeper-encoded payload (`%252e%252e`, `%253a%252f%252f`, ...) previously survived one decode still percent-encoded and slipped past the `..`/`://` checks, letting a malicious routeTemplate cause the facilitator to catalog a payment under an arbitrary URL. diff --git a/python/x402/changelog.d/celo-usdt-default-asset.feature.md b/python/x402/changelog.d/celo-usdt-default-asset.feature.md deleted file mode 100644 index f4dbb2e79c..0000000000 --- a/python/x402/changelog.d/celo-usdt-default-asset.feature.md +++ /dev/null @@ -1 +0,0 @@ -Add Celo mainnet USDT and USAT (EIP-3009) as default assets for `eip155:42220` so `"$0.10 USDT"` and `"$0.10 USAT"` resolve on Celo; bare `"$0.10"` still resolves to USDC. diff --git a/python/x402/client_base.py b/python/x402/client_base.py index fd85e28dc5..bb16345a0e 100644 --- a/python/x402/client_base.py +++ b/python/x402/client_base.py @@ -15,7 +15,7 @@ from typing_extensions import Self from .hook_adapters import collect_client_scheme_hook_handles, get_labeled_client_hooks -from .interfaces import SchemeNetworkClient, SchemeNetworkClientV1 +from .interfaces import PaymentPayloadContext, SchemeNetworkClient, SchemeNetworkClientV1 from .schemas import ( AbortResult, Money, @@ -213,6 +213,60 @@ class SpendControls(TypedDict, total=False): allowed_assets: Literal[True] | list[SpendControlAsset] +def _is_atomic_amount(amount: str) -> bool: + """Return whether ``amount`` is an integer atomic string.""" + return bool(_ATOMIC_AMOUNT.fullmatch(amount)) + + +def _find_spend_control_asset_entry( + controls: SpendControls, + network: str, + asset: str, + default_asset: dict[str, Any] | None, +) -> SpendControlAsset | None: + """Find the matching ``allowed_assets`` entry for a requirement.""" + if controls.get("allowed_assets") is True: + return None + for entry in controls.get("allowed_assets") or []: + if not matches_network_pattern(network, entry["network"]): + continue + if entry["asset"].lower() == asset.lower(): + return entry + if default_asset is not None and default_asset["symbol"].lower() == entry["asset"].lower(): + return entry + return None + + +def _resolve_atomic_spend_cap( + controls: SpendControls | Literal[False], + network: str, + asset: str, + default_asset: dict[str, Any] | None, +) -> str | None: + """Resolve the atomic spend cap for filtering and payload context. + + Returns ``None`` when the payment is uncapped. + """ + if controls is False: + return None + + asset_entry = _find_spend_control_asset_entry(controls, network, asset, default_asset) + if asset_entry is not None and asset_entry.get("max_amount_per_payment") is not None: + cap = asset_entry["max_amount_per_payment"] + if not _is_atomic_amount(cap): + raise ValueError( + "spend_controls.allowed_assets[].max_amount_per_payment must be an " + f"integer atomic amount, not a dollar value; got {cap!r}" + ) + return cap + + if not default_asset or controls.get("max_amount_per_payment") is False: + return None + + usd_limit = controls.get("max_amount_per_payment", DEFAULT_MAX_AMOUNT_PER_PAYMENT) + return convert_to_token_amount(parse_money(usd_limit)["amount"], default_asset["decimals"]) + + @dataclass class SchemeRegistration: """Configuration for registering a payment scheme with a specific network.""" @@ -505,9 +559,6 @@ def _apply_spend_controls( def raw_amount_of(requirement: RequirementsView) -> str: return requirement.get_amount() - def amount_of(requirement: RequirementsView) -> int: - return int(raw_amount_of(requirement)) - def scheme_for(requirement: RequirementsView) -> Any: schemes = find_schemes_by_network(client_schemes_by_network, requirement.network) if schemes is None: @@ -521,29 +572,15 @@ def default_asset_for(requirement: RequirementsView) -> dict[str, Any] | None: return None return finder(requirement.asset, requirement.network) - def matches_asset_entry(entry: SpendControlAsset, requirement: RequirementsView) -> bool: - if not matches_network_pattern(requirement.network, entry["network"]): - return False - if entry["asset"].lower() == requirement.asset.lower(): - return True - default_asset = default_asset_for(requirement) - return ( - default_asset is not None - and default_asset["symbol"].lower() == entry["asset"].lower() - ) - - asset_entries = ( - None if controls.get("allowed_assets") is True else controls.get("allowed_assets") - ) allow_any_asset = controls.get("allowed_assets") is True def find_asset_entry(requirement: RequirementsView) -> SpendControlAsset | None: - if not asset_entries: - return None - for entry in asset_entries: - if matches_asset_entry(entry, requirement): - return entry - return None + return _find_spend_control_asset_entry( + controls, + requirement.network, + requirement.asset, + default_asset_for(requirement), + ) if allow_any_asset: filtered = list(requirements) @@ -573,53 +610,47 @@ def find_asset_entry(requirement: RequirementsView) -> SpendControlAsset | None: kept: list[RequirementsView] = [] for requirement in filtered: - asset_entry = find_asset_entry(requirement) - if asset_entry is not None and asset_entry.get("max_amount_per_payment") is not None: - cap = asset_entry["max_amount_per_payment"] - if not _ATOMIC_AMOUNT.fullmatch(cap): - raise ValueError( - "spend_controls.allowed_assets[].max_amount_per_payment must be an " - f"integer atomic amount, not a dollar value; got {cap!r}" - ) - if not _ATOMIC_AMOUNT.fullmatch(raw_amount_of(requirement)): - rejected_by_asset_cap = True - continue - ok = amount_of(requirement) <= int(cap) - if not ok: - rejected_by_asset_cap = True - else: - kept.append(requirement) - continue - default_asset = default_asset_for(requirement) - if not default_asset: - kept.append(requirement) - continue - - if usd_limit is False: + asset_entry = _find_spend_control_asset_entry( + controls, requirement.network, requirement.asset, default_asset + ) + cap = _resolve_atomic_spend_cap( + controls, requirement.network, requirement.asset, default_asset + ) + if cap is None: kept.append(requirement) continue raw_amount = raw_amount_of(requirement) - if not _ATOMIC_AMOUNT.fullmatch(raw_amount): + if not _is_atomic_amount(raw_amount): + if ( + asset_entry is not None + and asset_entry.get("max_amount_per_payment") is not None + ): + rejected_by_asset_cap = True + continue + # Decimal ledger value (e.g. XRPL IOU "0.01") — 1:1 USD vs the Money cap. + if usd_limit is False: + kept.append(requirement) + continue value_scaled = int(convert_to_token_amount(raw_amount, 18)) cap_scaled = int(convert_to_token_amount(parse_money(usd_limit)["amount"], 18)) ok = value_scaled <= cap_scaled if not ok: - rejected_usd_symbol = default_asset["symbol"] + rejected_usd_symbol = default_asset["symbol"] if default_asset else None else: kept.append(requirement) continue - max_atomic = int( - convert_to_token_amount( - parse_money(usd_limit)["amount"], - default_asset["decimals"], - ) - ) - ok = amount_of(requirement) <= max_atomic + ok = int(raw_amount) <= int(cap) if not ok: - rejected_usd_symbol = default_asset["symbol"] + if ( + asset_entry is not None + and asset_entry.get("max_amount_per_payment") is not None + ): + rejected_by_asset_cap = True + elif default_asset: + rejected_usd_symbol = default_asset["symbol"] else: kept.append(requirement) @@ -770,13 +801,28 @@ def _create_payment_payload_v2_core( client = schemes[selected.scheme] - # 5. Create inner payload (pass extensions for enrichment if scheme supports it) + # 5. Create inner payload (pass extensions + spend cap when the scheme + # accepts them). server_extensions = payment_required.extensions + finder = getattr(client, "find_default_asset", None) + default_asset = finder(selected.asset, selected.network) if callable(finder) else None + payload_context = PaymentPayloadContext( + extensions=server_extensions, + max_amount_per_payment=_resolve_atomic_spend_cap( + self._spend_controls, + selected.network, + selected.asset, + default_asset, + ), + ) sig = inspect.signature(client.create_payment_payload) + kwargs: dict[str, Any] = {} + if "context" in sig.parameters: + kwargs["context"] = payload_context if "extensions" in sig.parameters: - inner_payload = client.create_payment_payload( - selected, extensions=server_extensions - ) + kwargs["extensions"] = server_extensions + if kwargs: + inner_payload = client.create_payment_payload(selected, **kwargs) else: inner_payload = client.create_payment_payload(selected) diff --git a/python/x402/interfaces.py b/python/x402/interfaces.py index cc913c187c..559e97ff48 100644 --- a/python/x402/interfaces.py +++ b/python/x402/interfaces.py @@ -86,6 +86,17 @@ def get_extension(self, key: str) -> FacilitatorExtension | None: # ============================================================================ +@dataclass +class PaymentPayloadContext: + """Context passed to scheme ``create_payment_payload``. + + ``max_amount_per_payment`` is the resolved atomic spend cap; omitted when uncapped. + """ + + extensions: dict[str, Any] | None = None + max_amount_per_payment: str | None = None + + class SchemeNetworkClient(Protocol): """V2 client-side payment mechanism. diff --git a/python/x402/mcp/README.md b/python/x402/mcp/README.md index 8c870db752..bc86544bd0 100644 --- a/python/x402/mcp/README.md +++ b/python/x402/mcp/README.md @@ -108,6 +108,8 @@ x402_mcp = wrap_mcp_client_with_payment( ) ``` +Derived read timeouts use accept `maxTimeoutSeconds` (default 300s), capped by `max_request_timeout_seconds` (default 600). Per-call `read_timeout_seconds` overrides both. + #### `wrap_mcp_client_with_payment_from_config` Wraps an MCP client using scheme registrations directly. diff --git a/python/x402/mcp/client.py b/python/x402/mcp/client.py index 397abf190c..a30d29ad5a 100644 --- a/python/x402/mcp/client.py +++ b/python/x402/mcp/client.py @@ -25,6 +25,7 @@ import re from contextlib import asynccontextmanager from dataclasses import dataclass, field +from datetime import timedelta from typing import Any from ..client import x402Client, x402ClientSync @@ -35,6 +36,9 @@ convert_mcp_result, extract_payment_required_from_result, extract_payment_response_from_meta, + paid_read_timeout_seconds, + probe_read_timeout_seconds, + resolve_max_request_timeout_seconds, ) __all__ = [ @@ -76,10 +80,14 @@ def __init__( session: Any, x402_client: x402Client, auto_payment: bool = True, + max_request_timeout_seconds: int | None = None, ) -> None: self._session = session self._x402_client = x402_client self._auto_payment = auto_payment + self._max_request_timeout_seconds = resolve_max_request_timeout_seconds( + max_request_timeout_seconds + ) async def initialize(self) -> None: """Initialize the MCP session.""" @@ -93,6 +101,7 @@ async def call_tool( self, name: str, arguments: dict[str, Any] | None = None, + read_timeout_seconds: timedelta | None = None, ) -> MCPToolCallResult: """Call a tool with automatic x402 payment handling. @@ -104,14 +113,19 @@ async def call_tool( Args: name: Tool name to call. arguments: Arguments to pass to the tool. + read_timeout_seconds: Per-call MCP timeout; overrides accept and cap. Returns: MCPToolCallResult with content, payment info, and error status. """ + probe_timeout = probe_read_timeout_seconds( + read_timeout_seconds, self._max_request_timeout_seconds + ) # First call without payment result = await self._session.call_tool( name=name, arguments=arguments or {}, + read_timeout_seconds=probe_timeout, ) # If no error, return directly @@ -132,11 +146,20 @@ async def call_tool( # Serialize for transmission payload_dict = payment_payload.model_dump(by_alias=True) + accepted = payment_payload.accepted + max_timeout_seconds = accepted.max_timeout_seconds if accepted is not None else None + paid_timeout = paid_read_timeout_seconds( + read_timeout_seconds, + max_timeout_seconds, + self._max_request_timeout_seconds, + ) + # Retry with payment in _meta result = await self._session.call_tool( name=name, arguments=arguments or {}, meta={MCP_PAYMENT_META_KEY: payload_dict}, + read_timeout_seconds=paid_timeout, ) return self._build_result(result, payment_made=True) @@ -206,6 +229,7 @@ def __init__( *, auto_payment: bool = True, on_payment_requested: Any = None, + max_request_timeout_seconds: int | None = None, ) -> None: """Initialize sync x402 MCP client. @@ -219,6 +243,9 @@ def __init__( self._payment_client = payment_client self._auto_payment = auto_payment self._on_payment_requested = on_payment_requested + self._max_request_timeout_seconds = resolve_max_request_timeout_seconds( + max_request_timeout_seconds + ) @property def client(self) -> Any: @@ -241,15 +268,19 @@ def call_tool( Args: name: Tool name args: Tool arguments - **kwargs: Additional MCP client options + **kwargs: MCP client options (``read_timeout_seconds`` overrides accept/cap). Returns: MCPToolCallResult with content, payment info, and error status """ args = args or {} params = {"name": name, "arguments": args} + probe_timeout = probe_read_timeout_seconds( + kwargs.get("read_timeout_seconds"), self._max_request_timeout_seconds + ) + probe_kwargs = {**kwargs, "read_timeout_seconds": probe_timeout} - result = self._mcp_client.call_tool(params, **kwargs) + result = self._mcp_client.call_tool(params, **probe_kwargs) mcp_result = convert_mcp_result(result) payment_required = extract_payment_required_from_result(mcp_result) @@ -274,7 +305,15 @@ def call_tool( "arguments": args, "_meta": {MCP_PAYMENT_META_KEY: payload_dict}, } - result = self._mcp_client.call_tool(params_with_meta, **kwargs) + accepted = payment_payload.accepted + max_timeout_seconds = accepted.max_timeout_seconds if accepted is not None else None + paid_timeout = paid_read_timeout_seconds( + kwargs.get("read_timeout_seconds"), + max_timeout_seconds, + self._max_request_timeout_seconds, + ) + paid_kwargs = {**kwargs, "read_timeout_seconds": paid_timeout} + result = self._mcp_client.call_tool(params_with_meta, **paid_kwargs) mcp_result = convert_mcp_result(result) return self._build_result(mcp_result, payment_made=True) @@ -296,6 +335,7 @@ async def create_x402_mcp_client( server_url: str, *, auto_payment: bool = True, + max_request_timeout_seconds: int | None = None, ): """Create an MCP client session with automatic x402 payment handling. @@ -327,7 +367,12 @@ async def create_x402_mcp_client( async with sse_client(sse_url) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: - mcp_session = x402MCPSession(session, x402_client, auto_payment) + mcp_session = x402MCPSession( + session, + x402_client, + auto_payment, + max_request_timeout_seconds=max_request_timeout_seconds, + ) await mcp_session.initialize() yield mcp_session @@ -365,6 +410,7 @@ def wrap_mcp_client_with_payment_sync( *, auto_payment: bool = True, on_payment_requested: Any = None, + max_request_timeout_seconds: int | None = None, ) -> x402MCPClientSync: """Wrap an existing sync MCP client with x402 payment handling.""" return x402MCPClientSync( @@ -372,6 +418,7 @@ def wrap_mcp_client_with_payment_sync( payment_client, auto_payment=auto_payment, on_payment_requested=on_payment_requested, + max_request_timeout_seconds=max_request_timeout_seconds, ) @@ -382,6 +429,7 @@ def wrap_mcp_client_with_payment_from_config_sync( config: Any = None, auto_payment: bool = True, on_payment_requested: Any = None, + max_request_timeout_seconds: int | None = None, ) -> x402MCPClientSync: """Wrap a sync MCP client using ``x402ClientSync.from_config``.""" from .utils import build_x402_client_config @@ -393,4 +441,5 @@ def wrap_mcp_client_with_payment_from_config_sync( payment_client, auto_payment=auto_payment, on_payment_requested=on_payment_requested, + max_request_timeout_seconds=max_request_timeout_seconds, ) diff --git a/python/x402/mcp/client_async.py b/python/x402/mcp/client_async.py index 20bf41363d..b889ee02b3 100644 --- a/python/x402/mcp/client_async.py +++ b/python/x402/mcp/client_async.py @@ -20,6 +20,9 @@ convert_mcp_result, extract_payment_required_from_result, extract_payment_response_from_meta, + paid_read_timeout_seconds, + probe_read_timeout_seconds, + resolve_max_request_timeout_seconds, ) # Async hook type aliases @@ -67,6 +70,7 @@ def __init__( on_payment_requested: ( Callable[[PaymentRequiredContext], bool | Awaitable[bool]] | None ) = None, + max_request_timeout_seconds: int | None = None, ): """Initialize async x402 MCP client. @@ -80,6 +84,9 @@ def __init__( self._payment_client = payment_client self._auto_payment = auto_payment self._on_payment_requested = on_payment_requested + self._max_request_timeout_seconds = resolve_max_request_timeout_seconds( + max_request_timeout_seconds + ) self._payment_required_hooks: list[PaymentRequiredHook] = [] self._before_payment_hooks: list[BeforePaymentHook] = [] self._after_payment_hooks: list[AfterPaymentHook] = [] @@ -150,7 +157,7 @@ async def call_tool( Args: name: Tool name args: Tool arguments - **kwargs: Additional MCP client options + **kwargs: MCP client options (``read_timeout_seconds`` overrides accept/cap). Returns: Tool call result with payment metadata @@ -160,7 +167,11 @@ async def call_tool( """ # First attempt without payment call_params = {"name": name, "arguments": args} - result = await self._call_mcp_tool(call_params, **kwargs) + probe_timeout = probe_read_timeout_seconds( + kwargs.get("read_timeout_seconds"), self._max_request_timeout_seconds + ) + probe_kwargs = {**kwargs, "read_timeout_seconds": probe_timeout} + result = await self._call_mcp_tool(call_params, **probe_kwargs) # Check if this is a payment required response payment_required = extract_payment_required_from_result(result) @@ -238,7 +249,7 @@ async def call_tool_with_payment( name: Tool name args: Tool arguments payload: Payment payload - **kwargs: Additional MCP client options + **kwargs: MCP client options (``read_timeout_seconds`` overrides accept/cap). Returns: Tool call result with payment metadata @@ -246,8 +257,17 @@ async def call_tool_with_payment( # Build call params with payment in _meta call_params = attach_payment_to_meta({"name": name, "arguments": args}, payload) + accepted = payload.accepted + max_timeout_seconds = accepted.max_timeout_seconds if accepted is not None else None + paid_timeout = paid_read_timeout_seconds( + kwargs.get("read_timeout_seconds"), + max_timeout_seconds, + self._max_request_timeout_seconds, + ) + paid_kwargs = {**kwargs, "read_timeout_seconds": paid_timeout} + # Call with payment - result = await self._call_mcp_tool(call_params, **kwargs) + result = await self._call_mcp_tool(call_params, **paid_kwargs) # Extract payment response settle_response = extract_payment_response_from_meta(result) @@ -430,6 +450,7 @@ def create_x402_mcp_client( on_payment_requested: ( Callable[[PaymentRequiredContext], bool | Awaitable[bool]] | None ) = None, + max_request_timeout_seconds: int | None = None, ) -> x402MCPClient: """Create a new async x402MCPClient instance. @@ -449,6 +470,7 @@ def create_x402_mcp_client( payment_client, auto_payment=auto_payment, on_payment_requested=on_payment_requested, + max_request_timeout_seconds=max_request_timeout_seconds, ) @@ -460,6 +482,7 @@ def wrap_mcp_client_with_payment( on_payment_requested: ( Callable[[PaymentRequiredContext], bool | Awaitable[bool]] | None ) = None, + max_request_timeout_seconds: int | None = None, ) -> x402MCPClient: """Wrap an existing async MCP client with x402 payment handling. @@ -496,6 +519,7 @@ def wrap_mcp_client_with_payment( payment_client, auto_payment=auto_payment, on_payment_requested=on_payment_requested, + max_request_timeout_seconds=max_request_timeout_seconds, ) @@ -507,6 +531,7 @@ def wrap_mcp_client_with_payment_from_config( on_payment_requested: ( Callable[[PaymentRequiredContext], bool | Awaitable[bool]] | None ) = None, + max_request_timeout_seconds: int | None = None, ) -> x402MCPClient: """Wrap an existing async MCP client with x402 payment handling from client config. @@ -551,6 +576,7 @@ def wrap_mcp_client_with_payment_from_config( payment_client, auto_payment=auto_payment, on_payment_requested=on_payment_requested, + max_request_timeout_seconds=max_request_timeout_seconds, ) @@ -601,6 +627,7 @@ def create_x402_mcp_client_from_config( auto_payment = config.get("auto_payment", True) on_payment_requested = config.get("on_payment_requested") + max_request_timeout_seconds = config.get("max_request_timeout_seconds") payment_client = x402ClientAsync.from_config(build_x402_client_config(config)) @@ -609,4 +636,5 @@ def create_x402_mcp_client_from_config( payment_client, auto_payment=auto_payment, on_payment_requested=on_payment_requested, + max_request_timeout_seconds=max_request_timeout_seconds, ) diff --git a/python/x402/mcp/constants.py b/python/x402/mcp/constants.py index 37902e5e03..034e270fc9 100644 --- a/python/x402/mcp/constants.py +++ b/python/x402/mcp/constants.py @@ -2,3 +2,8 @@ MCP_PAYMENT_META_KEY = "x402/payment" MCP_PAYMENT_RESPONSE_META_KEY = "x402/payment-response" + +DEFAULT_PROBE_TIMEOUT_SECONDS = 300 +DEFAULT_ACCEPT_TIMEOUT_SECONDS = 300 +DEFAULT_MAX_REQUEST_TIMEOUT_SECONDS = 600 +MAX_READ_TIMEOUT_SECONDS = 2**31 - 1 diff --git a/python/x402/mcp/utils.py b/python/x402/mcp/utils.py index d3662f613e..8364f21d8e 100644 --- a/python/x402/mcp/utils.py +++ b/python/x402/mcp/utils.py @@ -1,6 +1,7 @@ """Utility functions for MCP payment handling.""" import json +from datetime import timedelta from typing import Any from ..schemas import ( @@ -10,6 +11,12 @@ SettleResponse, parse_payment_required, ) +from .constants import ( + DEFAULT_ACCEPT_TIMEOUT_SECONDS, + DEFAULT_MAX_REQUEST_TIMEOUT_SECONDS, + DEFAULT_PROBE_TIMEOUT_SECONDS, + MAX_READ_TIMEOUT_SECONDS, +) from .types import ( MCP_PAYMENT_META_KEY, MCP_PAYMENT_REQUIRED_CODE, @@ -19,6 +26,48 @@ ) +def resolve_max_request_timeout_seconds(value: int | None) -> int: + if value is None: + return DEFAULT_MAX_REQUEST_TIMEOUT_SECONDS + if not isinstance(value, int) or value <= 0: + raise ValueError(f"max_request_timeout_seconds must be a positive int, got {value!r}") + return value + + +def effective_accept_timeout_seconds(max_timeout_seconds: int | None) -> int: + if max_timeout_seconds is not None and max_timeout_seconds > 0: + return max_timeout_seconds + return DEFAULT_ACCEPT_TIMEOUT_SECONDS + + +def _clamp_read_timeout_seconds(seconds: int) -> int: + return min(seconds, MAX_READ_TIMEOUT_SECONDS) + + +def probe_read_timeout_seconds( + override: timedelta | None, + max_request_timeout_seconds: int, +) -> timedelta: + if override is not None: + return override + seconds = _clamp_read_timeout_seconds( + min(DEFAULT_PROBE_TIMEOUT_SECONDS, max_request_timeout_seconds) + ) + return timedelta(seconds=seconds) + + +def paid_read_timeout_seconds( + override: timedelta | None, + max_timeout_seconds: int | None, + max_request_timeout_seconds: int, +) -> timedelta: + if override is not None: + return override + accept_seconds = effective_accept_timeout_seconds(max_timeout_seconds) + seconds = _clamp_read_timeout_seconds(min(accept_seconds, max_request_timeout_seconds)) + return timedelta(seconds=seconds) + + def extract_payment_from_meta(params: dict[str, Any]) -> PaymentPayload | None: """Extract payment payload from MCP request _meta field. diff --git a/python/x402/mechanisms/evm/asset_cache.py b/python/x402/mechanisms/evm/asset_cache.py new file mode 100644 index 0000000000..6704a0cb7a --- /dev/null +++ b/python/x402/mechanisms/evm/asset_cache.py @@ -0,0 +1,144 @@ +"""Process-wide cache of positive EVM asset-contract checks.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from threading import Lock + +from .constants import ERR_ASSET_NOT_DEPLOYED_CONTRACT +from .signer import FacilitatorEvmSigner +from .utils import normalize_address + +# Bounds how long a positive asset-contract check is reused. +DEFAULT_ASSET_CONTRACT_CACHE_TTL = timedelta(minutes=15) + +# Bounds the cache so callers naming many distinct deployed contracts cannot grow it +# without limit. A facilitator serves few assets per chain. +_MAX_ASSET_CONTRACT_CACHE_ENTRIES = 4096 + + +@dataclass(frozen=True) +class _AssetContractCacheKey: + network: str = "" + asset: str = "" + + +class _AssetContractCache: + """Memoizes "this asset address has bytecode" per network. + + Process-wide because validate_asset_is_contract is a free function shared by every + EVM facilitator scheme. + + Only positive results are stored: a negative result may be a token observed + mid-deployment, which has to self-heal on the next request. + """ + + def __init__(self, ttl: timedelta) -> None: + self._lock = Lock() + self._ttl = ttl + self._expiries: dict[_AssetContractCacheKey, datetime] = {} + + def is_fresh(self, key: _AssetContractCacheKey, now: datetime) -> bool: + """Report whether an unexpired positive result is cached. + + An empty network is never cached, since entries would otherwise collide + across chains where one address can hold bytecode on one chain and nothing + on another. + """ + if key.network == "": + return False + + with self._lock: + expiry = self._expiries.get(key) + return expiry is not None and now < expiry + + def record(self, key: _AssetContractCacheKey, now: datetime) -> None: + if key.network == "": + return + + with self._lock: + expired = [existing for existing, expiry in self._expiries.items() if now > expiry] + for existing in expired: + del self._expiries[existing] + if ( + key not in self._expiries + and len(self._expiries) >= _MAX_ASSET_CONTRACT_CACHE_ENTRIES + ): + return + self._expiries[key] = now + self._ttl + + +_global_asset_contract_cache = _AssetContractCache(ttl=DEFAULT_ASSET_CONTRACT_CACHE_TTL) + + +def reset_asset_contract_cache() -> None: + """Clear the process-wide asset-contract cache. + + For tests that assert on eth_getCode call counts across cases sharing an asset address. + """ + with _global_asset_contract_cache._lock: + _global_asset_contract_cache._expiries = {} + + +class AssetContractCheck: + """An asset-contract check whose result is delivered by await_result.""" + + def __init__(self, signer: FacilitatorEvmSigner, network: str, asset: str) -> None: + self._signer = signer + self._network = network + self._asset = asset + + def await_result(self) -> str: + """Return the check's result, caching a positive one for DEFAULT_ASSET_CONTRACT_CACHE_TTL. + + Recording on await_result rather than when the check is started keeps cache contents + independent of an early return: a check that is never awaited cannot publish a result + and does not issue eth_getCode. + """ + reason = validate_asset_is_contract(self._signer, self._network, self._asset) + if reason == "": + _global_asset_contract_cache.record( + _AssetContractCacheKey(network=self._network, asset=normalize_address(self._asset)), + datetime.now(timezone.utc), + ) + return reason + + +def validate_asset_is_contract( + signer: FacilitatorEvmSigner, + network: str, + asset: str, +) -> str: + """Check whether the payment asset is a deployed contract. + + Returns ERR_ASSET_NOT_DEPLOYED_CONTRACT for an EOA/empty address, + "" for a deployed contract, or raises if eth_getCode itself fails. + + network identifies the chain the signer is bound to. It must be accurate, since it scopes the + cache that serves positive results; an empty network disables caching for the call. Only + start_asset_contract_check populates that cache, so calling this directly always hits the RPC. + """ + normalized_asset = normalize_address(asset) + if _global_asset_contract_cache.is_fresh( + _AssetContractCacheKey(network=network, asset=normalized_asset), + datetime.now(timezone.utc), + ): + return "" + + try: + code = signer.get_code(normalized_asset) + except Exception as exc: + raise RuntimeError(f"failed to check whether asset is a contract: {exc}") from exc + if len(code) == 0: + return ERR_ASSET_NOT_DEPLOYED_CONTRACT + return "" + + +def start_asset_contract_check( + signer: FacilitatorEvmSigner, + network: str, + asset: str, +) -> AssetContractCheck: + """Create an asset-contract check. The RPC and cache write happen in await_result.""" + return AssetContractCheck(signer=signer, network=network, asset=asset) diff --git a/python/x402/mechanisms/evm/batch_settlement/client/__init__.py b/python/x402/mechanisms/evm/batch_settlement/client/__init__.py index 04112c8c18..3987b78ddc 100644 --- a/python/x402/mechanisms/evm/batch_settlement/client/__init__.py +++ b/python/x402/mechanisms/evm/batch_settlement/client/__init__.py @@ -19,8 +19,11 @@ BatchSettlementDepositStrategyContext, BatchSettlementEvmSchemeOptions, ResolvedClientOptions, + apply_max_deposit, deposit_amount_for_request, is_batch_settlement_evm_scheme_options, + max_deposit_from_spend_cap, + parse_announced_min_deposit, resolve_client_options, validate_deposit_policy, ) @@ -63,6 +66,9 @@ "create_batch_settlement_eip3009_deposit_payload", "create_batch_settlement_permit2_deposit_payload", "deposit_amount_for_request", + "apply_max_deposit", + "max_deposit_from_spend_cap", + "parse_announced_min_deposit", "get_channel", "has_channel", "is_batch_settlement_evm_scheme_options", diff --git a/python/x402/mechanisms/evm/batch_settlement/client/config.py b/python/x402/mechanisms/evm/batch_settlement/client/config.py index dc31066713..7da4d97291 100644 --- a/python/x402/mechanisms/evm/batch_settlement/client/config.py +++ b/python/x402/mechanisms/evm/batch_settlement/client/config.py @@ -38,6 +38,7 @@ class BatchSettlementDepositStrategyContext: current_balance: str minimum_deposit_amount: str deposit_amount: str + max_deposit: str | None = None # Return either a positive integer string, False to skip the deposit, or None @@ -128,16 +129,70 @@ def validate_deposit_policy(policy: BatchSettlementDepositPolicy | None) -> None raise ValueError("deposit_multiplier must be an integer >= 3") +_DIGITS = re.compile(r"^\d+$") + + +def parse_announced_min_deposit(value: Any, request_amount: int) -> int | None: + """Parse a server-announced ``extra.minDeposit`` when it is a valid deposit target. + + Returns the parsed minimum deposit target, or ``None`` when invalid or below + ``request_amount``. + """ + if not isinstance(value, str) or not _DIGITS.fullmatch(value): + return None + + parsed = int(value) + if parsed <= 0 or parsed < request_amount: + return None + + return parsed + + +def max_deposit_from_spend_cap( + max_amount_per_payment: Any, + deposit_multiplier: int = 5, +) -> int | None: + """Derive the deposit ceiling as ``deposit_multiplier ×`` the resolved spend cap. + + Returns the atomic deposit ceiling, or ``None`` when the payment is uncapped. + """ + if ( + not isinstance(max_amount_per_payment, str) + or not _DIGITS.fullmatch(max_amount_per_payment) + or int(max_amount_per_payment) <= 0 + ): + return None + return int(max_amount_per_payment) * deposit_multiplier + + +def apply_max_deposit(deposit: int, needed: int, max_deposit: int | None = None) -> str: + """Clamp a computed deposit to ``max_deposit``. Throw when the voucher gap exceeds the cap.""" + if max_deposit is None: + return str(deposit) + if needed > max_deposit: + raise ValueError( + f"Required deposit {needed} exceeds deposit_multiplier × " + f"spend_controls.max_amount_per_payment ({max_deposit}). " + "Raise max_amount_per_payment or deposit_multiplier." + ) + return str(max_deposit if deposit > max_deposit else deposit) + + def deposit_amount_for_request( policy: BatchSettlementDepositPolicy | None, request_amount: int, + needed: int, + extra: dict[str, Any] | None, + max_deposit: int | None = None, ) -> str: - """Compute the deposit amount based on the policy multiplier (default 5x).""" - mult = policy.deposit_multiplier if (policy and policy.deposit_multiplier) else 5 - return str(mult * int(request_amount)) - - -_DIGITS = re.compile(r"^\d+$") + """Compute the deposit amount from the voucher gap, server hint, or deposit multiplier.""" + announced = parse_announced_min_deposit( + extra.get("minDeposit") if extra else None, request_amount + ) + multiplier = policy.deposit_multiplier if (policy and policy.deposit_multiplier) else 5 + target = announced if announced is not None else multiplier * int(request_amount) + deposit = needed if needed > target else target + return apply_max_deposit(deposit, needed, max_deposit) def normalize_strategy_deposit_amount(value: str | int | bool) -> str: @@ -164,6 +219,9 @@ def normalize_strategy_deposit_amount(value: str | int | bool) -> str: "is_batch_settlement_evm_scheme_options", "resolve_client_options", "validate_deposit_policy", + "parse_announced_min_deposit", + "max_deposit_from_spend_cap", + "apply_max_deposit", "deposit_amount_for_request", "normalize_strategy_deposit_amount", ] diff --git a/python/x402/mechanisms/evm/batch_settlement/client/scheme.py b/python/x402/mechanisms/evm/batch_settlement/client/scheme.py index 83474c4169..af5e11bc1c 100644 --- a/python/x402/mechanisms/evm/batch_settlement/client/scheme.py +++ b/python/x402/mechanisms/evm/batch_settlement/client/scheme.py @@ -11,6 +11,7 @@ "EVM mechanism requires ethereum packages. Install with: pip install x402[evm]" ) from e +from .....interfaces import PaymentPayloadContext from .....schemas import PaymentRequired, PaymentRequirements, SettleResponse from ....evm.constants import ERC20_ALLOWANCE_ABI, PERMIT2_ADDRESS from ....evm.signer import ( @@ -32,7 +33,9 @@ BatchSettlementDepositPolicy, BatchSettlementDepositStrategyContext, BatchSettlementEvmSchemeOptions, + apply_max_deposit, deposit_amount_for_request, + max_deposit_from_spend_cap, normalize_strategy_deposit_amount, resolve_client_options, validate_deposit_policy, @@ -105,8 +108,21 @@ def create_payment_payload( self, requirements: PaymentRequirements, extensions: dict[str, Any] | None = None, + context: PaymentPayloadContext | None = None, ) -> dict[str, Any]: - """Create the inner payment payload dict for a batch-settlement request.""" + """Create the inner payment payload dict for a batch-settlement request. + + Args: + requirements: Server payment requirements (scheme, network, asset, amount). + extensions: Server-declared extensions from PaymentRequired. + context: Optional extensions and the resolved atomic spend cap. + """ + if context is not None: + if context.extensions is not None: + extensions = context.extensions + max_amount_per_payment = context.max_amount_per_payment + else: + max_amount_per_payment = None deps = self._deps() config = build_channel_config(deps, requirements) channel_id = compute_channel_id(config, str(requirements.network)) @@ -131,8 +147,20 @@ def create_payment_payload( needs_top_up = not needs_initial_deposit and int(max_claimable_amount) > current_balance if needs_initial_deposit or needs_top_up: - computed_deposit = deposit_amount_for_request(self._deposit_policy, request_amount) - minimum_deposit_amount = str(int(max_claimable_amount) - current_balance) + minimum_deposit_amount = int(max_claimable_amount) - current_balance + multiplier = ( + self._deposit_policy.deposit_multiplier + if (self._deposit_policy and self._deposit_policy.deposit_multiplier) + else 5 + ) + max_deposit = max_deposit_from_spend_cap(max_amount_per_payment, multiplier) + computed_deposit = deposit_amount_for_request( + self._deposit_policy, + request_amount, + minimum_deposit_amount, + requirements.extra, + max_deposit, + ) deposit_amount = self._resolve_deposit_amount( BatchSettlementDepositStrategyContext( payment_requirements=requirements, @@ -142,8 +170,9 @@ def create_payment_payload( request_amount=str(request_amount), max_claimable_amount=max_claimable_amount, current_balance=str(current_balance), - minimum_deposit_amount=minimum_deposit_amount, + minimum_deposit_amount=str(minimum_deposit_amount), deposit_amount=computed_deposit, + max_deposit=None if max_deposit is None else str(max_deposit), ) ) if deposit_amount is None: @@ -221,7 +250,11 @@ def _resolve_deposit_amount( f"deposit_strategy returned {deposit_amount}, below required top-up " f"{context.minimum_deposit_amount}" ) - return deposit_amount + return apply_max_deposit( + int(deposit_amount), + int(context.minimum_deposit_amount), + None if context.max_deposit is None else int(context.max_deposit), + ) def _create_voucher_payload( self, diff --git a/python/x402/mechanisms/evm/batch_settlement/constants.py b/python/x402/mechanisms/evm/batch_settlement/constants.py index b7baa12338..b2cab1e0ea 100644 --- a/python/x402/mechanisms/evm/batch_settlement/constants.py +++ b/python/x402/mechanisms/evm/batch_settlement/constants.py @@ -38,6 +38,9 @@ CHANNEL_STATE_POLL_S = 2.0 CHANNEL_STATE_POLL_INTERVAL_S = 0.15 +# Default server SDK multiplier for `extra.minDeposit` when no floor is configured. +DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER = 10 + # EIP-712 domain shared by all batch-settlement typed-data signatures BATCH_SETTLEMENT_DOMAIN_NAME = "x402 Batch Settlement" BATCH_SETTLEMENT_DOMAIN_VERSION = "1" @@ -152,6 +155,7 @@ "DEFAULT_MAX_CLAIMS_PER_BATCH", "CHANNEL_STATE_POLL_S", "CHANNEL_STATE_POLL_INTERVAL_S", + "DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER", "DEFAULT_ONCHAIN_STATE_TTL_MS", "PAYLOAD_TYPE_DEPOSIT", "PAYLOAD_TYPE_VOUCHER", diff --git a/python/x402/mechanisms/evm/batch_settlement/errors.py b/python/x402/mechanisms/evm/batch_settlement/errors.py index 7b6169c9ed..b5de516abd 100644 --- a/python/x402/mechanisms/evm/batch_settlement/errors.py +++ b/python/x402/mechanisms/evm/batch_settlement/errors.py @@ -29,6 +29,7 @@ ERR_DEPOSIT_PAYLOAD = "invalid_batch_settlement_evm_deposit_payload" ERR_DEPOSIT_SIMULATION_FAILED = "invalid_batch_settlement_evm_deposit_simulation_failed" +ERR_DEPOSIT_BELOW_MIN_DEPOSIT = "invalid_batch_settlement_evm_deposit_below_min_deposit" ERR_DEPOSIT_TRANSACTION_FAILED = "invalid_batch_settlement_evm_deposit_transaction_failed" # ERC-6492 counterfactual deployment errors (ERC-3009 deposit path). Wire values keep the diff --git a/python/x402/mechanisms/evm/batch_settlement/server/__init__.py b/python/x402/mechanisms/evm/batch_settlement/server/__init__.py index d757d893f5..54729a1c8f 100644 --- a/python/x402/mechanisms/evm/batch_settlement/server/__init__.py +++ b/python/x402/mechanisms/evm/batch_settlement/server/__init__.py @@ -3,6 +3,7 @@ Re-exports the scheme, storage backends, and channel managers. """ +from ..errors import ERR_DEPOSIT_BELOW_MIN_DEPOSIT from .channel_manager import ( AutoSettlementConfig, AutoSettlementContext, @@ -53,4 +54,5 @@ "RefundChannelSelector", "AutoSettlementConfig", "AutoSettlementContext", + "ERR_DEPOSIT_BELOW_MIN_DEPOSIT", ] diff --git a/python/x402/mechanisms/evm/batch_settlement/server/scheme.py b/python/x402/mechanisms/evm/batch_settlement/server/scheme.py index 86ee36c054..2aeac79e26 100644 --- a/python/x402/mechanisms/evm/batch_settlement/server/scheme.py +++ b/python/x402/mechanisms/evm/batch_settlement/server/scheme.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import threading from collections.abc import Callable from dataclasses import dataclass @@ -41,13 +42,18 @@ ) from ...default_assets import find_default_asset, get_default_asset from ...utils import get_asset_info, parse_amount -from ..constants import MIN_WITHDRAW_DELAY, SCHEME_BATCH_SETTLEMENT +from ..constants import ( + DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER, + MIN_WITHDRAW_DELAY, + SCHEME_BATCH_SETTLEMENT, +) from ..types import AuthorizerSigner from .storage import Channel, ChannelStorage, InMemoryChannelStorage MoneyParser = Callable[[str | int | float, str], AssetAmount | None] _ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" +_DIGITS = re.compile(r"^\d+$") @dataclass @@ -56,6 +62,7 @@ class BatchSettlementEvmSchemeServerConfig: receiver_authorizer_signer: AuthorizerSigner | None = None withdraw_delay: int | None = None onchain_state_ttl_ms: int | None = None + enforce_min_deposit: bool | None = None @dataclass @@ -105,6 +112,7 @@ def __init__( if cfg.onchain_state_ttl_ms is not None else _default_onchain_state_ttl_ms(self._withdraw_delay) ) + self._enforce_min_deposit = cfg.enforce_min_deposit if cfg.enforce_min_deposit else False self._money_parsers: list[MoneyParser] = [] self._request_lock = threading.Lock() @@ -122,6 +130,10 @@ def get_withdraw_delay(self) -> int: def get_onchain_state_ttl_ms(self) -> int: return self._onchain_state_ttl_ms + def get_enforce_min_deposit(self) -> bool: + """Return whether deposits below the announced ``extra.minDeposit`` hint are rejected.""" + return self._enforce_min_deposit + def get_receiver_authorizer_signer(self) -> AuthorizerSigner | None: return self._receiver_authorizer_signer @@ -266,9 +278,54 @@ def enhance_payment_requirements( if "assetTransferMethod" not in extra and atm: extra["assetTransferMethod"] = atm + extra["minDeposit"] = self.resolve_min_deposit_hint(requirements) + requirements.extra = extra return requirements + def resolve_min_deposit_hint(self, payment_requirements: PaymentRequirements) -> str: + """Resolve the ``extra.minDeposit`` hint written on every 402.""" + amount = int(payment_requirements.amount) + extra = payment_requirements.extra or {} + route_override = extra.get("minDeposit") + + configured_min: int | None = None + if isinstance(route_override, str): + if _DIGITS.fullmatch(route_override): + configured_min = self._parse_atomic_min_deposit(route_override) + else: + configured_min = self._resolve_route_money_min_deposit( + route_override, payment_requirements + ) + + if configured_min is None: + return str(amount * DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER) + + return str(amount if amount > configured_min else configured_min) + + def _resolve_route_money_min_deposit(self, money: str, requirement: PaymentRequirements) -> int: + """Convert a route-level Money ``extra.minDeposit`` override to atomic units.""" + default_asset = find_default_asset(requirement.asset, str(requirement.network)) + if not default_asset: + raise ValueError( + "extra.minDeposit money values are only supported for default assets; " + f"use an integer atomic string for {requirement.asset} on {requirement.network}." + ) + + parsed = parse_money(money) + return self._parse_atomic_min_deposit( + convert_to_token_amount(parsed["amount"], default_asset["decimals"]) + ) + + def _parse_atomic_min_deposit(self, amount: str) -> int: + """Validate and normalize an atomic min deposit amount.""" + if not _DIGITS.fullmatch(amount): + raise ValueError("minDeposit must resolve to a positive integer") + value = int(amount) + if value <= 0: + raise ValueError("minDeposit must resolve to a positive integer") + return value + def validate_facilitator_support( self, network: Network, diff --git a/python/x402/mechanisms/evm/batch_settlement/server/verify.py b/python/x402/mechanisms/evm/batch_settlement/server/verify.py index d3ea07048a..34facdf77b 100644 --- a/python/x402/mechanisms/evm/batch_settlement/server/verify.py +++ b/python/x402/mechanisms/evm/batch_settlement/server/verify.py @@ -13,6 +13,7 @@ ERR_CUMULATIVE_AMOUNT_BELOW_CLAIMED, ERR_CUMULATIVE_AMOUNT_MISMATCH, ERR_CUMULATIVE_EXCEEDS_BALANCE, + ERR_DEPOSIT_BELOW_MIN_DEPOSIT, ERR_INVALID_VOUCHER_SIGNATURE, ERR_VERIFICATION_STATE_UNAVAILABLE, ) @@ -191,6 +192,14 @@ def handle_before_verify(scheme: BatchSettlementEvmScheme, ctx: VerifyContext): if not is_paid_payload and not is_zero_charge_payload: return None + if scheme.get_enforce_min_deposit() and is_deposit_payload(raw): + min_deposit = int(scheme.resolve_min_deposit_hint(requirements)) + if int(raw["deposit"]["amount"]) < min_deposit: + return AbortResult( + reason=ERR_DEPOSIT_BELOW_MIN_DEPOSIT, + message="Deposit amount is below the server minimum", + ) + try: voucher = raw["voucher"] claimed_channel_id = str(voucher["channelId"]) diff --git a/python/x402/mechanisms/evm/batch_settlement/types.py b/python/x402/mechanisms/evm/batch_settlement/types.py index f9143b19c5..54f4c2b7fd 100644 --- a/python/x402/mechanisms/evm/batch_settlement/types.py +++ b/python/x402/mechanisms/evm/batch_settlement/types.py @@ -546,6 +546,7 @@ class PaymentRequirementsExtra: name: str version: str asset_transfer_method: str | None = None # "eip3009" (default) or "permit2" + min_deposit: str | None = None channel_state: ChannelStateExtra | None = None voucher_state: VoucherStateExtra | None = None @@ -558,6 +559,8 @@ def to_dict(self) -> dict[str, Any]: } if self.asset_transfer_method is not None: out["assetTransferMethod"] = self.asset_transfer_method + if self.min_deposit is not None: + out["minDeposit"] = self.min_deposit if self.channel_state is not None: out["channelState"] = self.channel_state.to_dict() if self.voucher_state is not None: @@ -574,6 +577,7 @@ def from_dict(cls, data: dict[str, Any]) -> PaymentRequirementsExtra: name=data["name"], version=data["version"], asset_transfer_method=data.get("assetTransferMethod"), + min_deposit=str(data["minDeposit"]) if data.get("minDeposit") is not None else None, channel_state=ChannelStateExtra.from_dict(cs) if cs else None, voucher_state=VoucherStateExtra.from_dict(vs) if vs else None, ) diff --git a/python/x402/mechanisms/evm/exact/facilitator.py b/python/x402/mechanisms/evm/exact/facilitator.py index d12b8720e3..60d585adee 100644 --- a/python/x402/mechanisms/evm/exact/facilitator.py +++ b/python/x402/mechanisms/evm/exact/facilitator.py @@ -13,8 +13,8 @@ SettleResponse, VerifyResponse, ) +from ..asset_cache import start_asset_contract_check from ..constants import ( - ERR_ASSET_NOT_DEPLOYED_CONTRACT, ERR_AUTHORIZATION_VALUE_MISMATCH, ERR_FACTORY_NOT_ALLOWED, ERR_FAILED_TO_GET_NETWORK_CONFIG, @@ -326,12 +326,12 @@ def _verify( None, ) - code = self._signer.get_code(token_address) - if len(code) == 0: + asset_reason = start_asset_contract_check( + self._signer, str(requirements.network), requirements.asset + ).await_result() + if asset_reason: return ( - VerifyResponse( - is_valid=False, invalid_reason=ERR_ASSET_NOT_DEPLOYED_CONTRACT, payer=payer - ), + VerifyResponse(is_valid=False, invalid_reason=asset_reason, payer=payer), None, ) diff --git a/python/x402/mechanisms/evm/exact/permit2_utils.py b/python/x402/mechanisms/evm/exact/permit2_utils.py index 1262f55d25..6d63f1ee9d 100644 --- a/python/x402/mechanisms/evm/exact/permit2_utils.py +++ b/python/x402/mechanisms/evm/exact/permit2_utils.py @@ -23,10 +23,10 @@ SettleResponse, VerifyResponse, ) +from ..asset_cache import start_asset_contract_check # noqa: E402 from ..constants import ( # noqa: E402 BALANCE_OF_ABI, ERC20_ALLOWANCE_ABI, - ERR_ASSET_NOT_DEPLOYED_CONTRACT, ERR_ERC20_APPROVAL_BROADCAST_FAILED, ERR_ERC20_APPROVAL_TX_FAILED, ERR_INSUFFICIENT_BALANCE, @@ -196,11 +196,9 @@ def verify_permit2( chain_id = get_evm_chain_id(str(requirements.network)) token_address = normalize_address(requirements.asset) - code = signer.get_code(token_address) - if len(code) == 0: - return VerifyResponse( - is_valid=False, invalid_reason=ERR_ASSET_NOT_DEPLOYED_CONTRACT, payer=payer - ) + # Start after network/asset are known; await after signature work so a failed + # pre-check does not RPC or populate the positive cache. + asset_check = start_asset_contract_check(signer, str(requirements.network), requirements.asset) # 3. Spender check try: @@ -305,6 +303,10 @@ def verify_permit2( is_valid=False, invalid_reason=ERR_PERMIT2_INVALID_SIGNATURE, payer=payer ) + asset_reason = asset_check.await_result() + if asset_reason: + return VerifyResponse(is_valid=False, invalid_reason=asset_reason, payer=payer) + # 10. Allowance check — with extension fallbacks allowance_result = _verify_permit2_allowance( signer, payload, requirements, payer, token_address, context diff --git a/python/x402/mechanisms/evm/upto/permit2_utils.py b/python/x402/mechanisms/evm/upto/permit2_utils.py index 88877f47ae..5fa7ee9fc5 100644 --- a/python/x402/mechanisms/evm/upto/permit2_utils.py +++ b/python/x402/mechanisms/evm/upto/permit2_utils.py @@ -23,9 +23,9 @@ SettleResponse, VerifyResponse, ) +from ..asset_cache import start_asset_contract_check # noqa: E402 from ..constants import ( # noqa: E402 BALANCE_OF_ABI, - ERR_ASSET_NOT_DEPLOYED_CONTRACT, ERR_ERC20_APPROVAL_BROADCAST_FAILED, ERR_ERC20_APPROVAL_TX_FAILED, ERR_PERMIT2_AMOUNT_MISMATCH, @@ -120,11 +120,9 @@ def verify_upto_permit2( ) token_address = normalize_address(requirements.asset) - code = signer.get_code(token_address) - if len(code) == 0: - return VerifyResponse( - is_valid=False, invalid_reason=ERR_ASSET_NOT_DEPLOYED_CONTRACT, payer=payer - ) + # Start after network/asset are known; await after signature work so a failed + # pre-check does not RPC or populate the positive cache. + asset_check = start_asset_contract_check(signer, str(requirements.network), requirements.asset) # 3. Spender check try: @@ -243,6 +241,10 @@ def verify_upto_permit2( is_valid=False, invalid_reason=ERR_PERMIT2_INVALID_SIGNATURE, payer=payer ) + asset_reason = asset_check.await_result() + if asset_reason: + return VerifyResponse(is_valid=False, invalid_reason=asset_reason, payer=payer) + # If simulation is disabled, skip allowance/balance/simulation checks (matches Go/TS). if not simulate: return VerifyResponse(is_valid=True, payer=payer) diff --git a/python/x402/pyproject.toml b/python/x402/pyproject.toml index 5a310ced97..50a1d5e29a 100644 --- a/python/x402/pyproject.toml +++ b/python/x402/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "x402" -version = "2.22.0" +version = "2.23.0" description = "x402 Payment Protocol SDK for Python" readme = "README.md" license = { text = "MIT" } diff --git a/python/x402/tests/unit/core/test_client.py b/python/x402/tests/unit/core/test_client.py index 1206393c68..5d6c99f16e 100644 --- a/python/x402/tests/unit/core/test_client.py +++ b/python/x402/tests/unit/core/test_client.py @@ -32,6 +32,7 @@ class MockSchemeClient: def __init__(self, scheme: str = "mock"): self.scheme = scheme self.create_calls: list = [] + self.create_contexts: list = [] # Treat any asset as a recognized default so non-spend-control tests pass # the default allowlist (USD cap still applies unless overridden). self.find_default_asset = lambda asset, _network=None: { @@ -40,8 +41,9 @@ def __init__(self, scheme: str = "mock"): "symbol": "MOCK", } - def create_payment_payload(self, requirements): + def create_payment_payload(self, requirements, context=None): self.create_calls.append(requirements) + self.create_contexts.append(context) return {"mock": "payload", "network": requirements.network} def set_find_default_asset(self, lookup): @@ -1134,6 +1136,61 @@ async def test_compares_non_integer_decimal_amounts_to_usd_cap_directly(self): self._required(self._req(asset=rlusd["asset"], amount="1.01", network=xrpl)) ) + @pytest.mark.asyncio + async def test_passes_the_resolved_atomic_spend_cap_on_payment_payload_context(self): + client, mock_client = self._client_with_default_asset(self.usdc) + await client.create_payment_payload( + self._required(self._req(asset=self.usdc["asset"], amount="1000")) + ) + assert mock_client.create_contexts[0].max_amount_per_payment == "1000000" + + @pytest.mark.asyncio + async def test_omits_the_spend_cap_on_context_when_spend_controls_are_disabled(self): + client, mock_client = self._client_with_default_asset(self.usdc, False) + await client.create_payment_payload( + self._required(self._req(asset=self.usdc["asset"], amount="5000000")) + ) + assert mock_client.create_contexts[0].max_amount_per_payment is None + + @pytest.mark.asyncio + async def test_omits_the_spend_cap_on_context_when_the_usd_cap_is_disabled(self): + client, mock_client = self._client_with_default_asset( + self.usdc, {"max_amount_per_payment": False} + ) + await client.create_payment_payload( + self._required(self._req(asset=self.usdc["asset"], amount="5000000")) + ) + assert mock_client.create_contexts[0].max_amount_per_payment is None + + @pytest.mark.asyncio + async def test_passes_a_custom_money_usd_cap_on_context_in_atomic_units(self): + client, mock_client = self._client_with_default_asset( + self.usdc, {"max_amount_per_payment": "$5"} + ) + await client.create_payment_payload( + self._required(self._req(asset=self.usdc["asset"], amount="1000")) + ) + assert mock_client.create_contexts[0].max_amount_per_payment == "5000000" + + @pytest.mark.asyncio + async def test_passes_an_allowed_assets_atomic_cap_on_context(self): + client, mock_client = self._client_with_default_asset( + self.usdc, + { + "allowed_assets": [ + { + "asset": self.usdc["asset"], + "network": self.network, + "max_amount_per_payment": "500000", + } + ] + }, + ) + await client.create_payment_payload( + self._required(self._req(asset=self.usdc["asset"], amount="100")) + ) + assert mock_client.create_contexts[0].max_amount_per_payment == "500000" + class TestPaymentFlowSelection: """Client selection drops unrecognized paymentFlow and prefers authorization.""" diff --git a/python/x402/tests/unit/mcp/test_client_timeout.py b/python/x402/tests/unit/mcp/test_client_timeout.py new file mode 100644 index 0000000000..47975b194e --- /dev/null +++ b/python/x402/tests/unit/mcp/test_client_timeout.py @@ -0,0 +1,322 @@ +"""MCP client tool-call timeouts follow accept maxTimeoutSeconds.""" + +from __future__ import annotations + +from datetime import timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from x402.mcp.client import x402MCPClientSync, x402MCPSession +from x402.mcp.client_async import x402MCPClient +from x402.schemas import PaymentPayload + + +def _payment_required_text(max_timeout_seconds: int = 300) -> str: + return ( + '{"x402Version":2,"accepts":[{"scheme":"exact","network":"eip155:84532",' + f'"amount":"1000","asset":"USDC","payTo":"0xrecipient",' + f'"maxTimeoutSeconds":{max_timeout_seconds}}}]}}' + ) + + +def _payload(max_timeout_seconds: int = 300) -> PaymentPayload: + return PaymentPayload( + x402_version=2, + accepted={ + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "USDC", + "pay_to": "0xrecipient", + "max_timeout_seconds": max_timeout_seconds, + }, + payload={"signature": "0x123"}, + ) + + +class _Text: + def __init__(self, text: str) -> None: + self.text = text + + +class _SessionResult: + def __init__(self, *, is_error: bool, text: str, meta: dict | None = None) -> None: + self.isError = is_error + self.content = [_Text(text)] + self.meta = meta + self.structuredContent = None + + +class _McpResult: + def __init__(self, *, is_error: bool, text: str, meta: dict | None = None) -> None: + self.content = [{"type": "text", "text": text}] + self.isError = is_error + self._meta = meta or {} + self.structuredContent = None + + +def _read_timeout(call) -> timedelta: + return call.kwargs["read_timeout_seconds"] + + +@pytest.mark.asyncio +async def test_session_probe_uses_300s_ceiling() -> None: + session = SimpleNamespace( + call_tool=AsyncMock(return_value=_SessionResult(is_error=False, text="pong")) + ) + x402_client = SimpleNamespace(create_payment_payload=AsyncMock()) + + result = await x402MCPSession(session, x402_client).call_tool("ping", {}) + + assert result.payment_made is False + assert result.content[0].text == "pong" + assert _read_timeout(session.call_tool.await_args) == timedelta(seconds=300) + x402_client.create_payment_payload.assert_not_called() + + +@pytest.mark.asyncio +async def test_session_paid_timeout_uses_accept_max_timeout_seconds() -> None: + session = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _SessionResult(is_error=True, text=_payment_required_text(600)), + _SessionResult(is_error=False, text="ok"), + ] + ) + ) + x402_client = SimpleNamespace(create_payment_payload=AsyncMock(return_value=_payload(600))) + + result = await x402MCPSession(session, x402_client).call_tool("paid_tool", {}) + + assert result.payment_made is True + assert result.content[0].text == "ok" + probe_call, paid_call = session.call_tool.await_args_list + assert _read_timeout(probe_call) == timedelta(seconds=300) + assert _read_timeout(paid_call) == timedelta(seconds=600) + + +@pytest.mark.asyncio +async def test_session_paid_timeout_defaults_to_300s_without_accept() -> None: + session = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _SessionResult(is_error=True, text=_payment_required_text()), + _SessionResult(is_error=False, text="ok"), + ] + ) + ) + payload = SimpleNamespace(accepted=None, model_dump=lambda **_kwargs: {"payload": {}}) + x402_client = SimpleNamespace(create_payment_payload=AsyncMock(return_value=payload)) + + result = await x402MCPSession(session, x402_client).call_tool("paid_tool", {}) + + assert result.payment_made is True + probe_call, paid_call = session.call_tool.await_args_list + assert _read_timeout(probe_call) == timedelta(seconds=300) + assert _read_timeout(paid_call) == timedelta(seconds=300) + + +@pytest.mark.asyncio +async def test_session_explicit_timeout_overrides_accept() -> None: + session = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _SessionResult(is_error=True, text=_payment_required_text(600)), + _SessionResult(is_error=False, text="ok"), + ] + ) + ) + x402_client = SimpleNamespace(create_payment_payload=AsyncMock(return_value=_payload(600))) + override = timedelta(seconds=12) + + await x402MCPSession(session, x402_client).call_tool( + "paid_tool", {}, read_timeout_seconds=override + ) + + probe_call, paid_call = session.call_tool.await_args_list + assert _read_timeout(probe_call) == override + assert _read_timeout(paid_call) == override + + +@pytest.mark.asyncio +async def test_async_client_probe_uses_300s_ceiling() -> None: + mock_mcp = SimpleNamespace( + call_tool=AsyncMock(return_value=_McpResult(is_error=False, text="pong")) + ) + mock_payment = SimpleNamespace(create_payment_payload=AsyncMock()) + + result = await x402MCPClient(mock_mcp, mock_payment).call_tool("ping", {}) + + assert result.payment_made is False + assert result.content[0]["text"] == "pong" + assert mock_mcp.call_tool.await_args.kwargs["read_timeout_seconds"] == timedelta(seconds=300) + mock_payment.create_payment_payload.assert_not_called() + + +@pytest.mark.asyncio +async def test_async_client_paid_timeout_uses_accept_max_timeout_seconds() -> None: + mock_mcp = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _McpResult(is_error=True, text=_payment_required_text(600)), + _McpResult(is_error=False, text="ok"), + ] + ) + ) + mock_payment = SimpleNamespace(create_payment_payload=AsyncMock(return_value=_payload(600))) + + result = await x402MCPClient(mock_mcp, mock_payment).call_tool("paid_tool", {}) + + assert result.payment_made is True + probe_call, paid_call = mock_mcp.call_tool.await_args_list + assert probe_call.kwargs["read_timeout_seconds"] == timedelta(seconds=300) + assert paid_call.kwargs["read_timeout_seconds"] == timedelta(seconds=600) + + +@pytest.mark.asyncio +async def test_async_client_call_tool_with_payment_uses_accept_timeout() -> None: + mock_mcp = SimpleNamespace( + call_tool=AsyncMock(return_value=_McpResult(is_error=False, text="ok")) + ) + mock_payment = SimpleNamespace(create_payment_payload=AsyncMock()) + + await x402MCPClient(mock_mcp, mock_payment).call_tool_with_payment( + "paid_tool", {}, _payload(90) + ) + + assert mock_mcp.call_tool.await_args.kwargs["read_timeout_seconds"] == timedelta(seconds=90) + + +def test_sync_client_paid_timeout_uses_accept_max_timeout_seconds() -> None: + mock_mcp = SimpleNamespace( + call_tool=Mock( + side_effect=[ + _McpResult(is_error=True, text=_payment_required_text(600)), + _McpResult(is_error=False, text="ok"), + ] + ) + ) + mock_payment = SimpleNamespace(create_payment_payload=Mock(return_value=_payload(600))) + + result = x402MCPClientSync(mock_mcp, mock_payment).call_tool("paid_tool", {}) + + assert result.payment_made is True + probe_call, paid_call = mock_mcp.call_tool.call_args_list + assert probe_call.kwargs["read_timeout_seconds"] == timedelta(seconds=300) + assert paid_call.kwargs["read_timeout_seconds"] == timedelta(seconds=600) + + +@pytest.mark.asyncio +async def test_session_paid_timeout_clamps_huge_accept_to_default_cap() -> None: + session = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _SessionResult(is_error=True, text=_payment_required_text(1_000_000)), + _SessionResult(is_error=False, text="ok"), + ] + ) + ) + x402_client = SimpleNamespace( + create_payment_payload=AsyncMock(return_value=_payload(1_000_000)) + ) + + result = await x402MCPSession(session, x402_client).call_tool("paid_tool", {}) + + assert result.payment_made is True + _, paid_call = session.call_tool.await_args_list + assert _read_timeout(paid_call) == timedelta(seconds=600) + + +@pytest.mark.asyncio +async def test_session_paid_timeout_under_cap_uses_accept() -> None: + session = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _SessionResult(is_error=True, text=_payment_required_text(120)), + _SessionResult(is_error=False, text="ok"), + ] + ) + ) + x402_client = SimpleNamespace(create_payment_payload=AsyncMock(return_value=_payload(120))) + + await x402MCPSession(session, x402_client).call_tool("paid_tool", {}) + + _, paid_call = session.call_tool.await_args_list + assert _read_timeout(paid_call) == timedelta(seconds=120) + + +@pytest.mark.asyncio +async def test_session_paid_timeout_honours_raised_cap() -> None: + session = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _SessionResult(is_error=True, text=_payment_required_text(900)), + _SessionResult(is_error=False, text="ok"), + ] + ) + ) + x402_client = SimpleNamespace(create_payment_payload=AsyncMock(return_value=_payload(900))) + + await x402MCPSession(session, x402_client, max_request_timeout_seconds=900).call_tool( + "paid_tool", {} + ) + + _, paid_call = session.call_tool.await_args_list + assert _read_timeout(paid_call) == timedelta(seconds=900) + + +@pytest.mark.asyncio +async def test_async_client_paid_timeout_clamps_huge_accept() -> None: + mock_mcp = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _McpResult(is_error=True, text=_payment_required_text(1_000_000)), + _McpResult(is_error=False, text="ok"), + ] + ) + ) + mock_payment = SimpleNamespace( + create_payment_payload=AsyncMock(return_value=_payload(1_000_000)) + ) + + await x402MCPClient(mock_mcp, mock_payment).call_tool("paid_tool", {}) + + _, paid_call = mock_mcp.call_tool.await_args_list + assert paid_call.kwargs["read_timeout_seconds"] == timedelta(seconds=600) + + +@pytest.mark.asyncio +async def test_async_client_paid_timeout_under_cap() -> None: + mock_mcp = SimpleNamespace( + call_tool=AsyncMock( + side_effect=[ + _McpResult(is_error=True, text=_payment_required_text(120)), + _McpResult(is_error=False, text="ok"), + ] + ) + ) + mock_payment = SimpleNamespace(create_payment_payload=AsyncMock(return_value=_payload(120))) + + await x402MCPClient(mock_mcp, mock_payment).call_tool("paid_tool", {}) + + _, paid_call = mock_mcp.call_tool.await_args_list + assert paid_call.kwargs["read_timeout_seconds"] == timedelta(seconds=120) + + +def test_sync_client_paid_timeout_clamps_huge_accept() -> None: + mock_mcp = SimpleNamespace( + call_tool=Mock( + side_effect=[ + _McpResult(is_error=True, text=_payment_required_text(1_000_000)), + _McpResult(is_error=False, text="ok"), + ] + ) + ) + mock_payment = SimpleNamespace(create_payment_payload=Mock(return_value=_payload(1_000_000))) + + x402MCPClientSync(mock_mcp, mock_payment).call_tool("paid_tool", {}) + + _, paid_call = mock_mcp.call_tool.call_args_list + assert paid_call.kwargs["read_timeout_seconds"] == timedelta(seconds=600) diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_config.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_config.py index 5a0089539b..4ecc0bf6fb 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_config.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_config.py @@ -9,8 +9,11 @@ DEFAULT_SALT, BatchSettlementDepositPolicy, BatchSettlementEvmSchemeOptions, + apply_max_deposit, deposit_amount_for_request, + max_deposit_from_spend_cap, normalize_strategy_deposit_amount, + parse_announced_min_deposit, resolve_client_options, validate_deposit_policy, ) @@ -85,16 +88,77 @@ def test_rejects_bool_multiplier(self): class TestDepositAmountForRequest: - def test_default_multiplier_is_5(self): - assert deposit_amount_for_request(None, 100) == "500" + cap = 1_000_000 - def test_explicit_multiplier(self): - policy = BatchSettlementDepositPolicy(deposit_multiplier=10) - assert deposit_amount_for_request(policy, 100) == "1000" + def test_uses_announced_min_deposit_when_valid(self): + assert ( + deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "12000"}, self.cap) + == "12000" + ) + + def test_ignores_invalid_min_deposit_values(self): + assert ( + deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "abc"}, self.cap) == "5000" + ) + assert deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "0"}, self.cap) == "5000" + assert ( + deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "500"}, self.cap) == "5000" + ) + + def test_falls_back_to_deposit_multiplier_when_min_deposit_is_absent(self): + assert ( + deposit_amount_for_request( + BatchSettlementDepositPolicy(deposit_multiplier=7), + 1000, + 1000, + None, + self.cap, + ) + == "7000" + ) + + def test_uses_the_voucher_gap_when_it_exceeds_the_target(self): + assert ( + deposit_amount_for_request(None, 1000, 8000, {"minDeposit": "5000"}, self.cap) == "8000" + ) + + def test_clamps_the_target_to_max_deposit_when_the_voucher_gap_still_fits(self): + assert deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "12000"}, 4000) == "4000" + + def test_throws_when_the_voucher_gap_exceeds_max_deposit(self): + with pytest.raises(ValueError, match="exceeds deposit_multiplier"): + deposit_amount_for_request(None, 1000, 8000, {"minDeposit": "5000"}, 4000) + + def test_skips_the_ceiling_when_no_spend_cap_is_set(self): + assert deposit_amount_for_request(None, 1000, 1000, {"minDeposit": "15000"}) == "15000" + + +class TestMaxDepositFromSpendCap: + def test_multiplies_the_spend_cap_by_deposit_multiplier(self): + assert max_deposit_from_spend_cap("1000") == 5000 + assert max_deposit_from_spend_cap("1000", 7) == 7000 + + def test_returns_none_when_no_spend_cap_is_configured(self): + assert max_deposit_from_spend_cap(None) is None + + +class TestApplyMaxDeposit: + def test_returns_the_deposit_when_it_is_within_the_ceiling(self): + assert apply_max_deposit(12000, 1000, 20000) == "12000" + + def test_returns_the_deposit_unchanged_when_uncapped(self): + assert apply_max_deposit(12000, 1000) == "12000" + + +class TestParseAnnouncedMinDeposit: + def test_accepts_positive_integers_at_or_above_request_amount(self): + assert parse_announced_min_deposit("1000", 1000) == 1000 + assert parse_announced_min_deposit("5000", 1000) == 5000 - def test_none_multiplier_falls_back_to_default(self): - policy = BatchSettlementDepositPolicy() - assert deposit_amount_for_request(policy, 100) == "500" + def test_rejects_invalid_values(self): + assert parse_announced_min_deposit(None, 1000) is None + assert parse_announced_min_deposit("999", 1000) is None + assert parse_announced_min_deposit("-1", 1000) is None class TestNormalizeStrategyDepositAmount: diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_scheme.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_scheme.py index ce34e2e8f5..f392f10d90 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_scheme.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/client/test_scheme.py @@ -7,6 +7,7 @@ try: from eth_account import Account + from x402.interfaces import PaymentPayloadContext from x402.mechanisms.evm.batch_settlement.client.channel import ( BatchSettlementClientDeps, build_channel_config, @@ -43,6 +44,7 @@ RECEIVER = "0x3333333333333333333333333333333333333333" RECEIVER_AUTHORIZER = "0x4444444444444444444444444444444444444444" TEST_PRIVATE_KEY = "0xa915e4eaadfaa5e6f59574d2c8e1d2a4cd2b6c0c0b9f6a3c7d9e2b8f5a4e3c2d" +SPEND_CAP = PaymentPayloadContext(max_amount_per_payment="1000000") def _signer() -> EthAccountSigner: @@ -184,6 +186,81 @@ def test_unsupported_asset_transfer_method_raises(self): with pytest.raises(ValueError, match="assetTransferMethod"): s.create_payment_payload(req) + def test_uses_deposit_multiplier_when_depositing(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer, BatchSettlementDepositPolicy(deposit_multiplier=7)) + result = s.create_payment_payload(_requirements(amount="1000"), context=SPEND_CAP) + assert result["deposit"]["amount"] == "7000" + + def test_honors_valid_extra_min_deposit_over_deposit_multiplier(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer, BatchSettlementDepositPolicy(deposit_multiplier=7)) + result = s.create_payment_payload( + _requirements( + amount="1000", + extra={ + "name": "USDC", + "version": "2", + "receiverAuthorizer": RECEIVER_AUTHORIZER, + "withdrawDelay": 900, + "minDeposit": "15000", + }, + ), + context=SPEND_CAP, + ) + assert result["deposit"]["amount"] == "15000" + + def test_falls_back_to_deposit_multiplier_when_extra_min_deposit_is_below_amount(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer, BatchSettlementDepositPolicy(deposit_multiplier=5)) + result = s.create_payment_payload( + _requirements( + amount="1000", + extra={ + "name": "USDC", + "version": "2", + "receiverAuthorizer": RECEIVER_AUTHORIZER, + "withdrawDelay": 900, + "minDeposit": "500", + }, + ), + context=SPEND_CAP, + ) + assert result["deposit"]["amount"] == "5000" + + def test_clamps_extra_min_deposit_to_spend_cap_times_deposit_multiplier(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer, BatchSettlementDepositPolicy(deposit_multiplier=5)) + result = s.create_payment_payload( + _requirements( + amount="1000", + extra={ + "name": "USDC", + "version": "2", + "receiverAuthorizer": RECEIVER_AUTHORIZER, + "withdrawDelay": 900, + "minDeposit": "15000", + }, + ), + context=PaymentPayloadContext(max_amount_per_payment="800"), + ) + assert result["deposit"]["amount"] == "4000" + + def test_rejects_a_deposit_when_the_voucher_gap_exceeds_spend_cap_times_multiplier(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer) + with pytest.raises(ValueError, match="exceeds deposit_multiplier"): + s.create_payment_payload( + _requirements(amount="1000"), + context=PaymentPayloadContext(max_amount_per_payment="100"), + ) + + def test_allows_a_deposit_when_no_spend_cap_is_configured(self): + signer = _signer() + s = BatchSettlementEvmScheme(signer) + result = s.create_payment_payload(_requirements(amount="1000")) + assert result["deposit"]["amount"] == "5000" + def _make_payment_payload(payload: dict) -> PaymentPayload: return PaymentPayload(x402_version=2, accepted=_requirements(), payload=payload) diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_hooks.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_hooks.py index acf5cab24e..0778e5fa68 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_hooks.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_hooks.py @@ -13,11 +13,13 @@ ERR_CHANNEL_BUSY, ERR_CHANNEL_ID_MISMATCH, ERR_CUMULATIVE_AMOUNT_MISMATCH, + ERR_DEPOSIT_BELOW_MIN_DEPOSIT, ERR_INVALID_CHANNEL_ID, ERR_VERIFICATION_STATE_UNAVAILABLE, ) from x402.mechanisms.evm.batch_settlement.server.scheme import ( BatchSettlementEvmScheme, + BatchSettlementEvmSchemeServerConfig, ) from x402.mechanisms.evm.batch_settlement.server.settle import handle_before_settle from x402.mechanisms.evm.batch_settlement.server.storage import ( @@ -94,6 +96,31 @@ def _voucher_payload( ) +def _deposit_payload( + deposit_amount: str, + max_claimable: str = "1000", + *, + channel_id: str | None = None, +) -> PaymentPayload: + return PaymentPayload( + x402_version=2, + payload={ + "type": "deposit", + "channelConfig": _channel_config().to_dict(), + "voucher": { + "channelId": channel_id or _channel_id(), + "maxClaimableAmount": max_claimable, + "signature": "0x" + "11" * 65, + }, + "deposit": { + "amount": deposit_amount, + "authorization": {}, + }, + }, + accepted=_requirements(amount=max_claimable), + ) + + def _requirements(amount: str = "100") -> PaymentRequirements: return PaymentRequirements( scheme=SCHEME_BATCH_SETTLEMENT, @@ -228,6 +255,74 @@ def _add_pending(current): out = handle_before_verify(scheme, ctx) assert out is None + def test_does_not_reject_deposits_below_min_deposit_when_enforcement_is_disabled(self): + scheme = _scheme() + requirements = PaymentRequirements( + scheme=SCHEME_BATCH_SETTLEMENT, + network=NETWORK, + asset=USDC, + amount="1000", + pay_to=RECEIVER, + max_timeout_seconds=60, + extra={"receiverAuthorizer": AUTHORIZER, "minDeposit": "10000"}, + ) + result = handle_before_verify( + scheme, + VerifyContext( + payment_payload=_deposit_payload("5000", "1000"), + requirements=requirements, + ), + ) + assert result is None + + def test_rejects_deposits_below_min_deposit_when_enforcement_is_enabled(self): + enforcing_server = BatchSettlementEvmScheme( + RECEIVER, BatchSettlementEvmSchemeServerConfig(enforce_min_deposit=True) + ) + requirements = PaymentRequirements( + scheme=SCHEME_BATCH_SETTLEMENT, + network=NETWORK, + asset=USDC, + amount="1000", + pay_to=RECEIVER, + max_timeout_seconds=60, + extra={"receiverAuthorizer": AUTHORIZER, "minDeposit": "10000"}, + ) + + below_min = handle_before_verify( + enforcing_server, + VerifyContext( + payment_payload=_deposit_payload("5000", "1000"), + requirements=requirements, + ), + ) + assert isinstance(below_min, AbortResult) + assert below_min.reason == ERR_DEPOSIT_BELOW_MIN_DEPOSIT + + at_min = handle_before_verify( + enforcing_server, + VerifyContext( + payment_payload=_deposit_payload("10000", "1000"), + requirements=requirements, + ), + ) + assert at_min is None + + def test_enforces_the_default_10x_min_deposit_hint_when_extra_min_deposit_is_omitted(self): + enforcing_server = BatchSettlementEvmScheme( + RECEIVER, BatchSettlementEvmSchemeServerConfig(enforce_min_deposit=True) + ) + requirements = _requirements(amount="1000") + below_default = handle_before_verify( + enforcing_server, + VerifyContext( + payment_payload=_deposit_payload("5000", "1000"), + requirements=requirements, + ), + ) + assert isinstance(below_default, AbortResult) + assert below_default.reason == ERR_DEPOSIT_BELOW_MIN_DEPOSIT + class TestHandleAfterVerify: def test_reserves_after_valid_verify(self): diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_scheme.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_scheme.py index 2727e882b1..4c5b6ef809 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_scheme.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/server/test_scheme.py @@ -81,6 +81,13 @@ def test_defaults(self): assert s.get_receiver_authorizer_signer() is None assert isinstance(s.get_storage(), InMemoryChannelStorage) assert s.scheme == SCHEME_BATCH_SETTLEMENT + assert s.get_enforce_min_deposit() is False + + def test_allows_enforce_min_deposit_to_be_enabled(self): + s = BatchSettlementEvmScheme( + RECEIVER, BatchSettlementEvmSchemeServerConfig(enforce_min_deposit=True) + ) + assert s.get_enforce_min_deposit() is True def test_overrides_applied(self): storage = InMemoryChannelStorage() @@ -194,6 +201,7 @@ def test_local_signer_sets_receiver_authorizer(self): out = s.enhance_payment_requirements(self._req(), self._kind(), []) assert out.extra["receiverAuthorizer"] == AUTHORIZER_ADDR assert out.extra["withdrawDelay"] == 1800 + assert out.extra["minDeposit"] == "10000" def test_falls_back_to_facilitator_authorizer(self): s = BatchSettlementEvmScheme(RECEIVER) @@ -245,6 +253,67 @@ def test_rejects_zero_receiver_authorizer(self): with pytest.raises(ValueError, match="receiverAuthorizer"): s.enhance_payment_requirements(self._req(), kind, []) + def test_defaults_extra_min_deposit_to_10x_amount(self): + s = BatchSettlementEvmScheme(RECEIVER) + enhanced = s.enhance_payment_requirements( + self._req(amount="2500"), + self._kind(extra={"receiverAuthorizer": AUTHORIZER_ADDR}), + [], + ) + assert enhanced.extra["minDeposit"] == "25000" + + def test_converts_route_extra_min_deposit_money_on_default_assets(self): + s = BatchSettlementEvmScheme(RECEIVER) + enhanced = s.enhance_payment_requirements( + self._req( + amount="1000", + extra={"receiverAuthorizer": AUTHORIZER_ADDR, "minDeposit": "$1"}, + ), + self._kind(extra={"receiverAuthorizer": AUTHORIZER_ADDR}), + [], + ) + assert enhanced.extra["minDeposit"] == "1000000" + + def test_uses_request_amount_when_it_exceeds_route_extra_min_deposit_money(self): + s = BatchSettlementEvmScheme(RECEIVER) + enhanced = s.enhance_payment_requirements( + self._req( + amount="2000000", + extra={"receiverAuthorizer": AUTHORIZER_ADDR, "minDeposit": "$1"}, + ), + self._kind(extra={"receiverAuthorizer": AUTHORIZER_ADDR}), + [], + ) + assert enhanced.extra["minDeposit"] == "2000000" + + def test_accepts_route_extra_min_deposit_atomic_strings_for_any_asset(self): + custom_asset = "0x00000000000000000000000000000000000000aa" + s = BatchSettlementEvmScheme(RECEIVER) + enhanced = s.enhance_payment_requirements( + self._req( + amount="1000", + asset=custom_asset, + extra={"receiverAuthorizer": AUTHORIZER_ADDR, "minDeposit": "5000000"}, + ), + self._kind(extra={"receiverAuthorizer": AUTHORIZER_ADDR}), + [], + ) + assert enhanced.extra["minDeposit"] == "5000000" + + def test_rejects_route_extra_min_deposit_money_for_non_default_assets(self): + custom_asset = "0x00000000000000000000000000000000000000aa" + s = BatchSettlementEvmScheme(RECEIVER) + with pytest.raises(ValueError, match="only supported for default assets"): + s.enhance_payment_requirements( + self._req( + amount="1000", + asset=custom_asset, + extra={"receiverAuthorizer": AUTHORIZER_ADDR, "minDeposit": "$1"}, + ), + self._kind(extra={"receiverAuthorizer": AUTHORIZER_ADDR}), + [], + ) + class TestRequestContext: def test_merge_and_take_round_trip(self): diff --git a/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py b/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py index f97541db56..0f5efbf6c3 100644 --- a/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py +++ b/python/x402/tests/unit/mechanisms/evm/batch_settlement/test_constants.py @@ -201,6 +201,7 @@ def test_all_err_constants_share_prefix(self): E.ERR_CHARGE_EXCEEDS_SIGNED_CUMULATIVE, E.ERR_REFUND_NO_BALANCE, E.ERR_REFUND_AMOUNT_INVALID, + E.ERR_DEPOSIT_BELOW_MIN_DEPOSIT, ], ) def test_documented_codes_present(self, code: str): @@ -222,3 +223,6 @@ def test_max_claims_per_batch(self): def test_onchain_state_ttl_ms(self): assert C.DEFAULT_ONCHAIN_STATE_TTL_MS == 60_000 + + def test_default_server_min_deposit_multiplier(self): + assert C.DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER == 10 diff --git a/python/x402/tests/unit/mechanisms/evm/test_asset_cache.py b/python/x402/tests/unit/mechanisms/evm/test_asset_cache.py new file mode 100644 index 0000000000..a69d2c91fe --- /dev/null +++ b/python/x402/tests/unit/mechanisms/evm/test_asset_cache.py @@ -0,0 +1,123 @@ +"""Tests for the process-wide EVM asset-contract cache.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from x402.mechanisms.evm.asset_cache import ( + _MAX_ASSET_CONTRACT_CACHE_ENTRIES, + DEFAULT_ASSET_CONTRACT_CACHE_TTL, + _AssetContractCacheKey, + _global_asset_contract_cache, + reset_asset_contract_cache, + start_asset_contract_check, +) +from x402.mechanisms.evm.constants import ERR_ASSET_NOT_DEPLOYED_CONTRACT + +CACHE_TEST_ASSET = "0x00000000000000000000000000000000000000bb" + + +class _CountingCodeSigner: + """Reports the asset as deployed and counts eth_getCode calls.""" + + def __init__(self) -> None: + self.calls = 0 + + def get_code(self, address: str) -> bytes: + self.calls += 1 + return b"\x60\x60" + + +def test_asset_contract_check_caches_positive_result() -> None: + reset_asset_contract_cache() + + signer = _CountingCodeSigner() + for _ in range(3): + reason = start_asset_contract_check(signer, "eip155:84532", CACHE_TEST_ASSET).await_result() + assert reason == "" + + assert signer.calls == 1, "only the first check should reach the RPC" + + +def test_asset_contract_check_without_await_does_not_cache() -> None: + # A caller that never awaits must leave the cache untouched, so cache contents do not depend on + # scheduling of the check relative to the caller. + reset_asset_contract_cache() + + signer = _CountingCodeSigner() + start_asset_contract_check(signer, "eip155:84532", CACHE_TEST_ASSET) + + reason = start_asset_contract_check(signer, "eip155:84532", CACHE_TEST_ASSET).await_result() + assert reason == "" + assert signer.calls == 1, "the abandoned check must not have populated the cache" + + +def test_asset_contract_cache_skips_empty_network() -> None: + # An empty network cannot be cached: entries would otherwise collide across chains, where one + # address can hold bytecode on one chain and nothing on another. + reset_asset_contract_cache() + + signer = _CountingCodeSigner() + for _ in range(2): + start_asset_contract_check(signer, "", CACHE_TEST_ASSET).await_result() + + assert signer.calls == 2, "an empty network must bypass the cache" + assert not _global_asset_contract_cache.is_fresh( + _AssetContractCacheKey(asset=CACHE_TEST_ASSET), datetime.now(timezone.utc) + ) + + +def test_asset_contract_cache_entries_expire() -> None: + reset_asset_contract_cache() + + key = _AssetContractCacheKey(network="eip155:84532", asset=CACHE_TEST_ASSET) + start = datetime.now(timezone.utc) + _global_asset_contract_cache.record(key, start) + + assert _global_asset_contract_cache.is_fresh( + key, start + DEFAULT_ASSET_CONTRACT_CACHE_TTL - timedelta(seconds=1) + ) + assert not _global_asset_contract_cache.is_fresh( + key, start + DEFAULT_ASSET_CONTRACT_CACHE_TTL + timedelta(seconds=1) + ) + + +def test_asset_contract_cache_does_not_cache_negative_result() -> None: + reset_asset_contract_cache() + + class _FlipCodeSigner: + def __init__(self) -> None: + self.calls = 0 + self.code = b"" + + def get_code(self, address: str) -> bytes: + self.calls += 1 + return self.code + + signer = _FlipCodeSigner() + reason = start_asset_contract_check(signer, "eip155:84532", CACHE_TEST_ASSET).await_result() + assert reason == ERR_ASSET_NOT_DEPLOYED_CONTRACT + assert signer.calls == 1 + + signer.code = b"\x60\x60" + reason = start_asset_contract_check(signer, "eip155:84532", CACHE_TEST_ASSET).await_result() + assert reason == "" + assert signer.calls == 2, "a negative result must not be reused within the TTL" + + +def test_asset_contract_cache_is_bounded() -> None: + # The cache is keyed partly by caller-supplied asset addresses, so it must not grow without + # bound when many distinct deployed contracts are named within one TTL window. + reset_asset_contract_cache() + + now = datetime.now(timezone.utc) + for i in range(_MAX_ASSET_CONTRACT_CACHE_ENTRIES + 500): + _global_asset_contract_cache.record( + _AssetContractCacheKey(network="eip155:84532", asset=f"0x{i:040x}"), + now, + ) + + with _global_asset_contract_cache._lock: + size = len(_global_asset_contract_cache._expiries) + + assert size <= _MAX_ASSET_CONTRACT_CACHE_ENTRIES diff --git a/python/x402/tests/unit/mechanisms/evm/test_facilitator.py b/python/x402/tests/unit/mechanisms/evm/test_facilitator.py index fbfe824a77..1decd0ab80 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_facilitator.py +++ b/python/x402/tests/unit/mechanisms/evm/test_facilitator.py @@ -13,6 +13,7 @@ pytest.skip("eth-abi not available", allow_module_level=True) from x402.mechanisms.evm import ERC6492_MAGIC_VALUE, get_default_asset +from x402.mechanisms.evm.asset_cache import reset_asset_contract_cache from x402.mechanisms.evm.constants import ( ERR_ASSET_NOT_DEPLOYED_CONTRACT, ERR_AUTHORIZATION_VALUE_MISMATCH, @@ -562,6 +563,9 @@ def test_eoa_invalid_signature_is_rejected_immediately(self): def test_rejects_eoa_asset(self): # When the asset address has no bytecode (EOA), verify must reject before signature checks. + # Other tests share TOKEN_ADDRESS but model it as deployed, and positive asset checks are + # cached process-wide, so drop those entries to force a real get_code here. + reset_asset_contract_cache() signer = MockFacilitatorSigner( code_by_address={ TOKEN_ADDRESS.lower(): b"", # token = EOA @@ -579,6 +583,8 @@ def test_getcode_rpc_error_raises(self): # An RPC error on get_code must propagate as an exception (internal error), not a 400. # Payer returns code so verify_typed_data_strict can complete via EIP-1271; # the RPC error fires on the asset-contract check (outside the sig try/except). + reset_asset_contract_cache() + class _RPCErrorSigner(MockFacilitatorSigner): def get_code(self, address: str) -> bytes: if address.lower() == PAYER.lower(): @@ -590,6 +596,51 @@ def get_code(self, address: str) -> bytes: with pytest.raises(RuntimeError, match="rpc: connection refused"): facilitator.verify(make_payment_payload(), make_requirements()) + def test_asset_contract_check_is_cached_but_payer_code_is_not(self): + # The asset check is cached across payments; the payer's code is not, being mutable under + # ERC-6492. + reset_asset_contract_cache() + + signer = MockFacilitatorSigner(code_by_address={PAYER.lower(): b"\x01"}) + facilitator = ExactEvmFacilitatorScheme(signer) + + for attempt in range(1, 4): + result = facilitator.verify(make_payment_payload(), make_requirements()) + assert result.is_valid is True, f"verify {attempt} failed: {result.invalid_reason}" + + assert signer.get_code_calls.get(TOKEN_ADDRESS.lower(), 0) == 1, ( + "expected the asset eth_getCode to be cached after the first verify" + ) + assert signer.get_code_calls.get(PAYER.lower(), 0) == 3, ( + "expected one payer eth_getCode per verify (never cached)" + ) + + def test_asset_contract_cache_is_scoped_per_network(self): + # The same address can hold bytecode on one chain and nothing on another, so a hit on one + # network must not answer for another. + reset_asset_contract_cache() + + signer = MockFacilitatorSigner(code_by_address={PAYER.lower(): b"\x01"}) + facilitator = ExactEvmFacilitatorScheme(signer) + + result = facilitator.verify(make_payment_payload(), make_requirements()) + assert result.is_valid is True, ( + f"verify on the first network failed: {result.invalid_reason}" + ) + + other_network = "eip155:84532" + result = facilitator.verify( + make_payment_payload(accepted_network=other_network), + make_requirements(network=other_network), + ) + assert result.is_valid is True, ( + f"verify on the second network failed: {result.invalid_reason}" + ) + + assert signer.get_code_calls.get(TOKEN_ADDRESS.lower(), 0) == 2, ( + "expected the asset to be re-checked on a different network" + ) + class TestSettle: def test_fails_settlement_if_verification_fails(self): diff --git a/python/x402/tests/unit/mechanisms/evm/test_permit2.py b/python/x402/tests/unit/mechanisms/evm/test_permit2.py index daef342580..80d28894eb 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_permit2.py +++ b/python/x402/tests/unit/mechanisms/evm/test_permit2.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import MagicMock, patch +from x402.mechanisms.evm.asset_cache import reset_asset_contract_cache from x402.mechanisms.evm.constants import ( ERR_ASSET_NOT_DEPLOYED_CONTRACT, ERR_ERC20_APPROVAL_BROADCAST_FAILED, @@ -187,6 +188,19 @@ def get_code(self, address: str) -> bytes: return b"" +class _CountingAssetCodeSigner(MockFacilitatorSigner): + """Counts get_code calls for the payment token.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.asset_get_code_calls = 0 + + def get_code(self, address: str) -> bytes: + if address.lower() == TOKEN_ADDRESS.lower(): + self.asset_get_code_calls += 1 + return super().get_code(address) + + class _ReceiptTimeoutSigner(MockFacilitatorSigner): """Signer whose broadcast never confirms in time (settlement_pending).""" @@ -302,6 +316,17 @@ def test_verify_rejects_invalid_spender(self): assert result.is_valid is False assert result.invalid_reason == ERR_PERMIT2_INVALID_SPENDER + def test_verify_invalid_spender_does_not_check_asset_contract(self): + reset_asset_contract_cache() + signer = _CountingAssetCodeSigner() + facilitator = ExactEvmFacilitatorScheme(signer) + auth = make_permit2_authorization(spender="0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + payload = make_payment_payload(payload_dict=make_permit2_payload_dict(auth)) + result = facilitator.verify(payload, make_requirements()) + assert result.is_valid is False + assert result.invalid_reason == ERR_PERMIT2_INVALID_SPENDER + assert signer.asset_get_code_calls == 0 + def test_verify_rejects_recipient_mismatch(self): facilitator = self._make_facilitator() wrong_recipient = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" @@ -390,14 +415,22 @@ def test_verify_rejects_insufficient_balance(self): assert result.invalid_reason == ERR_INSUFFICIENT_BALANCE def test_verify_rejects_eoa_asset(self): - # Asset address with no bytecode must be rejected before signature checks. + # Asset address with no bytecode must be rejected after signature work. # The mock returns non-empty code for TOKEN_ADDRESS by default; override to EOA. + # Other tests share TOKEN_ADDRESS but model it as deployed, and positive asset checks are + # cached process-wide, so drop those entries to force a real get_code here. + reset_asset_contract_cache() + class _EOAAssetSigner(MockFacilitatorSigner): def get_code(self, address: str) -> bytes: return b"" # all addresses, including token, are EOAs facilitator = ExactEvmFacilitatorScheme(_EOAAssetSigner()) - result = self._verify(facilitator) + with patch( + "x402.mechanisms.evm.exact.permit2_utils._verify_permit2_signature", + return_value=True, + ): + result = self._verify(facilitator) assert result.is_valid is False assert result.invalid_reason == ERR_ASSET_NOT_DEPLOYED_CONTRACT diff --git a/python/x402/tests/unit/mechanisms/evm/test_upto_facilitator.py b/python/x402/tests/unit/mechanisms/evm/test_upto_facilitator.py index 713173070a..1856d3eee0 100644 --- a/python/x402/tests/unit/mechanisms/evm/test_upto_facilitator.py +++ b/python/x402/tests/unit/mechanisms/evm/test_upto_facilitator.py @@ -6,6 +6,7 @@ from typing import Any from x402.mechanisms.evm import get_default_asset +from x402.mechanisms.evm.asset_cache import reset_asset_contract_cache from x402.mechanisms.evm.constants import ( ERR_ASSET_NOT_DEPLOYED_CONTRACT, ERR_ERC20_APPROVAL_BROADCAST_FAILED, @@ -201,6 +202,19 @@ def get_code(self, address: str) -> bytes: return self._code_by_address.get(address.lower(), self._code) +class _CountingAssetCodeSigner(MockFacilitatorSigner): + """Counts get_code calls for the payment token.""" + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.asset_get_code_calls = 0 + + def get_code(self, address: str) -> bytes: + if address.lower() == TOKEN_ADDRESS.lower(): + self.asset_get_code_calls += 1 + return super().get_code(address) + + class _ReceiptTimeoutSigner(MockFacilitatorSigner): """Signer whose broadcast never confirms in time (settlement_pending).""" @@ -296,6 +310,17 @@ def test_rejects_invalid_spender(self): assert result.is_valid is False assert result.invalid_reason == ERR_PERMIT2_INVALID_SPENDER + def test_invalid_spender_does_not_check_asset_contract(self): + reset_asset_contract_cache() + signer = _CountingAssetCodeSigner() + facilitator = UptoEvmFacilitatorScheme(signer) + auth = make_upto_permit2_authorization(spender="0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef") + payload = make_payment_payload(payload_dict=make_upto_payload_dict(auth)) + result = facilitator.verify(payload, make_requirements()) + assert result.is_valid is False + assert result.invalid_reason == ERR_PERMIT2_INVALID_SPENDER + assert signer.asset_get_code_calls == 0 + def test_rejects_recipient_mismatch(self): facilitator = self._make_facilitator() auth = make_upto_permit2_authorization( @@ -400,25 +425,44 @@ def test_rejects_unsupported_payload_type(self): def test_rejects_eoa_asset(self): # When the token address has no bytecode, verify must reject with asset_not_deployed_contract. + # Other tests share TOKEN_ADDRESS but model it as deployed, and positive asset checks are + # cached process-wide, so drop those entries to force a real get_code here. + reset_asset_contract_cache() + from unittest.mock import patch + facilitator = self._make_facilitator( code_by_address={TOKEN_ADDRESS.lower(): b""}, # token = EOA ) - result = facilitator.verify(make_payment_payload(), make_requirements()) + with patch( + "x402.mechanisms.evm.upto.permit2_utils._verify_upto_permit2_signature", + return_value=True, + ): + result = facilitator.verify(make_payment_payload(), make_requirements()) assert result.is_valid is False assert result.invalid_reason == ERR_ASSET_NOT_DEPLOYED_CONTRACT def test_getcode_rpc_error_raises(self): # An RPC error on get_code must propagate as an exception, not a 400 response. + from unittest.mock import patch + import pytest + reset_asset_contract_cache() + class _RPCErrorSigner(MockFacilitatorSigner): def get_code(self, address: str) -> bytes: - raise RuntimeError("rpc: connection refused") + if address.lower() == TOKEN_ADDRESS.lower(): + raise RuntimeError("rpc: connection refused") + return b"" facilitator = UptoEvmFacilitatorScheme(_RPCErrorSigner()) - with pytest.raises(RuntimeError, match="rpc: connection refused"): - facilitator.verify(make_payment_payload(), make_requirements()) + with patch( + "x402.mechanisms.evm.upto.permit2_utils._verify_upto_permit2_signature", + return_value=True, + ): + with pytest.raises(RuntimeError, match="rpc: connection refused"): + facilitator.verify(make_payment_payload(), make_requirements()) class TestSettle: diff --git a/python/x402/uv.lock b/python/x402/uv.lock index 68be0e5760..f37071eec1 100644 --- a/python/x402/uv.lock +++ b/python/x402/uv.lock @@ -3871,7 +3871,7 @@ wheels = [ [[package]] name = "x402" -version = "2.22.0" +version = "2.23.0" source = { editable = "." } dependencies = [ { name = "nest-asyncio" }, diff --git a/scripts/prepare-typescript-release.py b/scripts/prepare-typescript-release.py index 9984d839ca..bc5ee1a2f8 100755 --- a/scripts/prepare-typescript-release.py +++ b/scripts/prepare-typescript-release.py @@ -60,6 +60,7 @@ ("@x402/avm", "Publish @x402/avm package to NPM"), ("@x402/aptos", "Publish @x402/aptos package to NPM"), ("@x402/cardano", "Publish @x402/cardano package to NPM"), + ("@x402/casper", "Publish @x402/casper package to NPM"), ("@x402/stellar", "Publish @x402/stellar package to NPM"), ("@x402/hedera", "Publish @x402/hedera package to NPM"), ("@x402/tvm", "Publish @x402/tvm package to NPM"), diff --git a/scripts/tag-typescript-release.py b/scripts/tag-typescript-release.py index 35a0546d57..0d41e544f2 100755 --- a/scripts/tag-typescript-release.py +++ b/scripts/tag-typescript-release.py @@ -42,6 +42,7 @@ "@x402/avm", "@x402/aptos", "@x402/cardano", + "@x402/casper", "@x402/stellar", "@x402/hedera", "@x402/keeta", diff --git a/specs/schemes/exact/scheme_exact_casper.md b/specs/schemes/exact/scheme_exact_casper.md index 1a13a9bd0c..c15d3c65ec 100644 --- a/specs/schemes/exact/scheme_exact_casper.md +++ b/specs/schemes/exact/scheme_exact_casper.md @@ -85,7 +85,6 @@ A facilitator verifying an `exact` scheme payment on Casper MUST reject any payl - `authorization.to` MUST equal `PaymentRequirements.payTo`. - `authorization.value` MUST equal `PaymentRequirements.amount`. - Current chain time MUST satisfy `validAfter < now < validBefore` (strict bounds, per CEP-3009). - - `validBefore` MUST be equal to or later than `now + PaymentRequirements.maxTimeoutSeconds`. - `nonce` MUST NOT have previously been consumed or canceled for `from` on this token contract (CEP-3009 `authorization_state`). 4. **Verify** the token contract identified by `PaymentRequirements.asset` exists on `PaymentRequirements.network` and supports the CEP-3009 `transfer_with_authorization` entry point. 5. **Simulate (optional)** the `transfer_with_authorization` call on the token contract via Casper's `speculative_exec` RPC to ensure success. Facilitators MAY skip this step and rely on the targeted state checks above (balance, `authorization_state`, current chain time). diff --git a/typescript/.changeset/celo-usdt-default-asset.md b/typescript/.changeset/celo-usdt-default-asset.md deleted file mode 100644 index caaae0b567..0000000000 --- a/typescript/.changeset/celo-usdt-default-asset.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@x402/evm": patch ---- - -Add Celo mainnet USDT (`0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e`) and USAT (`0xD2ab3C9A02DBBAB236BfEC45D1d755DF4267F771`), both EIP-3009, as default assets for `eip155:42220`, so `"$0.10 USDT"` and `"$0.10 USAT"` resolve on Celo. Bare `"$0.10"` still resolves to USDC. diff --git a/typescript/.changeset/config.json b/typescript/.changeset/config.json index e8e3c00fe7..6070734204 100644 --- a/typescript/.changeset/config.json +++ b/typescript/.changeset/config.json @@ -12,6 +12,7 @@ "@x402/avm", "@x402/aptos", "@x402/cardano", + "@x402/casper", "@x402/stellar", "@x402/svm", "@x402/tvm", diff --git a/typescript/.changeset/evm-erc6492-settle-reuse-payer-code.md b/typescript/.changeset/evm-erc6492-settle-reuse-payer-code.md deleted file mode 100644 index c3065ac8a0..0000000000 --- a/typescript/.changeset/evm-erc6492-settle-reuse-payer-code.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@x402/evm": patch ---- - -EVM exact settle's ERC-6492 branch now reads payer deployment from the verify it already awaited rather than issuing a second eth_getCode. Both reads happen within one settle call and before any deploy transaction, so this is not the post-deploy re-read that races RPC state propagation. diff --git a/typescript/.changeset/mcp-tool-call-max-timeout-seconds.md b/typescript/.changeset/mcp-tool-call-max-timeout-seconds.md deleted file mode 100644 index 174490b986..0000000000 --- a/typescript/.changeset/mcp-tool-call-max-timeout-seconds.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@x402/mcp": patch ---- - -MCP tool calls now derive their request timeout from the accept's `maxTimeoutSeconds` (default 300s) instead of the MCP SDK's 60s default, so slow-finality settlements no longer abort mid-flight. The initial 402 probe uses a 300s ceiling unless the caller passes an explicit `timeout`. diff --git a/typescript/.changeset/mindeposit-hint-batch-settlement.md b/typescript/.changeset/mindeposit-hint-batch-settlement.md deleted file mode 100644 index 24aa7cf222..0000000000 --- a/typescript/.changeset/mindeposit-hint-batch-settlement.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@x402/core": minor -"@x402/evm": minor ---- - -Pass the resolved atomic `spendControls` cap to every scheme on `PaymentPayloadContext.maxAmountPerPayment` (omitted when uncapped) so capital-locking schemes can reuse client policy without re-resolving it. Batch-settlement EVM servers always announce `extra.minDeposit` (default `10 × amount`, optional per-route override via `accepts.extra.minDeposit`). Clients size deposits from the hint when valid, clamped to that cap × `depositMultiplier` when a spend cap is set. Uncapped payments (`spendControls: false` or no per-asset cap) also leave deposits uncapped. Older 402s fall back to `depositMultiplier` for sizing. Servers may opt in to SDK enforcement via `enforceMinDeposit: true` (default off; facilitator never enforces). Export `invalid_batch_settlement_evm_deposit_below_min_deposit` for custom server enforcement. diff --git a/typescript/.changeset/route-template-fixed-point-decode.md b/typescript/.changeset/route-template-fixed-point-decode.md deleted file mode 100644 index 9bd0e3ee0e..0000000000 --- a/typescript/.changeset/route-template-fixed-point-decode.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@x402/extensions": patch ---- - -`isValidRouteTemplate` now decodes a routeTemplate to a fixed point (up to a bounded pass budget) before running its traversal/scheme-injection checks, instead of a single decode pass. A double- or deeper-encoded payload (`%252e%252e`, `%253a%252f%252f`, ...) previously survived one decode still percent-encoded and slipped past the `..`/`://` checks, letting a malicious routeTemplate cause the facilitator to catalog a payment under an arbitrary URL. diff --git a/typescript/.changeset/svm-upto-linear-channel-backoff.md b/typescript/.changeset/svm-upto-linear-channel-backoff.md deleted file mode 100644 index 30cba81955..0000000000 --- a/typescript/.changeset/svm-upto-linear-channel-backoff.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@x402/svm": patch ---- - -SVM upto facilitator channel-account re-reads now back off linearly (200/400/600/800/1000ms across 6 reads) rather than doubling (200/400/800/1600ms across 5 reads). Replica lag behind a confirmed open is a small multiple of Solana's slot time, so the same 3.0s budget now buys one more read and caps any single wait at 1s. `UptoSvmFacilitatorConfig.channelReadMaxAttempts` and `channelReadBackoffStepMs` make it configurable. diff --git a/typescript/.changeset/upto-delegated-auth-store.md b/typescript/.changeset/upto-delegated-auth-store.md deleted file mode 100644 index b55070ea04..0000000000 --- a/typescript/.changeset/upto-delegated-auth-store.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@x402/svm": patch ---- - -SVM upto delegated claim settle now returns `invalid_upto_svm_delegated_auth_store` when the identity store Get fails, instead of collapsing that outage into unauthenticated. diff --git a/typescript/README.md b/typescript/README.md index 97106aad6d..c01b0bf7d4 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -28,6 +28,7 @@ This folder contains Typescript packages to help developers implement the x402 p | **Algorand** - [`@x402/avm`](./packages/mechanisms/avm) | AVM implementation of x402 using ASA transfers. | [![npm version](https://img.shields.io/npm/v/%40x402%2Favm.svg)](https://www.npmjs.com/package/@x402/avm) | | **Aptos** - [`@x402/aptos`](./packages/mechanisms/aptos) | Aptos implementation of the x402 payment protocol. | [![npm version](https://img.shields.io/npm/v/%40x402%2Faptos.svg)](https://www.npmjs.com/package/@x402/aptos) | | **Cardano** - [`@x402/cardano`](./packages/mechanisms/cardano) | Cardano implementation of x402 using the Exact scheme with lovelace and native-token (USDM) transfers. | [![npm version](https://img.shields.io/npm/v/%40x402%2Fcardano.svg)](https://www.npmjs.com/package/@x402/cardano) | +| **Casper** - [`@x402/casper`](./packages/mechanisms/casper) | Casper implementation of x402 using CEP-18 / CEP-3009 exact transfers with EIP-712 authorization. | [![npm version](https://img.shields.io/npm/v/%40x402%2Fcasper.svg)](https://www.npmjs.com/package/@x402/casper) | | **Stellar** - [`@x402/stellar`](./packages/mechanisms/stellar) | Stellar implementation of x402 using Soroban token transfers. | [![npm version](https://img.shields.io/npm/v/%40x402%2Fstellar.svg)](https://www.npmjs.com/package/@x402/stellar) | | **Solana** - [`@x402/svm`](./packages/mechanisms/svm) | SVM implementation of x402 using SPL token transfers. | [![npm version](https://img.shields.io/npm/v/%40x402%2Fsvm.svg)](https://www.npmjs.com/package/@x402/svm) | diff --git a/typescript/package.json b/typescript/package.json index 0ac1f49961..86d6cc8e02 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -17,7 +17,7 @@ "lint:check": "turbo run lint:check", "format:check": "turbo run format:check", "test": "turbo run test", - "test:integration": "pnpm --filter @x402/core --filter @x402/evm --filter @x402/svm --filter @x402/avm --filter @x402/aptos --filter @x402/hedera --filter @x402/keeta --filter @x402/near --filter @x402/stellar --filter @x402/xrpl --filter @x402/cardano test:integration", + "test:integration": "pnpm --filter @x402/core --filter @x402/evm --filter @x402/svm --filter @x402/avm --filter @x402/aptos --filter @x402/casper --filter @x402/hedera --filter @x402/keeta --filter @x402/near --filter @x402/stellar --filter @x402/xrpl --filter @x402/cardano test:integration", "test:all": "pnpm test && pnpm test:integration" }, "keywords": [], diff --git a/typescript/packages/core/CHANGELOG.md b/typescript/packages/core/CHANGELOG.md index ef08f53196..5f09b8fa9d 100644 --- a/typescript/packages/core/CHANGELOG.md +++ b/typescript/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @x402/core Changelog +## 2.26.0 + +### Minor Changes + +- [76fe973](https://github.com/x402-foundation/x402/commit/76fe973): Pass the resolved atomic `spendControls` cap to every scheme on `PaymentPayloadContext.maxAmountPerPayment` (omitted when uncapped) so capital-locking schemes can reuse client policy without re-resolving it. Batch-settlement EVM servers always announce `extra.minDeposit` (default `10 × amount`, optional per-route override via `accepts.extra.minDeposit`). Clients size deposits from the hint when valid, clamped to that cap × `depositMultiplier` when a spend cap is set. Uncapped payments (`spendControls: false` or no per-asset cap) also leave deposits uncapped. Older 402s fall back to `depositMultiplier` for sizing. Servers may opt in to SDK enforcement via `enforceMinDeposit: true` (default off; facilitator never enforces). Export `invalid_batch_settlement_evm_deposit_below_min_deposit` for custom server enforcement. ([#3372](https://github.com/x402-foundation/x402/pull/3372)) - Thanks [@phdargen](https://github.com/phdargen) and [@cursoragent](https://github.com/cursoragent)! + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/core/package.json b/typescript/packages/core/package.json index cf443bd966..64d80691d5 100644 --- a/typescript/packages/core/package.json +++ b/typescript/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@x402/core", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/extensions/CHANGELOG.md b/typescript/packages/extensions/CHANGELOG.md index 233c3d7f0d..8098d5e82a 100644 --- a/typescript/packages/extensions/CHANGELOG.md +++ b/typescript/packages/extensions/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/extensions Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 +- [8ae5ff6](https://github.com/x402-foundation/x402/commit/8ae5ff6): `isValidRouteTemplate` now decodes a routeTemplate to a fixed point (up to a bounded pass budget) before running its traversal/scheme-injection checks, instead of a single decode pass. A double- or deeper-encoded payload (`%252e%252e`, `%253a%252f%252f`, ...) previously survived one decode still percent-encoded and slipped past the `..`/`://` checks, letting a malicious routeTemplate cause the facilitator to catalog a payment under an arbitrary URL. ([#3213](https://github.com/x402-foundation/x402/pull/3213)) - Thanks [@ygd58](https://github.com/ygd58)! + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/extensions/package.json b/typescript/packages/extensions/package.json index ebc8c01067..7952f88c13 100644 --- a/typescript/packages/extensions/package.json +++ b/typescript/packages/extensions/package.json @@ -1,6 +1,6 @@ { "name": "@x402/extensions", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/http/axios/CHANGELOG.md b/typescript/packages/http/axios/CHANGELOG.md index aaa872c813..ffb0af8daa 100644 --- a/typescript/packages/http/axios/CHANGELOG.md +++ b/typescript/packages/http/axios/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/axios Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 +- [cbc4593](https://github.com/x402-foundation/x402/commit/cbc4593): Fixed payment-required hook URLs to use Axios base-path joining and query serialization when the transport does not expose a final response URL. ([#3454](https://github.com/x402-foundation/x402/pull/3454)) - Thanks [@sunruize93-cmyk](https://github.com/sunruize93-cmyk)! + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/http/axios/package.json b/typescript/packages/http/axios/package.json index 58b8e0adcf..b7795ed41b 100644 --- a/typescript/packages/http/axios/package.json +++ b/typescript/packages/http/axios/package.json @@ -1,6 +1,6 @@ { "name": "@x402/axios", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/axios/src/index.test.ts b/typescript/packages/http/axios/src/index.test.ts index 1fa2c2a6ca..770465e3f3 100644 --- a/typescript/packages/http/axios/src/index.test.ts +++ b/typescript/packages/http/axios/src/index.test.ts @@ -1,4 +1,4 @@ -import { +import axios, { AxiosError, AxiosHeaders, AxiosInstance, @@ -115,6 +115,7 @@ describe("wrapAxiosWithPayment()", () => { }, }, request: vi.fn(), + getUri: axios.getUri, } as unknown as AxiosInstance; // Create mock client diff --git a/typescript/packages/http/axios/src/index.ts b/typescript/packages/http/axios/src/index.ts index 703caa4039..995c058479 100644 --- a/typescript/packages/http/axios/src/index.ts +++ b/typescript/packages/http/axios/src/index.ts @@ -9,11 +9,13 @@ type AxiosHeaderRecord = Record; * Resolves the final absolute URL for an Axios 402 response. * * @param config - Original Axios request configuration + * @param axiosInstance - Axios instance used to serialize the request URL * @param response - Axios error response, if present * @returns Absolute request URL (prefers final URL after redirects) */ function resolveAxiosRequestUrl( config: InternalAxiosRequestConfig, + axiosInstance: AxiosInstance, response?: AxiosError["response"], ): string { const responseUrl = @@ -27,13 +29,13 @@ function resolveAxiosRequestUrl( const url = config.url ?? ""; if (config.baseURL) { try { - return new URL(url, config.baseURL).href; + return new URL(axiosInstance.getUri(config)).href; } catch { return url || config.baseURL; } } - return url; + return axiosInstance.getUri(config); } /** @@ -178,7 +180,7 @@ export function wrapAxiosWithPayment( } // Run payment required hooks - const requestUrl = resolveAxiosRequestUrl(originalConfig, error.response); + const requestUrl = resolveAxiosRequestUrl(originalConfig, axiosInstance, error.response); const hookHeaders = await httpClient.handlePaymentRequired(paymentRequired, requestUrl); if (hookHeaders) { const hookConfig = createX402RetryConfig(originalConfig); diff --git a/typescript/packages/http/axios/src/requestUrl.test.ts b/typescript/packages/http/axios/src/requestUrl.test.ts new file mode 100644 index 0000000000..ce9ecedf4e --- /dev/null +++ b/typescript/packages/http/axios/src/requestUrl.test.ts @@ -0,0 +1,86 @@ +import { createServer } from "node:http"; +import { AddressInfo } from "node:net"; +import axios from "axios"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { wrapAxiosWithPayment, x402Client, x402HTTPClient } from "./index"; + +describe("payment-required request URL", () => { + let origin: string; + const server = createServer((req, res) => { + if (req.headers["x-test-access"]) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ path: req.url })); + return; + } + const challenge = { + x402Version: 2, + resource: { url: `${origin}${req.url}` }, + accepts: [], + }; + res.writeHead(402, { + "payment-required": Buffer.from(JSON.stringify(challenge)).toString("base64"), + }); + res.end(); + }); + + beforeAll(async () => { + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => + server.close(error => (error ? reject(error) : resolve())), + ); + }); + + it.each([ + ["fetch", "/v1", "/quote", "/v1/quote?city=Seoul"], + ["fetch", "/v1", "quote", "/v1/quote?city=Seoul"], + ["fetch", "/v1/", "/quote", "/v1/quote?city=Seoul"], + ["fetch", "", "/quote", "/quote?city=Seoul"], + ["http", "/v1", "/quote", "/v1/quote?city=Seoul"], + ])("matches %s request with base %s and path %s", async (transport, basePath, path, expected) => { + let hookUrl: string | undefined; + const client = new x402HTTPClient(new x402Client()).onPaymentRequired(async context => { + hookUrl = context.requestUrl; + return { headers: { "X-Test-Access": "yes" } }; + }); + const api = wrapAxiosWithPayment( + axios.create({ + baseURL: basePath ? `${origin}${basePath}` : undefined, + adapter: transport, + proxy: false, + }), + client, + ); + const response = await api.get(basePath ? path : `${origin}${path}`, { + params: { city: "Seoul" }, + }); + + expect(response.data.path).toBe(expected); + expect(hookUrl).toBe(`${origin}${response.data.path}`); + }); + + it("uses the instance's default params and custom serializer", async () => { + let hookUrl: string | undefined; + const client = new x402HTTPClient(new x402Client()).onPaymentRequired(async context => { + hookUrl = context.requestUrl; + return { headers: { "X-Test-Access": "yes" } }; + }); + const api = wrapAxiosWithPayment( + axios.create({ + baseURL: `${origin}/v1`, + adapter: "fetch", + proxy: false, + params: { tag: ["a", "b"] }, + paramsSerializer: { serialize: params => `tag=${params.tag.join(",")}` }, + }), + client, + ); + const response = await api.get("/quote"); + + expect(response.data.path).toBe("/v1/quote?tag=a,b"); + expect(hookUrl).toBe(`${origin}${response.data.path}`); + }); +}); diff --git a/typescript/packages/http/express/CHANGELOG.md b/typescript/packages/http/express/CHANGELOG.md index b3b2f2f984..5c189ef137 100644 --- a/typescript/packages/http/express/CHANGELOG.md +++ b/typescript/packages/http/express/CHANGELOG.md @@ -1,5 +1,15 @@ # @x402/express Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) +- Updated dependencies [8ae5ff6](https://github.com/x402-foundation/x402/commit/8ae5ff6) + - @x402/core@2.26.0 + - @x402/extensions@2.26.0 + - @x402/paywall@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/http/express/package.json b/typescript/packages/http/express/package.json index 3d5eced1d6..23d19be566 100644 --- a/typescript/packages/http/express/package.json +++ b/typescript/packages/http/express/package.json @@ -1,6 +1,6 @@ { "name": "@x402/express", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/fastify/CHANGELOG.md b/typescript/packages/http/fastify/CHANGELOG.md index d6644485bf..1ea070bb1c 100644 --- a/typescript/packages/http/fastify/CHANGELOG.md +++ b/typescript/packages/http/fastify/CHANGELOG.md @@ -1,5 +1,15 @@ # @x402/fastify +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) +- Updated dependencies [8ae5ff6](https://github.com/x402-foundation/x402/commit/8ae5ff6) + - @x402/core@2.26.0 + - @x402/extensions@2.26.0 + - @x402/paywall@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/http/fastify/package.json b/typescript/packages/http/fastify/package.json index ee1ffb206e..dcf83cffe9 100644 --- a/typescript/packages/http/fastify/package.json +++ b/typescript/packages/http/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@x402/fastify", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/fetch/CHANGELOG.md b/typescript/packages/http/fetch/CHANGELOG.md index 30f64027f9..ccd89d6ed2 100644 --- a/typescript/packages/http/fetch/CHANGELOG.md +++ b/typescript/packages/http/fetch/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/fetch Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/http/fetch/package.json b/typescript/packages/http/fetch/package.json index fb8c453789..02e1961053 100644 --- a/typescript/packages/http/fetch/package.json +++ b/typescript/packages/http/fetch/package.json @@ -1,6 +1,6 @@ { "name": "@x402/fetch", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/hono/CHANGELOG.md b/typescript/packages/http/hono/CHANGELOG.md index 37709f2d13..f3b6f1e45b 100644 --- a/typescript/packages/http/hono/CHANGELOG.md +++ b/typescript/packages/http/hono/CHANGELOG.md @@ -1,5 +1,19 @@ # @x402/hono Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) +- Updated dependencies [8ae5ff6](https://github.com/x402-foundation/x402/commit/8ae5ff6) + - @x402/core@2.26.0 + - @x402/extensions@2.26.0 + - @x402/paywall@2.26.0 + +### Patch Changes + +- [ba7fc20](https://github.com/x402-foundation/x402/commit/ba7fc20): Preserved all values of repeated query parameters in the request adapter while keeping single values as strings. ([#3455](https://github.com/x402-foundation/x402/pull/3455)) - Thanks [@sunruize93-cmyk](https://github.com/sunruize93-cmyk)! + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/http/hono/package.json b/typescript/packages/http/hono/package.json index d07e7680b7..100fd2f546 100644 --- a/typescript/packages/http/hono/package.json +++ b/typescript/packages/http/hono/package.json @@ -1,6 +1,6 @@ { "name": "@x402/hono", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/hono/src/adapter.test.ts b/typescript/packages/http/hono/src/adapter.test.ts index d1e837415a..4399ed563d 100644 --- a/typescript/packages/http/hono/src/adapter.test.ts +++ b/typescript/packages/http/hono/src/adapter.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { Context } from "hono"; +import { Context, Hono } from "hono"; import { HonoAdapter } from "./adapter"; /** @@ -32,11 +32,12 @@ function createMockContext( method: options.method || "GET", path: url.pathname, url: url.toString(), - query: vi.fn((name?: string) => { - if (name === undefined) { - return query; - } - return query[name]; + query: vi.fn((name?: string) => (name === undefined ? query : query[name])), + queries: vi.fn((name?: string) => { + const values = Object.fromEntries( + Object.entries(query).map(([key, value]) => [key, [value]]), + ); + return name === undefined ? values : values[name]; }), json: vi.fn().mockResolvedValue(options.body), }, @@ -113,6 +114,17 @@ describe("HonoAdapter", () => { }); describe("getQueryParams", () => { + it.each([ + ["tag=a&tag=b&city=Seoul", { tag: ["a", "b"], city: "Seoul" }], + ["tag=&tag=b", { tag: ["", "b"] }], + ["tag=a%20b&tag=c%2Bd", { tag: ["a b", "c+d"] }], + ])("preserves repeated parameters in %s", async (query, expected) => { + const app = new Hono(); + app.get("/", c => c.json(new HonoAdapter(c).getQueryParams())); + const response = await app.request(`http://localhost/?${query}`); + expect(await response.json()).toEqual(expected); + }); + it("returns all query parameters", () => { const c = createMockContext({ query: { foo: "bar", baz: "qux" } }); const adapter = new HonoAdapter(c); @@ -127,6 +139,13 @@ describe("HonoAdapter", () => { }); describe("getQueryParam", () => { + it("preserves repeated values when looking up a parameter by name", async () => { + const app = new Hono(); + app.get("/", c => c.json({ tag: new HonoAdapter(c).getQueryParam("tag") })); + const response = await app.request("http://localhost/?tag=&tag=b"); + expect(await response.json()).toEqual({ tag: ["", "b"] }); + }); + it("returns single value for single param", () => { const c = createMockContext({ query: { city: "NYC" } }); const adapter = new HonoAdapter(c); diff --git a/typescript/packages/http/hono/src/adapter.ts b/typescript/packages/http/hono/src/adapter.ts index cfa4a070cf..cbe38c40fa 100644 --- a/typescript/packages/http/hono/src/adapter.ts +++ b/typescript/packages/http/hono/src/adapter.ts @@ -73,13 +73,12 @@ export class HonoAdapter implements HTTPAdapter { * @returns Record of query parameter key-value pairs */ getQueryParams(): Record { - const query = this.c.req.query(); - // Convert single values to match the interface - const result: Record = {}; - for (const [key, value] of Object.entries(query)) { - result[key] = value; - } - return result; + return Object.fromEntries( + Object.entries(this.c.req.queries()).map(([key, values]) => [ + key, + values.length === 1 ? values[0] : values, + ]), + ); } /** @@ -89,7 +88,9 @@ export class HonoAdapter implements HTTPAdapter { * @returns The query parameter value(s) or undefined */ getQueryParam(name: string): string | string[] | undefined { - return this.c.req.query(name); + const values = this.c.req.queries(name); + if (!values || values.length === 0) return undefined; + return values.length === 1 ? values[0] : values; } /** diff --git a/typescript/packages/http/next/CHANGELOG.md b/typescript/packages/http/next/CHANGELOG.md index 05c3e7e08b..ddfb1dc77d 100644 --- a/typescript/packages/http/next/CHANGELOG.md +++ b/typescript/packages/http/next/CHANGELOG.md @@ -1,5 +1,20 @@ # @x402/next Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) +- Updated dependencies [8ae5ff6](https://github.com/x402-foundation/x402/commit/8ae5ff6) + - @x402/core@2.26.0 + - @x402/extensions@2.26.0 + - @x402/paywall@2.26.0 + +### Patch Changes + +- [b444ce6](https://github.com/x402-foundation/x402/commit/b444ce6): Fixed Next.js adapter body reads consuming the request body before the route handler could read it. ([#3451](https://github.com/x402-foundation/x402/pull/3451)) - Thanks [@viviviviviid](https://github.com/viviviviviid)! +- [bb05610](https://github.com/x402-foundation/x402/commit/bb05610): Preserved leading empty values in repeated query parameters returned by the request adapter. ([#3456](https://github.com/x402-foundation/x402/pull/3456)) - Thanks [@sunruize93-cmyk](https://github.com/sunruize93-cmyk)! + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/http/next/package.json b/typescript/packages/http/next/package.json index 4505fced1d..647e6cc9a4 100644 --- a/typescript/packages/http/next/package.json +++ b/typescript/packages/http/next/package.json @@ -1,6 +1,6 @@ { "name": "@x402/next", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/http/next/src/adapter.test.ts b/typescript/packages/http/next/src/adapter.test.ts index 1e13e88f62..04708593dc 100644 --- a/typescript/packages/http/next/src/adapter.test.ts +++ b/typescript/packages/http/next/src/adapter.test.ts @@ -94,6 +94,16 @@ describe("NextAdapter", () => { }); describe("getQueryParams", () => { + it.each([ + ["tag=&tag=b", ["", "b"]], + ["tag=&tag=", ["", ""]], + ["tag=&tag=b&tag=c", ["", "b", "c"]], + ])("preserves empty values in %s", (query, expected) => { + const adapter = new NextAdapter(new NextRequest(`https://example.com/api?${query}`)); + expect(adapter.getQueryParams()).toEqual({ tag: expected }); + expect(adapter.getQueryParams().tag).toEqual(adapter.getQueryParam("tag")); + }); + it("returns all query parameters", () => { const req = createMockRequest({ url: "https://example.com/api?foo=bar&baz=qux" }); const adapter = new NextAdapter(req); @@ -134,7 +144,7 @@ describe("NextAdapter", () => { }); describe("getBody", () => { - it("returns parsed JSON body", async () => { + it("returns parsed JSON body without consuming the original request", async () => { const body = { data: "test" }; const req = new NextRequest("https://example.com/api", { method: "POST", @@ -143,6 +153,31 @@ describe("NextAdapter", () => { }); const adapter = new NextAdapter(req); expect(await adapter.getBody()).toEqual(body); + expect(await req.json()).toEqual(body); + }); + + it("returns parsed JSON body on repeated calls", async () => { + const body = { data: "test" }; + const req = new NextRequest("https://example.com/api", { + method: "POST", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); + const adapter = new NextAdapter(req); + expect(await adapter.getBody()).toEqual(body); + expect(await adapter.getBody()).toEqual(body); + }); + + it("returns undefined for invalid JSON without consuming the original request", async () => { + const body = "invalid JSON"; + const req = new NextRequest("https://example.com/api", { + method: "POST", + body, + headers: { "Content-Type": "application/json" }, + }); + const adapter = new NextAdapter(req); + expect(await adapter.getBody()).toBeUndefined(); + expect(await req.text()).toBe(body); }); it("returns undefined when body parsing fails", async () => { diff --git a/typescript/packages/http/next/src/adapter.ts b/typescript/packages/http/next/src/adapter.ts index 180ac1efc5..0472885872 100644 --- a/typescript/packages/http/next/src/adapter.ts +++ b/typescript/packages/http/next/src/adapter.ts @@ -76,7 +76,7 @@ export class NextAdapter implements HTTPAdapter { const params: Record = {}; this.req.nextUrl.searchParams.forEach((value, key) => { const existing = params[key]; - if (existing) { + if (existing !== undefined) { if (Array.isArray(existing)) { existing.push(value); } else { @@ -109,7 +109,7 @@ export class NextAdapter implements HTTPAdapter { */ async getBody(): Promise { try { - return await this.req.json(); + return await this.req.clone().json(); } catch { return undefined; } diff --git a/typescript/packages/http/paywall/CHANGELOG.md b/typescript/packages/http/paywall/CHANGELOG.md index 8c23721e2f..23d50dbf26 100644 --- a/typescript/packages/http/paywall/CHANGELOG.md +++ b/typescript/packages/http/paywall/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/paywall Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/http/paywall/package.json b/typescript/packages/http/paywall/package.json index 607ffb3b91..d50353cd41 100644 --- a/typescript/packages/http/paywall/package.json +++ b/typescript/packages/http/paywall/package.json @@ -1,6 +1,6 @@ { "name": "@x402/paywall", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", diff --git a/typescript/packages/mcp/CHANGELOG.md b/typescript/packages/mcp/CHANGELOG.md index e06a35ad7b..984ff8adfb 100644 --- a/typescript/packages/mcp/CHANGELOG.md +++ b/typescript/packages/mcp/CHANGELOG.md @@ -1,5 +1,16 @@ # @x402/mcp Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + +### Patch Changes + +- [fdeda56](https://github.com/x402-foundation/x402/commit/fdeda56): MCP tool calls derive their request timeout from the accept's `maxTimeoutSeconds` (default 300s when missing) instead of the MCP SDK's 60s default. A client-owned `maxRequestTimeoutSeconds` ceiling (default 600s) bounds hostile accepts; raise it when you need to wait longer. The initial 402 probe uses `min(300s, cap)` unless the caller passes an explicit per-call `timeout`. Auto-pay now forwards the original call options into the paid retry so accept timeouts apply. ([#3430](https://github.com/x402-foundation/x402/pull/3430)) - Thanks [@phdargen](https://github.com/phdargen)! + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mcp/README.md b/typescript/packages/mcp/README.md index 85dd7bcbfe..1334567540 100644 --- a/typescript/packages/mcp/README.md +++ b/typescript/packages/mcp/README.md @@ -273,6 +273,7 @@ For tool results, the client checks `result.structuredContent` first, then falls |--------|------|---------|-------------| | `autoPayment` | `boolean` | `true` | Automatically retry with payment when payment is required | | `onPaymentRequested` | `function` | `() => true` | Hook for human-in-the-loop approval when payment is requested | +| `maxRequestTimeoutSeconds` | `number` | `600` | Cap on derived MCP waits from accept `maxTimeoutSeconds` | ### MCPToolPaymentConfig diff --git a/typescript/packages/mcp/package.json b/typescript/packages/mcp/package.json index 25313ac359..4cc4645a75 100644 --- a/typescript/packages/mcp/package.json +++ b/typescript/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@x402/mcp", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mcp/src/client/x402MCPClient.ts b/typescript/packages/mcp/src/client/x402MCPClient.ts index 742770f15d..79ae9a649f 100644 --- a/typescript/packages/mcp/src/client/x402MCPClient.ts +++ b/typescript/packages/mcp/src/client/x402MCPClient.ts @@ -29,6 +29,94 @@ import { } from "../types"; import { extractPaymentResponseFromMeta } from "../utils"; +const MAX_TIMEOUT_MS = 2_147_483_647; +const DEFAULT_PROBE_TIMEOUT_SECONDS = 300; +const DEFAULT_ACCEPT_TIMEOUT_SECONDS = 300; +const DEFAULT_MAX_REQUEST_TIMEOUT_SECONDS = 600; + +/** + * Normalizes client maxRequestTimeoutSeconds (default 600). + * + * @param explicit - Constructor option + * @returns Cap in seconds + */ +function resolveMaxRequestTimeoutSeconds(explicit: number | undefined): number { + if (explicit === undefined) { + return DEFAULT_MAX_REQUEST_TIMEOUT_SECONDS; + } + if (!Number.isFinite(explicit) || explicit <= 0) { + throw new Error( + `maxRequestTimeoutSeconds must be a positive finite number, got ${explicit}`, + ); + } + return explicit; +} + +/** + * Accept maxTimeoutSeconds when valid, else 300. + * + * @param maxTimeoutSeconds - From payment accept + * @returns Timeout in seconds + */ +function effectiveAcceptTimeoutSeconds(maxTimeoutSeconds: number | undefined): number { + if ( + maxTimeoutSeconds !== undefined && + Number.isFinite(maxTimeoutSeconds) && + maxTimeoutSeconds > 0 + ) { + return maxTimeoutSeconds; + } + return DEFAULT_ACCEPT_TIMEOUT_SECONDS; +} + +/** + * Converts seconds to milliseconds capped for timer safety. + * + * @param seconds - Duration in seconds + * @returns Milliseconds + */ +function clampTimeoutMs(seconds: number): number { + const ms = Math.floor(seconds * 1000); + return Math.min(ms, MAX_TIMEOUT_MS); +} + +/** + * Probe call timeout in milliseconds. + * + * @param perCallTimeoutMs - Explicit callTool timeout override + * @param capSeconds - Client maxRequestTimeoutSeconds + * @returns Milliseconds for MCP SDK + */ +function probeTimeoutMs( + perCallTimeoutMs: number | undefined, + capSeconds: number, +): number { + if (perCallTimeoutMs !== undefined) { + return perCallTimeoutMs; + } + return clampTimeoutMs(Math.min(DEFAULT_PROBE_TIMEOUT_SECONDS, capSeconds)); +} + +/** + * Paid retry timeout in milliseconds. + * + * @param perCallTimeoutMs - Explicit callTool timeout override + * @param maxTimeoutSeconds - From signed accept + * @param capSeconds - Client maxRequestTimeoutSeconds + * @returns Milliseconds for MCP SDK + */ +function paidTimeoutMs( + perCallTimeoutMs: number | undefined, + maxTimeoutSeconds: number | undefined, + capSeconds: number, +): number { + if (perCallTimeoutMs !== undefined) { + return perCallTimeoutMs; + } + const acceptSeconds = effectiveAcceptTimeoutSeconds(maxTimeoutSeconds); + return clampTimeoutMs(Math.min(acceptSeconds, capSeconds)); +} + // ============================================================================ // MCP SDK Result Types // ============================================================================ @@ -172,7 +260,9 @@ export interface x402MCPToolCallResult { export class x402MCPClient { private readonly mcpClient: Client; private readonly _paymentClient: x402Client; - private readonly options: Required; + private readonly options: Required> & { + maxRequestTimeoutSeconds: number; + }; private readonly paymentRequiredHooks: PaymentRequiredHook[] = []; private readonly beforePaymentHooks: BeforePaymentHook[] = []; private readonly afterPaymentHooks: AfterPaymentHook[] = []; @@ -194,6 +284,9 @@ export class x402MCPClient { this.options = { autoPayment: options.autoPayment ?? true, onPaymentRequested: options.onPaymentRequested ?? (() => true), + maxRequestTimeoutSeconds: resolveMaxRequestTimeoutSeconds( + options.maxRequestTimeoutSeconds, + ), }; } @@ -471,7 +564,11 @@ export class x402MCPClient { args: Record = {}, options?: { timeout?: number; signal?: AbortSignal; resetTimeoutOnProgress?: boolean }, ): Promise { - const probeOptions = { ...options, timeout: options?.timeout ?? 300_000 }; + const capSeconds = this.options.maxRequestTimeoutSeconds; + const probeOptions = { + ...options, + timeout: probeTimeoutMs(options?.timeout, capSeconds), + }; // First attempt without payment let result: MCPCallToolResult; @@ -527,7 +624,7 @@ export class x402MCPClient { } if (hookResult.payment) { // Use the hook-provided payment - return this.callToolWithPayment(name, args, hookResult.payment, probeOptions); + return this.callToolWithPayment(name, args, hookResult.payment, options); } } } @@ -566,7 +663,7 @@ export class x402MCPClient { const paymentPayload = await this._paymentClient.createPaymentPayload(paymentRequired); // Retry with payment - return this.callToolWithPayment(name, args, paymentPayload, probeOptions); + return this.callToolWithPayment(name, args, paymentPayload, options); } /** @@ -592,9 +689,11 @@ export class x402MCPClient { ): Promise { const paidOptions = { ...options, - timeout: - options?.timeout ?? - (paymentPayload.accepted?.maxTimeoutSeconds ?? 300) * 1000, + timeout: paidTimeoutMs( + options?.timeout, + paymentPayload.accepted?.maxTimeoutSeconds, + this.options.maxRequestTimeoutSeconds, + ), }; // Build the call parameters with payment metadata @@ -693,8 +792,11 @@ export class x402MCPClient { }; const retryPaidOptions = { ...options, - timeout: - options?.timeout ?? (freshPayload.accepted?.maxTimeoutSeconds ?? 300) * 1000, + timeout: paidTimeoutMs( + options?.timeout, + freshPayload.accepted?.maxTimeoutSeconds, + this.options.maxRequestTimeoutSeconds, + ), }; const retryResult = await this.mcpClient.callTool(retryCallParams, undefined, retryPaidOptions); diff --git a/typescript/packages/mcp/src/types/mcp.ts b/typescript/packages/mcp/src/types/mcp.ts index 90a724ace0..951141a748 100644 --- a/typescript/packages/mcp/src/types/mcp.ts +++ b/typescript/packages/mcp/src/types/mcp.ts @@ -173,6 +173,9 @@ export interface x402MCPClientOptions { * This can be used to implement human-in-the-loop approval. */ onPaymentRequested?: (context: PaymentRequestedContext) => Promise | boolean; + + /** Cap on derived MCP waits from accept `maxTimeoutSeconds`. @default 600 */ + maxRequestTimeoutSeconds?: number; } /** diff --git a/typescript/packages/mcp/test/unit/client.test.ts b/typescript/packages/mcp/test/unit/client.test.ts index 10fbb7c9c2..7865694889 100644 --- a/typescript/packages/mcp/test/unit/client.test.ts +++ b/typescript/packages/mcp/test/unit/client.test.ts @@ -1438,4 +1438,131 @@ describe("x402MCPClient McpError(-32042) handling", () => { await expect(client.getToolPaymentRequirements("tool")).rejects.toThrow(networkError); }); }); + + describe("callTool - request timeouts", () => { + /** + * Builds PaymentRequired with a custom accept timeout. + * + * @param maxTimeoutSeconds - Accept timeout hint + * @returns PaymentRequired fixture + */ + function paymentRequiredWithTimeout(maxTimeoutSeconds: number): PaymentRequired { + return { + ...mockPaymentRequired, + accepts: [{ ...mockPaymentRequired.accepts[0], maxTimeoutSeconds }], + }; + } + + /** + * Builds PaymentPayload with a custom accept timeout. + * + * @param maxTimeoutSeconds - Accept timeout hint + * @returns PaymentPayload fixture + */ + function payloadWithTimeout(maxTimeoutSeconds: number): PaymentPayload { + return { + ...mockPaymentPayload, + accepted: { ...mockPaymentRequired.accepts[0], maxTimeoutSeconds }, + }; + } + + /** + * Reads callTool options.timeout from a mock call record. + * + * @param call - mockMcpClient.callTool mock invocation + * @returns timeout ms from third argument + */ + function thirdArgTimeout(call: unknown): number | undefined { + const args = call as [unknown, unknown, { timeout?: number } | undefined]; + return args[2]?.timeout; + } + + it("uses 300s probe timeout by default", async () => { + mockMcpClient.callTool.mockResolvedValue({ + content: [{ type: "text", text: "pong" }], + isError: false, + }); + + await client.callTool("ping"); + + expect(thirdArgTimeout(mockMcpClient.callTool.mock.calls[0])).toBe(300_000); + }); + + it("sizes paid retry from accept maxTimeoutSeconds up to the client cap", async () => { + mockMcpClient.callTool + .mockResolvedValueOnce(createEmbeddedPaymentError(paymentRequiredWithTimeout(120))) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "ok" }], + _meta: { "x402/payment-response": mockSettleResponse }, + }); + mockPaymentClient.createPaymentPayload.mockResolvedValueOnce(payloadWithTimeout(120)); + + await client.callTool("paid_tool"); + + expect(thirdArgTimeout(mockMcpClient.callTool.mock.calls[0])).toBe(300_000); + expect(thirdArgTimeout(mockMcpClient.callTool.mock.calls[1])).toBe(120_000); + }); + + it("waits up to 600s when accept maxTimeoutSeconds is 600", async () => { + mockMcpClient.callTool + .mockResolvedValueOnce(createEmbeddedPaymentError(paymentRequiredWithTimeout(600))) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "ok" }], + _meta: { "x402/payment-response": mockSettleResponse }, + }); + mockPaymentClient.createPaymentPayload.mockResolvedValueOnce(payloadWithTimeout(600)); + + await client.callTool("paid_tool"); + + expect(thirdArgTimeout(mockMcpClient.callTool.mock.calls[1])).toBe(600_000); + }); + + it("clamps hostile accept maxTimeoutSeconds to the default 600s cap", async () => { + mockMcpClient.callTool + .mockResolvedValueOnce(createEmbeddedPaymentError(paymentRequiredWithTimeout(1_000_000))) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "ok" }], + _meta: { "x402/payment-response": mockSettleResponse }, + }); + mockPaymentClient.createPaymentPayload.mockResolvedValueOnce(payloadWithTimeout(1_000_000)); + + await client.callTool("paid_tool"); + + expect(thirdArgTimeout(mockMcpClient.callTool.mock.calls[1])).toBe(600_000); + }); + + it("allows a longer accept when maxRequestTimeoutSeconds is raised", async () => { + const cappedClient = new x402MCPClient( + mockMcpClient as unknown as Parameters[0], + mockPaymentClient as unknown as Parameters[1], + { maxRequestTimeoutSeconds: 900 }, + ); + mockMcpClient.callTool + .mockResolvedValueOnce(createEmbeddedPaymentError(paymentRequiredWithTimeout(900))) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "ok" }], + _meta: { "x402/payment-response": mockSettleResponse }, + }); + mockPaymentClient.createPaymentPayload.mockResolvedValueOnce(payloadWithTimeout(900)); + + await cappedClient.callTool("paid_tool"); + + expect(thirdArgTimeout(mockMcpClient.callTool.mock.calls[1])).toBe(900_000); + }); + + it("uses per-call timeout instead of probe or accept defaults", async () => { + mockMcpClient.callTool + .mockResolvedValueOnce(createEmbeddedPaymentError(paymentRequiredWithTimeout(600))) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "ok" }], + _meta: { "x402/payment-response": mockSettleResponse }, + }); + mockPaymentClient.createPaymentPayload.mockResolvedValueOnce(payloadWithTimeout(600)); + + await client.callTool("paid_tool", {}, { timeout: 12_000 }); + + expect(thirdArgTimeout(mockMcpClient.callTool.mock.calls[0])).toBe(12_000); + expect(thirdArgTimeout(mockMcpClient.callTool.mock.calls[1])).toBe(12_000); + }); + }); }); diff --git a/typescript/packages/mechanisms/aptos/CHANGELOG.md b/typescript/packages/mechanisms/aptos/CHANGELOG.md index ada2fbc8ec..00d7d23a09 100644 --- a/typescript/packages/mechanisms/aptos/CHANGELOG.md +++ b/typescript/packages/mechanisms/aptos/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/aptos +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/aptos/package.json b/typescript/packages/mechanisms/aptos/package.json index 552e540235..7949b49ccb 100644 --- a/typescript/packages/mechanisms/aptos/package.json +++ b/typescript/packages/mechanisms/aptos/package.json @@ -1,6 +1,6 @@ { "name": "@x402/aptos", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/avm/CHANGELOG.md b/typescript/packages/mechanisms/avm/CHANGELOG.md index 5be9feb25e..2a4ef5ded6 100644 --- a/typescript/packages/mechanisms/avm/CHANGELOG.md +++ b/typescript/packages/mechanisms/avm/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/avm +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/avm/package.json b/typescript/packages/mechanisms/avm/package.json index 6b0f905911..a61e643cc9 100644 --- a/typescript/packages/mechanisms/avm/package.json +++ b/typescript/packages/mechanisms/avm/package.json @@ -1,6 +1,6 @@ { "name": "@x402/avm", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/cardano/CHANGELOG.md b/typescript/packages/mechanisms/cardano/CHANGELOG.md new file mode 100644 index 0000000000..f00df4a855 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/CHANGELOG.md @@ -0,0 +1,6 @@ +# @x402/cardano + +## 2.26.0 + +- Implement x402 v2 protocol support for the Cardano mechanism (exact scheme). + diff --git a/typescript/packages/mechanisms/cardano/package.json b/typescript/packages/mechanisms/cardano/package.json index 26ec3a3822..ad7d818384 100644 --- a/typescript/packages/mechanisms/cardano/package.json +++ b/typescript/packages/mechanisms/cardano/package.json @@ -1,6 +1,6 @@ { "name": "@x402/cardano", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/casper/.prettierrc b/typescript/packages/mechanisms/casper/.prettierrc new file mode 100644 index 0000000000..ffb416b74b --- /dev/null +++ b/typescript/packages/mechanisms/casper/.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/typescript/packages/mechanisms/casper/CHANGELOG.md b/typescript/packages/mechanisms/casper/CHANGELOG.md new file mode 100644 index 0000000000..28471d84d9 --- /dev/null +++ b/typescript/packages/mechanisms/casper/CHANGELOG.md @@ -0,0 +1,5 @@ +# @x402/casper + +## 2.26.0 + +- [f4c3f61](https://github.com/x402-foundation/x402/commit/f4c3f61): Add TypeScript support for the Casper exact payment mechanism. ([#2877](https://github.com/x402-foundation/x402/pull/2877)) - Thanks [@davidatwhiletrue](https://github.com/davidatwhiletrue)! diff --git a/typescript/packages/mechanisms/casper/README.md b/typescript/packages/mechanisms/casper/README.md new file mode 100644 index 0000000000..849a1e0611 --- /dev/null +++ b/typescript/packages/mechanisms/casper/README.md @@ -0,0 +1,113 @@ +# @x402/casper + +TypeScript implementation of the x402 v2 `exact` scheme for Casper CEP-18 tokens with CEP-3009 `transfer_with_authorization`. + +## Install + +```bash +pnpm add @x402/casper +``` + +## Network Identifiers + +- `casper:casper` - Casper Mainnet +- `casper:casper-test` - Casper Testnet + +## Address And Asset Formats + +- `asset`: 32-byte contract package hash as 64 hex characters. Do not include `0x` or `hash-`. +- `payTo`: 33-byte Casper address as 66 hex characters. Use the `00` account-hash prefix or `01` package-hash prefix. +- `extra.name`: required CEP-18 token name for the CEP-3009 EIP-712 domain. +- `extra.version`: required CEP-3009 EIP-712 domain version. + +## Client + +```ts +import { x402Client } from "@x402/core/client"; +import { createClientCasperSigner } from "@x402/casper"; +import { ExactCasperScheme } from "@x402/casper/exact/client"; + +const signer = await createClientCasperSigner(process.env.CASPER_CLIENT_PRIVATE_KEY!); +const client = new x402Client().register("casper:*", new ExactCasperScheme(signer)); +``` + +## Server + +```ts +import { x402ResourceServer } from "@x402/core/server"; +import { ExactCasperScheme } from "@x402/casper/exact/server"; + +const server = new x402ResourceServer(facilitatorClient); +server.register("casper:*", new ExactCasperScheme()); +``` + +Payment requirements must include the Casper EIP-712 domain fields: + +```ts +{ + scheme: "exact", + network: "casper:casper-test", + payTo: "00...", + price: { + amount: "1", + asset: "0cb6f94834c60510d532b0ae077b18b4100874a4c867396d61c2b13c790ead52", + extra: { + name: "csprUSD", + version: "1", + }, + }, +} +``` + +The csprUSD testnet asset above is registered as the Casper testnet default asset. If you use a different asset in payment requirements, or a payment amount above the default client spend cap, configure client spend controls with `allowedAssets` or disable spend controls explicitly. + +## Facilitator + +```ts +import { x402Facilitator } from "@x402/core/facilitator"; +import { createFacilitatorCasperSigner } from "@x402/casper"; +import { ExactCasperScheme } from "@x402/casper/exact/facilitator"; +import { KeyAlgorithm } from "casper-js-sdk"; + +const signer = await createFacilitatorCasperSigner( + process.env.CASPER_FACILITATOR_PRIVATE_KEY!, + KeyAlgorithm.ED25519, + { + rpcUrlConfig: { "casper:casper-test": "https://node.testnet.casper.network/rpc" }, + speculativeRpcUrlConfig: process.env.CASPER_SPECULATIVE_RPC_URL + ? { "casper:casper-test": process.env.CASPER_SPECULATIVE_RPC_URL } + : undefined, + }, +); + +const facilitator = new x402Facilitator().register("casper:*", new ExactCasperScheme(signer)); +``` + +Facilitator `verify()` always performs live preflight checks after validating the payment shape and signature. If `speculativeRpcUrlConfig` contains a URL for the payment network, `verify()` runs Casper speculative execution against that endpoint and skips preflight checks; the simulation covers balance, nonce reuse, and contract entry-point failures. The speculative endpoint is network-specific and is often exposed separately from standard node JSON-RPC, commonly on port `7778`. + +When no speculative RPC URL is configured for the payment network, `verify()` uses `rpcUrlConfig` or the SDK's default network RPC URL to run targeted checks: CEP-18 payer balance, CEP-3009 `authorization_state`, and support for `transfer_with_authorization` on the token package's active contract. + +## Integration Tests + +Live integration tests require a funded Casper testnet account and a CEP-3009-enabled CEP-18 token: + +Create or reuse a dedicated Casper testnet account or wallet key, then fund any account that submits Casper transactions with testnet CSPR from the [CSPR.live testnet faucet](https://testnet.cspr.live/tools/faucet). CSPR is required for gas on Casper Testnet. + +Use [testnet.cspr.trade](https://testnet.cspr.trade) to get wrapped CSPR (WCSPR) or csprUSD for the client payments. + +```bash +CASPER_RUN_LIVE=1 \ +CASPER_CLIENT_PRIVATE_KEY=... \ +CASPER_CLIENT_PRIVATE_KEY_ALGORITHM=secp256k1 \ +CASPER_FACILITATOR_PRIVATE_KEY=... \ +CASPER_FACILITATOR_PRIVATE_KEY_ALGORITHM=secp256k1 \ +CASPER_PAY_TO=00... \ +CASPER_ASSET=0cb6f94834c60510d532b0ae077b18b4100874a4c867396d61c2b13c790ead52 \ +CASPER_TOKEN_NAME="csprUSD" \ +CASPER_TOKEN_VERSION=1 \ +CASPER_NETWORK=casper:casper-test \ +CASPER_RPC_URL=https://node.testnet.casper.network/rpc \ +pnpm --filter @x402/casper test:integration +``` + +`CASPER_CLIENT_PRIVATE_KEY_ALGORITHM` and `CASPER_FACILITATOR_PRIVATE_KEY_ALGORITHM` are optional for ED25519 keys; set them to `secp256k1` for secp256k1 keys. `CASPER_SPECEXEC_RPC_URL` is optional and enables speculative execution during live verification when a Casper speculative execution endpoint is available. diff --git a/typescript/packages/mechanisms/casper/eslint.config.mjs b/typescript/packages/mechanisms/casper/eslint.config.mjs new file mode 100644 index 0000000000..a77672e000 --- /dev/null +++ b/typescript/packages/mechanisms/casper/eslint.config.mjs @@ -0,0 +1,74 @@ +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 sonarjs from "eslint-plugin-sonarjs"; + +export default [ + { + ignores: ["dist/**", "node_modules/**"], + }, + { + files: ["**/*.ts", "**/*.tsx"], + ignores: ["**/*.test.ts", "test/**/*"], + 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", + crypto: "readonly", + }, + }, + plugins: { + "@typescript-eslint": ts, + prettier: prettier, + sonarjs, + }, + rules: { + ...js.configs.recommended.rules, + ...ts.configs.recommended.rules, + complexity: ["error", 50], + "max-lines": ["error", { max: 2000 }], + "sonarjs/cognitive-complexity": ["error", 50], + "prettier/prettier": "error", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + }, + }, + { + files: ["**/*.test.ts", "test/**/*"], + languageOptions: { + parser: tsParser, + sourceType: "module", + ecmaVersion: 2020, + globals: { + Buffer: "readonly", + console: "readonly", + process: "readonly", + structuredClone: "readonly", + }, + }, + plugins: { + "@typescript-eslint": ts, + prettier: prettier, + sonarjs, + }, + rules: { + ...js.configs.recommended.rules, + "prettier/prettier": "error", + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "off", + }, + }, +]; diff --git a/typescript/packages/mechanisms/casper/package.json b/typescript/packages/mechanisms/casper/package.json new file mode 100644 index 0000000000..ee0ac8ec3d --- /dev/null +++ b/typescript/packages/mechanisms/casper/package.json @@ -0,0 +1,98 @@ +{ + "name": "@x402/casper", + "version": "2.26.0", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/cjs/index.d.ts", + "scripts": { + "start": "tsx --env-file=.env index.ts", + "build": "tsup", + "test": "vitest run --coverage", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:watch": "vitest", + "watch": "tsc --watch", + "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" + }, + "keywords": [ + "x402", + "payment", + "protocol", + "casper" + ], + "license": "Apache-2.0", + "author": "x402 Foundation", + "repository": "https://github.com/x402-foundation/x402", + "description": "x402 Payment Protocol Casper Implementation", + "devDependencies": { + "@eslint/js": "^9.24.0", + "@types/node": "^22.13.4", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "@vitest/coverage-v8": "^3.2.7", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsdoc": "^50.6.9", + "eslint-plugin-prettier": "^5.2.6", + "eslint-plugin-sonarjs": "^3.0.7", + "prettier": "3.5.2", + "tsup": "^8.4.0", + "tsx": "^4.21.0", + "typescript": "^5.7.3", + "vite": "^6.2.6", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.7" + }, + "dependencies": { + "@casper-ecosystem/casper-eip-712": "1.2.1", + "@x402/core": "workspace:~", + "casper-js-sdk": "5.1.1" + }, + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.mts", + "default": "./dist/esm/index.mjs" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./exact/client": { + "import": { + "types": "./dist/esm/exact/client/index.d.mts", + "default": "./dist/esm/exact/client/index.mjs" + }, + "require": { + "types": "./dist/cjs/exact/client/index.d.ts", + "default": "./dist/cjs/exact/client/index.js" + } + }, + "./exact/server": { + "import": { + "types": "./dist/esm/exact/server/index.d.mts", + "default": "./dist/esm/exact/server/index.mjs" + }, + "require": { + "types": "./dist/cjs/exact/server/index.d.ts", + "default": "./dist/cjs/exact/server/index.js" + } + }, + "./exact/facilitator": { + "import": { + "types": "./dist/esm/exact/facilitator/index.d.mts", + "default": "./dist/esm/exact/facilitator/index.mjs" + }, + "require": { + "types": "./dist/cjs/exact/facilitator/index.d.ts", + "default": "./dist/cjs/exact/facilitator/index.js" + } + } + }, + "files": [ + "dist" + ] +} diff --git a/typescript/packages/mechanisms/casper/src/constants.ts b/typescript/packages/mechanisms/casper/src/constants.ts new file mode 100644 index 0000000000..f494787dfc --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/constants.ts @@ -0,0 +1,75 @@ +/** + * Casper mainnet CAIP-2 network identifier. + */ +export const CASPER_MAINNET_CAIP2 = "casper:casper"; + +/** + * Casper testnet CAIP-2 network identifier. + */ +export const CASPER_TESTNET_CAIP2 = "casper:casper-test"; + +/** + * Casper CAIP-2 family wildcard. + */ +export const CASPER_CAIP2_FAMILY = "casper:*"; + +/** + * Exact payment scheme identifier. + */ +export const SCHEME_EXACT = "exact"; + +/** + * Default Casper payment amount, in motes, for settlement transactions. + */ +export const DEFAULT_PAYMENT_MOTES = 2_500_000_000; + +/** + * Default network configurations keyed by CAIP-2 network identifier. + */ +export const NetworkConfigs: Record = { + [CASPER_MAINNET_CAIP2]: { + chainName: "casper", + rpcUrl: "https://node.mainnet.casper.network/rpc", + }, + [CASPER_TESTNET_CAIP2]: { + chainName: "casper-test", + rpcUrl: "https://node.testnet.casper.network/rpc", + }, +}; + +/** + * csprUSD package address on Mainnet. + */ +export const CSPR_USDC_MAINNET_ASSET = + "23036be872bd574590a2c43d4a4eff76b18b4bca815790742841002fdab22cee"; + +/** + * csprUSD package address on Testnet. + */ +export const CSPR_USDC_TESTNET_ASSET = + "0cb6f94834c60510d532b0ae077b18b4100874a4c867396d61c2b13c790ead52"; + +/** + * csprUSD decimals. + */ +export const CSPR_USDC_DECIMALS = 6; + +/** + * csprUSD contract name. + */ +export const CSPR_USDC_NAME = "csprUSD"; + +/** + * csprUSD symbol. + */ +export const CSPR_USDC_SYMBOL = "csprUSD"; + +/** + * Configuration for a Casper network. + */ +export type NetworkConfig = { + /** Chain name used in transaction payloads, e.g. "casper" or "casper-test". */ + chainName: string; + /** Default JSON-RPC endpoint for the network. */ + rpcUrl: string; +}; diff --git a/typescript/packages/mechanisms/casper/src/contracts.ts b/typescript/packages/mechanisms/casper/src/contracts.ts new file mode 100644 index 0000000000..5b6590d534 --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/contracts.ts @@ -0,0 +1,175 @@ +import { + Contract, + Key, + ParamDictionaryIdentifier, + ParamDictionaryIdentifierContractNamedKey, + RpcClient, + StateGetDictionaryResult, +} from "casper-js-sdk"; + +import { hexToBytes } from "./utils"; + +type CasperContractHash = { + hash?: { toHex(): string }; + toJSON?(): string; +}; + +type CasperContractVersion = { + contractHash: CasperContractHash; + contractVersion: number; + protocolVersionMajor: number; +}; + +type CasperContractPackage = { + disabledVersions?: number[][]; + versions?: CasperContractVersion[]; +}; + +type CasperStateResult = { + storedValue: { + contractPackage?: CasperContractPackage; + contract?: CasperContract; + }; +}; + +type CasperEntryPoint = { + name?: string; +}; + +type CasperContract = { + entryPoints?: CasperEntryPoint[]; +}; + +export async function getActiveContractForToken( + rpcClient: InstanceType, + assetKey: string, +): Promise { + const packageState = await rpcClient.queryLatestGlobalState(`hash-${assetKey}`, []); + + const contractKey = findActiveContractHash(packageState); + try { + const contractState = await rpcClient.queryLatestGlobalState(contractKey, []); + if (!contractState.storedValue.contract) { + throw new Error("active contract not found"); + } + return { ...contractState.storedValue.contract, contractHash: contractKey }; + } catch (error) { + throw new Error( + `failed to get active contract for token: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function contractVersionHash(version: CasperContractVersion): string { + const hash = version.contractHash.hash?.toHex() ?? version.contractHash.toJSON?.(); + if (!hash) { + throw new Error("contract version is missing contract hash"); + } + return hash.replace(/^(hash-|contract-)/, ""); +} + +function findActiveContractHash(packageState: CasperStateResult): string { + if (packageState.storedValue.contractPackage) { + const contractPackage = packageState.storedValue.contractPackage; + + const versions = contractPackage?.versions ?? []; + if (versions.length === 0) { + throw new Error("contract package has no active versions"); + } + + const disabledVersions = new Set( + (contractPackage?.disabledVersions ?? []).map( + ([protocolVersionMajor, contractVersion]) => `${protocolVersionMajor}:${contractVersion}`, + ), + ); + const activeVersions = versions.filter( + version => + !disabledVersions.has(`${version.protocolVersionMajor}:${version.contractVersion}`), + ); + if (activeVersions.length === 0) { + throw new Error("contract package has no enabled versions"); + } + + const activeVersionHash = activeVersions.reduce((selected, version) => + version.contractVersion > selected.contractVersion ? version : selected, + ); + return `hash-${contractVersionHash(activeVersionHash)}`; + } + throw new Error("token package not found"); +} + +export async function readDictionaryU256OrDefault( + rpcClient: InstanceType, + contractHash: string, + dictionaryName: string, + itemKey: string, +): Promise { + try { + const result = await readDictionaryItem(rpcClient, contractHash, dictionaryName, itemKey); + const value = result.storedValue.clValue?.ui256?.toString(); + if (value && /^\d+$/.test(value)) { + return BigInt(value); + } + } catch (error) { + if (isMissingDictionaryItem(error)) { + return 0n; + } + throw error; + } + throw new Error("invalid U256 dictionary value"); +} + +async function readDictionaryItem( + rpcClient: InstanceType, + contractKey: string, + dictionaryName: string, + itemKey: string, +) { + return rpcClient.getDictionaryItemByIdentifier( + null, + new ParamDictionaryIdentifier( + undefined, + new ParamDictionaryIdentifierContractNamedKey(contractKey, dictionaryName, itemKey), + ), + ); +} + +function hasSourceErr(error: unknown): error is { sourceErr: { data?: unknown } } { + return typeof error === "object" && error !== null && "sourceErr" in error; +} + +export function isMissingDictionaryItem(error: unknown): boolean { + return hasSourceErr(error) && /dictionary URef not found/i.test(String(error.sourceErr?.data)); +} + +export function dictionaryKeyForAddress(address: string): string { + const key = Key.fromBytes(hexToBytes(address)).result; + return Buffer.from(key.bytes()).toString("base64"); +} + +export function dictionaryKeyForUsedNonces(payer: string, nonce: string): string { + const payerKey = Key.fromBytes(hexToBytes(payer)).result; + return Buffer.concat([Buffer.from(payerKey.bytes()), Buffer.from(hexToBytes(nonce))]).toString( + "base64", + ); +} + +export async function readDictionaryBool( + rpcClient: InstanceType, + contractKey: string, + dictionaryName: string, + itemKey: string, +): Promise { + return parseUsedNonceResult( + await readDictionaryItem(rpcClient, contractKey, dictionaryName, itemKey), + ); +} + +function parseUsedNonceResult(result: StateGetDictionaryResult): boolean { + const boolValue = result.storedValue.clValue?.bool?.getValue(); + if (typeof boolValue === "boolean") { + return boolValue; + } + + throw new Error("invalid used_nonces dictionary value"); +} diff --git a/typescript/packages/mechanisms/casper/src/defaultAssets.ts b/typescript/packages/mechanisms/casper/src/defaultAssets.ts new file mode 100644 index 0000000000..897b9c9e76 --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/defaultAssets.ts @@ -0,0 +1,84 @@ +import type { + DefaultAsset, + DefaultAssetTable, + FindDefaultAsset, + GetDefaultAsset, +} from "@x402/core/types"; +import { + CASPER_MAINNET_CAIP2, + CASPER_TESTNET_CAIP2, + CSPR_USDC_DECIMALS, + CSPR_USDC_MAINNET_ASSET, + CSPR_USDC_NAME, + CSPR_USDC_SYMBOL, + CSPR_USDC_TESTNET_ASSET, +} from "./constants"; + +export type CasperDefaultAsset = DefaultAsset & { + /** EIP-712 domain name (must match the token's domain separator) */ + name: string; + /** EIP-712 domain version (must match the token's domain separator) */ + version: string; +}; + +/** Default USD-pegged assets by CAIP-2 network; index 0 is the `"$0.10"` default. */ +export const DEFAULT_ASSETS: DefaultAssetTable = { + [CASPER_MAINNET_CAIP2]: [ + { + asset: CSPR_USDC_MAINNET_ASSET, + decimals: CSPR_USDC_DECIMALS, + name: CSPR_USDC_NAME, + symbol: CSPR_USDC_SYMBOL, + version: "1", + }, + ], + [CASPER_TESTNET_CAIP2]: [ + { + asset: CSPR_USDC_TESTNET_ASSET, + decimals: CSPR_USDC_DECIMALS, + name: CSPR_USDC_NAME, + symbol: CSPR_USDC_SYMBOL, + version: "1", + }, + ], +}; + +/** + * Look up a default asset by network and optional ticker. + * + * @param network - CAIP-2 network + * @param symbol - Ticker; omit for the network default + * @returns Matching entry + * @throws If network or ticker is unknown + */ +export const getDefaultAsset: GetDefaultAsset = (network, symbol?) => { + const assets = DEFAULT_ASSETS[network]; + if (!assets || assets.length === 0) { + throw new Error(`No default asset configured for network ${network}`); + } + if (!symbol) { + return assets[0]; + } + const normalized = symbol.toUpperCase(); + const match = assets.find(entry => entry.symbol.toUpperCase() === normalized); + if (!match) { + throw new Error(`No ${symbol} default asset configured for network ${network}`); + } + return match; +}; + +/** + * Reverse lookup by asset id and network. + * + * @param asset - contract package hash from payment requirements + * @param network - CAIP-2 network + * @returns Matching entry, or undefined + */ +export const findDefaultAsset: FindDefaultAsset = (asset, network) => { + const assets = DEFAULT_ASSETS[network]; + if (!assets) { + return undefined; + } + const normalized = asset.toLowerCase(); + return assets.find(entry => entry.asset.toLowerCase() === normalized); +}; diff --git a/typescript/packages/mechanisms/casper/src/exact/client/index.ts b/typescript/packages/mechanisms/casper/src/exact/client/index.ts new file mode 100644 index 0000000000..2d88ed0b0e --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/exact/client/index.ts @@ -0,0 +1 @@ +export { ExactCasperScheme } from "./scheme"; diff --git a/typescript/packages/mechanisms/casper/src/exact/client/scheme.ts b/typescript/packages/mechanisms/casper/src/exact/client/scheme.ts new file mode 100644 index 0000000000..ef58ae94a2 --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/exact/client/scheme.ts @@ -0,0 +1,105 @@ +import type { + PaymentPayloadResult, + PaymentRequirements, + SchemeNetworkClient, +} from "@x402/core/types"; +import { SCHEME_EXACT } from "../../constants"; +import { findDefaultAsset } from "../../defaultAssets"; +import type { ClientCasperSigner, ExactCasperAuthorization } from "../../types"; +import { + buildTransferWithAuthorizationDigest, + bytesToHex, + isValidCasperAddress, + isValidContractPackageHash, +} from "../../utils"; + +/** + * Casper client implementation for the exact payment scheme. + */ +export class ExactCasperScheme implements SchemeNetworkClient { + readonly scheme = SCHEME_EXACT; + findDefaultAsset = findDefaultAsset; + + /** + * Create a new exact Casper client scheme. + * + * @param signer - Casper client signer. + */ + constructor(private readonly signer: ClientCasperSigner) {} + + /** + * Create an exact Casper payment payload. + * + * @param x402Version - x402 protocol version. + * @param paymentRequirements - Selected payment requirements. + * @returns Payment payload result. + */ + async createPaymentPayload( + x402Version: number, + paymentRequirements: PaymentRequirements, + ): Promise { + if (paymentRequirements.scheme !== SCHEME_EXACT) { + throw new Error("invalid_exact_casper_client_invalid_scheme"); + } + if (!isValidContractPackageHash(paymentRequirements.asset)) { + throw new Error(`invalid_exact_casper_client_invalid_asset: ${paymentRequirements.asset}`); + } + if (!isValidCasperAddress(paymentRequirements.payTo)) { + throw new Error(`invalid_exact_casper_client_invalid_pay_to: ${paymentRequirements.payTo}`); + } + + const name = paymentRequirements.extra?.name; + const version = paymentRequirements.extra?.version; + if (typeof name !== "string" || name === "") { + throw new Error("invalid_exact_casper_client_missing_token_name"); + } + if (typeof version !== "string" || version === "") { + throw new Error("invalid_exact_casper_client_missing_token_version"); + } + + const now = Math.floor(Date.now() / 1000); + const validAfter = 0; + const validBefore = now + paymentRequirements.maxTimeoutSeconds; + const nonce = crypto.getRandomValues(new Uint8Array(32)); + + const authorization: ExactCasperAuthorization = { + from: this.signer.accountAddress(), + to: paymentRequirements.payTo, + value: paymentRequirements.amount, + validAfter: String(validAfter), + validBefore: String(validBefore), + nonce: bytesToHex(nonce), + }; + + let digest: Uint8Array; + try { + digest = buildTransferWithAuthorizationDigest({ + name, + version, + network: paymentRequirements.network, + asset: paymentRequirements.asset, + authorization, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`invalid_exact_casper_client_failed_to_hash: ${message}`); + } + + let signature: Uint8Array; + try { + signature = await this.signer.signEIP712(digest); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`invalid_exact_casper_client_failed_to_sign: ${message}`); + } + + return { + x402Version, + payload: { + signature: bytesToHex(signature), + publicKey: this.signer.publicKey(), + authorization, + }, + }; + } +} diff --git a/typescript/packages/mechanisms/casper/src/exact/facilitator/index.ts b/typescript/packages/mechanisms/casper/src/exact/facilitator/index.ts new file mode 100644 index 0000000000..2d88ed0b0e --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/exact/facilitator/index.ts @@ -0,0 +1 @@ +export { ExactCasperScheme } from "./scheme"; diff --git a/typescript/packages/mechanisms/casper/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/casper/src/exact/facilitator/scheme.ts new file mode 100644 index 0000000000..6a87273c2e --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/exact/facilitator/scheme.ts @@ -0,0 +1,652 @@ +import type { + FacilitatorContext, + Network, + PaymentPayload, + PaymentRequirements, + SchemeNetworkFacilitator, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import { + Args, + CLTypeUInt8, + CLValue, + ContractCallBuilder, + HttpHandler, + Key, + PublicKey, + RpcClient, + SpeculativeClient, + Transaction, +} from "casper-js-sdk"; +import { CASPER_CAIP2_FAMILY, DEFAULT_PAYMENT_MOTES, SCHEME_EXACT } from "../../constants"; +import type { NetworkConfig } from "../../constants"; +import type { ExactCasperPayload, FacilitatorCasperSigner } from "../../types"; +import { + buildTransferWithAuthorizationDigest, + hexToBytes, + isCanonicalSecp256k1Signature, + isValidCasperAccountHash, + isValidCasperAddress, + isValidContractPackageHash, +} from "../../utils"; +import { + dictionaryKeyForAddress, + dictionaryKeyForUsedNonces, + getActiveContractForToken, + isMissingDictionaryItem, + readDictionaryBool, + readDictionaryU256OrDefault, +} from "../../contracts"; + +export const ErrInvalidScheme = "invalid_exact_casper_facilitator_invalid_scheme"; +export const ErrNetworkMismatch = "invalid_exact_casper_facilitator_network_mismatch"; +export const ErrInvalidAsset = "invalid_exact_casper_facilitator_invalid_asset"; +export const ErrInvalidPayTo = "invalid_exact_casper_facilitator_invalid_payto"; +export const ErrInvalidPayer = "invalid_exact_casper_facilitator_invalid_payer"; +export const ErrAmountMismatch = "invalid_exact_casper_facilitator_amount_mismatch"; +export const ErrPayToMismatch = "invalid_exact_casper_facilitator_payto_mismatch"; +export const ErrExpired = "invalid_exact_casper_facilitator_expired"; +export const ErrNotYetValid = "invalid_exact_casper_facilitator_not_yet_valid"; +export const ErrInvalidSignature = "invalid_exact_casper_facilitator_invalid_signature"; +export const ErrNonCanonicalSignature = "invalid_exact_casper_facilitator_non_canonical_signature"; +export const ErrPublicKeyMismatch = "invalid_exact_casper_facilitator_publickey_mismatch"; +export const ErrSettleFailed = "invalid_exact_casper_facilitator_settle_failed"; +export const ErrMissingTokenName = "invalid_exact_casper_facilitator_missing_token_name"; +export const ErrMissingTokenVersion = "invalid_exact_casper_facilitator_missing_token_version"; +export const ErrFailedToHash = "invalid_exact_casper_facilitator_failed_to_hash"; +export const ErrInsufficientBalance = "invalid_exact_casper_facilitator_insufficient_balance"; +export const ErrAuthorizationUsed = + "invalid_exact_casper_facilitator_authorization_used_or_canceled"; +export const ErrUnsupportedAsset = "invalid_exact_casper_facilitator_unsupported_asset"; +export const ErrSpeculativeExecutionFailed = + "invalid_exact_casper_facilitator_speculative_execution_failed"; + +const TRANSFER_WITH_AUTHORIZATION_ENTRY_POINT = "transfer_with_authorization"; +const BALANCES_DICTIONARY = "balances"; +const AUTHORIZATION_STATE_DICTIONARY = "authorization_state"; + +/** + * Facilitator configuration for exact Casper. + */ +export type ExactCasperSchemeConfig = { + limitedPaymentMotes?: number; +}; + +/** + * Invalid verify response helper. + * + * @param invalidReason - Invalid reason. + * @param payer - Optional payer. + * @param invalidMessage - Optional message. + * @returns Verify response. + */ +function invalid(invalidReason: string, payer?: string, invalidMessage?: string): VerifyResponse { + return { isValid: false, invalidReason, invalidMessage, payer }; +} + +/** + * Casper facilitator implementation for the exact payment scheme. + */ +export class ExactCasperScheme implements SchemeNetworkFacilitator { + readonly scheme = SCHEME_EXACT; + readonly caipFamily = CASPER_CAIP2_FAMILY; + private readonly rpcClients = new Map>(); + private readonly speculativeClients = new Map< + string, + ReturnType + >(); + + /** + * Create an exact Casper facilitator scheme. + * + * @param signer - Facilitator signer. + * @param config - Optional config. + */ + constructor( + private readonly signer: FacilitatorCasperSigner, + private readonly config: ExactCasperSchemeConfig = {}, + ) {} + + /** + * Get supported endpoint extra data. + * + * @param _network - Network identifier. + * @returns Extra data. + */ + getExtra(_network: Network): Record { + return {}; + } + + /** + * Get signer addresses. + * + * @param network - Network identifier. + * @returns Signer addresses. + */ + getSigners(network: string): string[] { + return this.signer.getAddresses(network as Network); + } + + /** + * Verify an exact Casper payment payload. + * + * @param payload - Payment payload. + * @param requirements - Payment requirements. + * @param _context - Facilitator context. + * @returns Verify response. + */ + async verify( + payload: PaymentPayload, + requirements: PaymentRequirements, + _context?: FacilitatorContext, + ): Promise { + if (payload.accepted.scheme !== SCHEME_EXACT || requirements.scheme !== SCHEME_EXACT) { + return invalid(ErrInvalidScheme); + } + if (payload.accepted.network !== requirements.network) { + return invalid( + ErrNetworkMismatch, + undefined, + `payload=${payload.accepted.network} requirements=${requirements.network}`, + ); + } + + const exactPayload = this.extractPayload(payload); + if (!exactPayload) { + return invalid(ErrInvalidScheme, undefined, "malformed payload"); + } + const payer = exactPayload.authorization.from; + + const authorizationValidation = this.validateAuthorization(exactPayload, requirements); + if (authorizationValidation) { + return authorizationValidation; + } + + const signatureValidation = await this.validateSignature(exactPayload, requirements); + if (signatureValidation) { + return signatureValidation; + } + + const preflightValidation = await this.validatePreflight(exactPayload, requirements); + if (preflightValidation) { + return preflightValidation; + } + + return { isValid: true, payer }; + } + + /** + * Settle an exact Casper payment. + * + * @param payload - Payment payload. + * @param requirements - Payment requirements. + * @param context - Facilitator context. + * @returns Settlement response. + */ + async settle( + payload: PaymentPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + ): Promise { + const verifyResp = await this.verify(payload, requirements, context); + if (!verifyResp.isValid) { + return { + success: false, + errorReason: verifyResp.invalidReason, + errorMessage: verifyResp.invalidMessage, + payer: verifyResp.payer, + transaction: "", + network: requirements.network, + }; + } + + try { + const exactPayload = payload.payload as unknown as ExactCasperPayload; + const transaction = await this.buildTransferWithAuthorizationTransaction( + exactPayload, + requirements, + ); + + await this.signer.signTransaction(transaction, requirements.network); + const transactionHash = await this.signer.putTransaction(requirements.network, transaction); + await this.signer.waitForTransaction(requirements.network, transactionHash); + + return { + success: true, + transaction: transactionHash, + network: requirements.network, + payer: verifyResp.payer, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + errorReason: ErrSettleFailed, + errorMessage: message, + payer: verifyResp.payer, + transaction: "", + network: requirements.network, + }; + } + } + + /** + * Build a transfer_with_authorization transaction. + * + * @param payload - Exact Casper payload. + * @param requirements - Payment requirements. + * @param mode - Transaction build mode. + * @returns Casper transaction. + */ + private async buildTransferWithAuthorizationTransaction( + payload: ExactCasperPayload, + requirements: PaymentRequirements, + mode: "transaction-v1" | "deploy" = "transaction-v1", + ): Promise { + const facilitatorPublicKey = PublicKey.fromHex( + this.signer.getPublicKeyHex(requirements.network), + ); + const networkConfig = await this.signer.getNetworkConfig(requirements.network); + const builder = new ContractCallBuilder() + .from(facilitatorPublicKey) + .byPackageHash(requirements.asset) + .entryPoint("transfer_with_authorization") + .runtimeArgs(buildTransferWithAuthorizationArgs(payload)) + .chainName(networkConfig.chainName) + .payment(this.config.limitedPaymentMotes ?? DEFAULT_PAYMENT_MOTES); + + return mode === "deploy" ? builder.buildFor1_5() : builder.build(); + } + + /** + * Extract and validate basic payload shape. + * + * @param payload - Payment payload. + * @returns Exact payload or undefined. + */ + private extractPayload(payload: PaymentPayload): ExactCasperPayload | undefined { + const maybePayload = payload.payload as Partial | undefined; + const authorization = maybePayload?.authorization; + if ( + !maybePayload || + typeof maybePayload.signature !== "string" || + typeof maybePayload.publicKey !== "string" || + !authorization || + typeof authorization.from !== "string" || + typeof authorization.to !== "string" || + typeof authorization.value !== "string" || + typeof authorization.validAfter !== "string" || + typeof authorization.validBefore !== "string" || + typeof authorization.nonce !== "string" + ) { + return undefined; + } + return maybePayload as ExactCasperPayload; + } + + /** + * Validate authorization values against requirements. + * + * @param payload - Exact Casper payload. + * @param requirements - Payment requirements. + * @returns Invalid response or undefined. + */ + private validateAuthorization( + payload: ExactCasperPayload, + requirements: PaymentRequirements, + ): VerifyResponse | undefined { + const payer = payload.authorization.from; + if (payload.authorization.to !== requirements.payTo) { + return invalid( + ErrPayToMismatch, + payer, + `authorization.to=${payload.authorization.to} requirements.payTo=${requirements.payTo}`, + ); + } + if (payload.authorization.value !== requirements.amount) { + return invalid( + ErrAmountMismatch, + payer, + `authorization.value=${payload.authorization.value} requirements.amount=${requirements.amount}`, + ); + } + if (!isValidContractPackageHash(requirements.asset)) { + return invalid(ErrInvalidAsset, payer, requirements.asset); + } + if ( + !isValidCasperAddress(requirements.payTo) || + !isValidCasperAddress(payload.authorization.to) + ) { + return invalid(ErrInvalidPayTo, payer); + } + if (!isValidCasperAccountHash(payer)) { + return invalid(ErrInvalidPayer, payer); + } + if ( + !/^[1-9]\d*$/.test(requirements.amount) || + !/^[1-9]\d*$/.test(payload.authorization.value) + ) { + return invalid(ErrAmountMismatch, payer, "amount must be non-zero decimal string"); + } + if (!/^[0-9a-fA-F]{64}$/.test(payload.authorization.nonce)) { + return invalid(ErrInvalidSignature, payer, "nonce must be 32 bytes"); + } + + const validAfter = Number(payload.authorization.validAfter); + const validBefore = Number(payload.authorization.validBefore); + if (!Number.isSafeInteger(validAfter) || !Number.isSafeInteger(validBefore)) { + return invalid(ErrInvalidScheme, payer, "invalid validAfter/validBefore"); + } + const now = Math.floor(Date.now() / 1000); + // Verify validAfter is not in the future + if (validAfter > now) { + return invalid(ErrNotYetValid, payer, `validAfter=${validAfter} now=${now}`); + } + // Verify validBefore is in the future (with 8 second buffer for block time) + if (now + 8 >= validBefore) { + return invalid(ErrExpired, payer, `validBefore=${validBefore} now=${now}`); + } + + const name = requirements.extra?.name; + const version = requirements.extra?.version; + if (typeof name !== "string" || name === "") { + return invalid(ErrMissingTokenName, payer); + } + if (typeof version !== "string" || version === "") { + return invalid(ErrMissingTokenVersion, payer); + } + return undefined; + } + + /** + * Validate public key and signature. + * + * @param payload - Exact Casper payload. + * @param requirements - Payment requirements. + * @returns Invalid response or undefined. + */ + private async validateSignature( + payload: ExactCasperPayload, + requirements: PaymentRequirements, + ): Promise { + const payer = payload.authorization.from; + try { + if (payload.publicKey.slice(0, 2) !== payload.signature.slice(0, 2)) { + return invalid( + ErrInvalidSignature, + payer, + "public key and signature algorithm tags do not match", + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return invalid(ErrInvalidSignature, payer, message); + } + let signatureBytes: Uint8Array; + try { + signatureBytes = hexToBytes(payload.signature); + if (signatureBytes.length !== 65) { + return invalid(ErrInvalidSignature, payer, "signature must be 65 bytes"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return invalid(ErrInvalidSignature, payer, message); + } + + if (!isCanonicalSecp256k1Signature(signatureBytes)) { + return invalid(ErrNonCanonicalSignature, payer); + } + + let publicKey: PublicKey; + try { + publicKey = PublicKey.fromHex(payload.publicKey); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return invalid(ErrInvalidSignature, payer, message); + } + + if (publicKey.accountHash().toHex() !== payer.slice(2)) { + return invalid(ErrPublicKeyMismatch, payer, "public key does not match authorization.from"); + } + + const name = requirements.extra?.name as string; + const version = requirements.extra?.version as string; + let digest: Uint8Array; + try { + digest = buildTransferWithAuthorizationDigest({ + name, + version, + network: requirements.network, + asset: requirements.asset, + authorization: payload.authorization, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return invalid(ErrFailedToHash, payer, message); + } + + try { + if (!publicKey.verifySignature(digest, signatureBytes)) { + return invalid(ErrInvalidSignature, payer, "signature verification failed"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return invalid(ErrInvalidSignature, payer, message); + } + + return undefined; + } + + /** + * Get or create a cached RPC client for the given URL. + * + * @param rpcUrl - RPC node URL. + * @returns RPC client. + */ + private getRpcClient(rpcUrl: string): InstanceType { + const existing = this.rpcClients.get(rpcUrl); + if (existing) { + return existing; + } + const client = new RpcClient(new HttpHandler(rpcUrl)); + this.rpcClients.set(rpcUrl, client); + return client; + } + + /** + * Get or create a cached speculative execution client for the given URL. + * + * @param speculativeRpcUrl - Speculative RPC node URL. + * @returns Speculative execution client. + */ + private getSpeculativeClient( + speculativeRpcUrl: string, + ): ReturnType { + const existing = this.speculativeClients.get(speculativeRpcUrl); + if (existing) { + return existing; + } + const client = SpeculativeClient.newSpeculativeClient(new HttpHandler(speculativeRpcUrl)); + this.speculativeClients.set(speculativeRpcUrl, client); + return client; + } + + /** + * Validate live preflight requirements. + * + * @param payload - Exact Casper payload. + * @param requirements - Payment requirements. + * @returns Invalid response or undefined. + */ + private async validatePreflight( + payload: ExactCasperPayload, + requirements: PaymentRequirements, + ): Promise { + const payer = payload.authorization.from; + let networkConfig: NetworkConfig; + try { + networkConfig = await this.signer.getNetworkConfig(requirements.network); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return invalid(ErrNetworkMismatch, payer, message); + } + + const speculativeRpcUrl = this.signer.getSpeculativeRpcUrl(requirements.network); + if (speculativeRpcUrl) { + return this.validateSpeculativeExecution(payload, requirements, speculativeRpcUrl); + } + + return this.validateTargetedPreflight(payload, requirements, networkConfig.rpcUrl); + } + + /** + * Validate live preflight requirements via targeted RPC reads. + * + * @param payload - Exact Casper payload. + * @param requirements - Payment requirements. + * @param rpcUrl - RPC node URL. + * @returns Invalid response or undefined. + */ + private async validateTargetedPreflight( + payload: ExactCasperPayload, + requirements: PaymentRequirements, + rpcUrl: string, + ): Promise { + const payer = payload.authorization.from; + const rpcClient = this.getRpcClient(rpcUrl); + let tokenContractHash: string; + + try { + const tokenContract = await getActiveContractForToken(rpcClient, requirements.asset); + tokenContractHash = tokenContract.contractHash; + const supportsTransferWithAuthorization = (tokenContract.entryPoints ?? []).some( + entryPoint => entryPoint.name === TRANSFER_WITH_AUTHORIZATION_ENTRY_POINT, + ); + if (!supportsTransferWithAuthorization) { + return invalid( + ErrUnsupportedAsset, + payer, + `missing ${TRANSFER_WITH_AUTHORIZATION_ENTRY_POINT}`, + ); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return invalid(ErrUnsupportedAsset, payer, message); + } + + try { + const balance = await readDictionaryU256OrDefault( + rpcClient, + tokenContractHash, + BALANCES_DICTIONARY, + dictionaryKeyForAddress(payer), + ); + if (balance < BigInt(requirements.amount)) { + return invalid(ErrInsufficientBalance, payer); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return invalid(ErrInsufficientBalance, payer, message); + } + + try { + const usedNonce = await readDictionaryBool( + rpcClient, + tokenContractHash, + AUTHORIZATION_STATE_DICTIONARY, + dictionaryKeyForUsedNonces(payer, payload.authorization.nonce), + ); + if (usedNonce) { + return invalid(ErrAuthorizationUsed, payer, "authorization used or cancelled"); + } + } catch (error) { + if (!isMissingDictionaryItem(error)) { + const message = error instanceof Error ? error.message : String(error); + return invalid(ErrAuthorizationUsed, payer, message); + } + } + + return undefined; + } + + /** + * Validate the authorization with optional speculative execution. + * + * @param payload - Exact Casper payload. + * @param requirements - Payment requirements. + * @param speculativeRpcUrl - Speculative RPC node URL. + * @returns Invalid response or undefined. + */ + private async validateSpeculativeExecution( + payload: ExactCasperPayload, + requirements: PaymentRequirements, + speculativeRpcUrl: string, + ): Promise { + const payer = payload.authorization.from; + try { + const transaction = await this.buildTransferWithAuthorizationTransaction( + payload, + requirements, + "deploy", + ); + await this.signer.signTransaction(transaction, requirements.network); + const deploy = transaction.getDeploy(); + if (!deploy) { + return invalid( + ErrSpeculativeExecutionFailed, + payer, + "buildFor1_5 did not produce a deploy", + ); + } + const result = await this.getSpeculativeClient(speculativeRpcUrl).speculativeExec( + "1", + deploy, + ); + const v2ErrorMessage = result.executionResult?.errorMessage; + if (v2ErrorMessage) { + throw new Error(`speculative execution failed: ${v2ErrorMessage}`); + } + if (result.executionResult) { + return undefined; + } + + const rawJSON = result.rawJSON === undefined ? "" : `: ${JSON.stringify(result.rawJSON)}`; + throw new Error(`speculative execution returned an unrecognized response: ${rawJSON}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return invalid(ErrSpeculativeExecutionFailed, payer, message); + } + } +} + +/** + * Build runtime args for the transfer_with_authorization entry point. + * + * @param payload - Exact Casper payload. + * @returns Casper runtime args. + */ +function buildTransferWithAuthorizationArgs(payload: ExactCasperPayload) { + const fromKey = Key.fromBytes(hexToBytes(payload.authorization.from)).result; + const toKey = Key.fromBytes(hexToBytes(payload.authorization.to)).result; + const signatureBytes = hexToBytes(payload.signature); + const nonceBytes = hexToBytes(payload.authorization.nonce); + const publicKey = PublicKey.fromHex(payload.publicKey); + + return Args.fromMap({ + from: CLValue.newCLKey(fromKey), + to: CLValue.newCLKey(toKey), + value: CLValue.newCLUInt256(payload.authorization.value), + valid_after: CLValue.newCLUint64(Number(payload.authorization.validAfter)), + valid_before: CLValue.newCLUint64(Number(payload.authorization.validBefore)), + nonce: CLValue.newCLList( + CLTypeUInt8, + Array.from(nonceBytes).map(byte => CLValue.newCLUint8(byte)), + ), + public_key: CLValue.newCLPublicKey(publicKey), + signature: CLValue.newCLList( + CLTypeUInt8, + Array.from(signatureBytes).map(byte => CLValue.newCLUint8(byte)), + ), + }); +} diff --git a/typescript/packages/mechanisms/casper/src/exact/index.ts b/typescript/packages/mechanisms/casper/src/exact/index.ts new file mode 100644 index 0000000000..b1d3e09077 --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/exact/index.ts @@ -0,0 +1 @@ +export { ExactCasperScheme } from "./client/scheme"; diff --git a/typescript/packages/mechanisms/casper/src/exact/server/index.ts b/typescript/packages/mechanisms/casper/src/exact/server/index.ts new file mode 100644 index 0000000000..2d88ed0b0e --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/exact/server/index.ts @@ -0,0 +1 @@ +export { ExactCasperScheme } from "./scheme"; diff --git a/typescript/packages/mechanisms/casper/src/exact/server/scheme.ts b/typescript/packages/mechanisms/casper/src/exact/server/scheme.ts new file mode 100644 index 0000000000..16c9b67a36 --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/exact/server/scheme.ts @@ -0,0 +1,152 @@ +import type { + AssetAmount, + MoneyParser, + Network, + PaymentFlowConfig, + PaymentRequirements, + Price, + SchemeNetworkServer, + SupportedKind, +} from "@x402/core/types"; +import { convertToTokenAmount, parseMoney } from "@x402/core/utils"; +import { SCHEME_EXACT } from "../../constants"; +import { isValidCasperAddress, isValidContractPackageHash } from "../../utils"; +import { findDefaultAsset, getDefaultAsset, type CasperDefaultAsset } from "../../defaultAssets"; + +export const ErrInvalidAsset = "invalid_exact_casper_server_invalid_asset"; +export const ErrInvalidPayTo = "invalid_exact_casper_server_invalid_payto"; +export const ErrMissingTokenName = "invalid_exact_casper_server_missing_token_name"; +export const ErrMissingTokenVersion = "invalid_exact_casper_server_missing_token_version"; +export const ErrFailedToParseAmount = "invalid_exact_casper_server_failed_to_parse_amount"; + +/** + * Casper server implementation for the exact payment scheme. + */ +export class ExactCasperScheme implements SchemeNetworkServer { + readonly scheme = SCHEME_EXACT; + readonly defaultAssetTransferMethod = "default"; + readonly paymentFlows = { + default: { supported: ["authorization"], default: "authorization" }, + } as const satisfies Record; + private moneyParsers: MoneyParser[] = []; + private assetDecimals = new Map(); + + /** + * Register a custom money parser. + * + * @param parser - Money parser. + * @returns This scheme. + */ + registerMoneyParser(parser: MoneyParser): ExactCasperScheme { + this.moneyParsers.push(parser); + return this; + } + + /** + * Decimals for a known default asset, or undefined. + * + * @param asset - Asset package hash. + * @param network - Network identifier. + * @returns Token decimals. + */ + getAssetDecimals(asset: string, network: Network): number | undefined { + return findDefaultAsset(asset, network)?.decimals; + } + + /** + * Parse a price into Casper asset amount form. + * + * @param price - Price value. + * @param network - Network identifier. + * @returns Asset amount. + */ + async parsePrice(price: Price, network: Network): Promise { + if (typeof price === "object" && price !== null && "amount" in price) { + if (!isValidContractPackageHash(price.asset)) { + throw new Error(`${ErrInvalidAsset}: ${price.asset}`); + } + return { + amount: price.amount, + asset: price.asset, + extra: price.extra || {}, + }; + } + + const { amount, symbol } = parseMoney(price); + + // Try each custom money parser in order + for (const parser of this.moneyParsers) { + const result = await parser(amount, network); + if (result !== null) { + return result; + } + } + + // All custom parsers returned null, use default conversion + return this.defaultMoneyConversion(amount, network, symbol); + } + + /** + * Validate and enrich payment requirements. + * + * @param paymentRequirements - Base payment requirements. + * @param supportedKind - Facilitator supported kind. + * @param extensionKeys - Supported extension keys. + * @returns Enhanced payment requirements. + */ + async enhancePaymentRequirements( + paymentRequirements: PaymentRequirements, + supportedKind: SupportedKind, + extensionKeys: string[], + ): Promise { + if (!isValidContractPackageHash(paymentRequirements.asset)) { + throw new Error(`${ErrInvalidAsset}: ${paymentRequirements.asset}`); + } + if (!isValidCasperAddress(paymentRequirements.payTo)) { + throw new Error(`${ErrInvalidPayTo}: ${paymentRequirements.payTo}`); + } + + const extra = { ...paymentRequirements.extra }; + if (typeof extra.name !== "string" || extra.name === "") { + throw new Error(ErrMissingTokenName); + } + if (typeof extra.version !== "string" || extra.version === "") { + throw new Error(ErrMissingTokenVersion); + } + + if (supportedKind.extra) { + for (const key of extensionKeys) { + if (Object.prototype.hasOwnProperty.call(supportedKind.extra, key)) { + extra[key] = supportedKind.extra[key]; + } + } + } + + return { + ...paymentRequirements, + extra, + }; + } + + /** + * Converts a numeric dollar amount to an AssetAmount using the default token for the network. + * + * @param amount - The decimal amount as a string + * @param network - The target network + * @param symbol - Optional ticker from a suffixed price + * @returns The converted asset amount with token metadata + */ + private defaultMoneyConversion(amount: string, network: Network, symbol?: string): AssetAmount { + const defaultAsset: CasperDefaultAsset = getDefaultAsset(network, symbol); + const tokenAmount = convertToTokenAmount(amount, defaultAsset.decimals); + + return { + amount: tokenAmount, + asset: defaultAsset.asset, + extra: { + name: defaultAsset.name, + version: defaultAsset.version, + }, + }; + } +} diff --git a/typescript/packages/mechanisms/casper/src/index.ts b/typescript/packages/mechanisms/casper/src/index.ts new file mode 100644 index 0000000000..3bb3f23e1b --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/index.ts @@ -0,0 +1,18 @@ +export * from "./exact"; +export * from "./types"; +export * from "./constants"; +export * from "./signer"; +export * from "./utils"; + +export { + dictionaryKeyForAddress, + getActiveContractForToken, + readDictionaryU256OrDefault, +} from "./contracts"; + +export { + DEFAULT_ASSETS, + getDefaultAsset, + findDefaultAsset, + type CasperDefaultAsset, +} from "./defaultAssets"; diff --git a/typescript/packages/mechanisms/casper/src/signer.ts b/typescript/packages/mechanisms/casper/src/signer.ts new file mode 100644 index 0000000000..91e1817857 --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/signer.ts @@ -0,0 +1,172 @@ +import { HttpHandler, KeyAlgorithm, PrivateKey, RpcClient } from "casper-js-sdk"; +import type { Network } from "@x402/core/types"; +import { NetworkConfigs, type NetworkConfig } from "./constants"; +import type { + ClientCasperSigner, + FacilitatorCasperSigner, + FacilitatorCasperSignerConfig, + RpcUrlConfig, +} from "./types"; +import { chainNameFromNetwork } from "./utils"; + +const ACCOUNT_HASH_PREFIX = "00"; + +/** + * Pause execution for the given number of milliseconds. + * + * @param ms - Milliseconds to sleep. + * @returns Promise that resolves after the delay. + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Resolve an RPC URL for a network. + * + * @param network - CAIP-2 network identifier. + * @param config - Optional RPC URL config. + * @returns RPC URL. + */ +function resolveRpcUrl(network: Network, config?: RpcUrlConfig): string { + return config?.[network] ?? NetworkConfigs[network]?.rpcUrl ?? ""; +} + +/** + * Create a client signer from a Casper private key. + * + * @param privateKey - Casper private key. + * @returns Client signer. + */ +export function toClientCasperSigner(privateKey: PrivateKey): ClientCasperSigner { + const accountAddress = `${ACCOUNT_HASH_PREFIX}${privateKey.publicKey.accountHash().toHex()}`; + const publicKey = privateKey.publicKey.toHex(); + + return { + accountAddress: () => accountAddress, + publicKey: () => publicKey, + signEIP712: async digest => privateKey.signAndAddAlgorithmBytes(digest), + }; +} + +/** + * Create a client signer from a hex-encoded private key. + * + * @param privateKey - Hex-encoded private key. + * @param algorithm - Key algorithm. + * @returns Client signer. + */ +export async function createClientCasperSigner( + privateKey: string, + algorithm: KeyAlgorithm = KeyAlgorithm.ED25519, +): Promise { + return toClientCasperSigner(PrivateKey.fromHex(privateKey, algorithm)); +} + +/** + * Create a facilitator signer from a Casper private key. + * + * @param privateKey - Casper private key. + * @param config - RPC URL and optional SpecExec URL config. + * @returns Facilitator signer. + */ +export async function toFacilitatorCasperSigner( + privateKey: PrivateKey, + config: FacilitatorCasperSignerConfig, +): Promise { + const { rpcUrlConfig, speculativeRpcUrlConfig } = config; + const rpcClients = new Map>(); + + const getNetworkConfig = async (network: Network): Promise => { + const rpcUrl = resolveRpcUrl(network, rpcUrlConfig); + if (!rpcUrl) { + throw new Error(`unsupported Casper network: ${network}`); + } + return { + chainName: chainNameFromNetwork(network), + rpcUrl, + }; + }; + + const getRpcClient = async (network: Network): Promise> => { + const networkConfig = await getNetworkConfig(network); + const existing = rpcClients.get(networkConfig.rpcUrl); + if (existing) { + return existing; + } + const client = new RpcClient(new HttpHandler(networkConfig.rpcUrl)); + rpcClients.set(networkConfig.rpcUrl, client); + return client; + }; + + return { + getNetworkConfig, + + getAddresses: () => [privateKey.publicKey.accountHash().toHex()], + + getPublicKeyHex: () => privateKey.publicKey.toHex(), + + getSpeculativeRpcUrl: network => { + const speculativeRpcUrl = speculativeRpcUrlConfig?.[network]?.trim(); + return speculativeRpcUrl === "" ? undefined : speculativeRpcUrl; + }, + + signTransaction: async transaction => { + transaction.sign(privateKey); + }, + + putTransaction: async (network, transaction) => { + const rpcClient = await getRpcClient(network); + try { + const result = await rpcClient.putTransaction(transaction); + return result.transactionHash.toHex(); + } catch (error: unknown) { + const sourceErr = + typeof error === "object" && error !== null && "sourceErr" in error + ? ` - ${JSON.stringify(error.sourceErr)}` + : ""; + const message = error instanceof Error ? `${error.message}${sourceErr}` : String(error); + throw new Error(`transaction submission failed: ${message}`); + } + }, + + waitForTransaction: async (network, transactionHash) => { + const rpcClient = await getRpcClient(network); + const start = Date.now(); + const timeoutMs = 60_000; + const pollIntervalMs = 3_000; + + while (Date.now() - start < timeoutMs) { + const info = await rpcClient.getTransactionByTransactionHash(transactionHash); + const execInfo = info.executionInfo; + if (execInfo && execInfo.blockHeight !== 0 && execInfo.executionResult) { + const errorMessage = execInfo.executionResult.errorMessage; + if (errorMessage) { + throw new Error(`transaction execution failed: ${errorMessage}`); + } + return; + } + + await sleep(pollIntervalMs); + } + + throw new Error(`Timed out waiting for transaction ${transactionHash}`); + }, + }; +} + +/** + * Create a facilitator signer from a hex-encoded private key. + * + * @param privateKey - Hex-encoded private key. + * @param algorithm - Key algorithm. + * @param config - RPC URL and optional SpecExec URL config. + * @returns Facilitator signer. + */ +export async function createFacilitatorCasperSigner( + privateKey: string, + algorithm: KeyAlgorithm = KeyAlgorithm.ED25519, + config: FacilitatorCasperSignerConfig, +): Promise { + return toFacilitatorCasperSigner(PrivateKey.fromHex(privateKey, algorithm), config); +} diff --git a/typescript/packages/mechanisms/casper/src/types.ts b/typescript/packages/mechanisms/casper/src/types.ts new file mode 100644 index 0000000000..e440fd0062 --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/types.ts @@ -0,0 +1,72 @@ +import type { Network } from "@x402/core/types"; +import type { Transaction } from "casper-js-sdk"; +import type { NetworkConfig } from "./constants"; + +/** + * Authorization fields for an exact Casper payment. + */ +export type ExactCasperAuthorization = { + /** Payer account-hash address with "00" prefix. */ + from: string; + /** Recipient account-hash or package-hash address with "00" or "01" prefix. */ + to: string; + /** Atomic token amount as a decimal string. */ + value: string; + /** Unix timestamp after which the authorization is valid. */ + validAfter: string; + /** Unix timestamp before which the authorization must be used. */ + validBefore: string; + /** 32-byte nonce as a hex string. */ + nonce: string; +}; + +/** + * Payload for an exact Casper payment. + */ +export type ExactCasperPayload = { + /** Signature with leading Casper algorithm byte. */ + signature: string; + /** Payer public key with leading Casper algorithm byte. */ + publicKey: string; + /** Signed authorization fields. */ + authorization: ExactCasperAuthorization; +}; + +export type RpcUrlConfig = Record; + +export type FacilitatorCasperSignerConfig = { + rpcUrlConfig?: RpcUrlConfig; + speculativeRpcUrlConfig?: RpcUrlConfig; +}; + +/** + * Client-side signer for Casper x402 payments. + */ +export type ClientCasperSigner = { + /** Get the payer account-hash address as a 66-character hex string prefixed with "00". */ + accountAddress(): string; + /** Get the payer's public key hex, including the algorithm prefix byte. */ + publicKey(): string; + /** Sign a 32-byte EIP-712 digest. */ + signEIP712(digest: Uint8Array): Promise; +}; + +/** + * Facilitator-side signer for Casper x402 settlement and preflight checks. + */ +export type FacilitatorCasperSigner = { + /** Resolve network configuration. */ + getNetworkConfig(network: Network): Promise; + /** Get signer addresses for the supported endpoint. */ + getAddresses(network: Network): string[]; + /** Get the facilitator public key for settlement transactions. */ + getPublicKeyHex(network: Network): string; + /** Resolve an optional speculative execution RPC URL for the network. */ + getSpeculativeRpcUrl(network: Network): string | undefined; + /** Sign a Casper transaction. */ + signTransaction(transaction: Transaction, network: Network): Promise; + /** Submit a Casper transaction and return its hash. */ + putTransaction(network: Network, transaction: Transaction): Promise; + /** Wait for a submitted transaction to execute successfully. */ + waitForTransaction(network: Network, transactionHash: string): Promise; +}; diff --git a/typescript/packages/mechanisms/casper/src/utils.ts b/typescript/packages/mechanisms/casper/src/utils.ts new file mode 100644 index 0000000000..d58d26a127 --- /dev/null +++ b/typescript/packages/mechanisms/casper/src/utils.ts @@ -0,0 +1,180 @@ +import type { Network } from "@x402/core/types"; +import { buildDomain, CASPER_DOMAIN_TYPES, hashTypedData } from "@casper-ecosystem/casper-eip-712"; +import { Conversions } from "casper-js-sdk"; +import { NetworkConfigs } from "./constants"; +import type { ExactCasperAuthorization } from "./types"; + +/** + * Casper account hash regex. "00" is the account-hash prefix. + */ +export const CASPER_ACCOUNTHASH_REGEX = /^00[0-9a-fA-F]{64}$/; + +/** + * Casper address regex. "00" is account-hash and "01" is package hash. + */ +export const CASPER_ADDRESS_REGEX = /^(00|01)[0-9a-fA-F]{64}$/; + +/** + * Casper contract package hashes are 32-byte hex strings with no prefix. + */ +export const CONTRACT_PACKAGE_HASH_REGEX = /^[0-9a-fA-F]{64}$/; + +const SECP256K1_ORDER = BigInt( + "0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", +); +const SECP256K1_HALF_ORDER = SECP256K1_ORDER / 2n; + +const transferWithAuthorizationTypes = { + TransferWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], +}; + +/** + * Encode bytes as lowercase hex without a prefix. + * + * @param bytes - Bytes to encode. + * @returns Hex string. + */ +export function bytesToHex(bytes: Uint8Array): string { + return Conversions.encodeBase16(bytes).toLowerCase(); +} + +/** + * Decode a hex string into bytes. + * + * @param hex - Hex string, with or without "0x" prefix. + * @returns Decoded bytes. + */ +export function hexToBytes(hex: string): Uint8Array { + const cleaned = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex; + if (cleaned.length % 2 !== 0) { + throw new Error("hex string must have an even number of characters"); + } + if (!/^[0-9a-fA-F]*$/.test(cleaned)) { + throw new Error("hex string contains non-hex characters"); + } + return Conversions.decodeBase16(cleaned); +} + +/** + * Check whether a string is a valid Casper Account Hash. + * + * @param value - Value to validate. + * @returns True when the value is a valid Casper Account Hash. + */ +export function isValidCasperAccountHash(value: string): boolean { + return CASPER_ACCOUNTHASH_REGEX.test(value); +} + +/** + * Check whether a string is a valid Casper address. + * + * @param value - Value to validate. + * @returns True when the value is a valid Casper address. + */ +export function isValidCasperAddress(value: string): boolean { + return CASPER_ADDRESS_REGEX.test(value); +} + +/** + * Check whether a string is a valid contract package hash. + * + * @param value - Value to validate. + * @returns True when the value is a valid package hash. + */ +export function isValidContractPackageHash(value: string): boolean { + return CONTRACT_PACKAGE_HASH_REGEX.test(value); +} + +/** + * Extract the chain name portion from a Casper CAIP-2 identifier. + * + * @param network - CAIP-2 network identifier. + * @returns Chain name. + */ +export function chainNameFromNetwork(network: string): string { + if (!network.startsWith("casper:") || network.split(":").length !== 2) { + throw new Error(`Unsupported network format: ${network} (expected casper:CHAIN_NAME)`); + } + const chainName = network.slice("casper:".length); + if (!chainName) { + throw new Error(`Invalid Casper CAIP-2 network: ${network}`); + } + return chainName; +} + +/** + * Look up default network config. + * + * @param network - CAIP-2 network identifier. + * @returns Network config. + */ +export function getNetworkConfig(network: string) { + const config = NetworkConfigs[network]; + if (!config) { + throw new Error(`unsupported Casper network: ${network}`); + } + return config; +} + +/** + * Check canonical low-s form for Casper secp256k1 signatures. + * + * @param signature - Signature bytes with Casper algorithm tag. + * @returns True when canonical or when not a secp256k1 signature. + */ +export function isCanonicalSecp256k1Signature(signature: Uint8Array): boolean { + if (signature[0] !== 0x02) { + return true; + } + if (signature.length !== 65) { + return false; + } + const s = BigInt(`0x${bytesToHex(signature.slice(33, 65))}`); + return s <= SECP256K1_HALF_ORDER; +} + +/** + * Build the CEP-3009 EIP-712 digest for transfer_with_authorization. + * + * @param params - Digest input fields. + * @param params.name - Token name. + * @param params.version - Token domain version. + * @param params.network - Casper CAIP-2 network. + * @param params.asset - Contract package hash. + * @param params.authorization - Authorization fields. + * @returns 32-byte digest. + */ +export function buildTransferWithAuthorizationDigest(params: { + name: string; + version: string; + network: Network; + asset: string; + authorization: ExactCasperAuthorization; +}): Uint8Array { + const domain = buildDomain(params.name, params.version, params.network, `0x${params.asset}`); + const message = { + from: `0x${params.authorization.from}`, + to: `0x${params.authorization.to}`, + value: BigInt(params.authorization.value), + validAfter: BigInt(params.authorization.validAfter), + validBefore: BigInt(params.authorization.validBefore), + nonce: `0x${params.authorization.nonce}`, + }; + + return hashTypedData( + domain, + transferWithAuthorizationTypes, + "TransferWithAuthorization", + message, + { + domainTypes: CASPER_DOMAIN_TYPES, + }, + ); +} diff --git a/typescript/packages/mechanisms/casper/test/integrations/exact-casper.live.test.ts b/typescript/packages/mechanisms/casper/test/integrations/exact-casper.live.test.ts new file mode 100644 index 0000000000..b9d7680d20 --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/integrations/exact-casper.live.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { x402Client } from "@x402/core/client"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { type FacilitatorClient, x402ResourceServer } from "@x402/core/server"; +import type { + Network, + PaymentPayload, + PaymentRequirements, + SettleResponse, + SupportedResponse, + VerifyResponse, +} from "@x402/core/types"; +import { CASPER_TESTNET_CAIP2 } from "../../src/constants"; +import { ExactCasperScheme as ExactCasperClient } from "../../src/exact/client/scheme"; +import { ExactCasperScheme as ExactCasperFacilitator } from "../../src/exact/facilitator/scheme"; +import { ExactCasperScheme as ExactCasperServer } from "../../src/exact/server/scheme"; +import { createClientCasperSigner, createFacilitatorCasperSigner } from "../../src/signer"; +import type { ExactCasperPayload } from "../../src/types"; + +const CLIENT_PRIVATE_KEY = process.env.CASPER_CLIENT_PRIVATE_KEY; +const CLIENT_PRIVATE_KEY_ALGORITHM = + process.env.CASPER_CLIENT_PRIVATE_KEY_ALGORITHM === "secp256k1" ? 2 : 1; +const FACILITATOR_PRIVATE_KEY = process.env.CASPER_FACILITATOR_PRIVATE_KEY; +const FACILITATOR_PRIVATE_KEY_ALGORITHM = + process.env.CASPER_FACILITATOR_PRIVATE_KEY_ALGORITHM === "secp256k1" ? 2 : 1; +const PAY_TO = process.env.CASPER_PAY_TO; +const ASSET = process.env.CASPER_ASSET; +const TOKEN_NAME = process.env.CASPER_TOKEN_NAME; +const TOKEN_VERSION = process.env.CASPER_TOKEN_VERSION; +const TOKEN_DECIMALS = process.env.CASPER_TOKEN_DECIMALS + ? parseInt(process.env.CASPER_TOKEN_DECIMALS, 10) + : 6; +const NETWORK = (process.env.CASPER_NETWORK || CASPER_TESTNET_CAIP2) as Network; +const RPC_URL = process.env.CASPER_RPC_URL || "https://node.testnet.casper.network/rpc"; +const SPECEXEC_RPC_URL = process.env.CASPER_SPECEXEC_RPC_URL; +const RUN_LIVE = process.env.CASPER_RUN_LIVE === "1"; + +const missingLiveEnv = + !RUN_LIVE || + !CLIENT_PRIVATE_KEY || + !FACILITATOR_PRIVATE_KEY || + !PAY_TO || + !ASSET || + !TOKEN_NAME || + !TOKEN_VERSION; + +const describeLive = missingLiveEnv ? describe.skip : describe; + +if (missingLiveEnv) { + console.warn( + "[exact-casper.live] skipped: set CASPER_RUN_LIVE=1, CASPER_CLIENT_PRIVATE_KEY, CASPER_FACILITATOR_PRIVATE_KEY, CASPER_PAY_TO, CASPER_ASSET, CASPER_TOKEN_NAME, and CASPER_TOKEN_VERSION to run.", + ); +} + +class CasperFacilitatorClient implements FacilitatorClient { + readonly scheme = "exact"; + readonly network = NETWORK; + readonly x402Version = 2; + + /** + * Create a facilitator client wrapper. + * + * @param facilitator - x402 facilitator. + */ + constructor(private readonly facilitator: x402Facilitator) {} + + /** + * Verify payment. + * + * @param paymentPayload - Payment payload. + * @param paymentRequirements - Payment requirements. + * @returns Verify response. + */ + verify( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.verify(paymentPayload, paymentRequirements); + } + + /** + * Settle payment. + * + * @param paymentPayload - Payment payload. + * @param paymentRequirements - Payment requirements. + * @returns Settle response. + */ + settle( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.settle(paymentPayload, paymentRequirements); + } + + /** + * Get supported kinds. + * + * @returns Supported response. + */ + getSupported(): Promise { + return Promise.resolve(this.facilitator.getSupported()); + } +} + +describeLive( + "Exact Casper live integration", + () => { + it("verifies and settles an exact Casper payment on Casper testnet", async () => { + const clientSigner = await createClientCasperSigner( + CLIENT_PRIVATE_KEY!, + CLIENT_PRIVATE_KEY_ALGORITHM, + ); + const facilitatorSigner = await createFacilitatorCasperSigner( + FACILITATOR_PRIVATE_KEY!, + FACILITATOR_PRIVATE_KEY_ALGORITHM, + { + rpcUrlConfig: { [NETWORK]: RPC_URL }, + speculativeRpcUrlConfig: SPECEXEC_RPC_URL ? { [NETWORK]: SPECEXEC_RPC_URL } : undefined, + }, + ); + + const client = new x402Client() + .setSpendControls({ + allowedAssets: [{ network: NETWORK, asset: ASSET!, maxAmountPerPayment: "1000000" }], + }) + .register(NETWORK, new ExactCasperClient(clientSigner)); + const facilitator = new x402Facilitator().register( + NETWORK, + new ExactCasperFacilitator(facilitatorSigner), + ); + const server = new x402ResourceServer(new CasperFacilitatorClient(facilitator)); + server.register(NETWORK, new ExactCasperServer()); + await server.initialize(); + + const accepts = await server.buildPaymentRequirements({ + scheme: "exact", + network: NETWORK, + payTo: PAY_TO!, + price: { + amount: "10000", + asset: ASSET!, + extra: { + name: TOKEN_NAME!, + version: TOKEN_VERSION!, + decimals: TOKEN_DECIMALS, + }, + }, + maxTimeoutSeconds: 300, + }); + const paymentRequired = await server.createPaymentRequiredResponse(accepts, { + url: "https://example.com/casper", + description: "Casper integration resource", + mimeType: "application/json", + }); + + expect(accepts).toEqual([ + expect.objectContaining({ + scheme: "exact", + network: NETWORK, + asset: ASSET!, + amount: "10000", + payTo: PAY_TO!, + maxTimeoutSeconds: 300, + extra: expect.objectContaining({ + name: TOKEN_NAME!, + version: TOKEN_VERSION!, + }), + }), + ]); + + const paymentPayload = await client.createPaymentPayload(paymentRequired); + expect(paymentPayload.x402Version).toBe(2); + expect(paymentPayload.accepted.scheme).toBe("exact"); + expect(paymentPayload.accepted.network).toBe(NETWORK); + expect(paymentPayload.accepted.extra).toMatchObject({ + name: TOKEN_NAME!, + version: TOKEN_VERSION!, + }); + + const accepted = server.findMatchingRequirements(accepts, paymentPayload); + expect(accepted).toBeDefined(); + + const verifyResponse = await server.verifyPayment(paymentPayload, accepted!); + expect(verifyResponse.invalidReason).toBeUndefined(); + expect(verifyResponse.invalidMessage).toBeUndefined(); + expect(verifyResponse.isValid).toBe(true); + + const exactPayload = paymentPayload.payload as ExactCasperPayload; + expect(verifyResponse.payer).toBe(exactPayload.authorization.from); + + const settleResponse = await server.settlePayment(paymentPayload, accepted!); + expect(settleResponse.errorReason).toBeUndefined(); + expect(settleResponse.errorMessage).toBeUndefined(); + expect(settleResponse.success).toBe(true); + expect(settleResponse.network).toBe(NETWORK); + expect(settleResponse.transaction).toMatch(/^[0-9a-fA-F]{64}$/); + expect(settleResponse.payer).toBe(exactPayload.authorization.from); + }); + }, + 30_000 /* wait up to 30 seconds for execution of transaction in the Casper network */, +); diff --git a/typescript/packages/mechanisms/casper/test/integrations/exact-casper.test.ts b/typescript/packages/mechanisms/casper/test/integrations/exact-casper.test.ts new file mode 100644 index 0000000000..2e2b68913d --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/integrations/exact-casper.test.ts @@ -0,0 +1,538 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { newSpeculativeClient, speculativeExec } = vi.hoisted(() => { + const speculativeExec = vi.fn(async () => ({ executionResult: {} })); + return { + newSpeculativeClient: vi.fn(() => ({ speculativeExec })), + speculativeExec, + }; +}); + +vi.mock("casper-js-sdk", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + SpeculativeClient: { + newSpeculativeClient, + }, + }; +}); + +import { x402Client, x402HTTPClient } from "@x402/core/client"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { + type FacilitatorClient, + type HTTPAdapter, + type HTTPResponseInstructions, + x402HTTPResourceServer, + x402ResourceServer, +} from "@x402/core/server"; +import type { + Network, + PaymentPayload, + PaymentRequirements, + SettleResponse, + SupportedResponse, + VerifyResponse, +} from "@x402/core/types"; +import { KeyAlgorithm, PrivateKey, type Transaction } from "casper-js-sdk"; +import { CASPER_TESTNET_CAIP2 } from "../../src/constants"; +import { ExactCasperScheme as ExactCasperClient } from "../../src/exact/client/scheme"; +import { ExactCasperScheme as ExactCasperFacilitator } from "../../src/exact/facilitator/scheme"; +import { ExactCasperScheme as ExactCasperServer } from "../../src/exact/server/scheme"; +import { toClientCasperSigner } from "../../src/signer"; +import type { ExactCasperPayload, FacilitatorCasperSigner } from "../../src/types"; + +const NETWORK = CASPER_TESTNET_CAIP2 as Network; +const ASSET = "aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; +const PAY_TO = "00aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; +const TOKEN_NAME = "TestToken"; +const TOKEN_VERSION = "1"; +const CLIENT_PRIVATE_KEY = PrivateKey.generate(KeyAlgorithm.ED25519); +const FACILITATOR_PRIVATE_KEY = PrivateKey.generate(KeyAlgorithm.ED25519); +const CLIENT_SIGNER = toClientCasperSigner(CLIENT_PRIVATE_KEY); +const CLIENT_ADDRESS = CLIENT_SIGNER.accountAddress(); +const FACILITATOR_ADDRESS = FACILITATOR_PRIVATE_KEY.publicKey.accountHash().toHex(); + +class CasperFacilitatorClient implements FacilitatorClient { + readonly scheme = "exact"; + readonly network = NETWORK; + readonly x402Version = 2; + + /** + * Create a facilitator client wrapper. + * + * @param facilitator - x402 facilitator. + */ + constructor(private readonly facilitator: x402Facilitator) {} + + /** + * Verify payment. + * + * @param paymentPayload - Payment payload. + * @param paymentRequirements - Payment requirements. + * @returns Verify response. + */ + verify( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.verify(paymentPayload, paymentRequirements); + } + + /** + * Settle payment. + * + * @param paymentPayload - Payment payload. + * @param paymentRequirements - Payment requirements. + * @returns Settle response. + */ + settle( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.facilitator.settle(paymentPayload, paymentRequirements); + } + + /** + * Get supported kinds. + * + * @returns Supported response. + */ + getSupported(): Promise { + return Promise.resolve(this.facilitator.getSupported()); + } +} + +function createMockFacilitatorSigner( + overrides: Partial = {}, +): FacilitatorCasperSigner { + return { + getNetworkConfig: async () => ({ + chainName: "casper-test", + rpcUrl: "http://localhost:11101/rpc", + }), + getAddresses: () => [FACILITATOR_ADDRESS], + getPublicKeyHex: () => FACILITATOR_PRIVATE_KEY.publicKey.toHex(), + getSpeculativeRpcUrl: () => "http://localhost:7778/rpc", + signTransaction: vi.fn(async (_transaction: Transaction) => {}), + putTransaction: vi.fn(async () => "a".repeat(64)), + waitForTransaction: vi.fn(async () => {}), + ...overrides, + }; +} + +function buildPaymentRequirements( + overrides: Partial = {}, +): PaymentRequirements { + return { + scheme: "exact", + network: NETWORK, + asset: ASSET, + amount: "10000", + payTo: PAY_TO, + maxTimeoutSeconds: 300, + extra: { + name: TOKEN_NAME, + version: TOKEN_VERSION, + }, + ...overrides, + }; +} + +const resource = { + url: "https://example.com/casper", + description: "Casper integration resource", + mimeType: "application/json", +}; + +function buildClient(): x402Client { + return new x402Client() + .setSpendControls({ + allowedAssets: [{ network: NETWORK, asset: ASSET, maxAmountPerPayment: "3000000000" }], + }) + .register(NETWORK, new ExactCasperClient(CLIENT_SIGNER)); +} + +async function buildServer( + facilitatorSigner: FacilitatorCasperSigner = createMockFacilitatorSigner(), + casperServer: ExactCasperServer = new ExactCasperServer(), +): Promise { + const facilitator = new x402Facilitator().register( + NETWORK, + new ExactCasperFacilitator(facilitatorSigner), + ); + const server = new x402ResourceServer(new CasperFacilitatorClient(facilitator)); + server.register(NETWORK, casperServer); + await server.initialize(); + return server; +} + +describe("Casper integration", () => { + beforeEach(() => { + newSpeculativeClient.mockClear(); + speculativeExec.mockReset(); + speculativeExec.mockResolvedValue({ executionResult: {} }); + }); + + describe("x402Client / x402ResourceServer / x402Facilitator - Casper flow", () => { + let client: x402Client; + let server: x402ResourceServer; + + beforeEach(async () => { + client = buildClient(); + server = await buildServer(); + }); + + it("advertises Casper facilitator metadata through getSupported", () => { + const facilitator = new x402Facilitator().register( + NETWORK, + new ExactCasperFacilitator(createMockFacilitatorSigner()), + ); + + expect(facilitator.getSupported().kinds).toEqual([ + expect.objectContaining({ + x402Version: 2, + scheme: "exact", + network: NETWORK, + extra: {}, + }), + ]); + }); + + it("verifies and settles an exact Casper payment", async () => { + const accepts = [buildPaymentRequirements()]; + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const paymentPayload = await client.createPaymentPayload(paymentRequired); + + expect(paymentPayload.x402Version).toBe(2); + expect(paymentPayload.accepted.scheme).toBe("exact"); + expect(paymentPayload.accepted.network).toBe(NETWORK); + expect(paymentPayload.accepted.extra?.name).toBe(TOKEN_NAME); + expect(paymentPayload.accepted.extra?.version).toBe(TOKEN_VERSION); + + const exactPayload = paymentPayload.payload as ExactCasperPayload; + expect(exactPayload.signature).toMatch(/^01[0-9a-fA-F]{128}$/); + expect(exactPayload.publicKey).toMatch(/^01[0-9a-fA-F]{64}$/); + expect(exactPayload.authorization).toMatchObject({ + from: CLIENT_ADDRESS, + to: PAY_TO, + value: "10000", + nonce: expect.stringMatching(/^[0-9a-fA-F]{64}$/), + }); + + const accepted = server.findMatchingRequirements(accepts, paymentPayload); + expect(accepted).toBeDefined(); + + const verifyResponse = await server.verifyPayment(paymentPayload, accepted!); + expect(verifyResponse.isValid).toBe(true); + expect(verifyResponse.payer).toBe(CLIENT_ADDRESS); + + const settleResponse = await server.settlePayment(paymentPayload, accepted!); + expect(settleResponse.success).toBe(true); + expect(settleResponse.network).toBe(NETWORK); + expect(settleResponse.transaction).toBe("a".repeat(64)); + expect(settleResponse.payer).toBe(CLIENT_ADDRESS); + }); + + it("rejects a payment when the accepted amount is changed after signing", async () => { + const accepts = [buildPaymentRequirements()]; + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const paymentPayload = await client.createPaymentPayload(paymentRequired); + const tamperedRequirements = buildPaymentRequirements({ amount: "10001" }); + + const verifyResponse = await server.verifyPayment(paymentPayload, tamperedRequirements); + + expect(verifyResponse.isValid).toBe(false); + expect(verifyResponse.invalidReason).toBe("invalid_exact_casper_facilitator_amount_mismatch"); + expect(verifyResponse.payer).toBe(CLIENT_ADDRESS); + }); + + it("maps settlement verification failures to unsuccessful settlement responses", async () => { + const accepts = [buildPaymentRequirements()]; + const paymentRequired = await server.createPaymentRequiredResponse(accepts, resource); + const paymentPayload = await client.createPaymentPayload(paymentRequired); + const tamperedRequirements = buildPaymentRequirements({ amount: "10001" }); + + const settleResponse = await server.settlePayment(paymentPayload, tamperedRequirements); + + expect(settleResponse.success).toBe(false); + expect(settleResponse.errorReason).toBe("invalid_exact_casper_facilitator_amount_mismatch"); + expect(settleResponse.payer).toBe(CLIENT_ADDRESS); + expect(settleResponse.transaction).toBe(""); + }); + }); + + describe("x402HTTPClient / x402HTTPResourceServer / x402Facilitator - Casper flow", () => { + let client: x402HTTPClient; + let httpServer: x402HTTPResourceServer; + + const routes = { + "/api/protected": { + accepts: { + scheme: "exact", + payTo: PAY_TO, + price: { + amount: "10000", + asset: ASSET, + extra: { + name: TOKEN_NAME, + version: TOKEN_VERSION, + }, + }, + network: NETWORK, + maxTimeoutSeconds: 300, + }, + description: "Casper protected API", + mimeType: "application/json", + }, + }; + + const mockAdapter: HTTPAdapter = { + getAcceptHeader: () => "application/json", + getHeader: () => undefined, + getMethod: () => "GET", + getPath: () => "/api/protected", + getUrl: () => "https://example.com/api/protected", + getUserAgent: () => "IntegrationTest/1.0", + }; + + beforeEach(async () => { + client = new x402HTTPClient(buildClient()); + httpServer = new x402HTTPResourceServer(await buildServer(), routes); + mockAdapter.getHeader = () => undefined; + }); + + it("returns 402, verifies PAYMENT-SIGNATURE, and emits PAYMENT-RESPONSE", async () => { + const context = { + adapter: mockAdapter, + method: "GET", + path: "/api/protected", + }; + + const firstResult = await httpServer.processHTTPRequest(context); + expect(firstResult.type).toBe("payment-error"); + + const firstResponse = ( + firstResult as { type: "payment-error"; response: HTTPResponseInstructions } + ).response; + expect(firstResponse.status).toBe(402); + expect(firstResponse.headers["PAYMENT-REQUIRED"]).toBeDefined(); + + const paymentRequired = client.getPaymentRequiredResponse( + headerName => firstResponse.headers[headerName], + firstResponse.body, + ); + const paymentPayload = await client.createPaymentPayload(paymentRequired); + const encoded = await client.encodePaymentSignatureHeader(paymentPayload); + + mockAdapter.getHeader = (name: string) => { + if (name === "PAYMENT-SIGNATURE") { + return encoded["PAYMENT-SIGNATURE"]; + } + return undefined; + }; + + const secondResult = await httpServer.processHTTPRequest(context); + expect(secondResult.type).toBe("payment-verified"); + + const verified = secondResult as { + type: "payment-verified"; + paymentPayload: PaymentPayload; + paymentRequirements: PaymentRequirements; + }; + expect(verified.paymentPayload.accepted.network).toBe(NETWORK); + expect(verified.paymentRequirements.asset).toBe(ASSET); + expect(verified.paymentRequirements.extra).toMatchObject({ + name: TOKEN_NAME, + version: TOKEN_VERSION, + }); + + const settlementResult = await httpServer.processSettlement( + verified.paymentPayload, + verified.paymentRequirements, + 200, + ); + expect(settlementResult.success).toBe(true); + expect(settlementResult.headers?.["PAYMENT-RESPONSE"]).toBeDefined(); + }); + }); + + describe("Price Parsing Integration", () => { + let server: x402ResourceServer; + let casperServer: ExactCasperServer; + + beforeEach(async () => { + casperServer = new ExactCasperServer(); + server = await buildServer(createMockFacilitatorSigner(), casperServer); + }); + + it("handles AssetAmount pass-through and preserves Casper token metadata", async () => { + const requirements = await server.buildPaymentRequirements({ + scheme: "exact", + payTo: PAY_TO, + price: { + amount: "5000000", + asset: ASSET, + extra: { + name: TOKEN_NAME, + version: TOKEN_VERSION, + external: "abc123", + }, + }, + network: NETWORK, + }); + + expect(requirements).toHaveLength(1); + expect(requirements[0].amount).toBe("5000000"); + expect(requirements[0].asset).toBe(ASSET); + expect(requirements[0].extra).toMatchObject({ + name: TOKEN_NAME, + version: TOKEN_VERSION, + external: "abc123", + }); + }); + + it("converts decimal money prices using registered Casper money parser", async () => { + casperServer.registerMoneyParser(async (amount, network) => { + if (network !== NETWORK) { + return null; + } + return { + amount: String(Math.round(amount * 1e6)), + asset: ASSET, + extra: { + name: TOKEN_NAME, + version: TOKEN_VERSION, + }, + }; + }); + + const requirements = await server.buildPaymentRequirements({ + scheme: "exact", + payTo: PAY_TO, + price: "$0.05", + network: NETWORK, + }); + + expect(requirements[0].amount).toBe("50000"); + expect(requirements[0].extra).toMatchObject({ + name: TOKEN_NAME, + version: TOKEN_VERSION, + }); + }); + + it("uses registerMoneyParser for Casper money prices", async () => { + casperServer.registerMoneyParser(async amount => { + if (amount > 100) { + return { + amount: String(Math.round(amount * 1e6)), + asset: ASSET, + extra: { + name: TOKEN_NAME, + version: TOKEN_VERSION, + tier: "large", + }, + }; + } + return null; + }); + + const requirements = await server.buildPaymentRequirements({ + scheme: "exact", + payTo: PAY_TO, + price: 150, + network: NETWORK, + }); + + expect(requirements[0].amount).toBe("150000000"); + expect(requirements[0].asset).toBe(ASSET); + expect(requirements[0].extra).toMatchObject({ + name: TOKEN_NAME, + version: TOKEN_VERSION, + tier: "large", + }); + }); + + it("supports chained and async money parsers", async () => { + casperServer + .registerMoneyParser(async amount => { + if (amount > 1000) { + return { + amount: String(Math.round(amount * 1e6)), + asset: ASSET, + extra: { name: TOKEN_NAME, version: TOKEN_VERSION, tier: "vip" }, + }; + } + return null; + }) + .registerMoneyParser(async amount => { + await Promise.resolve(); + if (amount > 100) { + return { + amount: String(Math.round(amount * 1e6)), + asset: ASSET, + extra: { name: TOKEN_NAME, version: TOKEN_VERSION, tier: "premium" }, + }; + } + return null; + }); + + const vip = await server.buildPaymentRequirements({ + scheme: "exact", + payTo: PAY_TO, + price: 2000, + network: NETWORK, + }); + expect(vip[0].extra?.tier).toBe("vip"); + + const premium = await server.buildPaymentRequirements({ + scheme: "exact", + payTo: PAY_TO, + price: "$150.00", + network: NETWORK, + }); + expect(premium[0].amount).toBe("150000000"); + expect(premium[0].extra?.tier).toBe("premium"); + }); + + it("throws for money prices with an unknown Casper default asset symbol", async () => { + await expect( + server.buildPaymentRequirements({ + scheme: "exact", + payTo: PAY_TO, + price: "$1.00 UNKNOWN", + network: NETWORK, + }), + ).rejects.toThrow(`No UNKNOWN default asset configured for network ${NETWORK}`); + }); + + it("throws when AssetAmount omits required Casper token metadata", async () => { + await expect( + server.buildPaymentRequirements({ + scheme: "exact", + payTo: PAY_TO, + price: { + amount: "1", + asset: ASSET, + extra: { version: TOKEN_VERSION }, + }, + network: NETWORK, + }), + ).rejects.toThrow("invalid_exact_casper_server_missing_token_name"); + + await expect( + server.buildPaymentRequirements({ + scheme: "exact", + payTo: PAY_TO, + price: { + amount: "1", + asset: ASSET, + extra: { name: TOKEN_NAME }, + }, + network: NETWORK, + }), + ).rejects.toThrow("invalid_exact_casper_server_missing_token_version"); + }); + }); +}); diff --git a/typescript/packages/mechanisms/casper/test/unit/client.test.ts b/typescript/packages/mechanisms/casper/test/unit/client.test.ts new file mode 100644 index 0000000000..0015f09fae --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/unit/client.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { KeyAlgorithm, PrivateKey } from "casper-js-sdk"; +import { ExactCasperScheme } from "../../src/exact/client/scheme"; +import { toClientCasperSigner } from "../../src/signer"; +import type { ClientCasperSigner, ExactCasperPayload } from "../../src/types"; + +const testAsset = "aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; +const testPayTo = "00aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; + +function createTestSigner() { + return toClientCasperSigner(PrivateKey.generate(KeyAlgorithm.ED25519)); +} + +function buildRequirements(overrides: Record = {}) { + return { + scheme: "exact", + network: "casper:casper-test", + asset: testAsset, + amount: "1000000", + payTo: testPayTo, + maxTimeoutSeconds: 300, + extra: { + name: "TestToken", + version: "1", + }, + ...overrides, + }; +} + +describe("ExactCasperScheme client", () => { + it("creates a signed exact Casper payload", async () => { + const scheme = new ExactCasperScheme(createTestSigner()); + const result = await scheme.createPaymentPayload(2, buildRequirements()); + const payload = result.payload as ExactCasperPayload; + + expect(result.x402Version).toBe(2); + expect(payload.signature).toMatch(/^[0-9a-fA-F]{130}$/); + expect(payload.publicKey).toMatch(/^01[0-9a-fA-F]{64}$/); + expect(payload.authorization).toMatchObject({ + from: expect.stringMatching(/^00[0-9a-fA-F]{64}$/), + to: testPayTo, + value: "1000000", + nonce: expect.stringMatching(/^[0-9a-fA-F]{64}$/), + }); + }); + + it("sets validity timestamps around maxTimeoutSeconds", async () => { + const now = Math.floor(Date.now() / 1000); + const scheme = new ExactCasperScheme(createTestSigner()); + const result = await scheme.createPaymentPayload(2, buildRequirements()); + const payload = result.payload as ExactCasperPayload; + + expect(Number(payload.authorization.validAfter)).toBe(0); + expect(Number(payload.authorization.validBefore)).toBeGreaterThanOrEqual(now + 295); + expect(Number(payload.authorization.validBefore)).toBeLessThanOrEqual(now + 305); + }); + + it("rejects invalid requirements", async () => { + const scheme = new ExactCasperScheme(createTestSigner()); + + await expect( + scheme.createPaymentPayload(2, buildRequirements({ scheme: "upto" })), + ).rejects.toThrow("invalid_exact_casper_client_invalid_scheme"); + await expect( + scheme.createPaymentPayload(2, buildRequirements({ asset: "bad" })), + ).rejects.toThrow("invalid_exact_casper_client_invalid_asset"); + await expect( + scheme.createPaymentPayload(2, buildRequirements({ payTo: "bad" })), + ).rejects.toThrow("invalid_exact_casper_client_invalid_pay_to"); + await expect( + scheme.createPaymentPayload(2, buildRequirements({ extra: { version: "1" } })), + ).rejects.toThrow("invalid_exact_casper_client_missing_token_name"); + await expect( + scheme.createPaymentPayload(2, buildRequirements({ extra: { name: "TestToken" } })), + ).rejects.toThrow("invalid_exact_casper_client_missing_token_version"); + }); + + it("creates a unique nonce for each payload", async () => { + const scheme = new ExactCasperScheme(createTestSigner()); + const first = (await scheme.createPaymentPayload(2, buildRequirements())) + .payload as ExactCasperPayload; + const second = (await scheme.createPaymentPayload(2, buildRequirements())) + .payload as ExactCasperPayload; + + expect(first.authorization.nonce).not.toBe(second.authorization.nonce); + }); + + it("preserves signature algorithm byte", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.SECP256K1); + const scheme = new ExactCasperScheme(toClientCasperSigner(privateKey)); + const result = await scheme.createPaymentPayload(2, buildRequirements()); + const payload = result.payload as ExactCasperPayload; + + expect(payload.publicKey.slice(0, 2)).toBe("02"); + expect(payload.signature.slice(0, 2)).toBe("02"); + }); + + it("wraps hashing failures with client error context", async () => { + const scheme = new ExactCasperScheme(createTestSigner()); + + await expect( + scheme.createPaymentPayload(2, buildRequirements({ amount: "not-a-number" })), + ).rejects.toThrow("invalid_exact_casper_client_failed_to_hash"); + }); + + it("wraps signing failures with client error context", async () => { + const signer: ClientCasperSigner = { + accountAddress: () => testPayTo, + publicKey: () => `01${"a".repeat(64)}`, + signEIP712: async () => { + throw new Error("key unavailable"); + }, + }; + const scheme = new ExactCasperScheme(signer); + + await expect(scheme.createPaymentPayload(2, buildRequirements())).rejects.toThrow( + "invalid_exact_casper_client_failed_to_sign: key unavailable", + ); + }); +}); diff --git a/typescript/packages/mechanisms/casper/test/unit/constants.test.ts b/typescript/packages/mechanisms/casper/test/unit/constants.test.ts new file mode 100644 index 0000000000..d897c9e3b9 --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/unit/constants.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { + CASPER_CAIP2_FAMILY, + CASPER_MAINNET_CAIP2, + CASPER_TESTNET_CAIP2, + NetworkConfigs, + SCHEME_EXACT, +} from "../../src/constants"; + +describe("Casper constants", () => { + it("exports Casper network identifiers", () => { + expect(CASPER_MAINNET_CAIP2).toBe("casper:casper"); + expect(CASPER_TESTNET_CAIP2).toBe("casper:casper-test"); + expect(CASPER_CAIP2_FAMILY).toBe("casper:*"); + expect(SCHEME_EXACT).toBe("exact"); + }); + + it("exports default network configs", () => { + expect(NetworkConfigs[CASPER_MAINNET_CAIP2]).toMatchObject({ + chainName: "casper", + rpcUrl: "https://node.mainnet.casper.network/rpc", + }); + expect(NetworkConfigs[CASPER_TESTNET_CAIP2]).toMatchObject({ + chainName: "casper-test", + rpcUrl: "https://node.testnet.casper.network/rpc", + }); + }); +}); diff --git a/typescript/packages/mechanisms/casper/test/unit/contracts.test.ts b/typescript/packages/mechanisms/casper/test/unit/contracts.test.ts new file mode 100644 index 0000000000..511c072a16 --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/unit/contracts.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it, vi } from "vitest"; +import { RpcClient } from "casper-js-sdk"; +import { + dictionaryKeyForAddress, + dictionaryKeyForUsedNonces, + getActiveContractForToken, + isMissingDictionaryItem, + readDictionaryBool, + readDictionaryU256OrDefault, +} from "../../src/contracts"; + +type CasperRpcClient = InstanceType; + +function createRpcClient(methods: Partial): CasperRpcClient { + return methods as CasperRpcClient; +} + +function contractPackageState(versions: unknown[], disabledVersions: number[][] = []) { + return { + storedValue: { + contractPackage: { + disabledVersions, + versions, + }, + }, + }; +} + +function contractState(entryPoints: { name: string }[] = []) { + return { + storedValue: { + contract: { + entryPoints, + }, + }, + }; +} + +function dictionaryU256(value: string) { + return { + storedValue: { + clValue: { + ui256: { toString: () => value }, + }, + }, + }; +} + +function dictionaryBool(value: boolean) { + return { + storedValue: { + clValue: { + bool: { getValue: () => value }, + }, + }, + }; +} + +describe("Casper contract helpers", () => { + it("loads the highest enabled contract version for a token package", async () => { + const assetKey = "a5".repeat(32); + const selectedContractHash = "5a".repeat(32); + const queryLatestGlobalState = vi.fn(async (key: string) => { + if (key === `hash-${assetKey}`) { + return contractPackageState( + [ + { + contractVersion: 1, + protocolVersionMajor: 1, + contractHash: { + hash: { + toHex: () => `hash-${"5a".repeat(32)}`, + }, + }, + }, + { + contractVersion: 1, + protocolVersionMajor: 2, + contractHash: { + toJSON: () => `contract-${selectedContractHash}`, + }, + }, + { + contractVersion: 2, + protocolVersionMajor: 2, + contractHash: { + hash: { + toHex: () => "0e".repeat(32), + }, + }, + }, + ], + [[2, 2]], + ); + } + + if (key === `hash-${selectedContractHash}`) { + return contractState([{ name: "transfer_with_authorization" }]); + } + + throw new Error(`unexpected state key: ${key}`); + }); + const rpcClient = createRpcClient({ queryLatestGlobalState }); + + const result = await getActiveContractForToken(rpcClient, assetKey); + + expect(result.contractHash).toBe(`hash-${selectedContractHash}`); + expect(result.entryPoints).toEqual([{ name: "transfer_with_authorization" }]); + expect(queryLatestGlobalState).toHaveBeenNthCalledWith(1, `hash-${assetKey}`, []); + expect(queryLatestGlobalState).toHaveBeenNthCalledWith(2, `hash-${selectedContractHash}`, []); + }); + + it("rejects token packages without an enabled contract version", async () => { + const rpcClient = createRpcClient({ + queryLatestGlobalState: vi.fn(async () => + contractPackageState( + [ + { + contractVersion: 1, + protocolVersionMajor: 1, + contractHash: { + hash: { + toHex: () => "b".repeat(64), + }, + }, + }, + ], + [[1, 1]], + ), + ), + }); + + await expect(getActiveContractForToken(rpcClient, "a".repeat(64))).rejects.toThrow( + "contract package has no enabled versions", + ); + }); + + it("rejects token packages without versions", async () => { + const rpcClient = createRpcClient({ + queryLatestGlobalState: vi.fn(async () => contractPackageState([])), + }); + + await expect(getActiveContractForToken(rpcClient, "a".repeat(64))).rejects.toThrow( + "contract package has no active versions", + ); + }); + + it("rejects asset keys queries that are not token packages", async () => { + const rpcClient = createRpcClient({ + queryLatestGlobalState: vi.fn(async () => ({ storedValue: {} })), + }); + + await expect(getActiveContractForToken(rpcClient, "a".repeat(64))).rejects.toThrow( + "token package not found", + ); + }); + + it("rejects contract versions without a contract hash", async () => { + const rpcClient = createRpcClient({ + queryLatestGlobalState: vi.fn(async () => + contractPackageState([ + { + contractVersion: 1, + protocolVersionMajor: 1, + contractHash: {}, + }, + ]), + ), + }); + + await expect(getActiveContractForToken(rpcClient, "a".repeat(64))).rejects.toThrow( + "contract version is missing contract hash", + ); + }); + + it("wraps failures when the active contract state cannot be loaded", async () => { + const assetKey = "a".repeat(64); + const queryLatestGlobalState = vi.fn(async (key: string) => { + if (key === `hash-${assetKey}`) { + return contractPackageState([ + { + contractVersion: 1, + protocolVersionMajor: 1, + contractHash: { + hash: { + toHex: () => "b".repeat(64), + }, + }, + }, + ]); + } + + return { storedValue: {} }; + }); + const rpcClient = createRpcClient({ queryLatestGlobalState }); + + await expect(getActiveContractForToken(rpcClient, assetKey)).rejects.toThrow( + "failed to get active contract for token: active contract not found", + ); + }); + + it("wraps non-error active contract lookup failures", async () => { + const assetKey = "a".repeat(64); + const queryLatestGlobalState = vi.fn(async (key: string) => { + if (key === `hash-${assetKey}`) { + return contractPackageState([ + { + contractVersion: 1, + protocolVersionMajor: 1, + contractHash: { + hash: { + toHex: () => "b".repeat(64), + }, + }, + }, + ]); + } + + throw "rpc unavailable"; + }); + const rpcClient = createRpcClient({ queryLatestGlobalState }); + + await expect(getActiveContractForToken(rpcClient, assetKey)).rejects.toThrow( + "failed to get active contract for token: rpc unavailable", + ); + }); + + it("reads decimal U256 dictionary values as bigint", async () => { + const getDictionaryItemByIdentifier = vi.fn(async () => dictionaryU256("123456789")); + const rpcClient = createRpcClient({ getDictionaryItemByIdentifier }); + + await expect( + readDictionaryU256OrDefault(rpcClient, "hash-contract", "balances", "account-key"), + ).resolves.toBe(123456789n); + + expect(getDictionaryItemByIdentifier).toHaveBeenCalledTimes(1); + }); + + it("returns zero for missing U256 dictionary items", async () => { + const rpcClient = createRpcClient({ + getDictionaryItemByIdentifier: vi.fn(async () => { + throw { sourceErr: { data: "Dictionary URef not found" } }; + }), + }); + + await expect( + readDictionaryU256OrDefault(rpcClient, "hash-contract", "balances", "missing-key"), + ).resolves.toBe(0n); + }); + + it("rejects malformed U256 dictionary values", async () => { + const rpcClient = createRpcClient({ + getDictionaryItemByIdentifier: vi.fn(async () => dictionaryU256("12.5")), + }); + + await expect( + readDictionaryU256OrDefault(rpcClient, "hash-contract", "balances", "account-key"), + ).rejects.toThrow("invalid U256 dictionary value"); + }); + + it("propagates non-missing U256 dictionary errors", async () => { + const rpcClient = createRpcClient({ + getDictionaryItemByIdentifier: vi.fn(async () => { + throw new Error("rpc failed"); + }), + }); + + await expect( + readDictionaryU256OrDefault(rpcClient, "hash-contract", "balances", "account-key"), + ).rejects.toThrow("rpc failed"); + }); + + it("parses bool dictionary values and rejects malformed bool entries", async () => { + const validRpcClient = createRpcClient({ + getDictionaryItemByIdentifier: vi.fn(async () => dictionaryBool(true)), + }); + await expect( + readDictionaryBool(validRpcClient, "hash-contract", "authorization_state", "nonce-key"), + ).resolves.toBe(true); + + const invalidRpcClient = createRpcClient({ + getDictionaryItemByIdentifier: vi.fn(async () => ({ storedValue: { clValue: {} } })), + }); + await expect( + readDictionaryBool(invalidRpcClient, "hash-contract", "authorization_state", "nonce-key"), + ).rejects.toThrow("invalid used_nonces dictionary value"); + }); + + it("detects only missing dictionary item errors", () => { + expect(isMissingDictionaryItem({ sourceErr: { data: "dictionary URef not found" } })).toBe( + true, + ); + expect(isMissingDictionaryItem({ sourceErr: { data: "permission denied" } })).toBe(false); + expect(isMissingDictionaryItem(new Error("dictionary URef not found"))).toBe(false); + }); + + it("encodes dictionary keys for account addresses and used nonces", () => { + const zeroAccountHash = `00${"00".repeat(32)}`; + const repeatedNonce = "11".repeat(32); + + expect(dictionaryKeyForAddress(zeroAccountHash)).toBe( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + expect(dictionaryKeyForUsedNonces(zeroAccountHash, repeatedNonce)).toBe( + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERERERERERERERERERERERERERERERERERERERERERE=", + ); + }); +}); diff --git a/typescript/packages/mechanisms/casper/test/unit/defaultAssets.test.ts b/typescript/packages/mechanisms/casper/test/unit/defaultAssets.test.ts new file mode 100644 index 0000000000..ef92590ed5 --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/unit/defaultAssets.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { CASPER_MAINNET_CAIP2, CSPR_USDC_MAINNET_ASSET } from "../../src/constants"; +import { DEFAULT_ASSETS, findDefaultAsset, getDefaultAsset } from "../../src/defaultAssets"; + +const MAINNET_CSPR_USDC = DEFAULT_ASSETS[CASPER_MAINNET_CAIP2]![0]!; +const CASPER_UNKNOWN_CAIP2 = "casper:casper-unknown"; + +describe("defaultAssets (Casper)", () => { + describe("findDefaultAsset", () => { + it("matches the configured fungible asset address (case-insensitive)", () => { + expect(findDefaultAsset(CSPR_USDC_MAINNET_ASSET, CASPER_MAINNET_CAIP2)).toEqual( + MAINNET_CSPR_USDC, + ); + expect(findDefaultAsset(CSPR_USDC_MAINNET_ASSET.toUpperCase(), CASPER_MAINNET_CAIP2)).toEqual( + MAINNET_CSPR_USDC, + ); + }); + + it("returns undefined for an unknown network", () => { + expect( + findDefaultAsset(CSPR_USDC_MAINNET_ASSET, CASPER_UNKNOWN_CAIP2 as never), + ).toBeUndefined(); + }); + }); + + describe("getDefaultAsset", () => { + it("returns the first list entry as the network default", () => { + expect(getDefaultAsset(CASPER_MAINNET_CAIP2)).toEqual(MAINNET_CSPR_USDC); + }); + + it("throws for an unknown network", () => { + expect(() => getDefaultAsset(CASPER_UNKNOWN_CAIP2 as never)).toThrow( + /No default asset configured for network casper:casper-unknown/, + ); + }); + }); +}); diff --git a/typescript/packages/mechanisms/casper/test/unit/facilitator.preflight.test.ts b/typescript/packages/mechanisms/casper/test/unit/facilitator.preflight.test.ts new file mode 100644 index 0000000000..1858b64769 --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/unit/facilitator.preflight.test.ts @@ -0,0 +1,391 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + RpcClient, + newSpeculativeClient, + rpcClient, + speculativeExec, + contractHash, + tokenPackage, + tokenContract, + dictionaryU256, + dictionaryBool, +} = vi.hoisted(() => { + const rpcClient = { + queryLatestGlobalState: vi.fn(), + getDictionaryItemByIdentifier: vi.fn(), + }; + const speculativeExec = vi.fn(); + const contractHash = "b".repeat(64); + const tokenPackage = { + storedValue: { + contractPackage: { + disabledVersions: [], + versions: [ + { + contractVersion: 1, + protocolVersionMajor: 1, + contractHash: { + hash: { + toHex: () => contractHash, + }, + }, + }, + ], + }, + }, + }; + const tokenContract = { + storedValue: { + contract: { + entryPoints: [{ name: "transfer_with_authorization" }], + }, + }, + }; + return { + RpcClient: vi.fn(() => rpcClient), + newSpeculativeClient: vi.fn(() => ({ speculativeExec })), + rpcClient, + speculativeExec, + contractHash, + tokenPackage, + tokenContract, + dictionaryU256: (value: string) => ({ + storedValue: { + clValue: { + ui256: { toString: () => value }, + }, + }, + }), + dictionaryBool: (value: boolean) => ({ + storedValue: { + clValue: { + bool: { getValue: () => value }, + }, + }, + }), + }; +}); + +vi.mock("casper-js-sdk", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + RpcClient, + SpeculativeClient: { + newSpeculativeClient, + }, + }; +}); + +import { KeyAlgorithm, PrivateKey, type Transaction } from "casper-js-sdk"; +import { ExactCasperScheme as ClientExactCasperScheme } from "../../src/exact/client/scheme"; +import { + ErrAuthorizationUsed, + ErrInsufficientBalance, + ErrNetworkMismatch, + ErrSpeculativeExecutionFailed, + ErrUnsupportedAsset, + ExactCasperScheme, +} from "../../src/exact/facilitator/scheme"; +import { toClientCasperSigner } from "../../src/signer"; +import type { ExactCasperPayload, FacilitatorCasperSigner } from "../../src/types"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; + +const testAsset = "aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; +const testPayTo = "00aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; +const testNetwork = "casper:casper-test"; + +function createMockSigner( + overrides: Partial = {}, +): FacilitatorCasperSigner { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + return { + getNetworkConfig: async () => ({ + chainName: "casper-test", + rpcUrl: "http://localhost:11101/rpc", + }), + getSpeculativeRpcUrl: () => undefined, + getAddresses: () => [privateKey.publicKey.accountHash().toHex()], + getPublicKeyHex: () => privateKey.publicKey.toHex(), + signTransaction: vi.fn(async (_transaction: Transaction) => {}), + putTransaction: vi.fn(async () => "a".repeat(64)), + waitForTransaction: vi.fn(async () => {}), + ...overrides, + }; +} + +function buildRequirements(overrides: Partial = {}): PaymentRequirements { + return { + scheme: "exact", + network: testNetwork, + asset: testAsset, + amount: "1000000", + payTo: testPayTo, + maxTimeoutSeconds: 300, + extra: { + name: "TestToken", + version: "1", + }, + ...overrides, + }; +} + +function buildPaymentPayload(payload: ExactCasperPayload): PaymentPayload { + const requirements = buildRequirements(); + return { + x402Version: 2, + accepted: requirements, + payload: payload as unknown as Record, + }; +} + +async function createValidPayload( + requirements: PaymentRequirements = buildRequirements(), +): Promise { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const clientSigner = toClientCasperSigner(privateKey); + const clientScheme = new ClientExactCasperScheme(clientSigner); + const result = await clientScheme.createPaymentPayload(2, requirements); + return result.payload as ExactCasperPayload; +} + +describe("ExactCasperScheme facilitator preflight", () => { + beforeEach(() => { + RpcClient.mockClear(); + newSpeculativeClient.mockClear(); + speculativeExec.mockReset(); + rpcClient.queryLatestGlobalState.mockReset(); + rpcClient.getDictionaryItemByIdentifier.mockReset(); + rpcClient.queryLatestGlobalState + .mockResolvedValueOnce(tokenPackage) + .mockResolvedValueOnce(tokenContract); + rpcClient.getDictionaryItemByIdentifier + .mockResolvedValueOnce(dictionaryU256("1000000")) + .mockResolvedValueOnce(dictionaryBool(false)); + }); + + it("runs SDK-owned targeted RPC preflight when no speculative URL is configured", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + const result = await scheme.verify(buildPaymentPayload(payload), buildRequirements()); + + expect(result).toMatchObject({ isValid: true, payer: payload.authorization.from }); + expect(newSpeculativeClient).not.toHaveBeenCalled(); + expect(rpcClient.queryLatestGlobalState).toHaveBeenCalledWith(`hash-${testAsset}`, []); + expect(rpcClient.queryLatestGlobalState).toHaveBeenCalledWith(`hash-${contractHash}`, []); + expect(rpcClient.getDictionaryItemByIdentifier).toHaveBeenCalledTimes(2); + expect(rpcClient.getDictionaryItemByIdentifier.mock.calls[0]?.[1]).toMatchObject({ + contractNamedKey: { + key: `hash-${contractHash}`, + dictionaryName: "balances", + }, + }); + expect(rpcClient.getDictionaryItemByIdentifier.mock.calls[1]?.[1]).toMatchObject({ + contractNamedKey: { + key: `hash-${contractHash}`, + dictionaryName: "authorization_state", + }, + }); + }); + + it("maps signer network config failures to network mismatch", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme( + createMockSigner({ + getNetworkConfig: async () => { + throw new Error("unsupported network"); + }, + }), + ); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrNetworkMismatch, + invalidMessage: "unsupported network", + payer: payload.authorization.from, + }); + }); + + it("runs speculative execution only when a speculative URL is configured", async () => { + speculativeExec.mockResolvedValueOnce({ executionResult: {} }); + const payload = await createValidPayload(); + const signer = createMockSigner({ + getSpeculativeRpcUrl: () => "http://localhost:7778/rpc", + }); + const scheme = new ExactCasperScheme(signer); + + const result = await scheme.verify(buildPaymentPayload(payload), buildRequirements()); + + expect(result).toMatchObject({ isValid: true, payer: payload.authorization.from }); + expect(newSpeculativeClient).toHaveBeenCalledTimes(1); + expect(speculativeExec).toHaveBeenCalledTimes(1); + expect(rpcClient.queryLatestGlobalState).not.toHaveBeenCalled(); + expect(rpcClient.getDictionaryItemByIdentifier).not.toHaveBeenCalled(); + }); + + it("maps speculative execution failures to invalid verification", async () => { + speculativeExec.mockResolvedValueOnce({ executionResult: { errorMessage: "reverted" } }); + const payload = await createValidPayload(); + const signer = createMockSigner({ + getSpeculativeRpcUrl: () => "http://localhost:7778/rpc", + }); + const scheme = new ExactCasperScheme(signer); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrSpeculativeExecutionFailed, + invalidMessage: "speculative execution failed: reverted", + payer: payload.authorization.from, + }); + }); + + it("maps unrecognized speculative execution responses to invalid verification", async () => { + speculativeExec.mockResolvedValueOnce({ rawJSON: { api_version: "1.5.0" } }); + const payload = await createValidPayload(); + const signer = createMockSigner({ + getSpeculativeRpcUrl: () => "http://localhost:7778/rpc", + }); + const scheme = new ExactCasperScheme(signer); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrSpeculativeExecutionFailed, + invalidMessage: + 'speculative execution returned an unrecognized response: : {"api_version":"1.5.0"}', + payer: payload.authorization.from, + }); + }); + + it("rejects insufficient CEP-18 balances from targeted RPC preflight", async () => { + rpcClient.getDictionaryItemByIdentifier + .mockReset() + .mockResolvedValueOnce(dictionaryU256("999999")) + .mockResolvedValueOnce(dictionaryBool(false)); + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInsufficientBalance, + payer: payload.authorization.from, + }); + }); + + it("maps token package lookup failures to unsupported asset", async () => { + rpcClient.queryLatestGlobalState + .mockReset() + .mockRejectedValueOnce(new Error("rpc unavailable")); + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrUnsupportedAsset, + invalidMessage: "rpc unavailable", + payer: payload.authorization.from, + }); + }); + + it("maps malformed balance dictionary values to insufficient balance", async () => { + rpcClient.getDictionaryItemByIdentifier + .mockReset() + .mockResolvedValueOnce(dictionaryU256("not-a-number")); + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInsufficientBalance, + invalidMessage: "invalid U256 dictionary value", + payer: payload.authorization.from, + }); + }); + + it("rejects used CEP-3009 authorizations from targeted RPC preflight", async () => { + rpcClient.getDictionaryItemByIdentifier + .mockReset() + .mockResolvedValueOnce(dictionaryU256("1000000")) + .mockResolvedValueOnce(dictionaryBool(true)); + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrAuthorizationUsed, + payer: payload.authorization.from, + }); + }); + + it("allows missing authorization_state dictionary items", async () => { + rpcClient.getDictionaryItemByIdentifier + .mockReset() + .mockResolvedValueOnce(dictionaryU256("1000000")) + .mockRejectedValueOnce({ sourceErr: { data: "dictionary URef not found" } }); + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + isValid: true, + payer: payload.authorization.from, + }); + }); + + it("maps nonce dictionary read failures to authorization-used errors", async () => { + rpcClient.getDictionaryItemByIdentifier + .mockReset() + .mockResolvedValueOnce(dictionaryU256("1000000")) + .mockRejectedValueOnce(new Error("dictionary seed URef not found")); + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrAuthorizationUsed, + invalidMessage: "dictionary seed URef not found", + payer: payload.authorization.from, + }); + }); + + it("rejects token contracts without transfer_with_authorization", async () => { + rpcClient.queryLatestGlobalState + .mockReset() + .mockResolvedValueOnce(tokenPackage) + .mockResolvedValueOnce({ + storedValue: { + contract: { + entryPoints: [{ name: "transfer" }], + }, + }, + }); + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrUnsupportedAsset, + payer: payload.authorization.from, + }); + }); +}); diff --git a/typescript/packages/mechanisms/casper/test/unit/facilitator.test.ts b/typescript/packages/mechanisms/casper/test/unit/facilitator.test.ts new file mode 100644 index 0000000000..0319810191 --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/unit/facilitator.test.ts @@ -0,0 +1,563 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { RpcClient, rpcClient, tokenPackage, tokenContract, dictionaryU256, dictionaryBool } = + vi.hoisted(() => { + const asset = "aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; + const contractHash = "b".repeat(64); + const rpcClient = { + queryLatestGlobalState: vi.fn(async (key: string) => + key === `hash-${asset}` + ? { + storedValue: { + contractPackage: { + disabledVersions: [], + versions: [ + { + contractVersion: 1, + protocolVersionMajor: 1, + contractHash: { + hash: { + toHex: () => contractHash, + }, + }, + }, + ], + }, + }, + } + : { + storedValue: { + contract: { + entryPoints: [{ name: "transfer_with_authorization" }], + }, + }, + }, + ), + getDictionaryItemByIdentifier: vi.fn( + async ( + _stateRootHash: string | null, + identifier: { contractNamedKey?: { dictionaryName?: string } }, + ) => { + const dictionaryName = identifier.contractNamedKey?.dictionaryName; + return dictionaryName === "balances" + ? { + storedValue: { + clValue: { + ui256: { toString: () => "1000000" }, + }, + }, + } + : { + storedValue: { + clValue: { + bool: { getValue: () => false }, + }, + }, + }; + }, + ), + }; + return { + RpcClient: vi.fn(() => rpcClient), + rpcClient, + tokenPackage: { + storedValue: { + contractPackage: { + disabledVersions: [], + versions: [ + { + contractVersion: 1, + protocolVersionMajor: 1, + contractHash: { + hash: { + toHex: () => contractHash, + }, + }, + }, + ], + }, + }, + }, + tokenContract: { + storedValue: { + contract: { + entryPoints: [{ name: "transfer_with_authorization" }], + }, + }, + }, + dictionaryU256: (value: string) => ({ + storedValue: { + clValue: { + ui256: { toString: () => value }, + }, + }, + }), + dictionaryBool: (value: boolean) => ({ + storedValue: { + clValue: { + bool: { getValue: () => value }, + }, + }, + }), + }; + }); + +vi.mock("casper-js-sdk", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + RpcClient, + }; +}); + +import { KeyAlgorithm, PrivateKey, type Transaction } from "casper-js-sdk"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { ExactCasperScheme as ClientExactCasperScheme } from "../../src/exact/client/scheme"; +import { ExactCasperScheme } from "../../src/exact/facilitator/scheme"; +import { + ErrAmountMismatch, + ErrExpired, + ErrInvalidAsset, + ErrInvalidPayer, + ErrInvalidPayTo, + ErrInvalidScheme, + ErrInvalidSignature, + ErrMissingTokenName, + ErrMissingTokenVersion, + ErrNetworkMismatch, + ErrNonCanonicalSignature, + ErrNotYetValid, + ErrPayToMismatch, + ErrPublicKeyMismatch, + ErrSettleFailed, +} from "../../src/exact/facilitator/scheme"; +import { toClientCasperSigner } from "../../src/signer"; +import type { ExactCasperPayload, FacilitatorCasperSigner } from "../../src/types"; + +const testAsset = "aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; +const testPayTo = "00aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; +const testPackagePayTo = `01${"b".repeat(64)}`; +const testNetwork = "casper:casper-test"; + +function createMockSigner( + overrides: Partial = {}, +): FacilitatorCasperSigner { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + return { + getNetworkConfig: async () => ({ + chainName: "casper-test", + rpcUrl: "http://localhost:11101/rpc", + }), + getAddresses: () => [privateKey.publicKey.accountHash().toHex()], + getPublicKeyHex: () => privateKey.publicKey.toHex(), + getSpeculativeRpcUrl: () => undefined, + signTransaction: vi.fn(async () => {}), + putTransaction: vi.fn(async () => "a".repeat(64)), + waitForTransaction: vi.fn(async () => {}), + ...overrides, + }; +} + +function buildRequirements(overrides: Partial = {}): PaymentRequirements { + return { + scheme: "exact", + network: testNetwork, + asset: testAsset, + amount: "1000000", + payTo: testPayTo, + maxTimeoutSeconds: 300, + extra: { + name: "TestToken", + version: "1", + }, + ...overrides, + }; +} + +function buildPaymentPayload(payload: ExactCasperPayload): PaymentPayload { + const requirements = buildRequirements(); + return { + x402Version: 2, + accepted: requirements, + payload: payload as unknown as Record, + }; +} + +async function createValidPayload( + requirements: PaymentRequirements = buildRequirements(), + keyAlgorithm = KeyAlgorithm.ED25519, +): Promise { + const privateKey = PrivateKey.generate(keyAlgorithm); + const clientSigner = toClientCasperSigner(privateKey); + const clientScheme = new ClientExactCasperScheme(clientSigner); + const result = await clientScheme.createPaymentPayload(2, requirements); + return result.payload as ExactCasperPayload; +} + +describe("ExactCasperScheme facilitator", () => { + beforeEach(() => { + RpcClient.mockClear(); + rpcClient.queryLatestGlobalState.mockReset(); + rpcClient.queryLatestGlobalState.mockImplementation(async (key: string) => + key === `hash-${testAsset}` ? tokenPackage : tokenContract, + ); + rpcClient.getDictionaryItemByIdentifier.mockReset(); + rpcClient.getDictionaryItemByIdentifier.mockImplementation( + async ( + _stateRootHash: string | null, + identifier: { contractNamedKey?: { dictionaryName?: string } }, + ) => + identifier.contractNamedKey?.dictionaryName === "balances" + ? dictionaryU256("1000000") + : dictionaryBool(false), + ); + }); + + it("returns extra and signer addresses", () => { + const signer = createMockSigner(); + const scheme = new ExactCasperScheme(signer); + + expect(scheme.getExtra(testNetwork)).toEqual({}); + expect(scheme.getSigners(testNetwork)).toEqual(signer.getAddresses(testNetwork)); + }); + + it("validates a correct payload", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + const result = await scheme.verify(buildPaymentPayload(payload), buildRequirements()); + + expect(result.isValid).toBe(true); + expect(result.payer).toBe(payload.authorization.from); + }); + + it("rejects structural mismatches", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements({ scheme: "upto" })), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInvalidScheme, + }); + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements({ network: "casper:other" })), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrNetworkMismatch, + }); + await expect( + scheme.verify( + buildPaymentPayload(payload), + buildRequirements({ payTo: "00".padEnd(66, "0") }), + ), + ).resolves.toMatchObject({ isValid: false, invalidReason: ErrPayToMismatch }); + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements({ amount: "2000000" })), + ).resolves.toMatchObject({ isValid: false, invalidReason: ErrAmountMismatch }); + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements({ asset: "bad" })), + ).resolves.toMatchObject({ isValid: false, invalidReason: ErrInvalidAsset }); + }); + + it("rejects malformed payload shapes", async () => { + const scheme = new ExactCasperScheme(createMockSigner()); + const payment = buildPaymentPayload(await createValidPayload()); + payment.payload = { signature: "01" }; + + await expect(scheme.verify(payment, buildRequirements())).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInvalidScheme, + invalidMessage: "malformed payload", + }); + }); + + it("rejects invalid authorization fields", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + const invalidPayTo = structuredClone(payload); + invalidPayTo.authorization.to = "bad"; + await expect( + scheme.verify(buildPaymentPayload(invalidPayTo), buildRequirements({ payTo: "bad" })), + ).resolves.toMatchObject({ isValid: false, invalidReason: ErrInvalidPayTo }); + + const expired = structuredClone(payload); + expired.authorization.validAfter = String(Math.floor(Date.now() / 1000) - 100); + expired.authorization.validBefore = String(Math.floor(Date.now() / 1000) - 10); + await expect( + scheme.verify(buildPaymentPayload(expired), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrExpired, + }); + + const future = structuredClone(payload); + future.authorization.validAfter = String(Math.floor(Date.now() / 1000) + 100); + future.authorization.validBefore = String(Math.floor(Date.now() / 1000) + 200); + await expect( + scheme.verify(buildPaymentPayload(future), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrNotYetValid, + }); + }); + + it("rejects invalid payer, amount, nonce, validity, and token metadata", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + const invalidPayer = structuredClone(payload); + invalidPayer.authorization.from = `01${"a".repeat(64)}`; + await expect( + scheme.verify(buildPaymentPayload(invalidPayer), buildRequirements()), + ).resolves.toMatchObject({ isValid: false, invalidReason: ErrInvalidPayer }); + + const zeroAmount = structuredClone(payload); + zeroAmount.authorization.value = "0"; + await expect( + scheme.verify(buildPaymentPayload(zeroAmount), buildRequirements({ amount: "0" })), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrAmountMismatch, + invalidMessage: "amount must be non-zero decimal string", + }); + + const invalidNonce = structuredClone(payload); + invalidNonce.authorization.nonce = "not-a-nonce"; + await expect( + scheme.verify(buildPaymentPayload(invalidNonce), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInvalidSignature, + invalidMessage: "nonce must be 32 bytes", + }); + + const invalidDates = structuredClone(payload); + invalidDates.authorization.validAfter = "1.5"; + await expect( + scheme.verify(buildPaymentPayload(invalidDates), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInvalidScheme, + invalidMessage: "invalid validAfter/validBefore", + }); + + await expect( + scheme.verify(buildPaymentPayload(payload), buildRequirements({ extra: { version: "1" } })), + ).resolves.toMatchObject({ isValid: false, invalidReason: ErrMissingTokenName }); + + await expect( + scheme.verify( + buildPaymentPayload(payload), + buildRequirements({ extra: { name: "TestToken" } }), + ), + ).resolves.toMatchObject({ isValid: false, invalidReason: ErrMissingTokenVersion }); + }); + + it("rejects payloads whose signature and public key use different algorithms", async () => { + const scheme = new ExactCasperScheme(createMockSigner()); + + for (const { signingAlgorithm, publicKeyAlgorithm } of [ + { + signingAlgorithm: KeyAlgorithm.ED25519, + publicKeyAlgorithm: KeyAlgorithm.SECP256K1, + }, + { + signingAlgorithm: KeyAlgorithm.SECP256K1, + publicKeyAlgorithm: KeyAlgorithm.ED25519, + }, + ]) { + const payload = await createValidPayload(buildRequirements(), signingAlgorithm); + const mismatchedPublicKey = structuredClone(payload); + mismatchedPublicKey.publicKey = payload.publicKey.replace( + new RegExp(`^0${signingAlgorithm.toString()}`), + `0${publicKeyAlgorithm.toString()}`, + ); + + await expect( + scheme.verify(buildPaymentPayload(mismatchedPublicKey), buildRequirements()), + ).resolves.toMatchObject({ isValid: false, invalidReason: ErrInvalidSignature }); + } + }); + + it("rejects signature and public key failures", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + const wrongPublicKey = structuredClone(payload); + wrongPublicKey.publicKey = PrivateKey.generate(KeyAlgorithm.ED25519).publicKey.toHex(); + await expect( + scheme.verify(buildPaymentPayload(wrongPublicKey), buildRequirements()), + ).resolves.toMatchObject({ isValid: false, invalidReason: ErrPublicKeyMismatch }); + + const invalidSignature = structuredClone(payload); + invalidSignature.signature = "01" + "0".repeat(128); + await expect( + scheme.verify(buildPaymentPayload(invalidSignature), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInvalidSignature, + invalidMessage: "invalid signature", + }); + + const algoMismatch = structuredClone(payload); + algoMismatch.signature = "02" + payload.signature.slice(2); + await expect( + scheme.verify(buildPaymentPayload(algoMismatch), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInvalidSignature, + invalidMessage: "public key and signature algorithm tags do not match", + }); + + const secp256k1Payload = await createValidPayload(buildRequirements(), KeyAlgorithm.SECP256K1); + const highS = structuredClone(secp256k1Payload); + highS.signature = "02" + "0".repeat(64) + "f".repeat(64); + await expect( + scheme.verify(buildPaymentPayload(highS), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrNonCanonicalSignature, + }); + }); + + it("rejects malformed signatures and public keys", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + const shortSignature = structuredClone(payload); + shortSignature.signature = "01" + "0".repeat(126); + await expect( + scheme.verify(buildPaymentPayload(shortSignature), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInvalidSignature, + invalidMessage: "signature must be 65 bytes", + }); + + const nonHexSignature = structuredClone(payload); + nonHexSignature.signature = "01zz"; + await expect( + scheme.verify(buildPaymentPayload(nonHexSignature), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInvalidSignature, + invalidMessage: "hex string contains non-hex characters", + }); + + const invalidPublicKey = structuredClone(payload); + invalidPublicKey.publicKey = "01ff"; + await expect( + scheme.verify(buildPaymentPayload(invalidPublicKey), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInvalidSignature, + }); + + const badNetwork = structuredClone(payload); + await expect( + scheme.verify(buildPaymentPayload(badNetwork), buildRequirements({ network: "bad-network" })), + ).resolves.toMatchObject({ isValid: false, invalidReason: ErrNetworkMismatch }); + }); + + it("rejects tampered signatures that do not verify", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + const tampered = structuredClone(payload); + tampered.signature = `${payload.signature.slice(0, -2)}${ + payload.signature.endsWith("00") ? "01" : "00" + }`; + + await expect( + scheme.verify(buildPaymentPayload(tampered), buildRequirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: ErrInvalidSignature, + }); + }); + + it("settles valid payloads and maps failures", async () => { + const payload = await createValidPayload(); + const signer = createMockSigner(); + const scheme = new ExactCasperScheme(signer); + + const success = await scheme.settle(buildPaymentPayload(payload), buildRequirements()); + + expect(success).toMatchObject({ + success: true, + transaction: "a".repeat(64), + network: testNetwork, + payer: payload.authorization.from, + }); + expect(signer.signTransaction).toHaveBeenCalledTimes(1); + + const failed = await new ExactCasperScheme( + createMockSigner({ + putTransaction: vi.fn(async () => { + throw new Error("rpc error"); + }), + }), + ).settle(buildPaymentPayload(payload), buildRequirements()); + + expect(failed).toMatchObject({ + success: false, + errorReason: ErrSettleFailed, + transaction: "", + }); + }); + + it("returns verify failures directly during settlement", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme(createMockSigner()); + + await expect( + scheme.settle(buildPaymentPayload(payload), buildRequirements({ amount: "2000000" })), + ).resolves.toMatchObject({ + success: false, + errorReason: ErrAmountMismatch, + transaction: "", + network: testNetwork, + }); + }); + + it("maps non-error settlement failures", async () => { + const payload = await createValidPayload(); + const scheme = new ExactCasperScheme( + createMockSigner({ + putTransaction: vi.fn(async () => { + throw "offline"; + }), + }), + ); + + await expect( + scheme.settle(buildPaymentPayload(payload), buildRequirements()), + ).resolves.toMatchObject({ + success: false, + errorReason: ErrSettleFailed, + errorMessage: "offline", + transaction: "", + }); + }); + + it("preserves package-hash recipients in settlement runtime args", async () => { + const requirements = buildRequirements({ payTo: testPackagePayTo }); + const payload = await createValidPayload(requirements); + const signer = createMockSigner(); + const scheme = new ExactCasperScheme(signer); + + await scheme.settle(buildPaymentPayload(payload), requirements); + + const transaction = vi.mocked(signer.signTransaction).mock.calls[0]?.[0] as + | Transaction + | undefined; + expect(transaction?.args.getByName("from")?.key?.toString()).toBe( + `account-hash-${payload.authorization.from.slice(2)}`, + ); + expect(transaction?.args.getByName("to")?.key?.toString()).toBe( + `hash-${testPackagePayTo.slice(2)}`, + ); + }); +}); diff --git a/typescript/packages/mechanisms/casper/test/unit/index.test.ts b/typescript/packages/mechanisms/casper/test/unit/index.test.ts new file mode 100644 index 0000000000..f251c3926d --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/unit/index.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { + CASPER_CAIP2_FAMILY, + CASPER_TESTNET_CAIP2, + bytesToHex, + isValidCasperAddress, +} from "../../src"; + +describe("@x402/casper exports", () => { + it("exports constants and utilities from root", () => { + expect(CASPER_CAIP2_FAMILY).toBe("casper:*"); + expect(CASPER_TESTNET_CAIP2).toBe("casper:casper-test"); + expect(bytesToHex(new Uint8Array([0xab]))).toBe("ab"); + expect(isValidCasperAddress("00" + "a".repeat(64))).toBe(true); + }); +}); diff --git a/typescript/packages/mechanisms/casper/test/unit/server.test.ts b/typescript/packages/mechanisms/casper/test/unit/server.test.ts new file mode 100644 index 0000000000..26cf0abfca --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/unit/server.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; +import { ExactCasperScheme } from "../../src/exact/server/scheme"; +import { + CASPER_TESTNET_CAIP2, + CSPR_USDC_DECIMALS, + CSPR_USDC_NAME, + CSPR_USDC_TESTNET_ASSET, +} from "../../src"; + +const testPayTo = "00aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; +const customAsset = "aabbccddeeff0011223344556677889900aabbccddeeff001122334455667788"; + +function buildRequirements(overrides: Record = {}) { + return { + scheme: "exact", + network: CASPER_TESTNET_CAIP2, + asset: CSPR_USDC_TESTNET_ASSET, + amount: "1000000", + payTo: testPayTo, + maxTimeoutSeconds: 300, + extra: { + name: CSPR_USDC_NAME, + version: "1", + }, + ...overrides, + }; +} + +const supportedKind = { + x402Version: 2, + scheme: "exact", + network: CASPER_TESTNET_CAIP2, + extra: {}, +}; + +describe("ExactCasperScheme server", () => { + it("should parse dollar string prices", async () => { + const scheme = new ExactCasperScheme(); + const result = await scheme.parsePrice("$0.10", CASPER_TESTNET_CAIP2); + expect(result.amount).toBe("100000"); // 0.10 USDC = 100000 smallest units + expect(result.asset).toBe(CSPR_USDC_TESTNET_ASSET); + expect(result.extra).toEqual({ name: CSPR_USDC_NAME, version: "1" }); + expect(result.extra).not.toHaveProperty("assetTransferMethod"); + }); + + it("should parse simple number string prices", async () => { + const scheme = new ExactCasperScheme(); + const result = await scheme.parsePrice("0.10", CASPER_TESTNET_CAIP2); + expect(result.amount).toBe("100000"); + expect(result.asset).toBe(CSPR_USDC_TESTNET_ASSET); + }); + + it("should parse number prices", async () => { + const scheme = new ExactCasperScheme(); + const result = await scheme.parsePrice(100.053, CASPER_TESTNET_CAIP2); + expect(result.amount).toBe("100053000"); + expect(result.asset).toBe(CSPR_USDC_TESTNET_ASSET); + expect(result.extra).toEqual({ name: CSPR_USDC_NAME, version: "1" }); + }); + + it("returns explicit AssetAmount with extra preserved", async () => { + const scheme = new ExactCasperScheme(); + const result = await scheme.parsePrice( + { + amount: "1000000", + asset: CSPR_USDC_TESTNET_ASSET, + extra: { name: "MyToken", version: "1" }, + }, + CASPER_TESTNET_CAIP2, + ); + + expect(result).toEqual({ + amount: "1000000", + asset: CSPR_USDC_TESTNET_ASSET, + extra: { name: "MyToken", version: "1" }, + }); + }); + + it("returns explicit AssetAmount with empty extra when none is provided", async () => { + const scheme = new ExactCasperScheme(); + + await expect( + scheme.parsePrice( + { + amount: "1000000", + asset: CSPR_USDC_TESTNET_ASSET, + }, + CASPER_TESTNET_CAIP2, + ), + ).resolves.toEqual({ + amount: "1000000", + asset: CSPR_USDC_TESTNET_ASSET, + extra: {}, + }); + }); + + it("rejects explicit AssetAmount prices with invalid assets", async () => { + const scheme = new ExactCasperScheme(); + + await expect( + scheme.parsePrice( + { + amount: "1000000", + asset: "bad", + }, + CASPER_TESTNET_CAIP2, + ), + ).rejects.toThrow("invalid_exact_casper_server_invalid_asset: bad"); + }); + + it("uses custom money parsers for money prices", async () => { + const scheme = new ExactCasperScheme(); + scheme.registerMoneyParser(async () => ({ + amount: "9999", + asset: customAsset, + extra: { name: "Custom", version: "2" }, + })); + + expect(await scheme.parsePrice(1.0, CASPER_TESTNET_CAIP2)).toEqual({ + amount: "9999", + asset: customAsset, + extra: { name: "Custom", version: "2" }, + }); + }); + + it("enhances and validates payment requirements", async () => { + const scheme = new ExactCasperScheme(); + + const enhanced = await scheme.enhancePaymentRequirements( + buildRequirements({ amount: "1500000" }), + supportedKind, + [], + ); + + expect(enhanced.amount).toBe("1500000"); + expect(enhanced.extra).toMatchObject({ + name: CSPR_USDC_NAME, + version: "1", + }); + }); + + it("copies supported extension extras when enhancement keys request them", async () => { + const scheme = new ExactCasperScheme(); + + const enhanced = await scheme.enhancePaymentRequirements( + buildRequirements({ extra: { name: CSPR_USDC_NAME, version: "1", untouched: "local" } }), + { + ...supportedKind, + extra: { + resource: "premium", + ignored: "not requested", + }, + }, + ["resource"], + ); + + expect(enhanced.extra).toEqual({ + name: CSPR_USDC_NAME, + version: "1", + untouched: "local", + resource: "premium", + }); + }); + + it("rejects invalid server requirements", async () => { + const scheme = new ExactCasperScheme(); + + await expect( + scheme.enhancePaymentRequirements(buildRequirements({ asset: "bad" }), supportedKind, []), + ).rejects.toThrow("invalid_exact_casper_server_invalid_asset"); + await expect( + scheme.enhancePaymentRequirements(buildRequirements({ payTo: "bad" }), supportedKind, []), + ).rejects.toThrow("invalid_exact_casper_server_invalid_payto"); + await expect( + scheme.enhancePaymentRequirements( + buildRequirements({ extra: { version: "1" } }), + supportedKind, + [], + ), + ).rejects.toThrow("invalid_exact_casper_server_missing_token_name"); + await expect( + scheme.enhancePaymentRequirements( + buildRequirements({ extra: { name: CSPR_USDC_NAME } }), + supportedKind, + [], + ), + ).rejects.toThrow("invalid_exact_casper_server_missing_token_version"); + }); + + it("returns registered or default decimals", () => { + const scheme = new ExactCasperScheme(); + + expect(scheme.getAssetDecimals(CSPR_USDC_TESTNET_ASSET, CASPER_TESTNET_CAIP2)).toBe( + CSPR_USDC_DECIMALS, + ); + expect(scheme.getAssetDecimals("unknown", CASPER_TESTNET_CAIP2)).toBe(undefined); + }); +}); diff --git a/typescript/packages/mechanisms/casper/test/unit/signer.test.ts b/typescript/packages/mechanisms/casper/test/unit/signer.test.ts new file mode 100644 index 0000000000..7a7562c025 --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/unit/signer.test.ts @@ -0,0 +1,261 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { HttpHandler, RpcClient, rpcClient } = vi.hoisted(() => { + const rpcClient = { + putTransaction: vi.fn(), + getTransactionByTransactionHash: vi.fn(), + }; + + return { + HttpHandler: vi.fn((url: string) => ({ url })), + RpcClient: vi.fn(() => rpcClient), + rpcClient, + }; +}); + +vi.mock("casper-js-sdk", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + HttpHandler, + RpcClient, + }; +}); + +import { KeyAlgorithm, PrivateKey, type Transaction } from "casper-js-sdk"; +import { + createClientCasperSigner, + createFacilitatorCasperSigner, + toClientCasperSigner, + toFacilitatorCasperSigner, +} from "../../src/signer"; + +function privateKeyHex(privateKey: InstanceType): string { + return Buffer.from(privateKey.toBytes()).toString("hex"); +} + +describe("Casper signer adapters", () => { + beforeEach(() => { + vi.useRealTimers(); + HttpHandler.mockClear(); + RpcClient.mockClear(); + rpcClient.putTransaction.mockReset(); + rpcClient.getTransactionByTransactionHash.mockReset(); + }); + + it("wraps a private key for client signing", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = toClientCasperSigner(privateKey); + + expect(signer.accountAddress()).toMatch(/^00[0-9a-f]{64}$/i); + expect(signer.publicKey()).toMatch(/^01[0-9a-f]{64}$/i); + await expect(signer.signEIP712(new Uint8Array(32))).resolves.toHaveLength(65); + }); + + it("wraps a private key for facilitator settlement", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + rpcUrlConfig: { "casper:casper-test": "http://localhost:11101/rpc" }, + }); + + expect(await signer.getNetworkConfig("casper:casper-test")).toMatchObject({ + chainName: "casper-test", + rpcUrl: "http://localhost:11101/rpc", + }); + expect(signer.getAddresses("casper:casper-test")[0]).toMatch(/^[0-9a-f]{64}$/i); + expect(signer.getPublicKeyHex("casper:casper-test")).toMatch(/^01[0-9a-f]{64}$/i); + expect(signer.getSpeculativeRpcUrl("casper:casper-test")).toBeUndefined(); + }); + + it("uses default RPC URLs for known Casper networks", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, {}); + + await expect(signer.getNetworkConfig("casper:casper-test")).resolves.toEqual({ + chainName: "casper-test", + rpcUrl: "https://node.testnet.casper.network/rpc", + }); + }); + + it("resolves configured speculative RPC URLs per network", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + speculativeRpcUrlConfig: { + "casper:casper-test": " http://localhost:7778/rpc ", + "casper:casper": "", + }, + }); + + expect(signer.getSpeculativeRpcUrl("casper:casper-test")).toBe("http://localhost:7778/rpc"); + expect(signer.getSpeculativeRpcUrl("casper:casper")).toBeUndefined(); + expect(signer.getSpeculativeRpcUrl("casper:other")).toBeUndefined(); + }); + + it("creates a client signer from a hex private key", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await createClientCasperSigner(privateKeyHex(privateKey)); + + expect(signer.accountAddress()).toBe(`00${privateKey.publicKey.accountHash().toHex()}`); + expect(signer.publicKey()).toBe(privateKey.publicKey.toHex()); + }); + + it("creates a facilitator signer from a hex private key", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.SECP256K1); + const signer = await createFacilitatorCasperSigner( + privateKeyHex(privateKey), + KeyAlgorithm.SECP256K1, + { + rpcUrlConfig: { "casper:casper-test": "http://localhost:11101/rpc" }, + }, + ); + + expect(signer.getAddresses("casper:casper-test")).toEqual([ + privateKey.publicKey.accountHash().toHex(), + ]); + expect(signer.getPublicKeyHex("casper:casper-test")).toBe(privateKey.publicKey.toHex()); + }); + + it("rejects unsupported networks when no RPC URL is configured", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + rpcUrlConfig: { "casper:casper-test": "http://localhost:11101/rpc" }, + }); + + await expect(signer.getNetworkConfig("casper:casper-net-1")).rejects.toThrow( + "unsupported Casper network: casper:casper-net-1", + ); + }); + + it("derives chain names for configured custom networks", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + rpcUrlConfig: { "casper:custom-net": "http://localhost:11101/rpc" }, + }); + + await expect(signer.getNetworkConfig("casper:custom-net")).resolves.toEqual({ + chainName: "custom-net", + rpcUrl: "http://localhost:11101/rpc", + }); + }); + + it("signs settlement transactions with the configured private key", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + rpcUrlConfig: { "casper:casper-test": "http://localhost:11101/rpc" }, + }); + const transaction = { sign: vi.fn() } as unknown as Transaction; + + await signer.signTransaction(transaction, "casper:casper-test"); + + expect(transaction.sign).toHaveBeenCalledWith(privateKey); + }); + + it("submits transactions through a cached RPC client", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + rpcUrlConfig: { "casper:custom-net": "http://localhost:11101/rpc" }, + }); + const transaction = {} as Transaction; + rpcClient.putTransaction.mockResolvedValue({ + transactionHash: { toHex: () => "abc123" }, + }); + + await expect(signer.putTransaction("casper:custom-net", transaction)).resolves.toBe("abc123"); + await expect(signer.putTransaction("casper:custom-net", transaction)).resolves.toBe("abc123"); + + expect(HttpHandler).toHaveBeenCalledOnce(); + expect(HttpHandler).toHaveBeenCalledWith("http://localhost:11101/rpc"); + expect(RpcClient).toHaveBeenCalledOnce(); + expect(rpcClient.putTransaction).toHaveBeenCalledTimes(2); + }); + + it("wraps transaction submission failures with RPC source details", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + rpcUrlConfig: { "casper:casper-test": "http://localhost:11101/rpc" }, + }); + const submissionError = new Error("bad request") as Error & { + sourceErr: { code: number; message: string }; + }; + submissionError.sourceErr = { code: -32000, message: "rejected" }; + rpcClient.putTransaction.mockRejectedValue(submissionError); + + await expect(signer.putTransaction("casper:casper-test", {} as Transaction)).rejects.toThrow( + 'transaction submission failed: bad request - {"code":-32000,"message":"rejected"}', + ); + }); + + it("wraps non-error transaction submission failures", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + rpcUrlConfig: { "casper:casper-test": "http://localhost:11101/rpc" }, + }); + rpcClient.putTransaction.mockRejectedValue("offline"); + + await expect(signer.putTransaction("casper:casper-test", {} as Transaction)).rejects.toThrow( + "transaction submission failed: offline", + ); + }); + + it("waits until a transaction has an execution result", async () => { + vi.useFakeTimers(); + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + rpcUrlConfig: { "casper:casper-test": "http://localhost:11101/rpc" }, + }); + rpcClient.getTransactionByTransactionHash + .mockResolvedValueOnce({ executionInfo: undefined }) + .mockResolvedValueOnce({ + executionInfo: { + blockHeight: 42, + executionResult: {}, + }, + }); + + const waitPromise = signer.waitForTransaction("casper:casper-test", "abc123"); + await vi.advanceTimersByTimeAsync(3_000); + + await expect(waitPromise).resolves.toBeUndefined(); + expect(rpcClient.getTransactionByTransactionHash).toHaveBeenCalledTimes(2); + expect(rpcClient.getTransactionByTransactionHash).toHaveBeenCalledWith("abc123"); + }); + + it("rejects failed transaction execution results", async () => { + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + rpcUrlConfig: { "casper:casper-test": "http://localhost:11101/rpc" }, + }); + rpcClient.getTransactionByTransactionHash.mockResolvedValue({ + executionInfo: { + blockHeight: 42, + executionResult: { errorMessage: "execution reverted" }, + }, + }); + + await expect(signer.waitForTransaction("casper:casper-test", "abc123")).rejects.toThrow( + "transaction execution failed: execution reverted", + ); + }); + + it("times out when a transaction never reaches an execution result", async () => { + vi.useFakeTimers(); + const privateKey = PrivateKey.generate(KeyAlgorithm.ED25519); + const signer = await toFacilitatorCasperSigner(privateKey, { + rpcUrlConfig: { "casper:casper-test": "http://localhost:11101/rpc" }, + }); + rpcClient.getTransactionByTransactionHash.mockResolvedValue({ + executionInfo: { + blockHeight: 0, + executionResult: undefined, + }, + }); + + const waitPromise = signer.waitForTransaction("casper:casper-test", "abc123"); + const timeoutExpectation = expect(waitPromise).rejects.toThrow( + "Timed out waiting for transaction abc123", + ); + await vi.advanceTimersByTimeAsync(60_000); + + await timeoutExpectation; + }); +}); diff --git a/typescript/packages/mechanisms/casper/test/unit/utils.test.ts b/typescript/packages/mechanisms/casper/test/unit/utils.test.ts new file mode 100644 index 0000000000..2cba17e7f4 --- /dev/null +++ b/typescript/packages/mechanisms/casper/test/unit/utils.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + bytesToHex, + chainNameFromNetwork, + getNetworkConfig, + hexToBytes, + isCanonicalSecp256k1Signature, + isValidCasperAddress, + isValidContractPackageHash, +} from "../../src/utils"; + +describe("Casper utils", () => { + it("validates Casper address and contract package hash formats", () => { + expect(isValidCasperAddress("00" + "a".repeat(64))).toBe(true); + expect(isValidCasperAddress("01" + "a".repeat(64))).toBe(true); + expect(isValidCasperAddress("02" + "a".repeat(64))).toBe(false); + expect(isValidCasperAddress("00" + "a".repeat(63))).toBe(false); + expect(isValidContractPackageHash("a".repeat(64))).toBe(true); + expect(isValidContractPackageHash("0x" + "a".repeat(64))).toBe(false); + }); + + it("round-trips hex bytes", () => { + const bytes = hexToBytes("0x000102ff"); + expect(Array.from(bytes)).toEqual([0, 1, 2, 255]); + expect(bytesToHex(bytes)).toBe("000102ff"); + }); + + it("rejects odd-length hex strings", () => { + expect(() => hexToBytes("abc")).toThrow("even number of characters"); + }); + + it("extracts chain names from CAIP-2 network identifiers and rejects invalid formats", () => { + expect(chainNameFromNetwork("casper:casper-test")).toBe("casper-test"); + expect(() => chainNameFromNetwork("casper-test")).toThrow( + "Unsupported network format: casper-test (expected casper:CHAIN_NAME)", + ); + expect(() => chainNameFromNetwork("casper:casper:test")).toThrow( + "Unsupported network format: casper:casper:test (expected casper:CHAIN_NAME)", + ); + expect(() => chainNameFromNetwork("casper:")).toThrow("Invalid Casper CAIP-2 network: casper:"); + }); + + it("returns known network config and rejects unknown networks", () => { + expect(getNetworkConfig("casper:casper-test")).toMatchObject({ + chainName: "casper-test", + }); + expect(() => getNetworkConfig("casper:unknown")).toThrow("unsupported Casper network"); + }); + + it("enforces canonical low-s secp256k1 signatures", () => { + const lowS = new Uint8Array(65); + lowS[0] = 0x02; + lowS[64] = 1; + expect(isCanonicalSecp256k1Signature(lowS)).toBe(true); + + const highS = new Uint8Array(65).fill(0xff); + highS[0] = 0x02; + expect(isCanonicalSecp256k1Signature(highS)).toBe(false); + + const ed25519 = new Uint8Array(65).fill(0xff); + ed25519[0] = 0x01; + expect(isCanonicalSecp256k1Signature(ed25519)).toBe(true); + }); +}); diff --git a/typescript/packages/mechanisms/casper/tsconfig.json b/typescript/packages/mechanisms/casper/tsconfig.json new file mode 100644 index 0000000000..f600458996 --- /dev/null +++ b/typescript/packages/mechanisms/casper/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} diff --git a/typescript/packages/mechanisms/casper/tsup.config.ts b/typescript/packages/mechanisms/casper/tsup.config.ts new file mode 100644 index 0000000000..1143bfeaf5 --- /dev/null +++ b/typescript/packages/mechanisms/casper/tsup.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "tsup"; + +const baseConfig = { + entry: { + index: "src/index.ts", + "exact/client/index": "src/exact/client/index.ts", + "exact/server/index": "src/exact/server/index.ts", + "exact/facilitator/index": "src/exact/facilitator/index.ts", + }, + dts: { + resolve: true, + }, + sourcemap: true, + target: "es2020", +}; + +export default defineConfig([ + { + ...baseConfig, + format: "esm", + outDir: "dist/esm", + clean: true, + }, + { + ...baseConfig, + format: "cjs", + outDir: "dist/cjs", + clean: false, + }, +]); diff --git a/typescript/packages/mechanisms/casper/vitest.config.ts b/typescript/packages/mechanisms/casper/vitest.config.ts new file mode 100644 index 0000000000..f0aa23dce6 --- /dev/null +++ b/typescript/packages/mechanisms/casper/vitest.config.ts @@ -0,0 +1,24 @@ +import { loadEnv } from "vite"; +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(({ mode }) => ({ + test: { + env: loadEnv(mode, process.cwd(), ""), + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "**/*.d.ts", "**/gen/**", "**/dist/**"], + reportsDirectory: "./coverage", + reporter: ["text", "json-summary"], + thresholds: { + lines: 80, + functions: 80, + branches: 80, + statements: 80, + }, + }, + exclude: ["**/node_modules/**", "**/dist/**", "**/test/integrations/**"], + }, + plugins: [tsconfigPaths({ projects: ["."] })], +})); diff --git a/typescript/packages/mechanisms/casper/vitest.integration.config.ts b/typescript/packages/mechanisms/casper/vitest.integration.config.ts new file mode 100644 index 0000000000..ec2d58dd93 --- /dev/null +++ b/typescript/packages/mechanisms/casper/vitest.integration.config.ts @@ -0,0 +1,11 @@ +import { loadEnv } from "vite"; +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(({ mode }) => ({ + test: { + env: loadEnv(mode, process.cwd(), ""), + include: ["test/integrations/**/*.test.ts"], + }, + plugins: [tsconfigPaths({ projects: ["."] })], +})); diff --git a/typescript/packages/mechanisms/concordium/CHANGELOG.md b/typescript/packages/mechanisms/concordium/CHANGELOG.md index a43619b5b5..4cf84b970d 100644 --- a/typescript/packages/mechanisms/concordium/CHANGELOG.md +++ b/typescript/packages/mechanisms/concordium/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/concordium Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/concordium/package.json b/typescript/packages/mechanisms/concordium/package.json index a47ef363fe..5c16298354 100644 --- a/typescript/packages/mechanisms/concordium/package.json +++ b/typescript/packages/mechanisms/concordium/package.json @@ -1,6 +1,6 @@ { "name": "@x402/concordium", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/evm/CHANGELOG.md b/typescript/packages/mechanisms/evm/CHANGELOG.md index ca9b772371..4610b3e5d3 100644 --- a/typescript/packages/mechanisms/evm/CHANGELOG.md +++ b/typescript/packages/mechanisms/evm/CHANGELOG.md @@ -1,5 +1,19 @@ # @x402/evm Changelog +## 2.26.0 + +### Minor Changes + +- [c10d3bb](https://github.com/x402-foundation/x402/commit/c10d3bb): EVM facilitators now cache a positive asset-contract check for 15 minutes instead of issuing a fresh eth_getCode on the payment token for every payment. Only positive results are cached, so a token observed mid-deployment still recovers on the next request. The cache is keyed by network and asset so entries cannot collide across chains. ([#3363](https://github.com/x402-foundation/x402/pull/3363)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- [76fe973](https://github.com/x402-foundation/x402/commit/76fe973): Pass the resolved atomic `spendControls` cap to every scheme on `PaymentPayloadContext.maxAmountPerPayment` (omitted when uncapped) so capital-locking schemes can reuse client policy without re-resolving it. Batch-settlement EVM servers always announce `extra.minDeposit` (default `10 × amount`, optional per-route override via `accepts.extra.minDeposit`). Clients size deposits from the hint when valid, clamped to that cap × `depositMultiplier` when a spend cap is set. Uncapped payments (`spendControls: false` or no per-asset cap) also leave deposits uncapped. Older 402s fall back to `depositMultiplier` for sizing. Servers may opt in to SDK enforcement via `enforceMinDeposit: true` (default off; facilitator never enforces). Export `invalid_batch_settlement_evm_deposit_below_min_deposit` for custom server enforcement. ([#3372](https://github.com/x402-foundation/x402/pull/3372)) - Thanks [@phdargen](https://github.com/phdargen) and [@cursoragent](https://github.com/cursoragent)! +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + +### Patch Changes + +- [4fb5d07](https://github.com/x402-foundation/x402/commit/4fb5d07): Add Celo mainnet USDT (`0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e`) and USAT (`0xD2ab3C9A02DBBAB236BfEC45D1d755DF4267F771`), both EIP-3009, as default assets for `eip155:42220`, so `"$0.10 USDT"` and `"$0.10 USAT"` resolve on Celo. Bare `"$0.10"` still resolves to USDC. ([#3457](https://github.com/x402-foundation/x402/pull/3457)) - Thanks [@GigaHierz](https://github.com/GigaHierz)! +- [20e525c](https://github.com/x402-foundation/x402/commit/20e525c): EVM exact settle's ERC-6492 branch now reads payer deployment from the verify it already awaited rather than issuing a second eth_getCode. Both reads happen within one settle call and before any deploy transaction, so this is not the post-deploy re-read that races RPC state propagation. ([#3365](https://github.com/x402-foundation/x402/pull/3365)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/evm/package.json b/typescript/packages/mechanisms/evm/package.json index 923d095329..63e355095f 100644 --- a/typescript/packages/mechanisms/evm/package.json +++ b/typescript/packages/mechanisms/evm/package.json @@ -1,6 +1,6 @@ { "name": "@x402/evm", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/evm/src/assetCache.ts b/typescript/packages/mechanisms/evm/src/assetCache.ts new file mode 100644 index 0000000000..89acaa4959 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/assetCache.ts @@ -0,0 +1,173 @@ +import { getAddress } from "viem"; +import { ErrAssetNotDeployedContract } from "./exact/facilitator/errors"; +import type { FacilitatorEvmSigner } from "./signer"; + +/** Bounds how long a positive asset-contract check is reused. */ +export const DEFAULT_ASSET_CONTRACT_CACHE_TTL_MS = 15 * 60 * 1000; + +export const MAX_ASSET_CONTRACT_CACHE_ENTRIES = 4096; + +export type AssetContractCacheKey = { + network: string; + asset: string; +}; + +type AssetContractCache = { + ttl: number; + expiries: Map; + isFresh(key: AssetContractCacheKey, now: number): boolean; + record(key: AssetContractCacheKey, now: number): void; +}; + +const cacheKey = (key: AssetContractCacheKey): string => `${key.network}\0${key.asset}`; + +const normalizeAsset = (asset: string): string => getAddress(asset).toLowerCase(); + +/** + * Process-wide memo of "this asset address has bytecode" per network. + * Only positive results are stored: a negative result may be a token observed mid-deployment, + * which has to self-heal on the next request. + */ +export const globalAssetContractCache: AssetContractCache = { + ttl: DEFAULT_ASSET_CONTRACT_CACHE_TTL_MS, + // Node's event loop is single-threaded, so a plain Map is enough; Go needs RWMutex. + expiries: new Map(), + + isFresh(key: AssetContractCacheKey, now: number): boolean { + // An empty network is never cached, since entries would otherwise collide + // across chains where one address can hold bytecode on one chain and nothing + // on another. + if (key.network === "") { + return false; + } + + const expiry = this.expiries.get(cacheKey(key)); + return expiry !== undefined && now < expiry; + }, + + record(key: AssetContractCacheKey, now: number): void { + if (key.network === "") { + return; + } + + for (const [existing, expiry] of this.expiries) { + if (now > expiry) { + this.expiries.delete(existing); + } + } + + const serialized = cacheKey(key); + if (!this.expiries.has(serialized) && this.expiries.size >= MAX_ASSET_CONTRACT_CACHE_ENTRIES) { + return; + } + this.expiries.set(serialized, now + this.ttl); + }, +}; + +/** + * Clears the process-wide asset-contract cache, for tests that assert + * on eth_getCode call counts across cases sharing an asset address. + */ +export function resetAssetContractCache(): void { + globalAssetContractCache.expiries = new Map(); +} + +/** + * Checks whether the payment asset is a deployed contract. + * Returns {@link ErrAssetNotDeployedContract} for an EOA/empty address, + * `""` for a deployed contract, or throws if eth_getCode itself fails. + * + * `network` identifies the chain the signer is bound to. It must be accurate, since it scopes the + * cache that serves positive results; an empty network disables caching for the call. Only + * {@link AssetContractCheck.await} populates that cache, so calling this directly always hits the RPC + * on a miss. + * + * @param signer - Facilitator signer used to call eth_getCode on the asset. + * @param network - CAIP-2 network id that scopes the cache; empty disables caching. + * @param asset - Payment token address. + * @returns An empty string when the asset is a contract, or {@link ErrAssetNotDeployedContract}. + */ +export async function validateAssetIsContract( + signer: FacilitatorEvmSigner, + network: string, + asset: string, +): Promise { + const normalizedAsset = normalizeAsset(asset); + if (globalAssetContractCache.isFresh({ network, asset: normalizedAsset }, Date.now())) { + return ""; + } + + let code: `0x${string}` | undefined; + try { + code = await signer.getCode({ address: getAddress(asset) }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`failed to check whether asset is a contract: ${message}`); + } + + if (!code || code === "0x") { + return ErrAssetNotDeployedContract; + } + return ""; +} + +/** + * An asset-contract check running in the background. + */ +export class AssetContractCheck { + /** + * Resolves with the check result. Reading this does not populate the cache; + * only {@link await} does. + */ + readonly results: Promise; + private readonly network: string; + private readonly asset: string; + + /** + * Starts {@link validateAssetIsContract} immediately. Cache recording waits for {@link AssetContractCheck.await}. + * + * @param signer - Facilitator signer used to call eth_getCode on the asset. + * @param network - CAIP-2 network id that scopes the cache; empty disables caching. + * @param asset - Payment token address. + */ + constructor(signer: FacilitatorEvmSigner, network: string, asset: string) { + this.network = network; + this.asset = asset; + this.results = validateAssetIsContract(signer, network, asset); + } + + /** + * Returns the check's result, caching a positive one for {@link DEFAULT_ASSET_CONTRACT_CACHE_TTL_MS}. + * Recording on await rather than when the Promise settles keeps cache contents independent of + * scheduling: a check abandoned by an early return cannot publish a result. + * + * @returns An empty string when the asset is a contract, or {@link ErrAssetNotDeployedContract}. + */ + async await(): Promise { + const reason = await this.results; + if (reason === "") { + globalAssetContractCache.record( + { network: this.network, asset: normalizeAsset(this.asset) }, + Date.now(), + ); + } + return reason; + } +} + +/** + * Runs {@link validateAssetIsContract} in the background so callers can overlap + * it with signature verification. The result is delivered by {@link AssetContractCheck.await}. + * + * @param signer - Facilitator signer used to call eth_getCode on the asset. + * @param network - CAIP-2 network id that scopes the cache; empty disables caching. + * @param asset - Payment token address. + * @returns A check whose {@link AssetContractCheck.await} delivers the result. + */ +export function startAssetContractCheck( + signer: FacilitatorEvmSigner, + network: string, + asset: string, +): AssetContractCheck { + return new AssetContractCheck(signer, network, asset); +} diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts index 25d61f2dbc..99958581be 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts @@ -9,6 +9,7 @@ import { InMemoryPendingSettlementStore, PendingSettlementStore } from "@x402/co import { getAddress, Hex } from "viem"; import { authorizationTypes } from "../../constants"; import { FacilitatorEvmSigner } from "../../signer"; +import { startAssetContractCheck } from "../../assetCache"; import { getEvmChainId } from "../../utils"; import { ExactEIP3009Payload } from "../../types"; import * as Errors from "./errors"; @@ -141,6 +142,9 @@ export async function verifyEIP3009( const signature = eip3009Payload.signature!; + // Run the asset-contract check concurrently with signature classification. + const assetCheck = startAssetContractCheck(signer, requirements.network, requirements.asset); + // Classify the payer: fetch code once, parse ERC-6492 wrapper, determine counterfactual. // Using classifyErc6492Payer avoids duplicating this block across eip3009.ts / v1/scheme.ts. const classification = await classifyErc6492Payer(signer, signature, payer); @@ -250,10 +254,10 @@ export async function verifyEIP3009( // Reject payments whose asset is an EOA — eth_call on an EOA silently returns // empty data without reverting, so simulation would pass but no Transfer event // would be emitted, producing a silent no-op settlement. - const assetBytecode = await signer.getCode({ address: erc20Address }); - if (!assetBytecode || assetBytecode === "0x") { + const assetReason = await assetCheck.await(); + if (assetReason) { return { - response: { isValid: false, invalidReason: Errors.ErrAssetNotDeployedContract, payer }, + response: { isValid: false, invalidReason: assetReason, payer }, classification, }; } diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts index 6e8c73042f..10b03a081d 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts @@ -27,6 +27,7 @@ import { import * as Errors from "./errors"; import { FacilitatorEvmSigner } from "../../signer"; import { ExactPermit2Payload } from "../../types"; +import { startAssetContractCheck } from "../../assetCache"; import { finalHashFromTwoRequestSend, getEvmChainId, isValidTxHash } from "../../utils"; import { validateErc20ApprovalForPayment } from "./erc20approval"; import { verifyTypedDataSignature } from "../../shared/verifySignature"; @@ -112,10 +113,8 @@ export async function verifyPermit2( const chainId = getEvmChainId(requirements.network); const tokenAddress = getAddress(requirements.asset); - const assetBytecode = await signer.getCode({ address: tokenAddress }); - if (!assetBytecode || assetBytecode === "0x") { - return { isValid: false, invalidReason: Errors.ErrAssetNotDeployedContract, payer }; - } + // Run the asset-contract check concurrently with the signature check below. + const assetCheck = startAssetContractCheck(signer, requirements.network, requirements.asset); if ( getAddress(permit2Payload.permit2Authorization.spender) !== @@ -208,6 +207,12 @@ export async function verifyPermit2( ...permit2TypedData, signature: permit2Payload.signature, }); + + const assetReason = await assetCheck.await(); + if (assetReason) { + return { isValid: false, invalidReason: assetReason, payer }; + } + if (!signatureValid) { return { isValid: false, diff --git a/typescript/packages/mechanisms/evm/src/index.ts b/typescript/packages/mechanisms/evm/src/index.ts index 66616abf39..e4faaf4b68 100644 --- a/typescript/packages/mechanisms/evm/src/index.ts +++ b/typescript/packages/mechanisms/evm/src/index.ts @@ -78,6 +78,13 @@ export { claimBatchTypes, } from "./batch-settlement/constants"; +export { + DEFAULT_ASSET_CONTRACT_CACHE_TTL_MS, + resetAssetContractCache, + startAssetContractCheck, + validateAssetIsContract, +} from "./assetCache"; + export { getDefaultAsset, findDefaultAsset } from "./defaultAssets"; export type { DefaultAssetInfo, ExactDefaultAssetInfo } from "./defaultAssets"; diff --git a/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts b/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts index 026c9cda39..c9f12e907f 100644 --- a/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts @@ -24,7 +24,6 @@ import { x402UptoPermit2ProxyAddress, } from "../../constants"; import { - ErrAssetNotDeployedContract, ErrErc20ApprovalTxFailed, ErrPermit2AmountMismatch, ErrUptoSettlementExceedsAmount, @@ -35,6 +34,7 @@ import { } from "./errors"; import { FacilitatorEvmSigner } from "../../signer"; import { UptoPermit2Payload } from "../../types"; +import { startAssetContractCheck } from "../../assetCache"; import { finalHashFromTwoRequestSend, getEvmChainId, isValidTxHash } from "../../utils"; import { validateErc20ApprovalForPayment } from "../../shared/erc20approval"; import { verifyTypedDataSignature } from "../../shared/verifySignature"; @@ -112,10 +112,8 @@ export async function verifyUptoPermit2( const chainId = getEvmChainId(requirements.network); const tokenAddress = getAddress(requirements.asset); - const assetBytecode = await signer.getCode({ address: tokenAddress }); - if (!assetBytecode || assetBytecode === "0x") { - return { isValid: false, invalidReason: ErrAssetNotDeployedContract, payer }; - } + // Run the asset-contract check concurrently with the signature check below. + const assetCheck = startAssetContractCheck(signer, requirements.network, requirements.asset); if ( getAddress(permit2Payload.permit2Authorization.spender) !== @@ -221,6 +219,12 @@ export async function verifyUptoPermit2( ...permit2TypedData, signature: permit2Payload.signature, }); + + const assetReason = await assetCheck.await(); + if (assetReason) { + return { isValid: false, invalidReason: assetReason, payer }; + } + if (!signatureValid) { return { isValid: false, diff --git a/typescript/packages/mechanisms/evm/test/unit/assetCache.test.ts b/typescript/packages/mechanisms/evm/test/unit/assetCache.test.ts new file mode 100644 index 0000000000..3e6a480ea8 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/assetCache.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import type { FacilitatorEvmSigner } from "../../src/signer"; +import { ErrAssetNotDeployedContract } from "../../src/exact/facilitator/errors"; +import { + DEFAULT_ASSET_CONTRACT_CACHE_TTL_MS, + MAX_ASSET_CONTRACT_CACHE_ENTRIES, + globalAssetContractCache, + resetAssetContractCache, + startAssetContractCheck, + type AssetContractCacheKey, +} from "../../src/assetCache"; + +const cacheTestAsset = "0x00000000000000000000000000000000000000bb"; + +function countingCodeSigner(): { signer: FacilitatorEvmSigner; calls: { count: number } } { + const calls = { count: 0 }; + const signer: FacilitatorEvmSigner = { + getAddresses: () => [], + readContract: async () => 0n, + verifyTypedData: async () => false, + writeContract: async () => "0x", + sendTransaction: async () => "0x", + waitForTransactionReceipt: async () => ({ status: "success" }), + getCode: async () => { + calls.count += 1; + return "0x6060"; + }, + }; + return { signer, calls }; +} + +describe("asset contract cache", () => { + beforeEach(() => { + resetAssetContractCache(); + }); + + it("caches a positive result so only the first check reaches the RPC", async () => { + const { signer, calls } = countingCodeSigner(); + for (let i = 0; i < 3; i++) { + const reason = await startAssetContractCheck(signer, "eip155:84532", cacheTestAsset).await(); + expect(reason).toBe(""); + } + + expect(calls.count).toBe(1); + }); + + it("does not cache when the caller never awaits", async () => { + const { signer, calls } = countingCodeSigner(); + const abandoned = startAssetContractCheck(signer, "eip155:84532", cacheTestAsset); + await abandoned.results; + + const reason = await startAssetContractCheck(signer, "eip155:84532", cacheTestAsset).await(); + expect(reason).toBe(""); + expect(calls.count).toBe(2); + }); + + it("does not cache a negative result so a later deploy is visible", async () => { + let code: `0x${string}` = "0x"; + const calls = { count: 0 }; + const signer: FacilitatorEvmSigner = { + getAddresses: () => [], + readContract: async () => 0n, + verifyTypedData: async () => false, + writeContract: async () => "0x", + sendTransaction: async () => "0x", + waitForTransactionReceipt: async () => ({ status: "success" }), + getCode: async () => { + calls.count += 1; + return code; + }, + }; + + const first = await startAssetContractCheck(signer, "eip155:84532", cacheTestAsset).await(); + expect(first).toBe(ErrAssetNotDeployedContract); + expect(calls.count).toBe(1); + + code = "0x6060"; + const second = await startAssetContractCheck(signer, "eip155:84532", cacheTestAsset).await(); + expect(second).toBe(""); + expect(calls.count).toBe(2); + + const third = await startAssetContractCheck(signer, "eip155:84532", cacheTestAsset).await(); + expect(third).toBe(""); + expect(calls.count).toBe(2); + }); + + it("skips the cache when the network is empty", async () => { + const { signer, calls } = countingCodeSigner(); + for (let i = 0; i < 2; i++) { + await startAssetContractCheck(signer, "", cacheTestAsset).await(); + } + + expect(calls.count).toBe(2); + expect( + globalAssetContractCache.isFresh({ network: "", asset: cacheTestAsset }, Date.now()), + ).toBe(false); + }); + + it("expires entries after the TTL", () => { + const key: AssetContractCacheKey = { network: "eip155:84532", asset: cacheTestAsset }; + const start = Date.now(); + globalAssetContractCache.record(key, start); + + expect( + globalAssetContractCache.isFresh(key, start + DEFAULT_ASSET_CONTRACT_CACHE_TTL_MS - 1000), + ).toBe(true); + expect( + globalAssetContractCache.isFresh(key, start + DEFAULT_ASSET_CONTRACT_CACHE_TTL_MS + 1000), + ).toBe(false); + }); + + it("is bounded so many distinct deployed contracts cannot grow it without limit", () => { + const now = Date.now(); + for (let i = 0; i < MAX_ASSET_CONTRACT_CACHE_ENTRIES + 500; i++) { + globalAssetContractCache.record( + { network: "eip155:84532", asset: `0x${i.toString(16).padStart(40, "0")}` }, + now, + ); + } + + expect(globalAssetContractCache.expiries.size).toBeLessThanOrEqual( + MAX_ASSET_CONTRACT_CACHE_ENTRIES, + ); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts b/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts index b51a5ebd5b..e84924d579 100644 --- a/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts @@ -11,6 +11,7 @@ import { ERC20_APPROVAL_GAS_SPONSORING_KEY } from "../../../src/exact/extensions import { MULTICALL3_ADDRESS } from "../../../src/multicall"; import { concat, encodeAbiParameters } from "viem"; import * as Errors from "../../../src/exact/facilitator/errors"; +import { resetAssetContractCache } from "../../../src/assetCache"; import { checkPermit2Prerequisites, diagnosePermit2SimulationFailure, @@ -76,6 +77,8 @@ describe("ExactEvmScheme (Facilitator)", () => { let mockClientSigner: ClientEvmSigner; beforeEach(() => { + resetAssetContractCache(); + // Create mock client signer mockClientSigner = { address: "0x1234567890123456789012345678901234567890", @@ -2485,6 +2488,115 @@ describe("ExactEvmScheme (Facilitator)", () => { expect(mockFacilitatorSigner.sendTransaction).not.toHaveBeenCalled(); }); }); + + describe("asset contract cache", () => { + async function deployedPayment(): Promise<{ + payload: PaymentPayload; + requirements: PaymentRequirements; + }> { + const requirements: PaymentRequirements = { + scheme: "exact", + network: "eip155:84532", + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", + maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2" }, + }; + const paymentPayload = await client.createPaymentPayload(2, requirements); + return { + payload: { + ...paymentPayload, + accepted: requirements, + resource: { url: "test", description: "", mimeType: "" }, + }, + requirements, + }; + } + + function getCodeCount(address: string): number { + return vi + .mocked(mockFacilitatorSigner.getCode) + .mock.calls.filter(([args]) => args.address.toLowerCase() === address.toLowerCase()).length; + } + + it("caches the asset contract check but not the payer code lookup", async () => { + const { payload, requirements } = await deployedPayment(); + const payer = mockClientSigner.address; + + const first = await facilitator.verify(payload, requirements); + expect(first.isValid).toBe(true); + const payerCallsAfterFirst = getCodeCount(payer); + expect(payerCallsAfterFirst).toBeGreaterThan(0); + + for (let attempt = 2; attempt <= 3; attempt++) { + const result = await facilitator.verify(payload, requirements); + expect(result.isValid).toBe(true); + } + + expect(getCodeCount(requirements.asset)).toBe(1); + expect(getCodeCount(payer)).toBe(payerCallsAfterFirst * 3); + }); + + it("does not cache a positive asset check when Permit2 fails a cheap pre-check", async () => { + const requirements: PaymentRequirements = { + scheme: "exact", + network: "eip155:84532", + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", + maxTimeoutSeconds: 300, + extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, + }; + const permit2Payload: PaymentPayload = { + x402Version: 2, + payload: { + signature: "0xmocksignature", + permit2Authorization: { + from: mockClientSigner.address, + permitted: { + token: requirements.asset, + amount: requirements.amount, + }, + spender: "0x0000000000000000000000000000000000000001", + nonce: "12345", + deadline: "999999999999", + witness: { + to: requirements.payTo, + validAfter: "0", + }, + }, + }, + accepted: requirements, + resource: { url: "", description: "", mimeType: "" }, + }; + + const rejected = await facilitator.verify(permit2Payload, requirements); + expect(rejected.invalidReason).toBe("invalid_permit2_spender"); + + await vi.waitFor(() => expect(getCodeCount(requirements.asset)).toBeGreaterThanOrEqual(1)); + const assetCallsAfterReject = getCodeCount(requirements.asset); + + const { payload, requirements: eip3009Req } = await deployedPayment(); + const accepted = await facilitator.verify(payload, eip3009Req); + expect(accepted.isValid).toBe(true); + expect(getCodeCount(eip3009Req.asset)).toBe(assetCallsAfterReject + 1); + }); + + it("scopes the cache per network so a hit on one chain does not answer for another", async () => { + const { payload, requirements } = await deployedPayment(); + const first = await facilitator.verify(payload, requirements); + expect(first.isValid).toBe(true); + + const otherNetwork = "eip155:8453"; + requirements.network = otherNetwork; + payload.accepted.network = otherNetwork; + const second = await facilitator.verify(payload, requirements); + expect(second.isValid).toBe(true); + + expect(getCodeCount(requirements.asset)).toBe(2); + }); + }); }); describe("shared Permit2 helpers", () => { diff --git a/typescript/packages/mechanisms/evm/test/unit/exact/pendingSettlement.test.ts b/typescript/packages/mechanisms/evm/test/unit/exact/pendingSettlement.test.ts index 0d9dba610d..1936073ef5 100644 --- a/typescript/packages/mechanisms/evm/test/unit/exact/pendingSettlement.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/exact/pendingSettlement.test.ts @@ -10,6 +10,7 @@ import { import { x402ExactPermit2ProxyAddress } from "../../../src/constants"; import { ERC20_APPROVAL_GAS_SPONSORING_KEY } from "../../../src/exact/extensions"; import * as Errors from "../../../src/exact/facilitator/errors"; +import { resetAssetContractCache } from "../../../src/assetCache"; const MOCK_TX_HASH = ("0x" + "a1".repeat(32)) as `0x${string}`; @@ -77,6 +78,8 @@ describe("ExactEvmScheme pending-settlement store integration", () => { let store: PendingSettlementStore; beforeEach(() => { + resetAssetContractCache(); + mockClientSigner = { address: "0x1234567890123456789012345678901234567890", signTypedData: vi.fn().mockResolvedValue("0xmocksignature"), diff --git a/typescript/packages/mechanisms/evm/test/unit/upto/facilitator.test.ts b/typescript/packages/mechanisms/evm/test/unit/upto/facilitator.test.ts index 5e5036ad65..5b2c055301 100644 --- a/typescript/packages/mechanisms/evm/test/unit/upto/facilitator.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/upto/facilitator.test.ts @@ -20,6 +20,7 @@ import { } from "../../../src/exact/facilitator/errors"; import type { UptoPermit2Payload } from "../../../src/types"; import { ERC20_APPROVAL_GAS_SPONSORING_KEY } from "../../../src/upto/extensions"; +import { resetAssetContractCache } from "../../../src/assetCache"; vi.mock("viem", async importOriginal => { const actual = await importOriginal(); @@ -119,6 +120,8 @@ describe("UptoEvmScheme (Facilitator)", () => { let scheme: UptoEvmScheme; beforeEach(() => { + resetAssetContractCache(); + mockSigner = { getAddresses: () => [FACILITATOR_ADDRESS], readContract: rcWithSig(BigInt("999999999999999999")), diff --git a/typescript/packages/mechanisms/evm/test/unit/upto/pendingSettlement.test.ts b/typescript/packages/mechanisms/evm/test/unit/upto/pendingSettlement.test.ts index 747d4e7e71..8835ed0f27 100644 --- a/typescript/packages/mechanisms/evm/test/unit/upto/pendingSettlement.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/upto/pendingSettlement.test.ts @@ -10,6 +10,7 @@ import { x402UptoPermit2ProxyAddress } from "../../../src/constants"; import { ErrSettlementPending } from "../../../src/exact/facilitator/errors"; import { ERC20_APPROVAL_GAS_SPONSORING_KEY } from "../../../src/exact/extensions"; import type { UptoPermit2Payload } from "../../../src/types"; +import { resetAssetContractCache } from "../../../src/assetCache"; vi.mock("viem", async importOriginal => { const actual = await importOriginal(); @@ -88,6 +89,8 @@ describe("UptoEvmScheme pending-settlement store integration", () => { let store: PendingSettlementStore; beforeEach(() => { + resetAssetContractCache(); + mockSigner = { getAddresses: () => [FACILITATOR_ADDRESS], readContract: rcWithSig(BigInt("999999999999999999")), diff --git a/typescript/packages/mechanisms/hedera/CHANGELOG.md b/typescript/packages/mechanisms/hedera/CHANGELOG.md index 79f994f524..a83204bc4c 100644 --- a/typescript/packages/mechanisms/hedera/CHANGELOG.md +++ b/typescript/packages/mechanisms/hedera/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/hedera/package.json b/typescript/packages/mechanisms/hedera/package.json index 4ceb4ed83d..bbcdc5db3d 100644 --- a/typescript/packages/mechanisms/hedera/package.json +++ b/typescript/packages/mechanisms/hedera/package.json @@ -1,6 +1,6 @@ { "name": "@x402/hedera", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/keeta/CHANGELOG.md b/typescript/packages/mechanisms/keeta/CHANGELOG.md index cfc3f8391b..95144f67bc 100644 --- a/typescript/packages/mechanisms/keeta/CHANGELOG.md +++ b/typescript/packages/mechanisms/keeta/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/keeta +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/keeta/package.json b/typescript/packages/mechanisms/keeta/package.json index 892006343e..09a2f7493f 100644 --- a/typescript/packages/mechanisms/keeta/package.json +++ b/typescript/packages/mechanisms/keeta/package.json @@ -1,6 +1,6 @@ { "name": "@x402/keeta", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/near/CHANGELOG.md b/typescript/packages/mechanisms/near/CHANGELOG.md index 51262551d6..3ecdfc4659 100644 --- a/typescript/packages/mechanisms/near/CHANGELOG.md +++ b/typescript/packages/mechanisms/near/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/near +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/near/package.json b/typescript/packages/mechanisms/near/package.json index 4fdc597499..d001e12da2 100644 --- a/typescript/packages/mechanisms/near/package.json +++ b/typescript/packages/mechanisms/near/package.json @@ -1,6 +1,6 @@ { "name": "@x402/near", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/stellar/CHANGELOG.md b/typescript/packages/mechanisms/stellar/CHANGELOG.md index e279ef8474..2050ce6b52 100644 --- a/typescript/packages/mechanisms/stellar/CHANGELOG.md +++ b/typescript/packages/mechanisms/stellar/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/stellar Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/stellar/package.json b/typescript/packages/mechanisms/stellar/package.json index 8bb3ebf4d6..bc4c90f4d1 100644 --- a/typescript/packages/mechanisms/stellar/package.json +++ b/typescript/packages/mechanisms/stellar/package.json @@ -1,6 +1,6 @@ { "name": "@x402/stellar", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/svm/CHANGELOG.md b/typescript/packages/mechanisms/svm/CHANGELOG.md index 416f974cca..4116ce533b 100644 --- a/typescript/packages/mechanisms/svm/CHANGELOG.md +++ b/typescript/packages/mechanisms/svm/CHANGELOG.md @@ -1,5 +1,14 @@ # @x402/svm Changelog +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 +- [14e9c2a](https://github.com/x402-foundation/x402/commit/14e9c2a): SVM upto facilitator channel-account re-reads now back off linearly (200/400/600/800/1000ms across 6 reads) rather than doubling (200/400/800/1600ms across 5 reads). Replica lag behind a confirmed open is a small multiple of Solana's slot time, so the same 3.0s budget now buys one more read and caps any single wait at 1s. `UptoSvmFacilitatorConfig.channelReadMaxAttempts` and `channelReadBackoffStepMs` make it configurable. ([#3367](https://github.com/x402-foundation/x402/pull/3367)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- [3c2ddfb](https://github.com/x402-foundation/x402/commit/3c2ddfb): SVM upto delegated claim settle now returns `invalid_upto_svm_delegated_auth_store` when the identity store Get fails, instead of collapsing that outage into unauthenticated. ([#3431](https://github.com/x402-foundation/x402/pull/3431)) - Thanks [@phdargen](https://github.com/phdargen)! + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/svm/package.json b/typescript/packages/mechanisms/svm/package.json index 08da8c5b79..64b8958129 100644 --- a/typescript/packages/mechanisms/svm/package.json +++ b/typescript/packages/mechanisms/svm/package.json @@ -1,6 +1,6 @@ { "name": "@x402/svm", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/packages/mechanisms/tvm/CHANGELOG.md b/typescript/packages/mechanisms/tvm/CHANGELOG.md index 61207b3b3a..82ac99b87d 100644 --- a/typescript/packages/mechanisms/tvm/CHANGELOG.md +++ b/typescript/packages/mechanisms/tvm/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/tvm +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/tvm/package.json b/typescript/packages/mechanisms/tvm/package.json index c0665529d4..36590f1153 100644 --- a/typescript/packages/mechanisms/tvm/package.json +++ b/typescript/packages/mechanisms/tvm/package.json @@ -1,6 +1,6 @@ { "name": "@x402/tvm", - "version": "2.25.0", + "version": "2.26.0", "description": "TVM (TON) mechanism for x402 payment protocol", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", diff --git a/typescript/packages/mechanisms/xrpl/CHANGELOG.md b/typescript/packages/mechanisms/xrpl/CHANGELOG.md index d15a0bce3f..8bb51c3298 100644 --- a/typescript/packages/mechanisms/xrpl/CHANGELOG.md +++ b/typescript/packages/mechanisms/xrpl/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/xrpl +## 2.26.0 + +### Minor Changes + +- Updated dependencies [76fe973](https://github.com/x402-foundation/x402/commit/76fe973) + - @x402/core@2.26.0 + ## 2.25.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/xrpl/package.json b/typescript/packages/mechanisms/xrpl/package.json index 9bfc4c7d13..60022c5602 100644 --- a/typescript/packages/mechanisms/xrpl/package.json +++ b/typescript/packages/mechanisms/xrpl/package.json @@ -1,6 +1,6 @@ { "name": "@x402/xrpl", - "version": "2.25.0", + "version": "2.26.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 63d7727cea..b5b805eab9 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -1416,6 +1416,70 @@ importers: specifier: ^3.2.7 version: 3.2.7(@types/debug@4.1.13)(@types/node@22.19.17)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1) + packages/mechanisms/casper: + dependencies: + '@casper-ecosystem/casper-eip-712': + specifier: 1.2.1 + version: 1.2.1 + '@x402/core': + specifier: workspace:~ + version: link:../../core + casper-js-sdk: + specifier: 5.1.1 + version: 5.1.1 + devDependencies: + '@eslint/js': + specifier: ^9.24.0 + version: 9.34.0 + '@types/node': + specifier: ^22.13.4 + version: 22.19.17 + '@typescript-eslint/eslint-plugin': + specifier: ^8.29.1 + version: 8.49.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0)(typescript@5.9.2) + '@typescript-eslint/parser': + specifier: ^8.29.1 + version: 8.49.0(eslint@9.34.0)(typescript@5.9.2) + '@vitest/coverage-v8': + specifier: ^3.2.7 + version: 3.2.7(vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.19.17)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1)) + eslint: + specifier: ^9.24.0 + version: 9.34.0 + eslint-plugin-import: + specifier: ^2.31.0 + version: 2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.34.0)(typescript@5.9.2))(eslint@9.34.0) + eslint-plugin-jsdoc: + specifier: ^50.6.9 + version: 50.8.0(eslint@9.34.0) + eslint-plugin-prettier: + specifier: ^5.2.6 + version: 5.5.4(eslint@9.34.0)(prettier@3.5.2) + eslint-plugin-sonarjs: + specifier: ^3.0.7 + version: 3.0.7(eslint@9.34.0) + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsup: + specifier: ^8.4.0 + version: 8.5.0(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.1) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.2 + vite: + specifier: ^6.2.6 + version: 6.3.5(@types/node@22.19.17)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1) + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.9.2)(vite@6.3.5(@types/node@22.19.17)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1)) + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.19.17)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1) + packages/mechanisms/concordium: dependencies: '@concordium/web-sdk': @@ -2232,6 +2296,12 @@ packages: peerDependencies: algosdk: ^3.0.0 + '@casper-ecosystem/casper-eip-712@1.2.1': + resolution: {integrity: sha512-qhrpaISByIoOnbELH7hhLMVBinIP1vOc4Z+6ZI5aItcc1O3SrDXNu0fdKm5qRSLLb5p94JxaS+jGegiIAwiKDA==} + + '@casperlabs/ts-results@3.3.5': + resolution: {integrity: sha512-ymSQqqb4mOSet592li02u1Gd28LoOFJUm6R3jkdNQ+nqsnbHvN+izBigtP4aYmNwh6gFyCwDgjYporEJgDT4eA==} + '@changesets/apply-release-plan@7.0.14': resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==} @@ -2806,6 +2876,18 @@ packages: resolution: {integrity: sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA==} engines: {node: '>=14'} + '@ethersproject/bignumber@5.8.0': + resolution: {integrity: sha512-ZyaT24bHaSeJon2tGPKIiHszWjD/54Sz8t57Toch475lCLljC6MgPmxk7Gtzz+ddNN5LuHea9qhAe0x3D+uYPA==} + + '@ethersproject/bytes@5.8.0': + resolution: {integrity: sha512-vTkeohgJVCPVHu5c25XWaWQOZ4v+DkGoC42/TS2ond+PARCxTJvgTFUNDZovyQ/uAQ4EcpqqowKydcdmRKjg7A==} + + '@ethersproject/constants@5.8.0': + resolution: {integrity: sha512-wigX4lrf5Vu+axVTIvNsuL6YrV4O5AXl5ubcURKMEME5TnWBouUh0CDTWxZ2GpnRn1kcCgE7l8O5+VbV9QTTcg==} + + '@ethersproject/logger@5.8.0': + resolution: {integrity: sha512-Qe6knGmY+zPPWTC+wQrpitodgBfH7XoceCGL5bJVejmH+yCS3R8jJm8iiWuvWbG76RUmyEG53oqv6GMVWqunjA==} + '@evanhahn/lottie-web-light@5.8.1': resolution: {integrity: sha512-U0G1tt3/UEYnyCNNslWPi1dB7X1xQ9aoSip+B3GTKO/Bns8yz/p39vBkRSN9d25nkbHuCsbjky2coQftj5YVKw==} @@ -3537,6 +3619,9 @@ packages: resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} engines: {node: '>= 20.19.0'} + '@noble/ed25519@1.7.5': + resolution: {integrity: sha512-xuS0nwRMQBvSxDa7UxMb61xTiH3MxTgUfhyPUALVIe0FlOAz4sjELwyDRyUvqeEYfRSG9qNjFIycqLZppg4RSA==} + '@noble/ed25519@2.3.0': resolution: {integrity: sha512-M7dvXL2B92/M7dw9+gzuydL8qn/jiqNHaoR3Q+cb1q1GHV7uwE17WCyFMG+Y+TZb5izcaXk5TdJRrDUxHXL78A==} @@ -3574,6 +3659,9 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} + '@noble/secp256k1@1.7.2': + resolution: {integrity: sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5922,6 +6010,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + asn1js@3.0.6: resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} engines: {node: '>=12.0.0'} @@ -5965,6 +6056,9 @@ packages: axios@1.18.0: resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + babel-plugin-syntax-hermes-parser@0.33.3: resolution: {integrity: sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==} @@ -6040,6 +6134,9 @@ packages: bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} + bn.js@5.2.5: + resolution: {integrity: sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==} + body-parser@1.20.3: resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -6166,6 +6263,10 @@ packages: caniuse-lite@1.0.30001739: resolution: {integrity: sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==} + casper-js-sdk@5.1.1: + resolution: {integrity: sha512-KeKaBzvi+nK/1etL4D1nCPT8L7h+oBa3KXlB3ImifGlkDJwgBphva7Ep7e7fnhr9b2gFCWgDnJ0suTDl6ymbYQ==} + engines: {node: '>=18'} + cbor2@1.12.0: resolution: {integrity: sha512-3Cco8XQhi27DogSp9Ri6LYNZLi/TBY/JVnDe+mj06NkBjW/ZYOtekaEU4wZ4xcRMNrFkDv8KNtOAqHyDfz3lYg==} engines: {node: '>=18.7'} @@ -6873,6 +6974,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource@2.0.2: + resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} + engines: {node: '>=12.0.0'} + eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} @@ -7342,6 +7447,9 @@ packages: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true + humanize-duration@3.34.1: + resolution: {integrity: sha512-YIiigjQ+O31rvcyDJPK1ptTZtvSErjBtmHy93VhbwxB/VwMG0FszKs2IB667tFxmOhNapePQQxa24luQRDkKFQ==} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} @@ -9588,6 +9696,9 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + typedjson@1.8.0: + resolution: {integrity: sha512-taVJVGebQDagEmVc3Cu6vVVLkWLnxqPcTrkVgbpAsI02ZDDrnHy5zvt1JVqXv4/yztBgZAX1oR07+bkiusGJLQ==} + typescript@5.9.2: resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} engines: {node: '>=14.17'} @@ -10433,6 +10544,15 @@ snapshots: - bufferutil - utf-8-validate + '@casper-ecosystem/casper-eip-712@1.2.1': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + '@casperlabs/ts-results@3.3.5': + dependencies: + tslib: 2.8.1 + '@changesets/apply-release-plan@7.0.14': dependencies: '@changesets/config': 3.1.2 @@ -10667,7 +10787,7 @@ snapshots: '@coinbase/wallet-sdk@3.9.3': dependencies: - bn.js: 5.2.3 + bn.js: 5.2.5 buffer: 6.0.3 clsx: 1.2.1 eth-block-tracker: 7.1.0 @@ -11084,6 +11204,22 @@ snapshots: ethereum-cryptography: 2.2.1 micro-ftch: 0.3.1 + '@ethersproject/bignumber@5.8.0': + dependencies: + '@ethersproject/bytes': 5.8.0 + '@ethersproject/logger': 5.8.0 + bn.js: 5.2.5 + + '@ethersproject/bytes@5.8.0': + dependencies: + '@ethersproject/logger': 5.8.0 + + '@ethersproject/constants@5.8.0': + dependencies: + '@ethersproject/bignumber': 5.8.0 + + '@ethersproject/logger@5.8.0': {} + '@evanhahn/lottie-web-light@5.8.1': {} '@evolution-sdk/evolution@0.5.11(@effect/cluster@0.58.2(@effect/platform@0.96.2(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(effect@3.21.4))(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(bufferutil@4.0.9)(utf-8-validate@5.0.10)': @@ -12004,6 +12140,8 @@ snapshots: dependencies: '@noble/hashes': 2.2.0 + '@noble/ed25519@1.7.5': {} + '@noble/ed25519@2.3.0': {} '@noble/ed25519@3.0.0': {} @@ -12024,6 +12162,8 @@ snapshots: '@noble/hashes@2.2.0': {} + '@noble/secp256k1@1.7.2': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -16677,6 +16817,13 @@ snapshots: asap@2.0.6: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.3 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + asn1js@3.0.6: dependencies: pvtsutils: 1.3.6 @@ -16733,6 +16880,16 @@ snapshots: - debug - supports-color + axios@1.20.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + babel-plugin-syntax-hermes-parser@0.33.3: dependencies: hermes-parser: 0.33.3 @@ -16795,6 +16952,8 @@ snapshots: bn.js@5.2.3: {} + bn.js@5.2.5: {} + body-parser@1.20.3: dependencies: bytes: 3.1.2 @@ -16952,6 +17111,26 @@ snapshots: caniuse-lite@1.0.30001739: {} + casper-js-sdk@5.1.1: + dependencies: + '@ethersproject/bignumber': 5.8.0 + '@ethersproject/bytes': 5.8.0 + '@ethersproject/constants': 5.8.0 + '@noble/curves': 1.9.7 + '@noble/ed25519': 1.7.5 + '@noble/hashes': 1.8.0 + '@noble/secp256k1': 1.7.2 + asn1.js: 5.4.1 + axios: 1.20.0 + bn.js: 5.2.5 + eventsource: 2.0.2 + humanize-duration: 3.34.1 + ts-results: '@casperlabs/ts-results@3.3.5' + typedjson: 1.8.0 + transitivePeerDependencies: + - debug + - supports-color + cbor2@1.12.0: {} chai@5.3.3: @@ -17796,6 +17975,8 @@ snapshots: eventsource-parser@3.0.6: {} + eventsource@2.0.2: {} + eventsource@3.0.7: dependencies: eventsource-parser: 3.0.6 @@ -18436,6 +18617,8 @@ snapshots: human-id@4.1.3: {} + humanize-duration@3.34.1: {} + humanize-ms@1.2.1: dependencies: ms: 2.1.3 @@ -21121,6 +21304,10 @@ snapshots: dependencies: is-typedarray: 1.0.0 + typedjson@1.8.0: + dependencies: + tslib: 2.8.1 + typescript@5.9.2: {} typia@9.5.0(@types/node@22.19.17)(typescript@5.9.2):