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. | [](https://www.npmjs.com/package/@x402/avm) |
| **Aptos** - [`@x402/aptos`](./packages/mechanisms/aptos) | Aptos implementation of the x402 payment protocol. | [](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. | [](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. | [](https://www.npmjs.com/package/@x402/casper) |
| **Stellar** - [`@x402/stellar`](./packages/mechanisms/stellar) | Stellar implementation of x402 using Soroban token transfers. | [](https://www.npmjs.com/package/@x402/stellar) |
| **Solana** - [`@x402/svm`](./packages/mechanisms/svm) | SVM implementation of x402 using SPL token transfers. | [](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):