diff --git a/docs/upstream-typescript-sync-analysis.md b/docs/upstream-typescript-sync-analysis.md new file mode 100644 index 00000000..fc737f15 --- /dev/null +++ b/docs/upstream-typescript-sync-analysis.md @@ -0,0 +1,145 @@ +# Upstream TypeScript Sync Analysis + +## Comparison checkpoint + +This document records the checkpoint used to compare `x402-foundation/x402` with the BankofAI TypeScript fork. Future reviews should start after the recorded upstream end commit and compare against the then-current target HEAD. + +| Repository | Path | Commit | Date | +| --- | --- | --- | --- | +| Upstream source | `/Users/roger/Project/develop/foundation-x402/x402` | Start, exclusive: `d454eb9b9557c5d48dc641dd0ec48dc605919b03` | 2026-06-08 22:53:39 +02:00 | +| Upstream source | `/Users/roger/Project/develop/foundation-x402/x402` | End, inclusive: `2b74587b29bd7d986c4c562e46444d74606b0e1d` | 2026-07-06 | +| Target fork | `/Users/roger/Project/develop/x402` | HEAD examined: `e158640a7ebf2ade6c7a6e2dc0fbfdfc6f3f0e79` | 2026-07-07 | + +Upstream range examined: + +```text +d454eb9b9557c5d48dc641dd0ec48dc605919b03..2b74587b29bd7d986c4c562e46444d74606b0e1d +``` + +The range contains 72 commits, 615 changed files, 52,672 insertions, and 19,013 deletions. + +## Latest upstream supplement + +Fetched upstream `origin/main` on 2026-07-13. The new checkpoint extends the previous reviewed upstream end commit to: + +| Repository | Path | Commit | Date | +| --- | --- | --- | --- | +| Upstream source | `/Users/roger/Project/develop/foundation-x402/x402` | Previous end, exclusive: `2b74587b29bd7d986c4c562e46444d74606b0e1d` | 2026-07-06 | +| Upstream source | `/Users/roger/Project/develop/foundation-x402/x402` | Latest reviewed end, inclusive: `ea2cd8177d0b694945fcd3c69e962ad880e8cab0` | 2026-07-13 | +| Target fork | `/Users/roger/Project/develop/x402` | HEAD examined for this supplement: `ab923e64c109b5732e1286a3c08fe3845b1daa2c` | 2026-07-09 | + +Supplement range examined: + +```text +2b74587b29bd7d986c4c562e46444d74606b0e1d..ea2cd8177d0b694945fcd3c69e962ad880e8cab0 +``` + +The supplement range contains 14 commits, 681 changed files, 13,277 insertions, and 43,732 deletions. Most churn is the upstream website deprecation and release bookkeeping, not reusable TypeScript SDK logic. + +Commits reviewed: + +```text +ea2cd8177d0b694945fcd3c69e962ad880e8cab0 2026-07-13 docs(facilitators): add Solvador to facilitators list (#2757) +0a604079aca7b5a45a2e1620ba444e13982646c8 2026-07-10 fix e2e exit hang (#2830) +2aa22d3e6a38547a8232737599d9f519c9ac5533 2026-07-10 chore(go): release (#2829) +7301f6c553a7b4c2e12f19ef6683d8c2e38f1fde 2026-07-10 chore: version typescript packages (#2828) +79bad65290ded008779de54f6ebf3b00ceb3f4db 2026-07-10 chore: version python package (#2827) +07bd44dc0126c612c9806069f7ee357d6e0a7922 2026-07-10 docs: clarify production facilitator guidance for mainnet EVM routes (#2824) +765990cc1aa053326ecbdbc34bac584561f9e234 2026-07-10 fix(python/fastapi): return 500 (and log) on unexpected settlement error (#2622) +f5070b98c4f9b4363cb0577d3f287a38aab36380 2026-07-10 fix flask 3xx response bug (#2826) +8b1abaeaef282e6307a2936b102c6d9223e61802 2026-07-08 docs(specs): add exact scheme spec for Casper Network (#2741) +d9bd02d151cb142e76769f3b6150e4ff625c3217 2026-07-08 feat(evm): add Igra mainnet (eip155:38833) default stablecoin (#2800) +f4530e2766c822a9c7f880e2a86484dd98257fb1 2026-07-08 fix(python/flask): use sync server in payment_middleware_from_config (#2810) +49706f061b2f5deee256c2129bca74629dcf0100 2026-07-07 Add Python support for builder-code extension (#2809) +29311c1076bcd447639ed62d2daf4dcfb037c699 2026-07-07 Builder code py (#2795) +7be420af0bd4a72f34f2290b42abc4ac1c5660ec 2026-07-07 Deprecate website (#2794) +``` + +## Repository relationship + +The target is not a Git branch of upstream. It has an independent history and has been reorganized as a TypeScript-only fork with `@bankofai` package names, TRON support, network-driven wallet adapters, and other local API changes. Upstream commits should therefore be ported manually and tested; they should not be blindly cherry-picked. + +## Recommended ports + +### Required + +1. `04860337caca929bdf9ccba69f2413d6d659e9a2` — emit the spec-compliant EVM `authorization_value_mismatch` error. + - ✅ **Ported on branch `sync/upstream-2026-07`** (2026-07-13). Added `ErrAuthorizationValueMismatch` in `evm/src/exact/facilitator/errors.ts`, changed `eip3009.ts` to emit it instead of `ErrInvalidAuthorizationValue`, added the legacy `ErrorReasons` entry in `legacy/x402/src/types/verify/x402Specs.ts`, and updated the facilitator test assertion. EVM 710 tests pass, legacy 270 tests pass. + +2. `a3ad102baa16d05440855214d43acf293cd0400f` — MCP interoperability fixes. + - ✅ **Ported on branch `sync/upstream-2026-07`** (2026-07-13). Changed 7 optional fields in `core/src/schemas/index.ts` from `.optional()` to `.nullish().transform(v => v ?? undefined)` (5 in `ResourceInfoSchema`, 1 in `PaymentRequiredV2Schema.error`, 1 in `PaymentPayloadV2Schema.resource`). Replaced `isPaymentRequired` with `parsePaymentRequired` in MCP client `extractPaymentRequiredFromObject` and `extractPaymentRequiredFromError`, and in `encoding.ts` `extractPaymentRequiredFromError`. Added 4 null-normalization tests in core schemas, 1 null-field auto-pay test in MCP client, and fixed the V2 fixture in `utils.test.ts` (`maxAmountRequired` → `maxTimeoutSeconds`). Core 510 tests pass (1 pre-existing network failure), MCP 99 tests pass. + +### High priority + +3. `59ac597de8e3c15be6249dbba7aa5b7f08e0f47e` — dynamic extension info fields. + - ✅ **Ported on branch `sync/upstream-2026-07`** (2026-07-13). Added `dynamicInfoFields?: string[]` to `ResourceServerExtension` in `core/src/types/extensions.ts`. Added `omitFields()` helper in `core/src/server/x402ResourceServer.ts` and applied it to echo validation so dynamic fields are excluded from `extensionInfoMatchesAdvertised`. Declared `["offers"]` in `offer-receipt/server.ts` and `["nonce", "issuedAt", "expirationTime"]` in `sign-in-with-x/server.ts`. Added 3 tests in core resource server and 2 tests in SIWX extension (incl. end-to-end fresh-nonce echo validation). Core 513 tests pass, extensions 471 tests pass. + +4. `4cba2622badce62585ab4a41b3cb7a291ef96621` — EVM wallet compatibility and signature-verification hardening. + - ✅ **Ported on branch `sync/upstream-2026-07`** (2026-07-13). Added 3 new shared files: `verifySignature.ts` (strict EOA/ERC-1271 verification primitive + `classifyErc6492Payer`), `erc7702.ts` (delegation detection), `revert.ts` (contract revert vs RPC failure). Replaced `signer.verifyTypedData` with `verifyTypedDataSignature` in 6 facilitators (exact eip3009, exact permit2, upto permit2, v1 scheme, batch-settlement deposit-eip3009, deposit-permit2) + `verifyBatchSettlementVoucherTypedData`. Rewrote `verifyEIP3009` to use `classifyErc6492Payer` + counterfactual factory allowlist gate. Added `simulateEip3009TransferResult`, deploy receipt status check, inner-signature extraction for settle, and `errorMessage` preservation. Added counterfactual ERC-6492 deposit support to batch-settlement (`Erc3009DepositVerifyResult`, `simulateCounterfactualErc3009Deposit`, `tryDeployCounterfactualErc3009Deposit`). Added `BatchSettlementEvmSchemeConfig` with `eip6492AllowedFactories`. Ported 3 new test files + 4 modified test files (mock signer adapted with `rcWithSig` helper). EVM 738 tests pass. + +5. `f4c532e8` — EVM `validAfter` time-window correction. + - ✅ **Ported on branch `sync/upstream-2026-07`** (2026-07-13). Changed `(now - 600).toString()` / `BigInt(now - 600)` to `"0"` / `BigInt(0)` across 9 source files (EVM: 5, Tron: 4) and 3 test files. The original "Already represented" assessment was incorrect — the target still used the old `now - 600` behavior before this port. + +6. `8f22db34` — `Retry-After` handling. + - ✅ **Ported on branch `sync/upstream-2026-07`** (2026-07-13). Changed `computeRetryDelay()` in `core/src/http/httpFacilitatorClient.ts` from `Number()` + `isNaN` to `trim()` + `/^\d+$/` regex. The original "Already represented" assessment was incorrect — the target still used the old `Number()` parsing before this port. + +### Conditional + +7. `266b19d2251356ee958a1f4ffaa4e57aa2007f33` — optional Batch Settlement facilitator receiver authorizer. + - ✅ **Ported on branch `sync/upstream-2026-07`** (2026-07-13). Added `validateFacilitatorSupport?()` to `SchemeNetworkServer` in `core/src/types/mechanisms.ts` and wired `validateFacilitatorCapabilities()` into `x402ResourceServer.initialize()`. Made `authorizerSigner` optional in EVM + Tron facilitator schemes (`getExtra()` returns `undefined` when absent). Added `ErrAuthorizerNotConfigured` guard in EVM + Tron `claim.ts` and `refund.ts`. Added `validateFacilitatorSupport()` to EVM + Tron server schemes. Core 516 tests pass (+3), EVM 747 tests pass (+9), Tron 152 tests pass. + +8. `ef7db6793791ca86c209113073ecdc4e480e5eec` — EVM client signer type clarification. + - Review semantics only. The target's wallet interfaces have diverged, so the upstream patch should not be applied directly. + +9. `d9bd02d151cb142e76769f3b6150e4ff625c3217` — Igra mainnet `eip155:38833` default stablecoin. + - The target's `typescript/packages/mechanisms/evm/src/shared/defaultAssets.ts` does not include Igra. + - Port only if BankofAI wants to advertise Igra EVM support. The upstream asset is USDC at `0xA5b8BF902b2844dA17d4506cc827F7F1681735E7`, decimals `6`, `assetTransferMethod: "permit2"`, and no EIP-2612 support flag. + - This is a small registry/doc change, not a behavioral compatibility fix. + +## Already represented in the target + +No additional port is currently required for: + +- `238fac45` — Mezo mainnet default asset. ✅ Verified: target has `eip155:31612` (Mezo USD, permit2) in `defaultAssets.ts`. +- `53040052` — XDC mainnet and Apothem defaults. ✅ Verified: target has `eip155:50` (XDC USDC) and `eip155:51` (Apothem USDC) in `defaultAssets.ts`. +- `ae0bf9be` — Builder Code `s` array support. ✅ Verified: target has `s?: string | string[]` in `builder-code/types.ts`. +- `edc7280c` — partial-settlement convention; the target already overrides settlement requirements with the metered amount. ✅ Verified: target's `upto/facilitator/permit2.ts` supports metered-amount settlement via `buildUptoPermit2SettleArgs`. (Upstream commit also added 4 conformance tests not yet ported — low priority.) + +From the 2026-07-13 supplement, no additional port is currently required for: + +- `0a604079` — e2e exit-hang cleanup; upstream-specific `e2e/` harness changes do not map directly to the target fork's current layout. +- `29311c10` and `49706f06` — Python Builder Code support and docs. The earlier TypeScript Builder Code `s` array support remains the only TypeScript compatibility item already tracked above. +- `7301f6c5` — upstream TypeScript release/version bookkeeping. Do not port package version bumps or changelog entries directly into the BankofAI package namespace. + +### Not previously tracked + +- `10ccbdb2` — docs: replace dead `facilitator.x402.org` with `x402.org/facilitator` (#2787). README-only URL change in 5 files (bazaar, express, fastify, hono, next). Target still has old URL. Low priority — docs only, no runtime impact. + + +## Out of scope for this target + +The target currently retains only EVM and TRON mechanisms. Do not port chain-specific additions for TON/TVM, Keeta, Concordium, NEAR, Hedera, Stellar, or XRPL unless the target's supported-chain scope changes. + +Go/Python-only changes, upstream release commits, Mintlify/site changes, upstream CI maintenance, and example-only fixes were also excluded. + +The 2026-07-13 supplement also excludes the upstream website deprecation (`7be420af`), Python HTTP middleware fixes (`f4530e27`, `765990cc`, `f5070b98`), Go/Python release commits (`2aa22d3e`, `79bad652`), Casper-only specs (`8b1abaea`), facilitator-list/docs-only updates (`07bd44dc`, `ea2cd817`), and upstream site/docs cleanup. + +## Suggested implementation order + +```text +04860337 -> a3ad102b -> 59ac597d -> 4cba2622 -> f4c532e8 -> 8f22db34 -> 266b19d2 +``` + +Items `04860337` (authorization_value_mismatch), `a3ad102b` (MCP interop), `59ac597d` (dynamicInfoFields), `f4c532e8` (validAfter), `8f22db34` (Retry-After), `4cba2622` (wallet compatibility), and `266b19d2` (validateFacilitatorSupport) have been ported on branch `sync/upstream-2026-07`. If Igra support is desired, `d9bd02d1` can be handled independently because it is a registry-only EVM asset addition. + +## Next comparison + +For the next upstream review, use this upstream range: + +```bash +git -C /Users/roger/Project/develop/foundation-x402/x402 log \ + ea2cd8177d0b694945fcd3c69e962ad880e8cab0..HEAD \ + --date=short --pretty=format:'%H%x09%ad%x09%s' +``` + +Before relying on this checkpoint, verify whether the recommended ports above have landed in the target. Record the new target HEAD and replace the upstream end commit only after the new range has been fully reviewed. diff --git a/examples/typescript/.env-batch.example b/examples/typescript/.env-batch.example index 09deb86a..1db62999 100644 --- a/examples/typescript/.env-batch.example +++ b/examples/typescript/.env-batch.example @@ -20,7 +20,10 @@ TRON_GRID_API_KEY= # ── CLIENT · the payer (clients/batch-settlement) ───────────────────────── # Which chains to exercise, one burst (+refund) per entry, in order. Each entry is -# [@] network: "eip155:97"/"tron:0xcd8690dc" (or "eip155"/"tron"); +# [@] network: CAIP-2 chain id — +# EVM: "eip155:97" (BSC testnet), "eip155:56" (BSC mainnet) +# TRON: "tron:0xcd8690dc" (Nile testnet), "tron:0x94a9059e" (Shasta testnet), "tron:0x2b6653dc" (mainnet) +# Shortcuts: "eip155" or "tron" match any network of that family. # token optional (each chain advertises one: EVM=USDC, TRON=USDT) — symbol or # asset address. Use `@` (not `#`) — dotenv treats `#` as a comment. # Unset ⇒ each configured chain once. @@ -45,7 +48,9 @@ REFUND_AFTER_REQUESTS=false # refund the remaining channel balance after the bur # ── SERVER · the resource provider (servers/batch-settlement) ───────────── # Payout addresses (set the chain(s) you want to accept). EVM_ADDRESS=0x... # BSC, eip155:97 (token USDC, permit2) -TRON_ADDRESS=T... # TRON, tron:0xcd8690dc (token USDT, permit2) +TRON_ADDRESS=T... # TRON Nile (tron:0xcd8690dc); Shasta: tron:0x94a9059e; mainnet: tron:0x2b6653dc (token USDT, permit2) +# TRON network: tron:0xcd8690dc (Nile, default), tron:0x94a9059e (Shasta), tron:0x2b6653dc (mainnet) +# TRON_NETWORK=tron:0xcd8690dc # Where the server binds, and where it reaches the facilitator. SERVER_PORT=4041 diff --git a/examples/typescript/.env-exact.example b/examples/typescript/.env-exact.example index f5a65d25..86b24549 100644 --- a/examples/typescript/.env-exact.example +++ b/examples/typescript/.env-exact.example @@ -24,7 +24,10 @@ TRON_GRID_API_KEY= # ── CLIENT · the payer (clients/fetch · clients/mcp) ────────────────────── # Which chains/tokens to pay, one payment per entry, in order. -# [@] network: "eip155:97"/"tron:0xcd8690dc" (or "eip155"/"tron"); +# [@] network: CAIP-2 chain id — +# EVM: "eip155:97" (BSC testnet), "eip155:56" (BSC mainnet) +# TRON: "tron:0xcd8690dc" (Nile testnet), "tron:0x94a9059e" (Shasta testnet), "tron:0x2b6653dc" (mainnet) +# Shortcuts: "eip155" or "tron" match any network of that family. # token: symbol (DHLU/USDC/USDT on EVM, USDT/USDD on TRON) or an asset address; # omit the token to use that network's first advertised token. # Use `@` (not `#`) — dotenv treats `#` as a comment and would truncate the value. @@ -40,7 +43,7 @@ MCP_SERVER_URL=http://localhost:4023 # ── SERVER · the resource provider (servers/express · servers/mcp) ──────── # Payout addresses (set the chain(s) you want to accept). EVM_ADDRESS=0x... # BSC, eip155:97 -TRON_ADDRESS=T... # TRON, tron:0xcd8690dc +TRON_ADDRESS=T... # TRON Nile (tron:0xcd8690dc); Shasta: tron:0x94a9059e; mainnet: tron:0x2b6653dc # Where the server binds, and where it reaches the facilitator. SERVER_PORT=4021 diff --git a/examples/typescript/.env-gasfree.example b/examples/typescript/.env-gasfree.example index 50842110..ccfdbabc 100644 --- a/examples/typescript/.env-gasfree.example +++ b/examples/typescript/.env-gasfree.example @@ -30,7 +30,9 @@ RESOURCE_URL=http://localhost:4031/weather # ── SERVER · the resource provider (servers/gasfree) ────────────────────── # Payout address (TRON only). -TRON_ADDRESS=T... # TRON, tron:0xcd8690dc +TRON_ADDRESS=T... # TRON Nile (tron:0xcd8690dc); Shasta: tron:0x94a9059e; mainnet: tron:0x2b6653dc +# TRON network: tron:0xcd8690dc (Nile, default), tron:0x94a9059e (Shasta), tron:0x2b6653dc (mainnet) +# TRON_NETWORK=tron:0xcd8690dc # Where the server binds, and where it reaches the facilitator. SERVER_PORT=4031 diff --git a/examples/typescript/.env-multi-scheme.example b/examples/typescript/.env-multi-scheme.example new file mode 100644 index 00000000..3e774962 --- /dev/null +++ b/examples/typescript/.env-multi-scheme.example @@ -0,0 +1,32 @@ +# ── Scenario: Multi-scheme (EVM `exact` + TRON `exact`/`exact_gasfree`) ──── +# Copy to `.env-multi-scheme` (gitignored); the multi-scheme server loads it: +# +# cp .env-multi-scheme.example .env-multi-scheme +# +# One server, comma-separated facilitators. The server accepts BOTH TRON schemes +# on the same network plus EVM `exact`, and routes each payment to the right +# facilitator by `(network, scheme)`. Reuses the existing basic (:4022) and +# gasfree (:4032) facilitators — start both first. + +# ── Resource-server payout addresses (set the chain(s) you want to accept) ─ +EVM_ADDRESS=0x... # BSC, eip155:97 (omit to disable EVM) +TRON_ADDRESS=T... # TRON Nile (tron:0xcd8690dc); Shasta: tron:0x94a9059e; mainnet: tron:0x2b6653dc (omit to disable TRON) +# TRON network: tron:0xcd8690dc (Nile, default), tron:0x94a9059e (Shasta), tron:0x2b6653dc (mainnet) +# TRON_NETWORK=tron:0xcd8690dc + +# ── Facilitator URLs (comma-separated) ──────────────────────────────────── +# basic settles `exact` (EVM + TRON permit2); gasfree settles `exact_gasfree`. +FACILITATOR_URL=http://localhost:4022,http://localhost:4032 + +# ── Server ──────────────────────────────────────────────────────────────── +SERVER_PORT=4061 + +# ── Optional ─────────────────────────────────────────────────────────────── +# Drop `paymentPayload.resource` before verify/settle (see resourceStripping +# facilitator). Set "true" only for a LOCAL server pointed at a WAF-fronted +# facilitator that rejects loopback URLs in the body. +# STRIP_RESOURCE_URL=true + +# Facilitator API key, sent as `X-API-KEY` on every facilitator call. Hosted +# facilitators use it to select a rate-limit tier; anonymous calls still work. +# FACILITATOR_API_KEY=your-key-here diff --git a/examples/typescript/.env-upto.example b/examples/typescript/.env-upto.example index 4e4e57d3..ee474b2f 100644 --- a/examples/typescript/.env-upto.example +++ b/examples/typescript/.env-upto.example @@ -21,7 +21,10 @@ TRON_GRID_API_KEY= # ── CLIENT · the payer (clients/upto) ───────────────────────────────────── # Which chains to exercise, one N-request run per entry, in order. Each entry is -# [@] network: "eip155:97"/"tron:0xcd8690dc" (or "eip155"/"tron"); +# [@] network: CAIP-2 chain id — +# EVM: "eip155:97" (BSC testnet), "eip155:56" (BSC mainnet) +# TRON: "tron:0xcd8690dc" (Nile testnet), "tron:0x94a9059e" (Shasta testnet), "tron:0x2b6653dc" (mainnet) +# Shortcuts: "eip155" or "tron" match any network of that family. # token optional (each chain advertises one: EVM=USDC, TRON=USDT) — symbol or # asset address. Use `@` (not `#`) — dotenv treats `#` as a comment. # Unset ⇒ each configured chain once. @@ -41,7 +44,9 @@ NUMBER_OF_REQUESTS=3 # ── SERVER · the resource provider (servers/upto) ───────────────────────── # Payout addresses (set the chain(s) you want to accept). EVM_ADDRESS=0x... # BSC, eip155:97 (token USDC, permit2) -TRON_ADDRESS=T... # TRON, tron:0xcd8690dc (token USDT, permit2) +TRON_ADDRESS=T... # TRON Nile (tron:0xcd8690dc); Shasta: tron:0x94a9059e; mainnet: tron:0x2b6653dc (token USDT, permit2) +# TRON network: tron:0xcd8690dc (Nile, default), tron:0x94a9059e (Shasta), tron:0x2b6653dc (mainnet) +# TRON_NETWORK=tron:0xcd8690dc # Where the server binds, and where it reaches the facilitator. SERVER_PORT=4051 diff --git a/examples/typescript/.gitignore b/examples/typescript/.gitignore index ff5c8bb1..649f5345 100644 --- a/examples/typescript/.gitignore +++ b/examples/typescript/.gitignore @@ -5,10 +5,12 @@ # gasfree → .env-gasfree (clients/gasfree, servers/gasfree, facilitator/gasfree) # batch → .env-batch (clients/*, servers/*, facilitator/batch-settlement) # upto → .env-upto (clients/upto, servers/upto, facilitator/upto) +# multi → .env-multi-scheme (servers/multi-scheme; reuses basic + gasfree facilitators) .env-exact .env-gasfree .env-batch .env-upto +.env-multi-scheme .env node_modules/ diff --git a/examples/typescript/clients/batch-settlement/src/chains/tron.ts b/examples/typescript/clients/batch-settlement/src/chains/tron.ts index b2a79cfd..993ae196 100644 --- a/examples/typescript/clients/batch-settlement/src/chains/tron.ts +++ b/examples/typescript/clients/batch-settlement/src/chains/tron.ts @@ -4,19 +4,28 @@ * the address and re-adds the `0x` prefix agent-wallet strips. The TronWeb * instance carries no private key. * - * On `tron:0xcd8690dc` the server advertises USDT, a Permit2 token — the first request + * On `TRON_NILE` the server advertises USDT, a Permit2 token — the first request * opens the channel via a **Permit2 deposit**, which needs a one-time * `approve(Permit2)` from the payer. `signTransaction` lets the signer * auto-broadcast that approve (parity with the `exact` TRON client). Later * requests are voucher-only. */ -import { createClientTronSigner } from "@bankofai/x402-tron"; +import { + createClientTronSigner, + TRON_NILE, + TRON_MAINNET, +} from "@bankofai/x402-tron"; import { BatchSettlementTronScheme } from "@bankofai/x402-tron/batch-settlement/client"; import type { x402Client } from "@bankofai/x402-fetch"; -import { tryResolveWallet, type BatchClientOptions, type RefundableScheme } from "../env.js"; +import { + tryResolveWallet, + type BatchClientOptions, + type RefundableScheme, +} from "../env.js"; -const TRON_NETWORK = "tron:0xcd8690dc"; +const TRON_NETWORK = (process.env.TRON_NETWORK ?? + TRON_NILE) as `${string}:${string}`; /** * Registers the TRON `batch-settlement` client scheme, if a TRON wallet is @@ -47,6 +56,8 @@ export async function registerTron( depositPolicy: { depositMultiplier: opts.depositMultiplier }, }); client.register(TRON_NETWORK, scheme); - console.info(`[tron] client registered ${TRON_NETWORK} batch-settlement (${signer.address})`); + console.info( + `[tron] client registered ${TRON_NETWORK} batch-settlement (${signer.address})`, + ); return [{ label: TRON_NETWORK, refund: (url, o) => scheme.refund(url, o) }]; } diff --git a/examples/typescript/clients/batch-settlement/src/env.ts b/examples/typescript/clients/batch-settlement/src/env.ts index 3a4fdd34..73c1e8f1 100644 --- a/examples/typescript/clients/batch-settlement/src/env.ts +++ b/examples/typescript/clients/batch-settlement/src/env.ts @@ -2,6 +2,7 @@ * Wallet resolution via `@bankofai/agent-wallet` — the example never touches a * private key. A chain registers only when a wallet for it resolves. */ +import { TRON_NILE, TRON_MAINNET } from "@bankofai/x402-tron"; import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; /** @@ -10,7 +11,10 @@ import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; * `EvmSigner`/`TronSigner` (both `Eip712Capable`), so we surface that here. */ export type SignerWallet = Wallet & { - signTypedData(data: Record, options?: unknown): Promise; + signTypedData( + data: Record, + options?: unknown, + ): Promise; }; /** @@ -20,7 +24,7 @@ export type SignerWallet = Wallet & { */ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { evm: "eip155:97", - tron: "tron:0xcd8690dc", + tron: TRON_NILE, }; /** @@ -29,9 +33,13 @@ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { * @param family - `"evm"` or `"tron"`. * @returns The wallet, or `null` to skip that chain. */ -export async function tryResolveWallet(family: "evm" | "tron"): Promise { +export async function tryResolveWallet( + family: "evm" | "tron", +): Promise { try { - return (await resolveWallet({ network: CAIP2_BY_FAMILY[family] })) as SignerWallet; + return (await resolveWallet({ + network: CAIP2_BY_FAMILY[family], + })) as SignerWallet; } catch { return null; } diff --git a/examples/typescript/clients/batch-settlement/src/index.ts b/examples/typescript/clients/batch-settlement/src/index.ts index acd96614..efd2f39f 100644 --- a/examples/typescript/clients/batch-settlement/src/index.ts +++ b/examples/typescript/clients/batch-settlement/src/index.ts @@ -10,18 +10,25 @@ * target. This file imports no chain SDK directly. * * PAY_TARGETS — comma-separated, one burst (+refund) per entry, in order: - * [@] network: "eip155:97"/"tron:0xcd8690dc" (or "eip155"/"tron"); - * token: symbol or asset address; omit ⇒ the network's first advertised token. + * [@] network: full CAIP-2 ("eip155:97", TRON_NILE) or a + * family prefix ("eip155"/"tron") when no token is specified; + * token: symbol or asset address (requires a full CAIP-2 network to resolve). * (`@` not `#` — dotenv treats `#` as a comment.) * Unset ⇒ each configured chain once. */ -import { x402Client, wrapFetchWithPayment, decodePaymentResponseHeader } from "@bankofai/x402-fetch"; +import { TRON_NILE, TRON_MAINNET, TRON_SHASTA } from "@bankofai/x402-tron"; +import { + x402Client, + wrapFetchWithPayment, + decodePaymentResponseHeader, +} from "@bankofai/x402-fetch"; import { registerEvm } from "./chains/evm.js"; import { registerTron } from "./chains/tron.js"; import type { BatchClientOptions, RefundableScheme } from "./env.js"; -const RESOURCE_URL = process.env.RESOURCE_URL || "http://localhost:4041/weather"; +const RESOURCE_URL = + process.env.RESOURCE_URL || "http://localhost:4041/weather"; const NUMBER_OF_REQUESTS = Number(process.env.NUMBER_OF_REQUESTS ?? "3"); const REFUND_AFTER = process.env.REFUND_AFTER_REQUESTS === "true"; const REFUND_AMOUNT = process.env.REFUND_AMOUNT?.trim() || undefined; @@ -33,11 +40,22 @@ const opts: BatchClientOptions = { }; // Friendly token symbol → asset address, mirroring what the server advertises. -const TOKEN_ADDRESSES: Record = { - DHLU: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // eip155:97, ERC-3009 - USDC: "0x64544969ed7EBf5f083679233325356EbE738930", // eip155:97, permit2 (default) - USDT: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", // tron:0xcd8690dc, permit2 (default) - USDD: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", // tron:0xcd8690dc, permit2 +const TOKEN_ADDRESSES: Record> = { + "eip155:97": { + DHLU: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // ERC-3009 + USDC: "0x64544969ed7EBf5f083679233325356EbE738930", // permit2 (default) + }, + [TRON_NILE]: { + USDT: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", // permit2 (default) + USDD: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", // permit2 + }, + [TRON_SHASTA]: { + USDT: "TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", // permit2 + }, + [TRON_MAINNET]: { + USDT: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + USDD: "TXDk8mbtRbXeYuMNS83CfKPaYYT8XWv9Hz", + }, }; /** A single payment target: a network prefix + optional asset address. */ @@ -48,8 +66,22 @@ interface PayTarget { } /** Resolve a token tag (symbol or address) to an asset address. */ -function resolveToken(token: string): string { - return TOKEN_ADDRESSES[token.toUpperCase()] ?? token; +function resolveToken(prefix: string, token: string): string { + const entry = TOKEN_ADDRESSES[prefix]; + if (!entry) { + throw new Error( + `Cannot resolve token "${token}": "${prefix}" is a family prefix. ` + + `Use a full CAIP-2 network (e.g. ${TRON_NILE}) when specifying a token.`, + ); + } + const addr = entry[token.toUpperCase()]; + if (!addr) { + throw new Error( + `Unknown token symbol "${token}" for network "${prefix}". ` + + `Use an asset address or a known symbol.`, + ); + } + return addr; } /** Parse the PAY_TARGETS env var into targets (empty when unset). */ @@ -58,11 +90,15 @@ function parsePayTargets(): PayTarget[] { if (!raw) return []; return raw .split(",") - .map(s => s.trim()) + .map((s) => s.trim()) .filter(Boolean) - .map(entry => { + .map((entry) => { const [prefix, token] = entry.split("@", 2); - return { raw: entry, prefix: prefix!.trim(), asset: token ? resolveToken(token.trim()) : undefined }; + return { + raw: entry, + prefix: prefix!.trim(), + asset: token ? resolveToken(prefix!.trim(), token.trim()) : undefined, + }; }); } @@ -72,7 +108,9 @@ const client = new x402Client((_x402Version, accepts) => { const t = target; if (!t) return accepts[0]!; const match = accepts.find( - a => a.network.startsWith(t.prefix) && (!t.asset || a.asset.toLowerCase() === t.asset.toLowerCase()), + (a) => + a.network.startsWith(t.prefix) && + (!t.asset || a.asset.toLowerCase() === t.asset.toLowerCase()), ); if (!match) { throw new Error(`server offered no payment option matching "${t.raw}"`); @@ -94,16 +132,22 @@ if (targets.length === 0) { if (tron) targets.push({ raw: "tron", prefix: "tron:" }); } if (targets.length === 0) { - console.error("❌ No wallet configured for EVM or TRON (see agent-wallet setup)."); + console.error( + "❌ No wallet configured for EVM or TRON (see agent-wallet setup).", + ); process.exit(1); } for (const t of targets) { if (t.prefix.startsWith("eip155") && !evm) { - console.error(`❌ target "${t.raw}" needs an EVM wallet, but none is configured.`); + console.error( + `❌ target "${t.raw}" needs an EVM wallet, but none is configured.`, + ); process.exit(1); } if (t.prefix.startsWith("tron") && !tron) { - console.error(`❌ target "${t.raw}" needs a TRON wallet, but none is configured.`); + console.error( + `❌ target "${t.raw}" needs a TRON wallet, but none is configured.`, + ); process.exit(1); } } @@ -123,12 +167,14 @@ for (const t of targets) { usedNetwork ??= networkFromPaymentResponse(res); const body = await res.json(); const secs = ((performance.now() - t0) / 1000).toFixed(3); - console.log(`request ${i}/${NUMBER_OF_REQUESTS} — ${res.status} in ${secs}s`); + console.log( + `request ${i}/${NUMBER_OF_REQUESTS} — ${res.status} in ${secs}s`, + ); console.log(JSON.stringify(body, null, 2)); } if (REFUND_AFTER) { - const scheme = refundable.find(s => s.label === usedNetwork); + const scheme = refundable.find((s) => s.label === usedNetwork); if (!scheme) { console.log("\n↩️ nothing to refund (no settled payment captured)"); } else { @@ -137,7 +183,10 @@ for (const t of targets) { ? `\n↩️ refunding ${REFUND_AMOUNT} base units on ${scheme.label}` : `\n↩️ refunding remaining channel balance on ${scheme.label}`, ); - const result = await scheme.refund(RESOURCE_URL, REFUND_AMOUNT ? { amount: REFUND_AMOUNT } : {}); + const result = await scheme.refund( + RESOURCE_URL, + REFUND_AMOUNT ? { amount: REFUND_AMOUNT } : {}, + ); console.log(JSON.stringify(result, null, 2)); } } @@ -151,7 +200,9 @@ for (const t of targets) { * @returns The CAIP-2 network string, or `undefined`. */ function networkFromPaymentResponse(res: Response): string | undefined { - const header = res.headers.get("payment-response") ?? res.headers.get("x-payment-response"); + const header = + res.headers.get("payment-response") ?? + res.headers.get("x-payment-response"); if (!header) return undefined; try { return decodePaymentResponseHeader(header).network; diff --git a/examples/typescript/clients/fetch/src/chains/tron.ts b/examples/typescript/clients/fetch/src/chains/tron.ts index b465830f..fd9b89c9 100644 --- a/examples/typescript/clients/fetch/src/chains/tron.ts +++ b/examples/typescript/clients/fetch/src/chains/tron.ts @@ -9,13 +9,18 @@ * that USDT/USDD (no ERC-3009) need before their first payment — parity with the * Python client. */ -import { createClientTronSigner } from "@bankofai/x402-tron"; +import { + createClientTronSigner, + TRON_NILE, + TRON_MAINNET, + TRON_SHASTA, +} from "@bankofai/x402-tron"; import { ExactTronScheme } from "@bankofai/x402-tron/exact/client"; import type { x402Client } from "@bankofai/x402-fetch"; import { tryResolveWallet } from "../env.js"; -const TRON_NETWORKS = ["tron:0xcd8690dc", "tron:0x2b6653dc"] as const; +const TRON_NETWORKS = [TRON_NILE, TRON_SHASTA, TRON_MAINNET] as const; /** * Registers the TRON `exact` client scheme, if a TRON wallet is configured. diff --git a/examples/typescript/clients/fetch/src/env.ts b/examples/typescript/clients/fetch/src/env.ts index 18cd0cfe..799cea61 100644 --- a/examples/typescript/clients/fetch/src/env.ts +++ b/examples/typescript/clients/fetch/src/env.ts @@ -2,6 +2,7 @@ * Wallet resolution via `@bankofai/agent-wallet` — the example never touches a * private key. A chain registers only when a wallet for it resolves. */ +import { TRON_NILE, TRON_MAINNET } from "@bankofai/x402-tron"; import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; /** @@ -10,7 +11,10 @@ import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; * `EvmSigner`/`TronSigner` (both `Eip712Capable`), so we surface that here. */ export type SignerWallet = Wallet & { - signTypedData(data: Record, options?: unknown): Promise; + signTypedData( + data: Record, + options?: unknown, + ): Promise; }; /** @@ -23,7 +27,7 @@ export type SignerWallet = Wallet & { */ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { evm: "eip155:97", - tron: "tron:0xcd8690dc", + tron: TRON_NILE, }; /** @@ -32,9 +36,13 @@ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { * @param family - `"evm"` or `"tron"`. * @returns The wallet, or `null` to skip that chain. */ -export async function tryResolveWallet(family: "evm" | "tron"): Promise { +export async function tryResolveWallet( + family: "evm" | "tron", +): Promise { try { - return (await resolveWallet({ network: CAIP2_BY_FAMILY[family] })) as SignerWallet; + return (await resolveWallet({ + network: CAIP2_BY_FAMILY[family], + })) as SignerWallet; } catch { return null; } diff --git a/examples/typescript/clients/fetch/src/index.ts b/examples/typescript/clients/fetch/src/index.ts index c583072f..91b71a93 100644 --- a/examples/typescript/clients/fetch/src/index.ts +++ b/examples/typescript/clients/fetch/src/index.ts @@ -10,13 +10,15 @@ * * PAY_TARGETS — comma-separated, one payment per entry, in order: * [@] - * matched by prefix: "eip155:97" / "tron:0xcd8690dc" (or "eip155" / "tron") + * matched by prefix: "eip155:97" / TRON_NILE (or "eip155" / "tron" + * when no token is specified) * symbol (DHLU/USDC/USDT/USDD) or an asset address; omit ⇒ that * network's first advertised token - * e.g. PAY_TARGETS=eip155:97@DHLU,tron:0xcd8690dc@USDT,tron:0xcd8690dc@USDD + * e.g. PAY_TARGETS=eip155:97@DHLU,TRON_NILE@USDT,TRON_NILE@USDD * (`@` not `#` — dotenv treats `#` as a comment.) * Unset ⇒ each configured chain once, with its first advertised token. */ +import { TRON_NILE, TRON_MAINNET, TRON_SHASTA } from "@bankofai/x402-tron"; import { x402Client, wrapFetchWithPayment } from "@bankofai/x402-fetch"; import { registerEvm } from "./chains/evm.js"; @@ -28,7 +30,7 @@ const RESOURCE_URL = // Friendly token symbol → asset address, mirroring what `servers/express` // advertises. Lets PAY_TARGETS name tokens by symbol instead of address. // Indexed by chain family so the same symbol can resolve differently per -// network (e.g. USDT on eip155:97 vs tron:0xcd8690dc are different contracts). +// network (e.g. USDT on eip155:97 vs TRON_NILE are different contracts). const TOKEN_ADDRESSES: Record> = { "eip155:97": { DHLU: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // eip155:97, ERC-3009 @@ -39,14 +41,17 @@ const TOKEN_ADDRESSES: Record> = { USDC: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", USDT: "0x55d398326f99059fF775485246999027B3197955", }, - "tron:0xcd8690dc": { - USDT: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", // tron:0xcd8690dc, permit2 - USDD: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", // tron:0xcd8690dc, permit2 + [TRON_NILE]: { + USDT: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", // TRON_NILE, permit2 + USDD: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", // TRON_NILE, permit2 }, - "tron:0x2b6653dc": { + [TRON_MAINNET]: { USDT: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", USDD: "TXDk8mbtRbXeYuMNS83CfKPaYYT8XWv9Hz", }, + [TRON_SHASTA]: { + USDT: "TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", + }, }; /** A single payment to perform: a network prefix + optional asset address. */ @@ -58,7 +63,21 @@ interface PayTarget { /** Resolve a token tag (symbol or address) to an asset address. */ function resolveToken(prefix: string, token: string): string { - return TOKEN_ADDRESSES[prefix]?.[token.toUpperCase()] ?? token; + const entry = TOKEN_ADDRESSES[prefix]; + if (!entry) { + throw new Error( + `Cannot resolve token "${token}": "${prefix}" is a family prefix. ` + + `Use a full CAIP-2 network (e.g. ${TRON_NILE}) when specifying a token.`, + ); + } + const addr = entry[token.toUpperCase()]; + if (!addr) { + throw new Error( + `Unknown token symbol "${token}" for network "${prefix}". ` + + `Use an asset address or a known symbol.`, + ); + } + return addr; } /** Parse the PAY_TARGETS env var into targets (empty when unset). */ diff --git a/examples/typescript/clients/gasfree/src/chains/tron.ts b/examples/typescript/clients/gasfree/src/chains/tron.ts index 21114983..7602e964 100644 --- a/examples/typescript/clients/gasfree/src/chains/tron.ts +++ b/examples/typescript/clients/gasfree/src/chains/tron.ts @@ -10,13 +10,18 @@ * * GasFree is TRON-only — there is no EVM counterpart. */ -import { createClientTronSigner } from "@bankofai/x402-tron"; +import { + createClientTronSigner, + TRON_NILE, + TRON_MAINNET, +} from "@bankofai/x402-tron"; import { registerExactGasFreeTronScheme } from "@bankofai/x402-tron/gasfree/client"; import type { x402Client } from "@bankofai/x402-fetch"; import { tryResolveTronWallet } from "../env.js"; -const TRON_NETWORK = "tron:0xcd8690dc"; +const TRON_NETWORK = (process.env.TRON_NETWORK ?? + TRON_NILE) as `${string}:${string}`; /** * Registers the TRON `exact_gasfree` client scheme, if a TRON wallet resolves. @@ -24,7 +29,9 @@ const TRON_NETWORK = "tron:0xcd8690dc"; * @param client - The x402 client to register the scheme on. * @returns `true` if registered, `false` when no TRON wallet was configured. */ -export async function registerTronGasFree(client: x402Client): Promise { +export async function registerTronGasFree( + client: x402Client, +): Promise { const wallet = await tryResolveTronWallet(); if (!wallet) { return false; @@ -43,9 +50,15 @@ export async function registerTronGasFree(client: x402Client): Promise registerExactGasFreeTronScheme(client, { signer, ...(process.env.GASFREE_API_URL - ? { schemeOptions: { apiBaseUrls: { "tron:0xcd8690dc": process.env.GASFREE_API_URL } } } + ? { + schemeOptions: { + apiBaseUrls: { [TRON_NETWORK]: process.env.GASFREE_API_URL }, + }, + } : {}), }); - console.info(`[tron] client registered tron:* exact_gasfree (${signer.address})`); + console.info( + `[tron] client registered tron:* exact_gasfree (${signer.address})`, + ); return true; } diff --git a/examples/typescript/clients/gasfree/src/env.ts b/examples/typescript/clients/gasfree/src/env.ts index 1401bbd3..37f315f8 100644 --- a/examples/typescript/clients/gasfree/src/env.ts +++ b/examples/typescript/clients/gasfree/src/env.ts @@ -2,6 +2,7 @@ * Wallet resolution via `@bankofai/agent-wallet` — the example never touches a * private key. The TRON scheme registers only when a TRON wallet resolves. */ +import { TRON_NILE, TRON_MAINNET } from "@bankofai/x402-tron"; import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; /** @@ -10,7 +11,10 @@ import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; * `TronSigner` (`Eip712Capable`), so we surface that here. */ export type SignerWallet = Wallet & { - signTypedData(data: Record, options?: unknown): Promise; + signTypedData( + data: Record, + options?: unknown, + ): Promise; }; /** @@ -18,13 +22,13 @@ export type SignerWallet = Wallet & { * * `@bankofai/agent-wallet` expects a **CAIP-2** network id. Key derivation is * chain-id-independent within a family, so any `tron:` id resolves the same - * address; we use `tron:0xcd8690dc`. + * address; we use `TRON_NILE`. * * @returns The wallet, or `null` to skip TRON. */ export async function tryResolveTronWallet(): Promise { try { - return (await resolveWallet({ network: "tron:0xcd8690dc" })) as SignerWallet; + return (await resolveWallet({ network: TRON_NILE })) as SignerWallet; } catch { return null; } diff --git a/examples/typescript/clients/mcp/src/chains/tron.ts b/examples/typescript/clients/mcp/src/chains/tron.ts index 24acc696..aaa61e5f 100644 --- a/examples/typescript/clients/mcp/src/chains/tron.ts +++ b/examples/typescript/clients/mcp/src/chains/tron.ts @@ -4,14 +4,20 @@ * address and auto-broadcasts the one-time Permit2 `approve` that USDT/USDD need. * Returns the scheme entries for `createx402MCPClient({ schemes })`. */ -import { createClientTronSigner } from "@bankofai/x402-tron"; +import { + createClientTronSigner, + TRON_NILE, + TRON_MAINNET, + TRON_SHASTA, +} from "@bankofai/x402-tron"; import { ExactTronScheme } from "@bankofai/x402-tron/exact/client"; import type { Network, SchemeNetworkClient } from "@bankofai/x402-core/types"; import { tryResolveWallet } from "../env.js"; /** CAIP-2 network this client pays on. */ -export const TRON_NETWORK: Network = "tron:0xcd8690dc"; +export const TRON_NETWORK: Network = (process.env.TRON_NETWORK ?? + TRON_NILE) as Network; /** * Builds the TRON `exact` client scheme registration, if a TRON wallet resolves. diff --git a/examples/typescript/clients/mcp/src/env.ts b/examples/typescript/clients/mcp/src/env.ts index 49a19e21..941b796a 100644 --- a/examples/typescript/clients/mcp/src/env.ts +++ b/examples/typescript/clients/mcp/src/env.ts @@ -2,6 +2,7 @@ * Wallet resolution via `@bankofai/agent-wallet` — the example never touches a * private key. A chain registers only when a wallet for it resolves. */ +import { TRON_NILE, TRON_MAINNET } from "@bankofai/x402-tron"; import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; /** @@ -10,7 +11,10 @@ import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; * `EvmSigner`/`TronSigner` (both `Eip712Capable`), so we surface that here. */ export type SignerWallet = Wallet & { - signTypedData(data: Record, options?: unknown): Promise; + signTypedData( + data: Record, + options?: unknown, + ): Promise; }; /** @@ -21,7 +25,7 @@ export type SignerWallet = Wallet & { */ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { evm: "eip155:97", - tron: "tron:0xcd8690dc", + tron: TRON_NILE, }; /** @@ -30,9 +34,13 @@ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { * @param family - `"evm"` or `"tron"`. * @returns The wallet, or `null` to skip that chain. */ -export async function tryResolveWallet(family: "evm" | "tron"): Promise { +export async function tryResolveWallet( + family: "evm" | "tron", +): Promise { try { - return (await resolveWallet({ network: CAIP2_BY_FAMILY[family] })) as SignerWallet; + return (await resolveWallet({ + network: CAIP2_BY_FAMILY[family], + })) as SignerWallet; } catch { return null; } diff --git a/examples/typescript/clients/mcp/src/index.ts b/examples/typescript/clients/mcp/src/index.ts index 85932735..49379bd7 100644 --- a/examples/typescript/clients/mcp/src/index.ts +++ b/examples/typescript/clients/mcp/src/index.ts @@ -10,14 +10,16 @@ * lives in `src/chains/`. * * PAY_TARGETS — comma-separated, one paid call per entry, in order: - * [@] network: "eip155:97"/"tron:0xcd8690dc" (or "eip155"/"tron"); - * token: symbol (DHLU on EVM; USDT/USDD on TRON) or an asset address; omit ⇒ - * the network's first advertised token. + * [@] network: full CAIP-2 ("eip155:97", TRON_NILE) or a + * family prefix ("eip155"/"tron") when no token is specified; + * token: symbol (DHLU on EVM; USDT/USDD on TRON) or an asset address (requires + * a full CAIP-2 network to resolve); omit ⇒ the network's first advertised token. * (`@` not `#` — dotenv treats `#` as a comment.) * Unset ⇒ each configured chain once. * * Pair with `servers/mcp` and `facilitator/basic`. */ +import { TRON_NILE, TRON_MAINNET, TRON_SHASTA } from "@bankofai/x402-tron"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { x402Client, wrapMCPClientWithPayment } from "@bankofai/x402-mcp"; @@ -28,11 +30,22 @@ import { tronSchemes } from "./chains/tron.js"; const SERVER_URL = process.env.MCP_SERVER_URL || "http://localhost:4023"; // Friendly token symbol → asset address, mirroring what `servers/mcp` advertises. -const TOKEN_ADDRESSES: Record = { - DHLU: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // eip155:97, ERC-3009 - USDC: "0x64544969ed7EBf5f083679233325356EbE738930", // eip155:97, permit2 - USDT: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", // tron:0xcd8690dc, permit2 - USDD: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", // tron:0xcd8690dc, permit2 +const TOKEN_ADDRESSES: Record> = { + "eip155:97": { + DHLU: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // ERC-3009 + USDC: "0x64544969ed7EBf5f083679233325356EbE738930", // permit2 + }, + [TRON_NILE]: { + USDT: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", // permit2 + USDD: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", // permit2 + }, + [TRON_SHASTA]: { + USDT: "TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", // permit2 + }, + [TRON_MAINNET]: { + USDT: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + USDD: "TXDk8mbtRbXeYuMNS83CfKPaYYT8XWv9Hz", + }, }; /** A single payment target: a network prefix + optional asset address. */ @@ -43,8 +56,22 @@ interface PayTarget { } /** Resolve a token tag (symbol or address) to an asset address. */ -function resolveToken(token: string): string { - return TOKEN_ADDRESSES[token.toUpperCase()] ?? token; +function resolveToken(prefix: string, token: string): string { + const entry = TOKEN_ADDRESSES[prefix]; + if (!entry) { + throw new Error( + `Cannot resolve token "${token}": "${prefix}" is a family prefix. ` + + `Use a full CAIP-2 network (e.g. ${TRON_NILE}) when specifying a token.`, + ); + } + const addr = entry[token.toUpperCase()]; + if (!addr) { + throw new Error( + `Unknown token symbol "${token}" for network "${prefix}". ` + + `Use an asset address or a known symbol.`, + ); + } + return addr; } /** Parse the PAY_TARGETS env var into targets (empty when unset). */ @@ -53,11 +80,15 @@ function parsePayTargets(): PayTarget[] { if (!raw) return []; return raw .split(",") - .map(s => s.trim()) + .map((s) => s.trim()) .filter(Boolean) - .map(entry => { + .map((entry) => { const [prefix, token] = entry.split("@", 2); - return { raw: entry, prefix: prefix!.trim(), asset: token ? resolveToken(token.trim()) : undefined }; + return { + raw: entry, + prefix: prefix!.trim(), + asset: token ? resolveToken(prefix!.trim(), token.trim()) : undefined, + }; }); } @@ -65,7 +96,9 @@ const evm = await evmSchemes(); const tron = await tronSchemes(); const schemes = [...evm, ...tron]; if (schemes.length === 0) { - console.error("❌ No wallet configured for EVM or TRON (see agent-wallet setup)."); + console.error( + "❌ No wallet configured for EVM or TRON (see agent-wallet setup).", + ); process.exit(1); } @@ -75,7 +108,9 @@ const paymentClient = new x402Client((_x402Version, accepts) => { const t = target; if (!t) return accepts[0]!; const match = accepts.find( - a => a.network.startsWith(t.prefix) && (!t.asset || a.asset.toLowerCase() === t.asset.toLowerCase()), + (a) => + a.network.startsWith(t.prefix) && + (!t.asset || a.asset.toLowerCase() === t.asset.toLowerCase()), ); if (!match) { throw new Error(`server offered no payment option matching "${t.raw}"`); @@ -94,20 +129,29 @@ if (targets.length === 0) { } for (const t of targets) { if (t.prefix.startsWith("eip155") && evm.length === 0) { - console.error(`❌ target "${t.raw}" needs an EVM wallet, but none is configured.`); + console.error( + `❌ target "${t.raw}" needs an EVM wallet, but none is configured.`, + ); process.exit(1); } if (t.prefix.startsWith("tron") && tron.length === 0) { - console.error(`❌ target "${t.raw}" needs a TRON wallet, but none is configured.`); + console.error( + `❌ target "${t.raw}" needs a TRON wallet, but none is configured.`, + ); process.exit(1); } } -const mcpClient = new Client({ name: "x402-mcp-dual-chain-client", version: "1.0.0" }); +const mcpClient = new Client({ + name: "x402-mcp-dual-chain-client", + version: "1.0.0", +}); const x402Mcp = wrapMCPClientWithPayment(mcpClient, paymentClient, { autoPayment: true, - onPaymentRequested: async context => { - console.log(`\n💰 Payment required for tool: ${context.toolName} (target ${target?.raw})`); + onPaymentRequested: async (context) => { + console.log( + `\n💰 Payment required for tool: ${context.toolName} (target ${target?.raw})`, + ); return true; // approve }, }); @@ -117,19 +161,22 @@ await x402Mcp.connect(transport); console.log(`✅ Connected to MCP server at ${SERVER_URL}`); const tools = await x402Mcp.listTools(); -console.log( - "📋 Tools:", - tools.tools.map(t => t.name).join(", "), -); +console.log("📋 Tools:", tools.tools.map((t) => t.name).join(", ")); // Free tool — no payment. const ping = await x402Mcp.callTool("ping"); -console.log("\n🆓 ping →", ping.content[0]?.text, `(paymentMade=${ping.paymentMade})`); +console.log( + "\n🆓 ping →", + ping.content[0]?.text, + `(paymentMade=${ping.paymentMade})`, +); // Paid tool — once per target. for (const t of targets) { target = t; - const weather = await x402Mcp.callTool("get_weather", { city: "San Francisco" }); + const weather = await x402Mcp.callTool("get_weather", { + city: "San Francisco", + }); console.log(`\n💰 [${t.raw}] get_weather →`, weather.content[0]?.text); console.log(" paymentMade:", weather.paymentMade); if (weather.paymentResponse) { diff --git a/examples/typescript/clients/upto/src/chains/tron.ts b/examples/typescript/clients/upto/src/chains/tron.ts index 5e7e8cf0..d5926090 100644 --- a/examples/typescript/clients/upto/src/chains/tron.ts +++ b/examples/typescript/clients/upto/src/chains/tron.ts @@ -4,18 +4,23 @@ * the address and re-adds the `0x` prefix agent-wallet strips. The TronWeb * instance carries no private key. * - * On `tron:0xcd8690dc` the server advertises USDT, a **Permit2** token — the upto + * On `TRON_NILE` the server advertises USDT, a **Permit2** token — the upto * payment is a Permit2 `PermitWitnessTransferFrom` signed for up to the * advertised maximum, and needs a one-time `approve(Permit2)` from the payer. * `signTransaction` lets the signer auto-broadcast that approve. */ -import { createClientTronSigner } from "@bankofai/x402-tron"; +import { + createClientTronSigner, + TRON_NILE, + TRON_MAINNET, +} from "@bankofai/x402-tron"; import { UptoTronScheme } from "@bankofai/x402-tron/upto/client"; import type { x402Client } from "@bankofai/x402-fetch"; import { tryResolveWallet } from "../env.js"; -const TRON_NETWORK = "tron:0xcd8690dc"; +const TRON_NETWORK = (process.env.TRON_NETWORK ?? + TRON_NILE) as `${string}:${string}`; /** * Registers the TRON `upto` client scheme, if a TRON wallet is configured. @@ -35,6 +40,8 @@ export async function registerTron(client: x402Client): Promise { }); client.register(TRON_NETWORK, new UptoTronScheme(signer)); - console.info(`[tron] client registered ${TRON_NETWORK} upto (${signer.address})`); + console.info( + `[tron] client registered ${TRON_NETWORK} upto (${signer.address})`, + ); return [TRON_NETWORK]; } diff --git a/examples/typescript/clients/upto/src/env.ts b/examples/typescript/clients/upto/src/env.ts index eeed6f4c..1d5cf604 100644 --- a/examples/typescript/clients/upto/src/env.ts +++ b/examples/typescript/clients/upto/src/env.ts @@ -2,6 +2,7 @@ * Wallet resolution via `@bankofai/agent-wallet` — the example never touches a * private key. A chain registers only when a wallet for it resolves. */ +import { TRON_NILE, TRON_MAINNET } from "@bankofai/x402-tron"; import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; /** @@ -11,7 +12,10 @@ import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; * upto scheme signs a Permit2 `PermitWitnessTransferFrom` digest. */ export type SignerWallet = Wallet & { - signTypedData(data: Record, options?: unknown): Promise; + signTypedData( + data: Record, + options?: unknown, + ): Promise; }; /** @@ -21,7 +25,7 @@ export type SignerWallet = Wallet & { */ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { evm: "eip155:97", - tron: "tron:0xcd8690dc", + tron: TRON_NILE, }; /** @@ -30,9 +34,13 @@ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { * @param family - `"evm"` or `"tron"`. * @returns The wallet, or `null` to skip that chain. */ -export async function tryResolveWallet(family: "evm" | "tron"): Promise { +export async function tryResolveWallet( + family: "evm" | "tron", +): Promise { try { - return (await resolveWallet({ network: CAIP2_BY_FAMILY[family] })) as SignerWallet; + return (await resolveWallet({ + network: CAIP2_BY_FAMILY[family], + })) as SignerWallet; } catch { return null; } diff --git a/examples/typescript/clients/upto/src/index.ts b/examples/typescript/clients/upto/src/index.ts index ccd8e30c..3304e924 100644 --- a/examples/typescript/clients/upto/src/index.ts +++ b/examples/typescript/clients/upto/src/index.ts @@ -10,25 +10,39 @@ * imports no chain SDK directly. * * PAY_TARGETS — comma-separated, one run (N requests) per entry, in order: - * [@] network: "eip155:97"/"tron:0xcd8690dc" (or "eip155"/"tron"); - * token: symbol or asset address; omit ⇒ the network's first advertised token. + * [@] network: full CAIP-2 ("eip155:97", TRON_NILE) or a + * family prefix ("eip155"/"tron") when no token is specified; + * token: symbol or asset address (requires a full CAIP-2 network to resolve). * (`@` not `#` — dotenv treats `#` as a comment.) * Unset ⇒ each configured chain once. */ +import { TRON_NILE, TRON_MAINNET, TRON_SHASTA } from "@bankofai/x402-tron"; import { x402Client, wrapFetchWithPayment } from "@bankofai/x402-fetch"; import { registerEvm } from "./chains/evm.js"; import { registerTron } from "./chains/tron.js"; -const RESOURCE_URL = process.env.RESOURCE_URL || "http://localhost:4051/generate"; +const RESOURCE_URL = + process.env.RESOURCE_URL || "http://localhost:4051/generate"; const NUMBER_OF_REQUESTS = Number(process.env.NUMBER_OF_REQUESTS ?? "3"); // Friendly token symbol → asset address, mirroring what the server advertises. -const TOKEN_ADDRESSES: Record = { - DHLU: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // eip155:97, ERC-3009 - USDC: "0x64544969ed7EBf5f083679233325356EbE738930", // eip155:97, permit2 (default) - USDT: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", // tron:0xcd8690dc, permit2 (default) - USDD: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", // tron:0xcd8690dc, permit2 +const TOKEN_ADDRESSES: Record> = { + "eip155:97": { + DHLU: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", // ERC-3009 + USDC: "0x64544969ed7EBf5f083679233325356EbE738930", // permit2 (default) + }, + [TRON_NILE]: { + USDT: "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf", // permit2 (default) + USDD: "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK", // permit2 + }, + [TRON_SHASTA]: { + USDT: "TG3XXyExBkPp9nzdajDZsozEu4BkaSJozs", // permit2 + }, + [TRON_MAINNET]: { + USDT: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + USDD: "TXDk8mbtRbXeYuMNS83CfKPaYYT8XWv9Hz", + }, }; /** A single payment target: a network prefix + optional asset address. */ @@ -39,8 +53,22 @@ interface PayTarget { } /** Resolve a token tag (symbol or address) to an asset address. */ -function resolveToken(token: string): string { - return TOKEN_ADDRESSES[token.toUpperCase()] ?? token; +function resolveToken(prefix: string, token: string): string { + const entry = TOKEN_ADDRESSES[prefix]; + if (!entry) { + throw new Error( + `Cannot resolve token "${token}": "${prefix}" is a family prefix. ` + + `Use a full CAIP-2 network (e.g. ${TRON_NILE}) when specifying a token.`, + ); + } + const addr = entry[token.toUpperCase()]; + if (!addr) { + throw new Error( + `Unknown token symbol "${token}" for network "${prefix}". ` + + `Use an asset address or a known symbol.`, + ); + } + return addr; } /** Parse the PAY_TARGETS env var into targets (empty when unset). */ @@ -49,11 +77,15 @@ function parsePayTargets(): PayTarget[] { if (!raw) return []; return raw .split(",") - .map(s => s.trim()) + .map((s) => s.trim()) .filter(Boolean) - .map(entry => { + .map((entry) => { const [prefix, token] = entry.split("@", 2); - return { raw: entry, prefix: prefix!.trim(), asset: token ? resolveToken(token.trim()) : undefined }; + return { + raw: entry, + prefix: prefix!.trim(), + asset: token ? resolveToken(prefix!.trim(), token.trim()) : undefined, + }; }); } @@ -63,7 +95,9 @@ const client = new x402Client((_x402Version, accepts) => { const t = target; if (!t) return accepts[0]!; const match = accepts.find( - a => a.network.startsWith(t.prefix) && (!t.asset || a.asset.toLowerCase() === t.asset.toLowerCase()), + (a) => + a.network.startsWith(t.prefix) && + (!t.asset || a.asset.toLowerCase() === t.asset.toLowerCase()), ); if (!match) { throw new Error(`server offered no payment option matching "${t.raw}"`); @@ -81,16 +115,22 @@ if (targets.length === 0) { if (tron) targets.push({ raw: "tron", prefix: "tron:" }); } if (targets.length === 0) { - console.error("❌ No wallet configured for EVM or TRON (see agent-wallet setup)."); + console.error( + "❌ No wallet configured for EVM or TRON (see agent-wallet setup).", + ); process.exit(1); } for (const t of targets) { if (t.prefix.startsWith("eip155") && !evm) { - console.error(`❌ target "${t.raw}" needs an EVM wallet, but none is configured.`); + console.error( + `❌ target "${t.raw}" needs an EVM wallet, but none is configured.`, + ); process.exit(1); } if (t.prefix.startsWith("tron") && !tron) { - console.error(`❌ target "${t.raw}" needs a TRON wallet, but none is configured.`); + console.error( + `❌ target "${t.raw}" needs a TRON wallet, but none is configured.`, + ); process.exit(1); } } @@ -106,7 +146,9 @@ for (const t of targets) { const res = await fetchWithPay(RESOURCE_URL, { method: "GET" }); const body = await res.json(); const secs = ((performance.now() - t0) / 1000).toFixed(3); - console.log(`request ${i}/${NUMBER_OF_REQUESTS} — ${res.status} in ${secs}s`); + console.log( + `request ${i}/${NUMBER_OF_REQUESTS} — ${res.status} in ${secs}s`, + ); console.log(JSON.stringify(body, null, 2)); } } diff --git a/examples/typescript/facilitator/basic/src/chains/tron.ts b/examples/typescript/facilitator/basic/src/chains/tron.ts index c271bd3e..2f8500b5 100644 --- a/examples/typescript/facilitator/basic/src/chains/tron.ts +++ b/examples/typescript/facilitator/basic/src/chains/tron.ts @@ -3,14 +3,19 @@ * in `@bankofai/agent-wallet`, and `createFacilitatorTronSigner` sets the * issuer address from the wallet (the TronWeb instance carries no private key). */ -import { createFacilitatorTronSigner } from "@bankofai/x402-tron"; +import { + createFacilitatorTronSigner, + TRON_NILE, + TRON_MAINNET, + TRON_SHASTA, +} from "@bankofai/x402-tron"; import { ExactTronScheme } from "@bankofai/x402-tron/exact/facilitator"; import type { x402Facilitator } from "@bankofai/x402-core/facilitator"; import { tryResolveWallet } from "../env.js"; -/** TRON testnet + mainnet. */ -export const TRON_NETWORKS = ["tron:0xcd8690dc", "tron:0x2b6653dc"] as const; +/** TRON testnets + mainnet. */ +export const TRON_NETWORKS = [TRON_NILE, TRON_SHASTA, TRON_MAINNET] as const; /** * Registers the TRON `exact` scheme on the facilitator, if a TRON wallet is diff --git a/examples/typescript/facilitator/basic/src/env.ts b/examples/typescript/facilitator/basic/src/env.ts index ae4d4071..c5ac4f63 100644 --- a/examples/typescript/facilitator/basic/src/env.ts +++ b/examples/typescript/facilitator/basic/src/env.ts @@ -5,6 +5,7 @@ * wallet for it resolves, so the facilitator can run EVM-only, TRON-only, or * both. */ +import { TRON_NILE, TRON_MAINNET } from "@bankofai/x402-tron"; import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; /** @@ -13,7 +14,10 @@ import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; * `EvmSigner`/`TronSigner` (both `Eip712Capable`), so we surface that here. */ export type SignerWallet = Wallet & { - signTypedData(data: Record, options?: unknown): Promise; + signTypedData( + data: Record, + options?: unknown, + ): Promise; }; /** @@ -24,7 +28,7 @@ export type SignerWallet = Wallet & { */ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { evm: "eip155:97", - tron: "tron:0xcd8690dc", + tron: TRON_NILE, }; /** @@ -33,9 +37,13 @@ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { * @param family - `"evm"` or `"tron"`. * @returns The wallet, or `null` to skip that chain. */ -export async function tryResolveWallet(family: "evm" | "tron"): Promise { +export async function tryResolveWallet( + family: "evm" | "tron", +): Promise { try { - return (await resolveWallet({ network: CAIP2_BY_FAMILY[family] })) as SignerWallet; + return (await resolveWallet({ + network: CAIP2_BY_FAMILY[family], + })) as SignerWallet; } catch { return null; } diff --git a/examples/typescript/facilitator/batch-settlement/src/chains/evm.ts b/examples/typescript/facilitator/batch-settlement/src/chains/evm.ts index fca532c4..3cb54081 100644 --- a/examples/typescript/facilitator/batch-settlement/src/chains/evm.ts +++ b/examples/typescript/facilitator/batch-settlement/src/chains/evm.ts @@ -26,6 +26,14 @@ import { tryResolveWallet } from "../env.js"; /** CAIP-2 networks to settle batches on. Add an id here (e.g. "eip155:8453"). */ const EVM_NETWORKS = ["eip155:97"] as const; +// Optional RPC override. The default BSC testnet endpoint selected by viem is +// frequently unreachable, so use the same override as the exact facilitator. +const EVM_RPC_URL = process.env.EVM_RPC_URL?.trim() || undefined; +const EVM_RPC_NETWORK = + process.env.PAY_TARGETS?.split(",") + .map((target) => target.trim().split("@", 1)[0]) + .find((network) => network?.startsWith("eip155:")) || "eip155:97"; + /** * Registers the EVM `batch-settlement` scheme on the facilitator for every * configured network, if an EVM wallet is configured in agent-wallet. @@ -45,7 +53,10 @@ export async function registerEvm(facilitator: x402Facilitator): Promise { +export async function registerTron( + facilitator: x402Facilitator, +): Promise { const wallet = await tryResolveWallet("tron"); if (!wallet) { return false; diff --git a/examples/typescript/facilitator/batch-settlement/src/env.ts b/examples/typescript/facilitator/batch-settlement/src/env.ts index 4730b4aa..4bcf3186 100644 --- a/examples/typescript/facilitator/batch-settlement/src/env.ts +++ b/examples/typescript/facilitator/batch-settlement/src/env.ts @@ -3,6 +3,7 @@ * private key. A chain registers only when a wallet for it resolves, so the * facilitator can run EVM-only, TRON-only, or both. */ +import { TRON_NILE, TRON_MAINNET } from "@bankofai/x402-tron"; import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; /** @@ -13,7 +14,10 @@ import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; * receiver-authorizer (signs `claimWithSignature` / `refundWithSignature`). */ export type SignerWallet = Wallet & { - signTypedData(data: Record, options?: unknown): Promise; + signTypedData( + data: Record, + options?: unknown, + ): Promise; }; /** @@ -24,7 +28,7 @@ export type SignerWallet = Wallet & { */ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { evm: "eip155:97", - tron: "tron:0xcd8690dc", + tron: TRON_NILE, }; /** @@ -33,9 +37,13 @@ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { * @param family - `"evm"` or `"tron"`. * @returns The wallet, or `null` to skip that chain. */ -export async function tryResolveWallet(family: "evm" | "tron"): Promise { +export async function tryResolveWallet( + family: "evm" | "tron", +): Promise { try { - return (await resolveWallet({ network: CAIP2_BY_FAMILY[family] })) as SignerWallet; + return (await resolveWallet({ + network: CAIP2_BY_FAMILY[family], + })) as SignerWallet; } catch { return null; } diff --git a/examples/typescript/facilitator/gasfree/src/chains/tron.ts b/examples/typescript/facilitator/gasfree/src/chains/tron.ts index 18661263..2777e55d 100644 --- a/examples/typescript/facilitator/gasfree/src/chains/tron.ts +++ b/examples/typescript/facilitator/gasfree/src/chains/tron.ts @@ -8,14 +8,19 @@ * custody stays in `@bankofai/agent-wallet`, and the TronWeb instance carries no * private key. */ -import { createFacilitatorTronSigner } from "@bankofai/x402-tron"; +import { + createFacilitatorTronSigner, + TRON_NILE, + TRON_MAINNET, +} from "@bankofai/x402-tron"; import { registerExactGasFreeTronScheme } from "@bankofai/x402-tron/gasfree/facilitator"; import type { x402Facilitator } from "@bankofai/x402-core/facilitator"; import { tryResolveTronWallet } from "../env.js"; /** CAIP-2 network this facilitator settles on. */ -export const TRON_NETWORK = "tron:0xcd8690dc"; +export const TRON_NETWORK = (process.env.TRON_NETWORK ?? + TRON_NILE) as `${string}:${string}`; /** * Registers the TRON `exact_gasfree` scheme on the facilitator, if a TRON wallet @@ -24,7 +29,9 @@ export const TRON_NETWORK = "tron:0xcd8690dc"; * @param facilitator - The facilitator to register the scheme on. * @returns `true` if registered, `false` when no TRON wallet was configured. */ -export async function registerTronGasFree(facilitator: x402Facilitator): Promise { +export async function registerTronGasFree( + facilitator: x402Facilitator, +): Promise { const wallet = await tryResolveTronWallet(); if (!wallet) { return false; @@ -44,9 +51,11 @@ export async function registerTronGasFree(facilitator: x402Facilitator): Promise signer, networks: TRON_NETWORK, ...(process.env.GASFREE_API_URL - ? { apiBaseUrls: { "tron:0xcd8690dc": process.env.GASFREE_API_URL } } + ? { apiBaseUrls: { [TRON_NETWORK]: process.env.GASFREE_API_URL } } : {}), }); - console.info(`[tron] facilitator registered ${TRON_NETWORK} exact_gasfree (${address})`); + console.info( + `[tron] facilitator registered ${TRON_NETWORK} exact_gasfree (${address})`, + ); return true; } diff --git a/examples/typescript/facilitator/gasfree/src/env.ts b/examples/typescript/facilitator/gasfree/src/env.ts index 240e4096..c773bad9 100644 --- a/examples/typescript/facilitator/gasfree/src/env.ts +++ b/examples/typescript/facilitator/gasfree/src/env.ts @@ -2,6 +2,7 @@ * Wallet resolution via `@bankofai/agent-wallet` — the example never touches a * private key. The facilitator registers TRON only when a TRON wallet resolves. */ +import { TRON_NILE, TRON_MAINNET } from "@bankofai/x402-tron"; import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; /** @@ -10,7 +11,10 @@ import { resolveWallet, type Wallet } from "@bankofai/agent-wallet"; * the `signTypedData` capability here. */ export type SignerWallet = Wallet & { - signTypedData(data: Record, options?: unknown): Promise; + signTypedData( + data: Record, + options?: unknown, + ): Promise; }; /** @@ -20,7 +24,7 @@ export type SignerWallet = Wallet & { */ export async function tryResolveTronWallet(): Promise { try { - return (await resolveWallet({ network: "tron:0xcd8690dc" })) as SignerWallet; + return (await resolveWallet({ network: TRON_NILE })) as SignerWallet; } catch { return null; } diff --git a/examples/typescript/facilitator/upto/src/chains/evm.ts b/examples/typescript/facilitator/upto/src/chains/evm.ts index 47e94468..1efc8152 100644 --- a/examples/typescript/facilitator/upto/src/chains/evm.ts +++ b/examples/typescript/facilitator/upto/src/chains/evm.ts @@ -19,6 +19,14 @@ import { tryResolveWallet } from "../env.js"; /** CAIP-2 networks to settle upto payments on. Add an id here (e.g. "eip155:8453"). */ const EVM_NETWORKS = ["eip155:97"] as const; +// Optional RPC override. The default BSC testnet endpoint selected by viem is +// frequently unreachable, so use the same override as the exact facilitator. +const EVM_RPC_URL = process.env.EVM_RPC_URL?.trim() || undefined; +const EVM_RPC_NETWORK = + process.env.PAY_TARGETS?.split(",") + .map((target) => target.trim().split("@", 1)[0]) + .find((network) => network?.startsWith("eip155:")) || "eip155:97"; + /** * Registers the EVM `upto` scheme on the facilitator for every configured * network, if an EVM wallet is configured in agent-wallet. @@ -33,7 +41,10 @@ export async function registerEvm(facilitator: x402Facilitator): Promise { +export async function registerTron( + facilitator: x402Facilitator, +): Promise { const wallet = await tryResolveWallet("tron"); if (!wallet) { return false; @@ -38,6 +45,8 @@ export async function registerTron(facilitator: x402Facilitator): Promise = { evm: "eip155:97", - tron: "tron:0xcd8690dc", + tron: TRON_NILE, }; /** @@ -28,7 +29,9 @@ const CAIP2_BY_FAMILY: Record<"evm" | "tron", string> = { * @param family - `"evm"` or `"tron"`. * @returns The wallet, or `null` to skip that chain. */ -export async function tryResolveWallet(family: "evm" | "tron"): Promise { +export async function tryResolveWallet( + family: "evm" | "tron", +): Promise { try { return await resolveWallet({ network: CAIP2_BY_FAMILY[family] }); } catch { diff --git a/examples/typescript/package.json b/examples/typescript/package.json index dd315839..e9ed59ce 100644 --- a/examples/typescript/package.json +++ b/examples/typescript/package.json @@ -19,7 +19,8 @@ "dev:upto-client": "pnpm --filter @bankofai/x402-example-upto-client dev", "dev:mcp-server": "pnpm --filter @bankofai/x402-example-mcp-server dev", "dev:mcp-client": "pnpm --filter @bankofai/x402-example-mcp-client dev", - "dev:logging": "pnpm --filter @bankofai/x402-example-logging dev" + "dev:logging": "pnpm --filter @bankofai/x402-example-logging dev", + "dev:multi-scheme-server": "pnpm --filter @bankofai/x402-example-server-multi-scheme dev" }, "engines": { "node": ">=20" diff --git a/examples/typescript/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index 913fc5ff..fb28649d 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -1110,6 +1110,40 @@ importers: specifier: ^5.7.0 version: 5.9.3 + servers/multi-scheme: + dependencies: + '@bankofai/x402-core': + specifier: workspace:* + version: link:../../../../typescript/packages/core + '@bankofai/x402-evm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/evm + '@bankofai/x402-express': + specifier: workspace:* + version: link:../../../../typescript/packages/http/express + '@bankofai/x402-extensions': + specifier: workspace:* + version: link:../../../../typescript/packages/extensions + '@bankofai/x402-tron': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/tron + express: + specifier: ^4.21.2 + version: 4.22.2 + devDependencies: + '@types/express': + specifier: ^5.0.1 + version: 5.0.6 + '@types/node': + specifier: ^22.0.0 + version: 22.19.21 + tsx: + specifier: ^4.19.2 + version: 4.22.4 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + servers/upto: dependencies: '@bankofai/x402-core': diff --git a/examples/typescript/servers/batch-settlement/src/chains/tron.ts b/examples/typescript/servers/batch-settlement/src/chains/tron.ts index 81b6c1d8..9dfdc3c0 100644 --- a/examples/typescript/servers/batch-settlement/src/chains/tron.ts +++ b/examples/typescript/servers/batch-settlement/src/chains/tron.ts @@ -5,21 +5,23 @@ * and runs a `BatchSettlementChannelManager` that claims + settles to `payTo`. * The `receiverAuthorizer` comes from the facilitator (`/supported`). * - * `tron:0xcd8690dc` USDT IS in the default-asset registry, so the channel manager is + * `TRON_NILE` USDT IS in the default-asset registry, so the channel manager is * built via `scheme.createChannelManager()` (it resolves the token) and the price * is given in `"$"` form (the scheme maps it to the default asset). USDT is a * Permit2 token (no ERC-3009), so the client deposits via Permit2 — the payer * needs a one-time `approve(Permit2)` (the shipped client auto-broadcasts it). */ +import { TRON_NILE, TRON_MAINNET, TRON_SHASTA } from "@bankofai/x402-tron"; import { BatchSettlementTronScheme } from "@bankofai/x402-tron/batch-settlement/server"; import type { FacilitatorClient } from "@bankofai/x402-core/server"; import type { x402ResourceServer } from "@bankofai/x402-express"; import type { StoppableManager } from "./evm.js"; -// Switch to "tron:0x2b6653dc" for production (REAL FUNDS): USDT is registered there +// Switch to TRON_MAINNET for production (REAL FUNDS): USDT is registered there // too — only the facilitator/client TronWeb `fullHost` must point at mainnet. -export const TRON_NETWORK: `${string}:${string}` = "tron:0xcd8690dc"; +export const TRON_NETWORK: `${string}:${string}` = (process.env.TRON_NETWORK ?? + TRON_NILE) as `${string}:${string}`; /** TRON is enabled when a payout address is configured. */ export function hasTron(): boolean { @@ -49,17 +51,28 @@ export function registerTron( refundIntervalSecs: 180, maxClaimsPerBatch: 100, selectRefundChannels: (channels, context) => - channels.filter(channel => { + channels.filter((channel) => { if (BigInt(channel.balance) === 0n) return false; - if (channel.pendingRequest && channel.pendingRequest.expiresAt > context.now) return false; + if ( + channel.pendingRequest && + channel.pendingRequest.expiresAt > context.now + ) + return false; return context.now - channel.lastRequestTimestamp >= 180_000; }), - onClaim: r => console.log(`[tron] claimed ${r.vouchers} vouchers (tx ${r.transaction})`), - onSettle: r => console.log(`[tron] settled to ${payTo} (tx ${r.transaction})`), - onRefund: r => console.log(`[tron] refunded channel ${r.channel} (tx ${r.transaction})`), - onError: e => console.error("[tron] settlement error:", e), + onClaim: (r) => + console.log( + `[tron] claimed ${r.vouchers} vouchers (tx ${r.transaction})`, + ), + onSettle: (r) => + console.log(`[tron] settled to ${payTo} (tx ${r.transaction})`), + onRefund: (r) => + console.log(`[tron] refunded channel ${r.channel} (tx ${r.transaction})`), + onError: (e) => console.error("[tron] settlement error:", e), }); - console.info(`[tron] server registered ${TRON_NETWORK} batch-settlement (payTo ${payTo})`); + console.info( + `[tron] server registered ${TRON_NETWORK} batch-settlement (payTo ${payTo})`, + ); return [manager]; } diff --git a/examples/typescript/servers/express/src/chains/tron.ts b/examples/typescript/servers/express/src/chains/tron.ts index c65346a9..3ebea24d 100644 --- a/examples/typescript/servers/express/src/chains/tron.ts +++ b/examples/typescript/servers/express/src/chains/tron.ts @@ -2,18 +2,20 @@ * TRON setup for the resource server — mirrors the EVM module. Keyless: just the * `exact` server scheme and `accepts` entries with the TRON payout address. * - * Offers both USDT and USDD on `tron:0xcd8690dc`. Both are permit2 tokens (no ERC-3009); + * Offers both USDT and USDD on `TRON_NILE`. Both are permit2 tokens (no ERC-3009); * the client's shipped auto-approve handles the one-time Permit2 `approve`. Prices * use the `" "` form so the TRON scheme resolves each token from * its registry (USDD is registered alongside USDT). */ +import { TRON_NILE, TRON_MAINNET, TRON_SHASTA } from "@bankofai/x402-tron"; import { ExactTronScheme } from "@bankofai/x402-tron/exact/server"; +import { getNetworkTokens } from "@bankofai/x402-tron"; import type { x402ResourceServer } from "@bankofai/x402-express"; -// Switch to "tron:0x2b6653dc" for production (REAL FUNDS). USDT/USDD are registered +// Switch to TRON_MAINNET for production (REAL FUNDS). USDT/USDD are registered // for mainnet too (both permit2), so `tronAccepts()` works unchanged — only the // client/facilitator TronWeb `fullHost` must point at a mainnet node. -export const TRON_NETWORKS = ["tron:0xcd8690dc", "tron:0x2b6653dc"] as const; +export const TRON_NETWORKS = [TRON_NILE, TRON_SHASTA, TRON_MAINNET] as const; /** TRON is enabled when a payout address is configured. */ export function hasTron(): boolean { @@ -39,11 +41,11 @@ export function registerTron(resourceServer: x402ResourceServer): void { export function tronAccepts() { const payTo = process.env.TRON_ADDRESS as string; return TRON_NETWORKS.flatMap((network) => - ["0.001 USDT", "0.001 USDD"].map((price) => ({ + Object.keys(getNetworkTokens(network)).map((symbol) => ({ scheme: "exact", network, payTo, - price, + price: `0.001 ${symbol}`, })), ); } diff --git a/examples/typescript/servers/gasfree/src/chains/tron.ts b/examples/typescript/servers/gasfree/src/chains/tron.ts index f3d7b1ba..5d0bf5bc 100644 --- a/examples/typescript/servers/gasfree/src/chains/tron.ts +++ b/examples/typescript/servers/gasfree/src/chains/tron.ts @@ -6,12 +6,14 @@ * * GasFree is TRON-only — there is no EVM counterpart. */ +import { TRON_NILE, TRON_MAINNET, TRON_SHASTA } from "@bankofai/x402-tron"; import { registerExactGasFreeTronScheme } from "@bankofai/x402-tron/gasfree/server"; import type { x402ResourceServer } from "@bankofai/x402-express"; -// Switch to "tron:0x2b6653dc" for production (REAL FUNDS); only the client and +// Switch to TRON_MAINNET for production (REAL FUNDS); only the client and // facilitator TronWeb/relayer endpoints change, this module is unchanged. -export const TRON_NETWORK: `${string}:${string}` = "tron:0xcd8690dc"; +export const TRON_NETWORK: `${string}:${string}` = (process.env.TRON_NETWORK ?? + TRON_NILE) as `${string}:${string}`; /** TRON GasFree is enabled when a payout address is configured. */ export function hasTron(): boolean { @@ -35,5 +37,7 @@ export function registerTron(resourceServer: x402ResourceServer): void { */ export function tronAccepts() { const payTo = process.env.TRON_ADDRESS as string; - return [{ scheme: "exact_gasfree", network: TRON_NETWORK, payTo, price: "$0.001" }]; + return [ + { scheme: "exact_gasfree", network: TRON_NETWORK, payTo, price: "$0.001" }, + ]; } diff --git a/examples/typescript/servers/mcp/src/chains/tron.ts b/examples/typescript/servers/mcp/src/chains/tron.ts index 3a4ee619..c4123aaa 100644 --- a/examples/typescript/servers/mcp/src/chains/tron.ts +++ b/examples/typescript/servers/mcp/src/chains/tron.ts @@ -1,19 +1,25 @@ /** * TRON setup for the MCP resource server — mirrors the EVM module. Keyless: just - * the `exact` server scheme on `tron:0xcd8690dc` and `accepts` with the payout address. + * the `exact` server scheme on `TRON_NILE` and `accepts` with the payout address. * * Offers USDT and USDD (both permit2 tokens — no ERC-3009). The TRON client ships * an auto-approve that broadcasts the one-time Permit2 `approve`, so the server * needs no gas-sponsoring extension. Prices use the `" "` form so * the TRON scheme resolves each token from its registry. */ +import { TRON_NILE, TRON_MAINNET, TRON_SHASTA } from "@bankofai/x402-tron"; import { ExactTronScheme } from "@bankofai/x402-tron/exact/server"; +import { getNetworkTokens } from "@bankofai/x402-tron"; import type { Network } from "@bankofai/x402-core/types"; -import type { ResourceConfig, x402ResourceServer } from "@bankofai/x402-core/server"; +import type { + ResourceConfig, + x402ResourceServer, +} from "@bankofai/x402-core/server"; -/** CAIP-2 network this server accepts TRON payments on. Switch to "tron:0x2b6653dc" +/** CAIP-2 network this server accepts TRON payments on. Switch to TRON_MAINNET * for production (REAL FUNDS); USDT/USDD are registered there too. */ -export const TRON_NETWORK: Network = "tron:0xcd8690dc"; +export const TRON_NETWORK: Network = (process.env.TRON_NETWORK ?? + TRON_NILE) as Network; /** TRON is enabled when a payout address is configured. */ export function hasTron(): boolean { @@ -21,7 +27,7 @@ export function hasTron(): boolean { } /** - * Registers the TRON `exact` server scheme on `tron:0xcd8690dc`. + * Registers the TRON `exact` server scheme on `TRON_NILE`. * * @param resourceServer - The resource server to register on. */ @@ -36,7 +42,10 @@ export function registerTron(resourceServer: x402ResourceServer): void { */ export function tronAccepts(): ResourceConfig[] { const payTo = process.env.TRON_ADDRESS as string; - return ["0.001 USDT", "0.001 USDD"].map( - price => ({ scheme: "exact", network: TRON_NETWORK, payTo, price }) as ResourceConfig, - ); + return Object.keys(getNetworkTokens(TRON_NETWORK)).map((symbol) => ({ + scheme: "exact", + network: TRON_NETWORK, + payTo, + price: `0.001 ${symbol}`, + }) as ResourceConfig); } diff --git a/examples/typescript/servers/multi-scheme/README.md b/examples/typescript/servers/multi-scheme/README.md new file mode 100644 index 00000000..fc8df47f --- /dev/null +++ b/examples/typescript/servers/multi-scheme/README.md @@ -0,0 +1,58 @@ +# Multi-scheme resource server (EVM `exact` + TRON `exact`/`exact_gasfree`) + +Protects `GET /weather` behind x402 payment and accepts **both TRON schemes on the +same network** — `exact` (permit2, payer pays TRX energy) and `exact_gasfree` +(relayer pays energy, payer needs no TRX) — alongside EVM `exact`. The resource +server is keyless: it advertises `accepts` and delegates verify/settle to +facilitators over HTTP, routing each payment by `(network, scheme)`. + +## Why two schemes on one server + +The resource server keys registered schemes by `(network, scheme)`, so `exact` +and `exact_gasfree` coexist on `tron:0xcd8690dc` without conflict. The client +chooses which scheme to pay by honoring one of the advertised `accepts` entries +in the 402 challenge. + +## Facilitators + +`FACILITATOR_URL` accepts a comma-separated list. Typically two facilitators are +wired (run both first): + +- **basic** (`:4022`) — settles `exact` (EVM + TRON permit2). See `../../facilitator/basic`. +- **gasfree** (`:4032`) — settles `exact_gasfree` (TRON relayer). See `../../facilitator/gasfree`. + +On startup the resource server fetches `/supported` from each and builds a +`(network, scheme) → facilitator` dispatch table; payments land at the right one. + +## Run + +```bash +# Start both facilitators (from examples/typescript): +pnpm dev:facilitator # :4022 (exact) +pnpm dev:gasfree-facilitator # :4032 (exact_gasfree) + +# Then the multi-scheme server: +pnpm dev:multi-scheme-server # :4061 +``` + +Pay it with a fetch/gasfree client pointed at `http://localhost:4061/weather`. + +## Networks + +| Chain | Network | Schemes advertised | Tokens | payTo env | +|---|---|---|---|---| +| EVM | `eip155:97` (BSC testnet) | `exact` | DHLU (eip3009), USDC/USDT (permit2) | `EVM_ADDRESS` | +| TRON | `tron:0xcd8690dc` | `exact`, `exact_gasfree` | USDT, USDD (`exact`); USDT (`exact_gasfree`) | `TRON_ADDRESS` | + +EVM tokens are configured in `src/chains/evm.ts` (`EVM_TOKENS`); TRON schemes in +`src/chains/tron.ts`. BSC USDC/USDT need a one-time Permit2 approve, advertised +via the gas-sponsoring extension; DHLU (ERC-3009) and GasFree need none. + +## Env (`.env-multi-scheme`) + +| Var | Purpose | +|---|---| +| `EVM_ADDRESS` | EVM payout address; omit to disable EVM | +| `TRON_ADDRESS` | TRON payout address (`T...`); omit to disable TRON | +| `FACILITATOR_URL` | comma-separated facilitator URLs (defaults `http://localhost:4022,http://localhost:4032`) | +| `SERVER_PORT` | defaults to `4061` | diff --git a/examples/typescript/servers/multi-scheme/package.json b/examples/typescript/servers/multi-scheme/package.json new file mode 100644 index 00000000..d3255cb6 --- /dev/null +++ b/examples/typescript/servers/multi-scheme/package.json @@ -0,0 +1,24 @@ +{ + "name": "@bankofai/x402-example-server-multi-scheme", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx --env-file=../../.env-multi-scheme src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bankofai/x402-core": "workspace:*", + "@bankofai/x402-express": "workspace:*", + "@bankofai/x402-evm": "workspace:*", + "@bankofai/x402-extensions": "workspace:*", + "@bankofai/x402-tron": "workspace:*", + "express": "^4.21.2" + }, + "devDependencies": { + "@types/express": "^5.0.1", + "@types/node": "^22.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.0" + } +} diff --git a/examples/typescript/servers/multi-scheme/src/chains/evm.ts b/examples/typescript/servers/multi-scheme/src/chains/evm.ts new file mode 100644 index 00000000..765a7623 --- /dev/null +++ b/examples/typescript/servers/multi-scheme/src/chains/evm.ts @@ -0,0 +1,111 @@ +/** + * EVM setup for the resource server. The server holds no key — it registers the + * `exact` server scheme per network and declares `accepts` (price + payTo) plus + * the gas-sponsoring extension. Signing/settlement happens at client + facilitator. + * + * Tokens are configured per CAIP-2 network in `EVM_TOKENS`. Two approve paths: + * - **ERC-3009** tokens (e.g. BSC DHLU) → `exact` eip3009: gasless, no approve. + * Advertise with the token's EIP-712 domain (`name`/`version`). + * - **plain BEP-20** (e.g. BSC USDC, no 2612/3009) → `exact` permit2, needs a + * one-time `approve(Permit2)`. Mark `extra.assetTransferMethod: "permit2"`; the + * gas-sponsoring extension lets the client sign that approve offline. + * + * BSC has no default-token registry entry, so tokens are advertised as explicit + * `{ amount, asset, extra }` prices. Adding a chain (e.g. Base Sepolia) is one + * table entry. Amounts are ≈ $0.001 per the token's decimals. + */ +import { ExactEvmScheme } from "@bankofai/x402-evm/exact/server"; +import { declareErc20ApprovalGasSponsoringExtension } from "@bankofai/x402-extensions"; +import type { Network } from "@bankofai/x402-core/types"; +import type { x402ResourceServer } from "@bankofai/x402-express"; + +type EvmToken = { + asset: string; + amount: string; + extra: Record; +}; + +/** CAIP-2 network → tokens advertised on it (see specs/config.md for addresses). */ +const EVM_TOKENS: Record = { + "eip155:97": [ + // DHLU (6 dec, ERC-3009) — eip3009, gasless. Domain verified on-chain. + { + asset: "0x375cADdd2cB68cE82e3D9B075D551067a7b4B816", + amount: "1000", // 0.001 × 1e6 + extra: { name: "DA HULU", version: "1" }, + }, + // USDC (18 dec, plain BEP-20) — permit2 + gas-sponsored approve. + { + asset: "0x64544969ed7EBf5f083679233325356EbE738930", + amount: "1000000000000000", // 0.001 × 1e18 + extra: { assetTransferMethod: "permit2" }, + }, + // USDT (18 dec, plain BEP-20) — permit2 + gas-sponsored approve. + { + asset: "0x337610d27c682E347C9cD60BD4b3b107C9d34dDd", + amount: "1000000000000000", // 0.001 × 1e18 + extra: { assetTransferMethod: "permit2" }, + }, + ], + // BSC mainnet — REAL FUNDS. + "eip155:56": [ + { + asset: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + amount: "1000000000000000", + extra: { assetTransferMethod: "permit2" }, + }, // USDC (18) + { + asset: "0x55d398326f99059fF775485246999027B3197955", + amount: "1000000000000000", + extra: { assetTransferMethod: "permit2" }, + }, // USDT (18) + ], + // ── Other EVM testnet: Base Sepolia USDC (eip3009) ──────────────────── + // "eip155:84532": [ { asset: "0x036CbD…", amount: "1000", extra: { name: "USDC", version: "2" } } ], +}; + +/** EVM is enabled when a payout address is configured. */ +export function hasEvm(): boolean { + return !!process.env.EVM_ADDRESS; +} + +/** + * Registers the EVM `exact` server scheme for every configured network. + * + * @param resourceServer - The resource server to register on. + */ +export function registerEvm(resourceServer: x402ResourceServer): void { + for (const network of Object.keys(EVM_TOKENS) as Network[]) { + resourceServer.register(network, new ExactEvmScheme()); + } +} + +/** + * Builds the `accepts` entries advertised for EVM payments — one per token per + * network, each an explicit asset. + * + * @returns Payment-requirements accept entries. + */ +export function evmAccepts() { + const payTo = process.env.EVM_ADDRESS as string; + return (Object.entries(EVM_TOKENS) as [Network, EvmToken[]][]).flatMap( + ([network, tokens]) => + tokens.map((token) => ({ + scheme: "exact", + network, + payTo, + price: { amount: token.amount, asset: token.asset, extra: token.extra }, + })), + ); +} + +/** + * Route extension that lets the facilitator broadcast the client's pre-signed + * Permit2 `approve` (needed by plain-ERC20 tokens). Spread into the route's + * `extensions`. + * + * @returns The extension declaration keyed by its extension id. + */ +export function evmExtensions(): Record { + return { ...declareErc20ApprovalGasSponsoringExtension() }; +} diff --git a/examples/typescript/servers/multi-scheme/src/chains/tron.ts b/examples/typescript/servers/multi-scheme/src/chains/tron.ts new file mode 100644 index 00000000..4f6403dd --- /dev/null +++ b/examples/typescript/servers/multi-scheme/src/chains/tron.ts @@ -0,0 +1,64 @@ +/** + * TRON setup for the multi-scheme resource server. Registers BOTH TRON schemes + * on the same network so one server can accept `exact` (permit2, payer pays TRX + * energy) and `exact_gasfree` (relayer pays energy, payer needs no TRX) payments + * side by side. Keyless in both cases: only the payout address is advertised. + * + * The resource server keys registered schemes by `(network, scheme)`, so the two + * schemes coexist on `TRON_NILE` without conflict — the client picks one via + * the `accepts` entry it honors in the 402 challenge. + */ +import { TRON_NILE, TRON_MAINNET, TRON_SHASTA } from "@bankofai/x402-tron"; +import { ExactTronScheme } from "@bankofai/x402-tron/exact/server"; +import { registerExactGasFreeTronScheme } from "@bankofai/x402-tron/gasfree/server"; +import type { x402ResourceServer } from "@bankofai/x402-express"; + +// Switch to TRON_MAINNET for production (REAL FUNDS); only the client and +// facilitator TronWeb/relayer endpoints change, this module is unchanged. +export const TRON_NETWORK: `${string}:${string}` = (process.env.TRON_NETWORK ?? + TRON_NILE) as `${string}:${string}`; + +/** TRON is enabled when a payout address is configured. */ +export function hasTron(): boolean { + return !!process.env.TRON_ADDRESS; +} + +/** + * Registers both TRON server schemes: + * - `exact` — permit2 transfer (USDT/USDD), payer broadcasts and pays energy. + * - `exact_gasfree` — relayer pays energy from the payer's GasFree custodial wallet. + * + * @param resourceServer - The resource server to register on. + */ +export function registerTron(resourceServer: x402ResourceServer): void { + resourceServer.register(TRON_NETWORK, new ExactTronScheme()); + registerExactGasFreeTronScheme(resourceServer, { networks: [TRON_NETWORK] }); +} + +/** + * Builds the `accepts` entries advertised for TRON payments — USDT/USDD via + * `exact`, plus USDT via `exact_gasfree`. The client chooses which scheme to pay. + * + * @returns Payment-requirements accept entries. + */ +export function tronAccepts() { + const payTo = process.env.TRON_ADDRESS as string; + return [ + // exact: permit2, payer pays TRX energy. USDT and USDD both resolve from the + // scheme's token registry via the " " price form. + ...["0.001 USDT", "0.001 USDD"].map((price) => ({ + scheme: "exact" as const, + network: TRON_NETWORK, + payTo, + price, + })), + // exact_gasfree: relayer pays energy; GasFree maps "$" to the network's + // default asset (USDT). Funds come from the payer's GasFree custodial wallet. + { + scheme: "exact_gasfree" as const, + network: TRON_NETWORK, + payTo, + price: "$0.001", + }, + ]; +} diff --git a/examples/typescript/servers/multi-scheme/src/index.ts b/examples/typescript/servers/multi-scheme/src/index.ts new file mode 100644 index 00000000..658f55a6 --- /dev/null +++ b/examples/typescript/servers/multi-scheme/src/index.ts @@ -0,0 +1,109 @@ +/** + * x402 multi-scheme resource server (Express, EVM `exact` + TRON `exact`/`exact_gasfree`). + * + * Protects `GET /weather` behind payment and accepts BOTH TRON schemes on the same + * network — `exact` (permit2, payer pays TRX energy) and `exact_gasfree` (relayer + * pays energy, payer needs no TRX) — alongside EVM `exact`. The resource server + * is keyless: it advertises `accepts` and delegates verify/settle to facilitators + * over HTTP, routing each payment by `(network, scheme)`. + * + * One or more facilitators are wired via `FACILITATOR_URL` (comma-separated). + * Typically two: basic (:4022, settles `exact`) and gasfree (:4032, settles + * `exact_gasfree`). The resource server fetches `/supported` from each and + * dispatches accordingly. + */ +import express from "express"; +import { HTTPFacilitatorClient } from "@bankofai/x402-core/server"; +import { createResourceServer } from "@bankofai/x402-core"; +import { + x402HTTPResourceServer, + paymentMiddlewareFromHTTPServer, +} from "@bankofai/x402-express"; + +import { hasEvm, registerEvm, evmAccepts, evmExtensions } from "./chains/evm.js"; +import { hasTron, registerTron, tronAccepts } from "./chains/tron.js"; +import { ResourceStrippingFacilitatorClient } from "./resourceStrippingFacilitator.js"; + +// Dedicated env vars (set in .env-multi-scheme) so this scenario keeps its own +// :4061, independent of the other scenarios. +const PORT = parseInt(process.env.SERVER_PORT || "4061", 10); +// Comma-separated facilitator URLs. Order matters only on overlap: earlier +// facilitators win a `(network, scheme)` slot. `exact` and `exact_gasfree` are +// distinct schemes, so there is no clash — basic handles `exact`, gasfree handles +// `exact_gasfree`. +const FACILITATOR_URLS = (process.env.FACILITATOR_URL || "http://localhost:4022,http://localhost:4032") + .split(",") + .map(url => url.trim()) + .filter(Boolean); +// Opt-in: drop `paymentPayload.resource` (a localhost URL when running locally) +// before verify/settle, to dodge edge WAFs that flag it as SSRF. See +// resourceStrippingFacilitator.ts. Off by default — wire payload stays untouched. +const STRIP_RESOURCE_URL = process.env.STRIP_RESOURCE_URL === "true"; +// Optional facilitator API key. When set, it's sent as `X-API-KEY` on every +// facilitator call (verify/settle/supported). Hosted facilitators use it to pick +// a rate-limit tier; anonymous calls still work, so it's unset by default. +const FACILITATOR_API_KEY = process.env.FACILITATOR_API_KEY; + +function makeFacilitator(url: string): HTTPFacilitatorClient { + return new HTTPFacilitatorClient({ + url, + ...(FACILITATOR_API_KEY + ? { + createAuthHeaders: async () => ({ + verify: { "X-API-KEY": FACILITATOR_API_KEY }, + settle: { "X-API-KEY": FACILITATOR_API_KEY }, + supported: { "X-API-KEY": FACILITATOR_API_KEY }, + }), + } + : {}), + }); +} + +function wrap(client: HTTPFacilitatorClient): HTTPFacilitatorClient | ResourceStrippingFacilitatorClient { + return STRIP_RESOURCE_URL ? new ResourceStrippingFacilitatorClient(client) : client; +} + +const facilitatorClients = FACILITATOR_URLS.map(url => wrap(makeFacilitator(url))); +// createResourceServer pre-attaches verify/settle logging (see express example). +const resourceServer = createResourceServer(facilitatorClients); + +if (!hasEvm() && !hasTron()) { + console.error("❌ No payout address configured (set EVM_ADDRESS and/or TRON_ADDRESS)."); + process.exit(1); +} + +const accepts: Array[number] | ReturnType[number]> = []; +let extensions: Record = {}; +if (hasEvm()) { + registerEvm(resourceServer); + accepts.push(...evmAccepts()); + extensions = { ...extensions, ...evmExtensions() }; +} +if (hasTron()) { + registerTron(resourceServer); + accepts.push(...tronAccepts()); +} + +const routes = { + "GET /weather": { + accepts, + extensions, + description: "Current weather (paid, multi-scheme)", + mimeType: "application/json", + }, +}; + +const httpServer = new x402HTTPResourceServer(resourceServer, routes); + +const app = express(); +app.use(paymentMiddlewareFromHTTPServer(httpServer)); + +app.get("/weather", (_req, res) => { + res.json({ report: { weather: "sunny", temperature: 70 } }); +}); + +app.listen(PORT, () => { + console.log( + `🌤️ Multi-scheme resource server on http://localhost:${PORT} (evm=${hasEvm()}, tron=${hasTron()}) → facilitators ${FACILITATOR_URLS.join(", ")}${STRIP_RESOURCE_URL ? " [resource.url stripped]" : ""}${FACILITATOR_API_KEY ? " [api key]" : ""}`, + ); +}); diff --git a/examples/typescript/servers/multi-scheme/src/resourceStrippingFacilitator.ts b/examples/typescript/servers/multi-scheme/src/resourceStrippingFacilitator.ts new file mode 100644 index 00000000..1f2732ea --- /dev/null +++ b/examples/typescript/servers/multi-scheme/src/resourceStrippingFacilitator.ts @@ -0,0 +1,58 @@ +/** + * Optional FacilitatorClient decorator that drops `paymentPayload.resource` + * before forwarding verify/settle to the wrapped facilitator. + * + * Why this exists + * --------------- + * `resource.url` is informational metadata: the facilitator never reads it on + * the verify/settle path, and the payment signature does not cover it. When a + * resource server runs locally, its `resource.url` is `http://localhost:`. + * Some edge WAFs in front of a hosted facilitator (observed: AWS WAF managed + * rule `EC2MetaDataSSRF_BODY`) treat that loopback URL in the request body as an + * SSRF probe and reject `POST /verify` and `/settle` with `403 Forbidden` — + * which surfaces to the client as a failed payment (the resource server falls + * back to 402). Dropping the field removes the false positive without changing + * verification or settlement in any way. + * + * This is opt-in: wrap your `HTTPFacilitatorClient` with it only when you need + * it (e.g. local server pointed at a WAF-fronted facilitator). It is a no-op for + * correctness — keep it off when you want the wire payload untouched. + */ +import type { FacilitatorClient } from "@bankofai/x402-core/server"; +import type { + PaymentPayload, + PaymentRequirements, + VerifyResponse, + SettleResponse, + SupportedResponse, +} from "@bankofai/x402-core/types"; + +/** Return a shallow copy of the payload with the optional `resource` removed. */ +function stripResource(paymentPayload: PaymentPayload): PaymentPayload { + const copy = { ...paymentPayload }; + delete copy.resource; + return copy; +} + +/** Decorates a FacilitatorClient, stripping `resource` from verify/settle. */ +export class ResourceStrippingFacilitatorClient implements FacilitatorClient { + constructor(private readonly inner: FacilitatorClient) {} + + verify( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.inner.verify(stripResource(paymentPayload), paymentRequirements); + } + + settle( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ): Promise { + return this.inner.settle(stripResource(paymentPayload), paymentRequirements); + } + + getSupported(): Promise { + return this.inner.getSupported(); + } +} diff --git a/examples/typescript/servers/multi-scheme/tsconfig.json b/examples/typescript/servers/multi-scheme/tsconfig.json new file mode 100644 index 00000000..564a5990 --- /dev/null +++ b/examples/typescript/servers/multi-scheme/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/examples/typescript/servers/upto/src/chains/tron.ts b/examples/typescript/servers/upto/src/chains/tron.ts index c57d2d29..5dd844ff 100644 --- a/examples/typescript/servers/upto/src/chains/tron.ts +++ b/examples/typescript/servers/upto/src/chains/tron.ts @@ -5,16 +5,18 @@ * actual charge is decided per request in `index.ts` via a `Settlement-Overrides` * response header. * - * `tron:0xcd8690dc` USDT is in the default-asset registry, so the price is given in + * `TRON_NILE` USDT is in the default-asset registry, so the price is given in * `"$"` form (the scheme maps it to USDT). USDT is a **Permit2** token, so the * client authorizes via Permit2 — the payer needs a one-time `approve(Permit2)`. */ +import { TRON_NILE, TRON_MAINNET, TRON_SHASTA } from "@bankofai/x402-tron"; import { UptoTronScheme } from "@bankofai/x402-tron/upto/server"; import type { x402ResourceServer } from "@bankofai/x402-express"; -// Switch to "tron:0x2b6653dc" for production (REAL FUNDS): USDT is registered there +// Switch to TRON_MAINNET for production (REAL FUNDS): USDT is registered there // too — only the facilitator/client TronWeb `fullHost` must point at mainnet. -export const TRON_NETWORK: `${string}:${string}` = "tron:0xcd8690dc"; +export const TRON_NETWORK: `${string}:${string}` = (process.env.TRON_NETWORK ?? + TRON_NILE) as `${string}:${string}`; /** TRON is enabled when a payout address is configured. */ export function hasTron(): boolean { @@ -29,7 +31,9 @@ export function hasTron(): boolean { export function registerTron(resourceServer: x402ResourceServer): void { const payTo = process.env.TRON_ADDRESS as string; resourceServer.register(TRON_NETWORK, new UptoTronScheme()); - console.info(`[tron] server registered ${TRON_NETWORK} upto (payTo ${payTo})`); + console.info( + `[tron] server registered ${TRON_NETWORK} upto (payTo ${payTo})`, + ); } /** diff --git a/typescript/.changeset/dynamic-info-fields.md b/typescript/.changeset/dynamic-info-fields.md new file mode 100644 index 00000000..5bd3c250 --- /dev/null +++ b/typescript/.changeset/dynamic-info-fields.md @@ -0,0 +1,6 @@ +--- +"@bankofai/x402-core": patch +"@bankofai/x402-extensions": patch +--- + +Added a dynamicInfoFields capability so an extension can mark certain info fields (nonces, timestamps) as regenerated per PaymentRequired response. Those fields are then excluded from the client-echo validation (extension_echo_mismatch), while all other fields stay strictly compared. Wired into the offer-receipt (["offers"]) and sign-in-with-x (["nonce", "issuedAt", "expirationTime"]) extensions. diff --git a/typescript/.changeset/mcp-interop-null-normalization.md b/typescript/.changeset/mcp-interop-null-normalization.md new file mode 100644 index 00000000..966d1f63 --- /dev/null +++ b/typescript/.changeset/mcp-interop-null-normalization.md @@ -0,0 +1,6 @@ +--- +"@bankofai/x402-core": patch +"@bankofai/x402-mcp": patch +--- + +Fixed cross-SDK MCP interop: optional `PaymentRequired`/`ResourceInfo`/`PaymentPayload` wire fields serialized as explicit `null` by the Python and Go SDKs are now accepted and normalized to `undefined` instead of failing validation. The MCP client routes both result and error extraction through `parsePaymentRequired`, so 402 responses from other implementations reliably trigger auto-payment. diff --git a/typescript/.changeset/optional-authorizer-signer.md b/typescript/.changeset/optional-authorizer-signer.md new file mode 100644 index 00000000..1be24009 --- /dev/null +++ b/typescript/.changeset/optional-authorizer-signer.md @@ -0,0 +1,6 @@ +--- +"@bankofai/x402-evm": minor +"@bankofai/x402-tron": minor +--- + +Declares `receiverAuthorizer` in facilitator supported as optional. If a facilitator opts in to provide `receiverAuthorizer`, servers may delegate to it. Otherwise, they must provide their own. diff --git a/typescript/.changeset/tron-remove-advisory-fee.md b/typescript/.changeset/tron-remove-advisory-fee.md index 62d39f4d..c1ddfe4c 100644 --- a/typescript/.changeset/tron-remove-advisory-fee.md +++ b/typescript/.changeset/tron-remove-advisory-fee.md @@ -2,10 +2,13 @@ "@bankofai/x402-tron": major --- -**Breaking:** Remove advisory fee configuration and metadata from the TRON -`exact` and `upto` schemes. These schemes now transfer exactly the requested -amount and no longer accept a fee configuration through -`TronFacilitatorConfig` or their facilitator constructors. +**Breaking:** Remove facilitator service-fee configuration and metadata from +all TRON schemes (`exact`, `upto`, `exact_gasfree`). The `fee` property is +removed from `TronFacilitatorConfig` and `TronGasFreeFacilitatorConfig`, and +the `FeeInfo` / `ExactTronFeeConfig` / `GasFreeTronFeeConfig` type exports and +`shared/fee` module are deleted. All three schemes now transfer exactly the +requested amount. -GasFree fee handling is unchanged because those fees are enforced by the -GasFree relayer rather than advertised as advisory payment metadata. +GasFree provider-driven fees (`transferFee`, `activateFee`) are inherent to +the relayer protocol and remain unchanged — they are read from the relayer API, +not configured via the SDK. diff --git a/typescript/.changeset/validate-facilitator-support-hook.md b/typescript/.changeset/validate-facilitator-support-hook.md new file mode 100644 index 00000000..18055d8d --- /dev/null +++ b/typescript/.changeset/validate-facilitator-support-hook.md @@ -0,0 +1,5 @@ +--- +"@bankofai/x402-core": minor +--- + +Added an optional `validateFacilitatorSupport` hook to `SchemeNetworkServer` and wired it into `x402ResourceServer.initialize()`. After supported kinds are loaded, each registered scheme that the facilitator actually supports is asked to validate the advertised capabilities against its own configuration; any reported problems are aggregated and thrown so misconfigurations fail fast at server startup, not just on the first protected request. diff --git a/typescript/.changeset/wallet-type-compatibility.md b/typescript/.changeset/wallet-type-compatibility.md new file mode 100644 index 00000000..0ab5287d --- /dev/null +++ b/typescript/.changeset/wallet-type-compatibility.md @@ -0,0 +1,5 @@ +--- +"@bankofai/x402-evm": minor +--- + +Expanded wallet compatibility so payments verify and settle consistently across plain EOAs, deployed smart accounts (ERC-4337 / ERC-7579), counterfactual ERC-6492 wallets, and ERC-7702-delegated EOAs. Pre-verification now mirrors on-chain signature checking, so a payment that passes `verify` is the same one that succeeds at `settle`. Added counterfactual ERC-6492 support to the `exact` and `batch-settlement` flows — the wallet is deployed and its signature validated together during `verify` — gated by a new `eip6492AllowedFactories` allowlist you set on the facilitator scheme config. diff --git a/typescript/packages/core/src/http/httpFacilitatorClient.ts b/typescript/packages/core/src/http/httpFacilitatorClient.ts index 9ded98aa..5d0c4f42 100644 --- a/typescript/packages/core/src/http/httpFacilitatorClient.ts +++ b/typescript/packages/core/src/http/httpFacilitatorClient.ts @@ -80,10 +80,10 @@ export function computeRetryDelay(retryAfter: string | null, attempt: number): n let delay: number | null = null; if (retryAfter !== null) { - const seconds = Number(retryAfter); - if (!isNaN(seconds)) { + const trimmedRetryAfter = retryAfter.trim(); + if (/^\d+$/.test(trimmedRetryAfter)) { // delta-seconds form - delay = seconds * 1000; + delay = Number(trimmedRetryAfter) * 1000; } else { // HTTP-date form const retryDate = Date.parse(retryAfter); diff --git a/typescript/packages/core/src/schemas/index.ts b/typescript/packages/core/src/schemas/index.ts index b21d3469..6d2a8248 100644 --- a/typescript/packages/core/src/schemas/index.ts +++ b/typescript/packages/core/src/schemas/index.ts @@ -69,11 +69,31 @@ const PRINTABLE_ASCII_REGEX = /^[\x20-\x7e]+$/; export const ResourceInfoSchema = z.object({ url: NonEmptyString, - description: z.string().optional(), - mimeType: z.string().optional(), - serviceName: z.string().min(1).max(32).regex(PRINTABLE_ASCII_REGEX).optional(), - tags: z.array(z.string().min(1).max(32).regex(PRINTABLE_ASCII_REGEX)).max(5).optional(), - iconUrl: z.string().max(2048).optional(), + description: z + .string() + .nullish() + .transform(v => v ?? undefined), + mimeType: z + .string() + .nullish() + .transform(v => v ?? undefined), + serviceName: z + .string() + .min(1) + .max(32) + .regex(PRINTABLE_ASCII_REGEX) + .nullish() + .transform(v => v ?? undefined), + tags: z + .array(z.string().min(1).max(32).regex(PRINTABLE_ASCII_REGEX)) + .max(5) + .nullish() + .transform(v => v ?? undefined), + iconUrl: z + .string() + .max(2048) + .nullish() + .transform(v => v ?? undefined), }); export type ResourceInfo = z.infer; @@ -148,7 +168,10 @@ export type PaymentRequirementsV2 = z.infer; */ export const PaymentRequiredV2Schema = z.object({ x402Version: z.literal(2), - error: z.string().optional(), + error: z + .string() + .nullish() + .transform(v => v ?? undefined), resource: ResourceInfoSchema, accepts: z.array(PaymentRequirementsV2Schema).min(1), extensions: OptionalAny, @@ -161,7 +184,7 @@ export type PaymentRequiredV2 = z.infer; */ export const PaymentPayloadV2Schema = z.object({ x402Version: z.literal(2), - resource: ResourceInfoSchema.optional(), + resource: ResourceInfoSchema.nullish().transform(v => v ?? undefined), accepted: PaymentRequirementsV2Schema, payload: Any, extensions: OptionalAny, diff --git a/typescript/packages/core/src/server/x402ResourceServer.ts b/typescript/packages/core/src/server/x402ResourceServer.ts index 16dc75de..85867b10 100644 --- a/typescript/packages/core/src/server/x402ResourceServer.ts +++ b/typescript/packages/core/src/server/x402ResourceServer.ts @@ -622,6 +622,8 @@ export class x402ResourceServer { "Failed to initialize: no supported payment kinds loaded from any facilitator.", ); } + + this.validateFacilitatorCapabilities(); } /** @@ -1288,7 +1290,14 @@ export class x402ResourceServer { const advertisedInfo = getExtensionInfo(serverExtensions[key]); const echoedInfo = getExtensionInfo(echoedValue); - if (!extensionInfoMatchesAdvertised(advertisedInfo, echoedInfo)) { + + const dynamicFields = this.registeredExtensions.get(key)?.dynamicInfoFields; + if ( + !extensionInfoMatchesAdvertised( + omitFields(advertisedInfo, dynamicFields), + omitFields(echoedInfo, dynamicFields), + ) + ) { return { valid: false, invalidReason: "extension_echo_mismatch", @@ -1332,6 +1341,42 @@ export class x402ResourceServer { } } + /** + * Validates that each registered scheme's configuration is compatible with the + * facilitator capabilities advertised for the scheme/network combinations it + * supports. Only schemes the facilitator actually supports are validated. + * + * @throws Error listing every capability problem when one or more schemes report one. + */ + private validateFacilitatorCapabilities(): void { + const configErrors: string[] = []; + + for (const [network, schemeMap] of this.registeredServerSchemes) { + for (const [scheme, server] of schemeMap) { + if (!server.validateFacilitatorSupport) continue; + + for (const x402Version of this.supportedResponsesMap.keys()) { + const supportedKind = this.getSupportedKind(x402Version, network as Network, scheme); + if (!supportedKind) continue; + + const extensions = this.getFacilitatorExtensions(x402Version, network as Network, scheme); + const problem = server.validateFacilitatorSupport( + network as Network, + supportedKind, + extensions, + ); + if (problem) configErrors.push(`${scheme} on ${network}: ${problem}`); + } + } + } + + if (configErrors.length > 0) { + throw new Error( + `x402 facilitator capability errors:\n${configErrors.map(e => ` - ${e}`).join("\n")}`, + ); + } + } + /** * Logs a warning when a manual or extension adapter lifecycle hook throws. * @@ -1592,6 +1637,27 @@ function getExtensionInfo(value: unknown): unknown { return value; } +/** + * Returns a copy of an extension info object without the named dynamic fields. + * + * @param value - Extension info payload to filter. + * @param fields - Field names regenerated per response that must not be compared. + * @returns The value unchanged when no fields apply; otherwise a copy without them. + */ +function omitFields(value: unknown, fields?: string[]): unknown { + if (!fields || fields.length === 0) { + return value; + } + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return value; + } + const copy = { ...(value as Record) }; + for (const field of fields) { + delete copy[field]; + } + return copy; +} + /** * Returns whether a client-echoed extension payload preserves the server advertisement. * diff --git a/typescript/packages/core/src/types/extensions.ts b/typescript/packages/core/src/types/extensions.ts index a85823da..231710da 100644 --- a/typescript/packages/core/src/types/extensions.ts +++ b/typescript/packages/core/src/types/extensions.ts @@ -69,6 +69,13 @@ export interface ResourceServerExtensionHooks { export interface ResourceServerExtension { key: string; + /** + * Names of fields under the extension's `info` that are dynamic - regenerated + * on every PaymentRequired response (e.g. nonces, timestamps) - rather than + * static committed terms. Dynamic fields are excluded from client echo + * validation. Defaults to none (all info fields treated as static / strict). + */ + dynamicInfoFields?: string[]; enrichDeclaration?: (declaration: unknown, transportContext: unknown) => unknown; /** * Return value merges into `extensions[key]`. In-place edits to `accepts` are allowlisted only diff --git a/typescript/packages/core/src/types/mechanisms.ts b/typescript/packages/core/src/types/mechanisms.ts index 2a0cc691..9c09b359 100644 --- a/typescript/packages/core/src/types/mechanisms.ts +++ b/typescript/packages/core/src/types/mechanisms.ts @@ -236,4 +236,21 @@ export interface SchemeNetworkServer { supportedKind: SupportedKind, facilitatorExtensions: string[], ): Promise; + + /** + * Optional: validate that the facilitator's advertised capabilities for this + * scheme/network are sufficient given the scheme's own configuration. Invoked + * during initialize(), only when the facilitator supports the scheme. + * + * @param network - The network identifier being validated + * @param supportedKind - The facilitator's advertised kind for this scheme/network + * @param facilitatorExtensions - Extensions advertised by the facilitator + * @returns A human-readable problem message when the configuration cannot be + * fulfilled, or void/undefined when valid. + */ + validateFacilitatorSupport?( + network: Network, + supportedKind: SupportedKind, + facilitatorExtensions: string[], + ): string | void; } diff --git a/typescript/packages/core/test/unit/schemas/schemas.test.ts b/typescript/packages/core/test/unit/schemas/schemas.test.ts index 84dfb280..a199b79b 100644 --- a/typescript/packages/core/test/unit/schemas/schemas.test.ts +++ b/typescript/packages/core/test/unit/schemas/schemas.test.ts @@ -264,6 +264,60 @@ describe("x402 Schemas", () => { const result = PaymentPayloadV2Schema.safeParse(withoutResource); expect(result.success).toBe(true); }); + + it("should accept null resource and normalize it to undefined", () => { + const result = PaymentPayloadV2Schema.safeParse({ + ...validPaymentPayloadV2, + resource: null, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.resource).toBeUndefined(); + } + }); + }); + + describe("Nullable optional field normalization", () => { + it("should accept explicit null for optional ResourceInfo fields and normalize to undefined", () => { + const result = PaymentRequiredV2Schema.safeParse({ + ...validPaymentRequiredV2, + resource: { + url: "https://api.example.com/premium-data", + description: null, + mimeType: null, + serviceName: null, + tags: null, + iconUrl: null, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.resource.description).toBeUndefined(); + expect(result.data.resource.mimeType).toBeUndefined(); + expect(result.data.resource.serviceName).toBeUndefined(); + expect(result.data.resource.tags).toBeUndefined(); + expect(result.data.resource.iconUrl).toBeUndefined(); + } + }); + + it("should accept explicit null for PaymentRequiredV2 error and normalize to undefined", () => { + const result = PaymentRequiredV2Schema.safeParse({ + ...validPaymentRequiredV2, + error: null, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.error).toBeUndefined(); + } + }); + + it("should still reject a resource missing the required url", () => { + const result = PaymentRequiredV2Schema.safeParse({ + ...validPaymentRequiredV2, + resource: { url: null }, + }); + expect(result.success).toBe(false); + }); }); describe("V2 Type Guards", () => { diff --git a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts index 81fadbac..12951aad 100644 --- a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts +++ b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts @@ -316,6 +316,61 @@ describe("x402ResourceServer", () => { }); }); + describe("initialize - validateFacilitatorSupport", () => { + class ValidatingScheme extends MockSchemeNetworkServer { + public validateCalls = 0; + private problem: string | undefined; + + constructor(scheme: string, problem: string | undefined) { + super(scheme); + this.problem = problem; + } + + validateFacilitatorSupport(): string | void { + this.validateCalls++; + return this.problem; + } + } + + /** + * Builds a facilitator advertising the `exact` scheme on Base. + * + * @returns Mock facilitator client supporting `exact` on `eip155:8453`. + */ + function buildExactFacilitator(): MockFacilitatorClient { + return new MockFacilitatorClient( + buildSupportedResponse({ + kinds: [{ x402Version: 2, scheme: "exact", network: "eip155:8453" as Network }], + }), + ); + } + + it("rejects when a registered scheme reports a capability problem", async () => { + const server = new x402ResourceServer(buildExactFacilitator()); + server.register("eip155:8453" as Network, new ValidatingScheme("exact", "needs a signer")); + + await expect(server.initialize()).rejects.toThrow(/exact on eip155:8453: needs a signer/); + }); + + it("resolves when the hook returns void", async () => { + const server = new x402ResourceServer(buildExactFacilitator()); + const scheme = new ValidatingScheme("exact", undefined); + server.register("eip155:8453" as Network, scheme); + + await expect(server.initialize()).resolves.not.toThrow(); + expect(scheme.validateCalls).toBe(1); + }); + + it("skips the hook when the facilitator does not support the scheme/network", async () => { + const server = new x402ResourceServer(buildExactFacilitator()); + const scheme = new ValidatingScheme("unsupported", "should not be reported"); + server.register("eip155:8453" as Network, scheme); + + await expect(server.initialize()).resolves.not.toThrow(); + expect(scheme.validateCalls).toBe(0); + }); + }); + describe("buildPaymentRequirements", () => { it("should build requirements from ResourceConfig", async () => { const mockClient = new MockFacilitatorClient( @@ -1649,6 +1704,51 @@ describe("x402ResourceServer", () => { expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); }); + + it("passes when only a declared dynamic info field differs", () => { + const server = new x402ResourceServer(); + server.registerExtension({ key: "siwx", dynamicInfoFields: ["nonce"] }); + const paymentRequired = buildPaymentRequired({ + extensions: { siwx: { info: { domain: "example.com", nonce: "fresh" } } }, + }); + const payload = buildPaymentPayload({ + extensions: { siwx: { info: { domain: "example.com", nonce: "stale" } } }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ valid: true }); + }); + + it("fails when a static info field differs despite a declared dynamic field", () => { + const server = new x402ResourceServer(); + server.registerExtension({ key: "siwx", dynamicInfoFields: ["nonce"] }); + const paymentRequired = buildPaymentRequired({ + extensions: { siwx: { info: { domain: "example.com", nonce: "fresh" } } }, + }); + const payload = buildPaymentPayload({ + extensions: { siwx: { info: { domain: "evil.com", nonce: "stale" } } }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ + valid: false, + invalidReason: "extension_echo_mismatch", + extensionKey: "siwx", + }); + }); + + it("keeps strict comparison when no dynamic fields are declared", () => { + const server = new x402ResourceServer(); + server.registerExtension({ key: "builder" }); + const paymentRequired = buildPaymentRequired({ extensions: serverExtensions }); + const payload = buildPaymentPayload({ + extensions: { builder: { info: { code: "tampered" } } }, + }); + + expect(server.validateExtensions(paymentRequired, payload)).toEqual({ + valid: false, + invalidReason: "extension_echo_mismatch", + extensionKey: "builder", + }); + }); }); describe("findMatchingRequirements", () => { diff --git a/typescript/packages/extensions/src/offer-receipt/server.ts b/typescript/packages/extensions/src/offer-receipt/server.ts index 5af989e2..47d11d83 100644 --- a/typescript/packages/extensions/src/offer-receipt/server.ts +++ b/typescript/packages/extensions/src/offer-receipt/server.ts @@ -147,6 +147,10 @@ export function createOfferReceiptExtension(issuer: OfferReceiptIssuer): Resourc return { key: OFFER_RECEIPT, + // `offers` is regenerated on every PaymentRequired response (fresh `validUntil` + // timestamp and signature), so it is excluded from the client echo subset check. + dynamicInfoFields: ["offers"], + // Add signed offers to 402 PaymentRequired response enrichPaymentRequiredResponse: async ( declaration: unknown, diff --git a/typescript/packages/extensions/src/sign-in-with-x/server.ts b/typescript/packages/extensions/src/sign-in-with-x/server.ts index 8540061d..6a54da9b 100644 --- a/typescript/packages/extensions/src/sign-in-with-x/server.ts +++ b/typescript/packages/extensions/src/sign-in-with-x/server.ts @@ -119,6 +119,7 @@ export function createSIWxResourceServerExtension( return { key: SIGN_IN_WITH_X, + dynamicInfoFields: ["nonce", "issuedAt", "expirationTime"], enrichPaymentRequiredResponse: enrichSIWxPaymentRequiredResponse, transportHooks: { http: { diff --git a/typescript/packages/extensions/test/sign-in-with-x.test.ts b/typescript/packages/extensions/test/sign-in-with-x.test.ts index 574e46ad..c6e860c4 100644 --- a/typescript/packages/extensions/test/sign-in-with-x.test.ts +++ b/typescript/packages/extensions/test/sign-in-with-x.test.ts @@ -28,16 +28,19 @@ import { createSIWxClientHook, createSIWxClientExtension, createSIWxResourceServerExtension, + SIGN_IN_WITH_X, type SIWxHookEvent, type SolanaSigner, type EVMSigner, type EVMMessageVerifier, } from "../src/sign-in-with-x/index"; import { safeBase64Encode } from "@bankofai/x402-core/utils"; +import { x402ResourceServer } from "@bankofai/x402-core/server"; import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; import nacl from "tweetnacl"; import { randomBytes } from "crypto"; import type { SIWxExtension } from "../src/sign-in-with-x/index"; +import type { PaymentRequired, PaymentPayload } from "@bankofai/x402-core/types"; /** * Test-only helper: builds a complete SIWX extension with nonce/issuedAt. @@ -1654,6 +1657,52 @@ describe("createSIWxResourceServerExtension", () => { expect(result.info.uri).toBe("https://api.example.com/data"); }); + it("declares nonce, issuedAt, and expirationTime as dynamic info fields", () => { + const storage = new InMemorySIWxStorage(); + const extension = createSIWxResourceServerExtension({ storage }); + + expect(extension.dynamicInfoFields).toEqual(["nonce", "issuedAt", "expirationTime"]); + }); + + it("validates a client echo against a regenerated challenge with a fresh nonce", async () => { + const storage = new InMemorySIWxStorage(); + const extension = createSIWxResourceServerExtension({ storage }); + const declaration = declareSIWxExtension({ expirationSeconds: 300 }); + const ext = declaration["sign-in-with-x"]; + + const challengeA = (await extension.enrichPaymentRequiredResponse!( + ext, + mockContext(["eip155:8453"]), + )) as SIWxExtension; + const challengeB = (await extension.enrichPaymentRequiredResponse!( + ext, + mockContext(["eip155:8453"]), + )) as SIWxExtension; + + expect(challengeA.info.nonce).not.toBe(challengeB.info.nonce); + + const server = new x402ResourceServer().registerExtension(extension); + const paymentRequired = { + x402Version: 2 as const, + accepts: [], + extensions: { [SIGN_IN_WITH_X]: challengeB }, + }; + const paymentPayload = { + x402Version: 2 as const, + scheme: "exact", + network: "eip155:8453", + payload: {}, + extensions: { [SIGN_IN_WITH_X]: challengeA }, + }; + + expect( + server.validateExtensions( + paymentRequired as unknown as PaymentRequired, + paymentPayload as unknown as PaymentPayload, + ), + ).toEqual({ valid: true }); + }); + it("should generate time-based fields from static declaration", async () => { const storage = new InMemorySIWxStorage(); const extension = createSIWxResourceServerExtension({ storage }); diff --git a/typescript/packages/legacy/x402/src/types/verify/x402Specs.ts b/typescript/packages/legacy/x402/src/types/verify/x402Specs.ts index 53ab4d7a..0c9964d5 100644 --- a/typescript/packages/legacy/x402/src/types/verify/x402Specs.ts +++ b/typescript/packages/legacy/x402/src/types/verify/x402Specs.ts @@ -17,6 +17,7 @@ export const ErrorReasons = [ "invalid_exact_evm_payload_authorization_valid_after", "invalid_exact_evm_payload_authorization_valid_before", "invalid_exact_evm_payload_authorization_value", + "invalid_exact_evm_payload_authorization_value_mismatch", "invalid_exact_evm_payload_signature", "invalid_exact_evm_payload_undeployed_smart_wallet", "invalid_exact_evm_payload_recipient_mismatch", diff --git a/typescript/packages/mcp/src/client/x402MCPClient.ts b/typescript/packages/mcp/src/client/x402MCPClient.ts index 51e39837..5b1e0c8d 100644 --- a/typescript/packages/mcp/src/client/x402MCPClient.ts +++ b/typescript/packages/mcp/src/client/x402MCPClient.ts @@ -5,7 +5,7 @@ import type { Network, SchemeNetworkClient, } from "@bankofai/x402-core/types"; -import { isPaymentRequired } from "@bankofai/x402-core/schemas"; +import { parsePaymentRequired } from "@bankofai/x402-core/schemas"; import { x402Client } from "@bankofai/x402-core/client"; import type { x402ClientConfig } from "@bankofai/x402-core/client"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; @@ -17,11 +17,7 @@ import type { PaymentRequiredHook, PaymentRequiredContext, } from "../types"; -import { - MCP_PAYMENT_REQUIRED_CODE, - MCP_PAYMENT_META_KEY, - isPaymentRequiredError, -} from "../types"; +import { MCP_PAYMENT_REQUIRED_CODE, MCP_PAYMENT_META_KEY, isPaymentRequiredError } from "../types"; import { extractPaymentResponseFromMeta } from "../utils"; // ============================================================================ @@ -179,11 +175,7 @@ export class x402MCPClient { * @param paymentClient - The x402 client for creating payment payloads * @param options - Configuration options */ - constructor( - mcpClient: Client, - paymentClient: x402Client, - options: x402MCPClientOptions = {}, - ) { + constructor(mcpClient: Client, paymentClient: x402Client, options: x402MCPClientOptions = {}) { this.mcpClient = mcpClient; this._paymentClient = paymentClient; this.options = { @@ -279,7 +271,9 @@ export class x402MCPClient { * @param args - Arguments for readResource method * @returns Promise resolving to the resource content */ - async readResource(...args: Parameters): ReturnType { + async readResource( + ...args: Parameters + ): ReturnType { return this.mcpClient.readResource(...args); } @@ -290,7 +284,9 @@ export class x402MCPClient { * @param args - Arguments for listResourceTemplates method * @returns Promise resolving to the list of resource templates */ - async listResourceTemplates(...args: Parameters): ReturnType { + async listResourceTemplates( + ...args: Parameters + ): ReturnType { return this.mcpClient.listResourceTemplates(...args); } @@ -301,7 +297,9 @@ export class x402MCPClient { * @param args - Arguments for subscribeResource method * @returns Promise resolving when subscribed */ - async subscribeResource(...args: Parameters): ReturnType { + async subscribeResource( + ...args: Parameters + ): ReturnType { return this.mcpClient.subscribeResource(...args); } @@ -312,7 +310,9 @@ export class x402MCPClient { * @param args - Arguments for unsubscribeResource method * @returns Promise resolving when unsubscribed */ - async unsubscribeResource(...args: Parameters): ReturnType { + async unsubscribeResource( + ...args: Parameters + ): ReturnType { return this.mcpClient.unsubscribeResource(...args); } @@ -345,7 +345,9 @@ export class x402MCPClient { * @param args - Arguments for setLoggingLevel method * @returns Promise resolving when level is set */ - async setLoggingLevel(...args: Parameters): ReturnType { + async setLoggingLevel( + ...args: Parameters + ): ReturnType { return this.mcpClient.setLoggingLevel(...args); } @@ -787,9 +789,7 @@ export class x402MCPClient { try { const parsed: unknown = JSON.parse(firstItem.text); if (typeof parsed === "object" && parsed !== null) { - const extracted = this.extractPaymentRequiredFromObject( - parsed as Record, - ); + const extracted = this.extractPaymentRequiredFromObject(parsed as Record); if (extracted) { return extracted; } @@ -809,11 +809,9 @@ export class x402MCPClient { * @returns PaymentRequired if found, null otherwise */ private extractPaymentRequiredFromObject(obj: Record): PaymentRequired | null { - if (isPaymentRequired(obj)) { - return obj as PaymentRequired; - } - - return null; + // parsePaymentRequired yields the schema (V1 | V2) union; cast to the transport PaymentRequired type + const result = parsePaymentRequired(obj); + return result.success ? (result.data as PaymentRequired) : null; } /** @@ -832,9 +830,10 @@ export class x402MCPClient { return null; } - return "x402" in error.data ? error.data.x402 : error.data; + const data = "x402" in error.data ? error.data.x402 : error.data; + const result = parsePaymentRequired(data); + return result.success ? (result.data as PaymentRequired) : null; } - } /** diff --git a/typescript/packages/mcp/src/utils/encoding.ts b/typescript/packages/mcp/src/utils/encoding.ts index 68fb7b66..2eb68425 100644 --- a/typescript/packages/mcp/src/utils/encoding.ts +++ b/typescript/packages/mcp/src/utils/encoding.ts @@ -1,4 +1,5 @@ import type { PaymentPayload, PaymentRequired, SettleResponse } from "@bankofai/x402-core/types"; +import { parsePaymentRequired } from "@bankofai/x402-core/schemas"; import { MCP_PAYMENT_META_KEY, MCP_PAYMENT_REQUIRED_CODE, @@ -214,7 +215,9 @@ export function extractPaymentRequiredFromError(error: unknown): PaymentRequired return null; } - return data; + // parsePaymentRequired yields the schema (V1 | V2) union; cast to the transport PaymentRequired type + const result = parsePaymentRequired(data); + return result.success ? (result.data as PaymentRequired) : null; } /** diff --git a/typescript/packages/mcp/test/unit/client.test.ts b/typescript/packages/mcp/test/unit/client.test.ts index 645ef5f8..fe6e9815 100644 --- a/typescript/packages/mcp/test/unit/client.test.ts +++ b/typescript/packages/mcp/test/unit/client.test.ts @@ -596,6 +596,40 @@ describe("x402MCPClient response format interoperability", () => { expect(mockPaymentClient.createPaymentPayload).toHaveBeenCalledWith(mockPaymentRequired); }); + it("should auto-pay when structuredContent has null optional resource fields", async () => { + // Other SDKs may serialize unset optional fields as explicit null. Before + // the schema normalization, strict parsing rejected these and the 402 was + // returned as ordinary tool data instead of triggering payment. + const paymentRequiredWithNulls = { + ...mockPaymentRequired, + error: null, + resource: { + url: "mcp://tool/test", + description: null, + mimeType: null, + serviceName: null, + tags: null, + iconUrl: null, + }, + }; + + mockMcpClient.callTool + .mockResolvedValueOnce( + createStructuredContentDirectPaymentError( + paymentRequiredWithNulls as unknown as PaymentRequired, + ), + ) + .mockResolvedValueOnce({ + content: [{ type: "text", text: "success" }], + _meta: { "x402/payment-response": mockSettleResponse }, + }); + + const result = await client.callTool("paid_tool"); + + expect(result.paymentMade).toBe(true); + expect(mockMcpClient.callTool).toHaveBeenCalledTimes(2); + }); + it("should parse structuredContent with direct PaymentRequired V1 (ethanniser/x402-mcp style)", async () => { mockMcpClient.callTool .mockResolvedValueOnce(createStructuredContentDirectPaymentError(mockPaymentRequiredV1)) diff --git a/typescript/packages/mcp/test/unit/utils.test.ts b/typescript/packages/mcp/test/unit/utils.test.ts index a77fd5e6..7c4b111d 100644 --- a/typescript/packages/mcp/test/unit/utils.test.ts +++ b/typescript/packages/mcp/test/unit/utils.test.ts @@ -49,7 +49,7 @@ const mockPaymentRequired: PaymentRequired = { amount: "1000", asset: "0xtoken", payTo: "0xrecipient", - maxAmountRequired: "1000", + maxTimeoutSeconds: 60, extra: {}, }, ], diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/client/eip3009.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/client/eip3009.ts index d6a234c6..8c98d764 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/client/eip3009.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/client/eip3009.ts @@ -62,7 +62,7 @@ export async function createBatchSettlementEIP3009DepositPayload( from: getAddress(signer.address), to: getAddress(ERC3009_DEPOSIT_COLLECTOR_ADDRESS), value: BigInt(depositAmount), - validAfter: BigInt(now - 600), + validAfter: BigInt(0), validBefore: BigInt(now + paymentRequirements.maxTimeoutSeconds), nonce: erc3009Nonce, }, @@ -84,7 +84,7 @@ export async function createBatchSettlementEIP3009DepositPayload( amount: depositAmount, authorization: { erc3009Authorization: { - validAfter: (now - 600).toString(), + validAfter: "0", validBefore: (now + paymentRequirements.maxTimeoutSeconds).toString(), salt, signature, diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts index f7526366..55f2880b 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts @@ -34,6 +34,7 @@ export const ErrReceiverAuthorizerMismatch = export const ErrWithdrawDelayMismatch = "invalid_batch_settlement_evm_withdraw_delay_mismatch"; export const ErrAuthorizerAddressMismatch = "invalid_batch_settlement_evm_authorizer_address_mismatch"; +export const ErrAuthorizerNotConfigured = "invalid_batch_settlement_evm_authorizer_not_configured"; export const ErrDepositSimulationFailed = "invalid_batch_settlement_evm_deposit_simulation_failed"; export const ErrClaimSimulationFailed = "invalid_batch_settlement_evm_claim_simulation_failed"; export const ErrSettleSimulationFailed = "invalid_batch_settlement_evm_settle_simulation_failed"; @@ -66,3 +67,9 @@ export const ErrChargeExceedsSignedCumulative = export const ErrMissingChannel = "invalid_batch_settlement_evm_missing_channel"; export const ErrRefundNoBalance = "invalid_batch_settlement_evm_refund_no_balance"; export const ErrRefundAmountInvalid = "invalid_batch_settlement_evm_refund_amount_invalid"; + +// ERC-6492 counterfactual deployment errors (ERC-3009 deposit path). Wire values keep the +// scheme prefix to match the rest of this module's contract. +export const ErrFactoryNotAllowed = "invalid_batch_settlement_evm_eip6492_factory_not_allowed"; +export const ErrSmartWalletDeploymentFailed = + "invalid_batch_settlement_evm_smart_wallet_deployment_failed"; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts index 3ee709c5..538b5453 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts @@ -38,6 +38,7 @@ export function buildVoucherClaimArgs(claims: BatchSettlementClaimPayload["claim * @param payload - Claim payload containing voucher claims and optional authorizer signature. * @param requirements - Payment requirements for network identification. * @param authorizerSigner - Dedicated key for producing `ClaimBatch` EIP-712 signatures. + * When omitted, the payload must already carry a `claimAuthorizerSignature`. * @param dataSuffix - Optional hex suffix appended to the claim transaction. * @returns A {@link SettleResponse} with the transaction hash on success. */ @@ -45,7 +46,7 @@ export async function executeClaimWithSignature( signer: FacilitatorEvmSigner, payload: BatchSettlementClaimPayload, requirements: PaymentRequirements, - authorizerSigner: AuthorizerSigner, + authorizerSigner: AuthorizerSigner | undefined, dataSuffix?: `0x${string}`, ): Promise { const network = requirements.network; @@ -54,6 +55,14 @@ export async function executeClaimWithSignature( let sig = payload.claimAuthorizerSignature; if (!sig) { + if (!authorizerSigner) { + return { + success: false, + errorReason: Errors.ErrAuthorizerNotConfigured, + transaction: "", + network, + }; + } for (const claim of payload.claims) { if ( getAddress(claim.voucher.channel.receiverAuthorizer) !== diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts index 40e46077..00e7cbed 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts @@ -1,5 +1,6 @@ import { PaymentRequirements, VerifyResponse } from "@bankofai/x402-core/types"; -import { getAddress, parseErc6492Signature } from "viem"; +import { getAddress, parseErc6492Signature, isAddressEqual } from "viem"; +import { verifyTypedDataSignature } from "../../shared/verifySignature"; import { FacilitatorEvmSigner } from "../../signer"; import { BatchSettlementDepositPayload } from "../types"; import { ERC3009_DEPOSIT_COLLECTOR_ADDRESS, receiveAuthorizationTypes } from "../constants"; @@ -7,6 +8,28 @@ import { buildErc3009CollectorData, buildErc3009DepositNonce } from "../encoding import * as Errors from "../errors"; import { erc3009AuthorizationTimeInvalidReason } from "./utils"; +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; + +/** Factory deployment info for an undeployed (ERC-6492 counterfactual) deposit wallet. */ +export type Erc3009CounterfactualDeployment = { + factory: `0x${string}`; + factoryCalldata: `0x${string}`; +}; + +/** + * Result of ERC-3009 deposit authorization verification. + * + * - `response` non-null: terminal verification outcome (a rejection). + * - `counterfactual` non-null: the deposit is from an undeployed ERC-6492 wallet with an + * allowlisted factory; the caller must validate the inner signature via the deploy+deposit + * simulation rather than a direct (no-code) signature check. + * - both null: a deployed wallet / plain EOA signature is valid. + */ +export type Erc3009DepositVerifyResult = { + response: VerifyResponse | null; + counterfactual: Erc3009CounterfactualDeployment | null; +}; + /** * Returns the collector contract used for EIP-3009 deposits. * @@ -41,32 +64,84 @@ export function buildEip3009DepositCollectorData( * @param payload - Deposit payload to verify. * @param requirements - Payment requirements containing token domain metadata. * @param chainId - EVM chain id. - * @returns A failure response, or `null` when valid. + * @param allowedFactories - Allowlisted ERC-6492 factory addresses for counterfactual deposits. + * @returns The verification result (rejection, valid, or counterfactual-deferred). */ export async function verifyEip3009DepositAuthorization( signer: FacilitatorEvmSigner, payload: BatchSettlementDepositPayload, requirements: PaymentRequirements, chainId: number, -): Promise { + allowedFactories: string[] = [], +): Promise { const { deposit, voucher } = payload; const payer = payload.channelConfig.payer; const auth = deposit.authorization.erc3009Authorization; if (!auth) { - return { isValid: false, invalidReason: Errors.ErrErc3009AuthorizationRequired, payer }; + return { + response: { isValid: false, invalidReason: Errors.ErrErc3009AuthorizationRequired, payer }, + counterfactual: null, + }; } const extra = requirements.extra as { name?: string; version?: string } | undefined; if (!extra?.name || !extra?.version) { - return { isValid: false, invalidReason: Errors.ErrMissingEip712Domain, payer }; + return { + response: { isValid: false, invalidReason: Errors.ErrMissingEip712Domain, payer }, + counterfactual: null, + }; } const validAfter = BigInt(auth.validAfter); const validBefore = BigInt(auth.validBefore); const timeInvalid = erc3009AuthorizationTimeInvalidReason(validAfter, validBefore); if (timeInvalid) { - return { isValid: false, invalidReason: timeInvalid, payer }; + return { + response: { isValid: false, invalidReason: timeInvalid, payer }, + counterfactual: null, + }; + } + + // Parse the ERC-6492 wrapper (a no-op for unwrapped signatures, which return the signature + // unchanged as `signature`). + const { + address: factory, + data: factoryCalldata, + signature: innerSig, + } = parseErc6492Signature(auth.signature); + const hasDeploymentInfo = !!( + factory && + factoryCalldata && + !isAddressEqual(factory, ZERO_ADDRESS) + ); + + // Counterfactual detection: only fetch code when there is deployment info so the common + // (already-deployed / plain EOA) path keeps a single RPC round-trip. + if (hasDeploymentInfo) { + let code: `0x${string}` | undefined; + try { + code = await signer.getCode({ address: payer }); + } catch { + code = undefined; + } + if (!code || code === "0x") { + const normalizedFactory = factory.toLowerCase(); + const isAllowed = allowedFactories.some(a => a.trim().toLowerCase() === normalizedFactory); + if (!isAllowed) { + return { + response: { isValid: false, invalidReason: Errors.ErrFactoryNotAllowed, payer }, + counterfactual: null, + }; + } + // Counterfactual + allowlisted: defer signature validation to the deploy+deposit + // simulation performed by the caller. + return { + response: null, + counterfactual: { factory, factoryCalldata: factoryCalldata as `0x${string}` }, + }; + } + // Already deployed despite the wrapper — fall through and validate the inner signature. } const erc3009Nonce = buildErc3009DepositNonce(voucher.channelId, auth.salt); @@ -80,14 +155,21 @@ export async function verifyEip3009DepositAuthorization( validAfter, validBefore, nonce: erc3009Nonce, - signature: auth.signature, + signature: hasDeploymentInfo ? (innerSig as `0x${string}`) : auth.signature, }); if (!receiveAuthOk) { - return { isValid: false, invalidReason: Errors.ErrInvalidReceiveAuthorizationSignature, payer }; + return { + response: { + isValid: false, + invalidReason: Errors.ErrInvalidReceiveAuthorizationSignature, + payer, + }, + counterfactual: null, + }; } - return null; + return { response: null, counterfactual: null }; } /** @@ -122,28 +204,24 @@ async function verifyReceiveAuth( signature: `0x${string}`; }, ): Promise { - try { - return await signer.verifyTypedData({ - address: getAddress(params.payer), - domain: { - name: params.name, - version: params.version, - chainId: params.chainId, - verifyingContract: getAddress(params.asset), - }, - types: receiveAuthorizationTypes, - primaryType: "ReceiveWithAuthorization", - message: { - from: getAddress(params.payer), - to: getAddress(ERC3009_DEPOSIT_COLLECTOR_ADDRESS), - value: BigInt(params.amount), - validAfter: params.validAfter, - validBefore: params.validBefore, - nonce: params.nonce, - }, - signature: params.signature, - }); - } catch { - return false; - } + return verifyTypedDataSignature(signer, { + address: getAddress(params.payer), + domain: { + name: params.name, + version: params.version, + chainId: params.chainId, + verifyingContract: getAddress(params.asset), + }, + types: receiveAuthorizationTypes, + primaryType: "ReceiveWithAuthorization", + message: { + from: getAddress(params.payer), + to: getAddress(ERC3009_DEPOSIT_COLLECTOR_ADDRESS), + value: BigInt(params.amount), + validAfter: params.validAfter, + validBefore: params.validBefore, + nonce: params.nonce, + }, + signature: params.signature, + }); } diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts index be1bfd9b..434fcfac 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts @@ -5,6 +5,7 @@ import { VerifyResponse, } from "@bankofai/x402-core/types"; import { encodeFunctionData, getAddress, parseErc6492Signature } from "viem"; +import { verifyTypedDataSignature } from "../../shared/verifySignature"; import { extractEip2612GasSponsoringInfo, extractErc20ApprovalGasSponsoringInfo, @@ -305,30 +306,31 @@ async function verifyPermit2TypedData( return { isValid: false, invalidReason: Errors.ErrPermit2DeadlineExpired, payer }; } - try { - const ok = await signer.verifyTypedData({ - address: getAddress(auth.from), - domain: { name: "Permit2", chainId, verifyingContract: PERMIT2_ADDRESS }, - types: batchPermit2WitnessTypes, - primaryType: "PermitWitnessTransferFrom", - message: { - permitted: { - token: getAddress(auth.permitted.token), - amount: BigInt(auth.permitted.amount), - }, - spender: getAddress(auth.spender), - nonce: BigInt(auth.nonce), - deadline: BigInt(auth.deadline), - witness: { - channelId: auth.witness.channelId, - }, + // Mirror Permit2's on-chain SignatureVerification: ecrecover when the address + // has no code, strict EIP-1271 isValidSignature when it does. No ECDSA + // fallback for code addresses — that fallback would accept sigs Permit2 + // rejects on-chain (notably for ERC-7702 EOAs whose delegate doesn't accept + // raw owner ECDSA). + const ok = await verifyTypedDataSignature(signer, { + address: getAddress(auth.from), + domain: { name: "Permit2", chainId, verifyingContract: PERMIT2_ADDRESS }, + types: batchPermit2WitnessTypes, + primaryType: "PermitWitnessTransferFrom", + message: { + permitted: { + token: getAddress(auth.permitted.token), + amount: BigInt(auth.permitted.amount), }, - signature: auth.signature, - }); - if (!ok) { - return { isValid: false, invalidReason: Errors.ErrPermit2InvalidSignature, payer }; - } - } catch { + spender: getAddress(auth.spender), + nonce: BigInt(auth.nonce), + deadline: BigInt(auth.deadline), + witness: { + channelId: auth.witness.channelId, + }, + }, + signature: auth.signature, + }); + if (!ok) { return { isValid: false, invalidReason: Errors.ErrPermit2InvalidSignature, payer }; } diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts index c116018f..e58a0c53 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts @@ -5,8 +5,10 @@ import { VerifyResponse, SettleResponse, } from "@bankofai/x402-core/types"; -import { getAddress } from "viem"; +import { getAddress, encodeFunctionData } from "viem"; +import { parseErc6492Signature, isAddressEqual } from "viem"; import { FacilitatorEvmSigner } from "../../signer"; +import type { Erc3009CounterfactualDeployment } from "./deposit-eip3009"; import type { TransactionRequest } from "../../exact/extensions"; import { BatchSettlementAssetTransferMethod, BatchSettlementDepositPayload } from "../types"; import { batchSettlementABI, erc20BalanceOfABI } from "../abi"; @@ -56,6 +58,7 @@ export async function verifyDeposit( payload: BatchSettlementDepositPayload, requirements: PaymentRequirements, context?: FacilitatorContext, + allowedFactories: string[] = [], ): Promise { const payer = payload.channelConfig.payer; const chainId = getEvmChainId(requirements.network); @@ -73,20 +76,34 @@ export async function verifyDeposit( return { isValid: false, invalidReason: Errors.ErrInvalidPayloadType, payer }; } - const methodErr = - transferMethod === "permit2" - ? await verifyPermit2DepositAuthorization( - signer, - payment, - payload, - requirements, - chainId, - context, - ) - : await verifyEip3009DepositAuthorization(signer, payload, requirements, chainId); - - if (methodErr) { - return methodErr; + // erc3009Counterfactual is non-null when the ERC-3009 deposit is from an undeployed + // ERC-6492 wallet with an allowlisted factory; its inner signature is validated by the + // deploy+deposit simulation below rather than a direct (no-code) signature check. + let erc3009Counterfactual: Erc3009CounterfactualDeployment | null = null; + if (transferMethod === "permit2") { + const methodErr = await verifyPermit2DepositAuthorization( + signer, + payment, + payload, + requirements, + chainId, + context, + ); + if (methodErr) { + return methodErr; + } + } else { + const result = await verifyEip3009DepositAuthorization( + signer, + payload, + requirements, + chainId, + allowedFactories, + ); + if (result.response) { + return result.response; + } + erc3009Counterfactual = result.counterfactual; } const shared = await verifySharedDepositState(signer, payload, requirements); @@ -101,7 +118,22 @@ export async function verifyDeposit( return execution; } - if (!execution.skipDirectSimulation) { + if (erc3009Counterfactual) { + // Counterfactual ERC-6492 wallet: the payer has no code yet, so a plain deposit() + // eth_call would revert (no code → isValidSignature reverts). Simulate factory-deploy + + // deposit atomically in one Multicall3 eth_call so the inner signature is validated + // against the just-deployed wallet — mirroring how settle deploys then deposits. + const simulationSucceeded = await simulateCounterfactualErc3009Deposit( + signer, + erc3009Counterfactual, + payload, + depositAmount, + execution, + ); + if (!simulationSucceeded) { + return { isValid: false, invalidReason: Errors.ErrDepositSimulationFailed, payer }; + } + } else if (!execution.skipDirectSimulation) { try { await signer.readContract({ address: getAddress(BATCH_SETTLEMENT_ADDRESS), @@ -293,12 +325,20 @@ export async function settleDeposit( requirements: PaymentRequirements, context?: FacilitatorContext, dataSuffix?: `0x${string}`, + allowedFactories: string[] = [], ): Promise { const { deposit, voucher } = payload; const config = payload.channelConfig; const payer = config.payer; - const verified = await verifyDeposit(signer, payment, payload, requirements, context); + const verified = await verifyDeposit( + signer, + payment, + payload, + requirements, + context, + allowedFactories, + ); if (!verified.isValid) { const reason = verified.invalidReason ?? Errors.ErrInvalidPayloadType; return { @@ -331,6 +371,19 @@ export async function settleDeposit( }; } + // ERC-6492 counterfactual deposit: deploy the undeployed wallet (gated by the factory + // allowlist) before the deposit, then simulate with the inner signature to catch wallets + // whose validator is installed lazily. + const deployResult = await tryDeployCounterfactualErc3009Deposit( + signer, + payload, + requirements, + allowedFactories, + ); + if (deployResult) { + return deployResult; + } + const depositTx = buildDepositTransaction(payload, execution.collectorData, dataSuffix); const tx = @@ -507,3 +560,125 @@ function resolveDepositTransferMethod( } return payload.deposit.authorization.permit2Authorization ? "permit2" : "eip3009"; } + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; + +/** + * Simulates a counterfactual ERC-6492 deposit: factory-deploy + deposit atomically in one + * Multicall3 eth_call so the inner signature is validated against the just-deployed wallet. + * + * @param signer - Facilitator signer for onchain reads. + * @param counterfactual - Factory deployment info for the undeployed wallet. + * @param payload - Deposit payload. + * @param depositAmount - Amount to deposit. + * @param execution - Resolved deposit execution (collector + collectorData). + * @returns `true` when the simulation succeeds, `false` on revert or transport failure. + */ +async function simulateCounterfactualErc3009Deposit( + signer: FacilitatorEvmSigner, + counterfactual: Erc3009CounterfactualDeployment, + payload: BatchSettlementDepositPayload, + depositAmount: bigint, + execution: { collector: `0x${string}`; collectorData: `0x${string}` }, +): Promise { + try { + const depositCallData = encodeFunctionData({ + abi: batchSettlementABI, + functionName: "deposit", + args: [ + toContractChannelConfig(payload.channelConfig), + depositAmount, + execution.collector, + execution.collectorData, + ], + }); + const results = await multicall(signer.readContract.bind(signer), [ + { + address: counterfactual.factory, + callData: counterfactual.factoryCalldata, + }, + { + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + callData: depositCallData, + }, + ]); + return results.every(r => r.status === "success"); + } catch { + return false; + } +} + +/** + * Deploys an undeployed ERC-6492 counterfactual smart wallet before the deposit transaction. + * Gated by the factory allowlist. Returns a failure response on error, or `null` when the + * wallet is already deployed or was successfully deployed (or has no ERC-6492 wrapper). + * + * @param signer - Facilitator signer for tx submission and onchain reads. + * @param payload - Deposit payload. + * @param requirements - Payment requirements. + * @param allowedFactories - Allowlisted ERC-6492 factory addresses. + * @returns A failure response, or `null` to proceed with the standard deposit. + */ +async function tryDeployCounterfactualErc3009Deposit( + signer: FacilitatorEvmSigner, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + allowedFactories: string[], +): Promise { + const config = payload.channelConfig; + const payer = config.payer; + const auth = payload.deposit.authorization.erc3009Authorization; + if (!auth) { + return null; + } + + const { address: factory, data: factoryCalldata } = parseErc6492Signature(auth.signature); + const hasDeploymentInfo = !!( + factory && + factoryCalldata && + !isAddressEqual(factory, ZERO_ADDRESS) + ); + if (!hasDeploymentInfo) { + return null; + } + + let code: `0x${string}` | undefined; + try { + code = await signer.getCode({ address: payer }); + } catch { + code = undefined; + } + if (code && code !== "0x") { + // Already deployed — nothing to do; proceed with the standard deposit. + return null; + } + + const normalizedFactory = factory.toLowerCase(); + if (!allowedFactories.some(a => a.trim().toLowerCase() === normalizedFactory)) { + return { + success: false, + errorReason: Errors.ErrFactoryNotAllowed, + errorMessage: "factory not in eip6492AllowedFactories allowlist", + transaction: "", + network: requirements.network, + payer, + }; + } + + const deployTx = await signer.sendTransaction({ + to: factory, + data: factoryCalldata as `0x${string}`, + }); + const deployReceipt = await signer.waitForTransactionReceipt({ hash: deployTx }); + if (deployReceipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrSmartWalletDeploymentFailed, + transaction: "", + network: requirements.network, + payer, + }; + } + + return null; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/index.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/index.ts index a1bca12e..735a2d85 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/index.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/index.ts @@ -1 +1,2 @@ export { BatchSettlementEvmScheme } from "./scheme"; +export type { BatchSettlementEvmSchemeConfig } from "./scheme"; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts index 9716687b..4bcae699 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts @@ -188,6 +188,7 @@ function buildRefundExtraFromPostState( * @param payload - Refund payload with optional signatures, amount, and nonce. * @param requirements - Payment requirements for network identification. * @param authorizerSigner - Dedicated key for producing EIP-712 signatures. + * When omitted, the payload must already carry the required authorizer signatures. * @param dataSuffix - Optional hex suffix appended to the refund transaction. * @returns A {@link SettleResponse} with the transaction hash on success. */ @@ -195,7 +196,7 @@ export async function executeRefundWithSignature( signer: FacilitatorEvmSigner, payload: BatchSettlementEnrichedRefundPayload, requirements: PaymentRequirements, - authorizerSigner: AuthorizerSigner, + authorizerSigner: AuthorizerSigner | undefined, dataSuffix?: `0x${string}`, ): Promise { const network = requirements.network; @@ -217,8 +218,19 @@ export async function executeRefundWithSignature( } const hasClientSig = payload.refundAuthorizerSignature !== undefined; - const authorizerMismatch = - getAddress(payload.channelConfig.receiverAuthorizer) !== getAddress(authorizerSigner.address); + const authorizerMismatch = authorizerSigner + ? getAddress(payload.channelConfig.receiverAuthorizer) !== + getAddress(authorizerSigner.address) + : false; + + if (!hasClientSig && !authorizerSigner) { + return { + success: false, + errorReason: Errors.ErrAuthorizerNotConfigured, + transaction: "", + network, + }; + } if (!hasClientSig && authorizerMismatch) { return { @@ -231,7 +243,15 @@ export async function executeRefundWithSignature( const refundSig = payload.refundAuthorizerSignature ?? - (await signRefund(authorizerSigner, channelId, payload.amount, payload.refundNonce, network)); + (authorizerSigner + ? await signRefund( + authorizerSigner, + channelId, + payload.amount, + payload.refundNonce, + network, + ) + : undefined); const refundCalldata = encodeFunctionData({ abi: batchSettlementABI, @@ -249,6 +269,14 @@ export async function executeRefundWithSignature( if (payload.claims.length > 0) { let claimSig = payload.claimAuthorizerSignature; if (!claimSig) { + if (!authorizerSigner) { + return { + success: false, + errorReason: Errors.ErrAuthorizerNotConfigured, + transaction: "", + network, + }; + } claimSig = await signClaimBatch(authorizerSigner, payload.claims, network); } diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts index a2c4bc58..a517caca 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts @@ -31,33 +31,61 @@ import * as Errors from "../errors"; * Routes incoming verify/settle requests to the appropriate handler based on payload * type (deposit, voucher, claim, settle, refund). */ +export interface BatchSettlementEvmSchemeConfig { + /** + * Allowlist of factory contract addresses (hex strings, case-insensitive) the facilitator + * will call to deploy an undeployed (ERC-6492 counterfactual) smart wallet before an + * ERC-3009 deposit. An empty or omitted list denies all factory deployment (feature + * disabled by default). + * + * @default [] + */ + eip6492AllowedFactories?: string[]; +} + export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { readonly scheme = BATCH_SETTLEMENT_SCHEME; readonly caipFamily = "eip155:*"; + private readonly config: Required; + /** * Creates a facilitator scheme for verifying and settling batch-settlement payments. * * @param signer - Facilitator EVM signer(s) used for tx submission and onchain reads. - * @param authorizerSigner - Dedicated key that provides EIP-712 signatures for - * `claimWithSignature` / `refundWithSignature`. The facilitator will sign missing - * authorizer signatures using this key when the server omits them. + * @param authorizerSigner - Optional dedicated key that provides EIP-712 signatures for + * `claimWithSignature` / `refundWithSignature`. When provided, the facilitator advertises + * its address as `receiverAuthorizer` in `/supported` and signs missing authorizer + * signatures using this key when the server omits them. A facilitator that advertises a + * `receiverAuthorizer` for servers to delegate to must authenticate refund requests (see the + * spec); when no such mechanism exists, omit this signer so no `receiverAuthorizer` is + * advertised and servers supply their own signatures. + * @param config - Optional configuration (e.g. ERC-6492 factory allowlist). */ constructor( private readonly signer: FacilitatorEvmSigner, - private readonly authorizerSigner: AuthorizerSigner, - ) {} + private readonly authorizerSigner?: AuthorizerSigner, + config?: BatchSettlementEvmSchemeConfig, + ) { + this.config = { + eip6492AllowedFactories: config?.eip6492AllowedFactories ?? [], + }; + } /** * Returns facilitator-specific extra fields to be merged into payment requirements. * * Exposes the configured `receiverAuthorizer` address so the server and client can - * embed it in `ChannelConfig`. + * embed it in `ChannelConfig`. Returns `undefined` when no authorizer signer is + * configured, signalling that servers must supply their own authorizer signatures. * * @param _ - Network identifier (unused). - * @returns Extra fields containing `receiverAuthorizer`. + * @returns Extra fields containing `receiverAuthorizer`, or `undefined`. */ getExtra(_: string): { receiverAuthorizer: `0x${string}` } | undefined { + if (!this.authorizerSigner) { + return undefined; + } return { receiverAuthorizer: this.authorizerSigner.address }; } @@ -100,7 +128,14 @@ export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { } if (isBatchSettlementDepositPayload(rawPayload)) { - return verifyDeposit(this.signer, payload, rawPayload, requirements, context); + return verifyDeposit( + this.signer, + payload, + rawPayload, + requirements, + context, + this.config.eip6492AllowedFactories, + ); } if (isBatchSettlementVoucherPayload(rawPayload)) { @@ -141,7 +176,15 @@ export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { }); if (isBatchSettlementDepositPayload(rawPayload)) { - return settleDeposit(this.signer, payload, rawPayload, requirements, context, dataSuffix); + return settleDeposit( + this.signer, + payload, + rawPayload, + requirements, + context, + dataSuffix, + this.config.eip6492AllowedFactories, + ); } if (isBatchSettlementClaimPayload(rawPayload)) { diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/utils.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/utils.ts index 2d1388c8..325bd410 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/utils.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/utils.ts @@ -1,4 +1,5 @@ -import { getAddress, verifyTypedData as viemVerifyTypedData } from "viem"; +import { getAddress, hashTypedData, recoverAddress, isAddressEqual } from "viem"; +import { verifyTypedDataSignature } from "../../shared/verifySignature"; import type { PaymentRequirements } from "@bankofai/x402-core/types"; import { FacilitatorEvmSigner } from "../../signer"; import { multicall } from "../../multicall"; @@ -108,17 +109,27 @@ export async function verifyBatchSettlementVoucherTypedData( const zeroAddress = "0x0000000000000000000000000000000000000000"; if (params.payerAuthorizer !== zeroAddress) { - return viemVerifyTypedData({ - address: getAddress(params.payerAuthorizer), - domain, - types: voucherTypes, - primaryType: "Voucher", - message, - signature: params.signature, - }); + // on-chain x402BatchSettlement uses ECDSA.recoverCalldata — pure ecrecover, + // no code check, no EIP-1271. Use recoverAddress directly so there is no + // ambiguity: this path never issues an RPC call regardless of address state. + try { + const digest = hashTypedData({ + domain, + types: voucherTypes, + primaryType: "Voucher", + message, + }); + const recovered = await recoverAddress({ + hash: digest, + signature: params.signature as `0x${string}`, + }); + return isAddressEqual(recovered, getAddress(params.payerAuthorizer)); + } catch { + return false; + } } - return signer.verifyTypedData({ + return verifyTypedDataSignature(signer, { address: getAddress(params.payer), domain, types: voucherTypes, diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/scheme.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/scheme.ts index ca3b5102..b4f7751a 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/server/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/scheme.ts @@ -7,10 +7,15 @@ import { SchemeNetworkServer, SchemeServerHooks, MoneyParser, + SupportedKind, } from "@bankofai/x402-core/types"; import type { DeepReadonly } from "@bankofai/x402-core/types"; import type { SettleContext, SettleResultContext } from "@bankofai/x402-core/server"; -import { convertToTokenAmount, numberToDecimalString, parseMoneyString } from "@bankofai/x402-core/utils"; +import { + convertToTokenAmount, + numberToDecimalString, + parseMoneyString, +} from "@bankofai/x402-core/utils"; import type { FacilitatorClient } from "@bankofai/x402-core/server"; import { getAddress } from "viem"; import { BatchSettlementChannelManager } from "./channelManager"; @@ -350,6 +355,36 @@ export class BatchSettlementEvmScheme implements SchemeNetworkServer { return this.receiverAuthorizerSigner; } + /** + * Fails server startup when this scheme delegates the receiver-authorizer role + * but the facilitator does not advertise a usable `receiverAuthorizer`. + * + * @param network - The network identifier being validated. + * @param supportedKind - The facilitator's advertised kind for this scheme/network. + * @param _ - Extensions advertised by the facilitator (unused). + * @returns A problem message when delegation is impossible, or void when valid. + */ + validateFacilitatorSupport( + network: Network, + supportedKind: SupportedKind, + _: string[], + ): string | void { + if (this.receiverAuthorizerSigner) return; + + const advertised = supportedKind.extra?.receiverAuthorizer; + const hasValid = + typeof advertised === "string" && + getAddress(advertised) !== "0x0000000000000000000000000000000000000000"; + + if (!hasValid) { + return ( + `no receiverAuthorizerSigner is configured and the facilitator does not advertise a ` + + `receiverAuthorizer on ${network}. Configure a receiverAuthorizerSigner or use a ` + + `facilitator that advertises one.` + ); + } + } + /** * Creates a {@link BatchSettlementChannelManager} pre-configured with this scheme's * receiver, default token for the given network, and the provided facilitator. diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/server/settle.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/server/settle.ts index 2c53280d..cc81d716 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/server/settle.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/server/settle.ts @@ -1,5 +1,9 @@ import type { SettleResponse } from "@bankofai/x402-core/types"; -import type { SettleContext, SettleFailureContext, SettleResultContext } from "@bankofai/x402-core/server"; +import type { + SettleContext, + SettleFailureContext, + SettleResultContext, +} from "@bankofai/x402-core/server"; import { signClaimBatch, signRefund } from "../authorizerSigner"; import { isBatchSettlementDepositPayload, diff --git a/typescript/packages/mechanisms/evm/src/exact/client/eip3009.ts b/typescript/packages/mechanisms/evm/src/exact/client/eip3009.ts index ea5208ad..cca5f60a 100644 --- a/typescript/packages/mechanisms/evm/src/exact/client/eip3009.ts +++ b/typescript/packages/mechanisms/evm/src/exact/client/eip3009.ts @@ -25,7 +25,7 @@ export async function createEIP3009Payload( from: signer.address, to: getAddress(paymentRequirements.payTo), value: paymentRequirements.amount, - validAfter: (now - 600).toString(), + validAfter: "0", validBefore: (now + paymentRequirements.maxTimeoutSeconds).toString(), nonce, }; diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts index adfbc6aa..bf6b5a97 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009-utils.ts @@ -11,6 +11,15 @@ export interface Eip6492Deployment { factoryCalldata: `0x${string}`; } +/** + * Outcome of a transfer simulation. `error` is populated only when the underlying `eth_call` + * threw (revert or transport), letting callers distinguish a contract revert from an RPC failure. + */ +export interface SimulateEip3009Result { + ok: boolean; + error?: unknown; +} + /** * Simulates transferWithAuthorization via eth_call. * Returns true if simulation succeeded, false if it failed. @@ -28,6 +37,25 @@ export async function simulateEip3009Transfer( payload: ExactEIP3009Payload, eip6492Deployment?: Eip6492Deployment, ): Promise { + return (await simulateEip3009TransferResult(signer, erc20Address, payload, eip6492Deployment)).ok; +} + +/** + * Like {@link simulateEip3009Transfer} but returns the thrown error (if any) alongside the + * boolean outcome, so callers can tell a contract revert apart from a transport/RPC failure. + * + * @param signer - EVM signer for contract reads + * @param erc20Address - ERC-20 token contract address + * @param payload - EIP-3009 transfer authorization payload + * @param eip6492Deployment - Optional EIP-6492 factory info for undeployed smart wallets + * @returns The simulation outcome and, on failure via a thrown error, that error. + */ +export async function simulateEip3009TransferResult( + signer: FacilitatorEvmSigner, + erc20Address: `0x${string}`, + payload: ExactEIP3009Payload, + eip6492Deployment?: Eip6492Deployment, +): Promise { const auth = payload.authorization; const transferArgs = [ getAddress(auth.from), @@ -58,9 +86,9 @@ export async function simulateEip3009Transfer( } satisfies RawContractCall, ]); - return results[1]?.status === "success"; - } catch { - return false; + return { ok: results[1]?.status === "success" }; + } catch (error) { + return { ok: false, error }; } } @@ -90,9 +118,9 @@ export async function simulateEip3009Transfer( args: [...transferArgs, sig], }); } - return true; - } catch { - return false; + return { ok: true }; + } catch (error) { + return { ok: false, error }; } } diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts index 50541d5a..9b2e7cd1 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/eip3009.ts @@ -12,11 +12,12 @@ import { getEvmChainId } from "../../utils"; import { ExactEIP3009Payload } from "../../types"; import * as Errors from "./errors"; import { resolveDataSuffix } from "../../shared/extensions"; +import { verifyTypedDataSignature, classifyErc6492Payer } from "../../shared/verifySignature"; import { diagnoseEip3009SimulationFailure, executeTransferWithAuthorization, parseEip3009TransferError, - simulateEip3009Transfer, + simulateEip3009TransferResult, } from "./eip3009-utils"; export interface VerifyEIP3009Options { @@ -53,6 +54,8 @@ export interface EIP3009FacilitatorConfig { * @param requirements - The payment requirements * @param eip3009Payload - The EIP-3009 specific payload * @param options - Optional verification options + * @param allowedFactories - Allowlisted ERC-6492 factory addresses; a counterfactual payment whose + * factory is not in this list is rejected here so verify mirrors settle's policy gate. * @returns Promise resolving to verification response */ export async function verifyEIP3009( @@ -61,6 +64,7 @@ export async function verifyEIP3009( requirements: PaymentRequirements, eip3009Payload: ExactEIP3009Payload, options?: VerifyEIP3009Options, + allowedFactories: string[] = [], ): Promise { const payer = eip3009Payload.authorization.from; let eip6492Deployment: @@ -85,7 +89,7 @@ export async function verifyEIP3009( }; } - const { name, version } = requirements.extra; + const { name, version } = requirements.extra as { name: string; version: string }; const erc20Address = getAddress(requirements.asset); // Verify network matches @@ -117,65 +121,57 @@ export async function verifyEIP3009( }, }; - // Verify signature - // Note: verifyTypedData is implementation-dependent and pluggable on FacilitatorEvmSigner - // Some implementations only do EOA-style ECDSA recovery (e.g. viem/utils verifyTypedData, ethers.verifyTypedData) - // Viem's publicClient.verifyTypedData supports EOA and Smart Contract Account (ERC-1271 / ERC-6492) signature verification - let isValid = false; - try { - isValid = await signer.verifyTypedData({ - address: eip3009Payload.authorization.from, - ...permitTypedData, - signature: eip3009Payload.signature!, - }); - } catch { - isValid = false; - } const signature = eip3009Payload.signature!; - const sigLen = signature.startsWith("0x") ? signature.length - 2 : signature.length; - - // Extract EIP-6492 deployment info (factory address + calldata) if present - const erc6492Data = parseErc6492Signature(signature); - const hasDeploymentInfo = - erc6492Data.address && - erc6492Data.data && - !isAddressEqual(erc6492Data.address, "0x0000000000000000000000000000000000000000"); - - if (hasDeploymentInfo) { - eip6492Deployment = { - factoryAddress: erc6492Data.address!, - factoryCalldata: erc6492Data.data!, - }; - } - if (!isValid) { - // Check if signature is from a smart wallet - const isSmartWallet = sigLen > 130; // 65 bytes = 130 hex chars for EOA + // 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 { + isCounterfactual, + innerSignature, + eip6492Deployment: classification6492, + } = await classifyErc6492Payer(signer, signature, payer); - // EOA signature that failed verification — definitely invalid - if (!isSmartWallet) { + if (classification6492) { + eip6492Deployment = classification6492; + } + + if (isCounterfactual) { + // Counterfactual deposits are deployed by settle via the factory, which is gated by the + // allowlist. Enforce the same gate here so verify does not return isValid:true for a payment + // that settle will reject with ErrFactoryNotAllowed (verify must predict settle). + const factory = classification6492?.factoryAddress; + const factoryAllowed = + !!factory && allowedFactories.some(a => a.trim().toLowerCase() === factory.toLowerCase()); + if (!factoryAllowed) { return { isValid: false, - invalidReason: Errors.ErrInvalidSignature, + invalidReason: Errors.ErrFactoryNotAllowed, payer, }; } + } - // Smart wallet signature: check if deployed or has ERC-6492 deployment info - const bytecode = await signer.getCode({ address: payer }); - const isDeployed = bytecode && bytecode !== "0x"; - - if (!isDeployed && !hasDeploymentInfo) { - // Undeployed smart wallet with no factory info + if (!isCounterfactual) { + // For deployed addresses (plain EOA, smart contract, 7702 EOA): verify the + // signature using a strict primitive that mirrors on-chain SignatureChecker + // semantics — ecrecover when no code, strict EIP-1271 when code is present. + // No ECDSA fallback for code addresses; that fallback causes pre-verify to + // accept sigs the on-chain token rejects (empirically confirmed on Base Sepolia). + const isValid = await verifyTypedDataSignature(signer, { + address: eip3009Payload.authorization.from, + ...permitTypedData, + signature: innerSignature, + }); + if (!isValid) { return { isValid: false, - invalidReason: Errors.ErrUndeployedSmartWallet, + invalidReason: Errors.ErrInvalidSignature, payer, }; } - // Deployed smart wallet or undeployed with ERC-6492 factory info - // fall through to remaining field checks and onchain simulation } + // Counterfactual: skip pre-verify and defer to on-chain simulation/settle which + // deploys the factory first then atomically validates the signature. // Verify payment recipient matches if (getAddress(eip3009Payload.authorization.to) !== getAddress(requirements.payTo)) { @@ -209,7 +205,7 @@ export async function verifyEIP3009( if (BigInt(eip3009Payload.authorization.value) !== BigInt(requirements.amount)) { return { isValid: false, - invalidReason: Errors.ErrInvalidAuthorizationValue, + invalidReason: Errors.ErrAuthorizationValueMismatch, payer, }; } @@ -224,20 +220,24 @@ export async function verifyEIP3009( // Transaction simulation if (options?.simulate !== false) { - const simulationSucceeded = await simulateEip3009Transfer( + const { ok, error: simError } = await simulateEip3009TransferResult( signer, erc20Address, eip3009Payload, eip6492Deployment, ); - if (!simulationSucceeded) { - return diagnoseEip3009SimulationFailure( + if (!ok) { + const diagnosis = await diagnoseEip3009SimulationFailure( signer, erc20Address, eip3009Payload, requirements, requirements.amount, ); + // Carry the raw revert text so the concrete reason survives the mapping to a code. + const rawMessage = + simError instanceof Error ? simError.message : simError ? String(simError) : undefined; + return rawMessage ? { ...diagnosis, invalidMessage: rawMessage } : diagnosis; } } @@ -270,9 +270,14 @@ export async function settleEIP3009( const payer = eip3009Payload.authorization.from; // Re-verify before settling - const valid = await verifyEIP3009(signer, payload, requirements, eip3009Payload, { - simulate: config.simulateInSettle ?? false, - }); + const valid = await verifyEIP3009( + signer, + payload, + requirements, + eip3009Payload, + { simulate: config.simulateInSettle ?? false }, + config.eip6492AllowedFactories ?? [], + ); if (!valid.isValid) { return { success: false, @@ -284,10 +289,15 @@ export async function settleEIP3009( } try { - // Parse ERC-6492 signature if applicable (for optional deployment) - const { address: factoryAddress, data: factoryCalldata } = parseErc6492Signature( - eip3009Payload.signature!, - ); + // Parse ERC-6492 signature if applicable (for optional deployment). + // Keep the full result so we can access the inner signature later for + // the post-deploy transfer simulation. + const settleErc6492Data = parseErc6492Signature(eip3009Payload.signature!); + const { + address: factoryAddress, + data: factoryCalldata, + signature: erc6492InnerSig, + } = settleErc6492Data; // Deploy ERC-4337 smart wallet via EIP-6492 if factory is in the allowlist if ( @@ -319,8 +329,29 @@ export async function settleEIP3009( data: factoryCalldata as Hex, }); - // Wait for deployment transaction - await signer.waitForTransactionReceipt({ hash: deployTx }); + // Wait for deployment and check whether it actually succeeded. + // A reverted factory tx would silently proceed without this check, misclassifying + // the downstream transfer failure as an invalid-signature error. + const deployReceipt = await signer.waitForTransactionReceipt({ hash: deployTx }); + if (deployReceipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrSmartWalletDeploymentFailed, + transaction: "", + network: payload.accepted.network, + payer, + }; + } + + // Do NOT re-simulate the transfer here. The single authoritative pre-check is the + // atomic Multicall3 deploy+transfer simulation that runs in verify (one eth_call, + // state carried across both sub-calls). A second standalone eth_call after the real + // deploy tx is unreliable — the read can race the deploy's state propagation across + // load-balanced RPC nodes — and was producing false `eip6492_deployed_inner_wallet_ + // signature_unsupported` rejections for valid wallets (e.g. Coinbase Smart Wallet). + // The on-chain transferWithAuthorization below is itself the definitive signature + // check (the token routes to the wallet's isValidSignature); a genuinely + // unsupported inner signature reverts there and is classified by the catch block. } } @@ -329,10 +360,18 @@ export async function settleEIP3009( paymentRequirements: requirements, }); + // When the original signature was ERC-6492 wrapped (deployed wallet), use the + // extracted inner signature for the on-chain transferWithAuthorization call. + // FiatTokenV2_2's isValidSignature on the deployed contract expects the compact inner signature + const settlePayload = + erc6492InnerSig && erc6492InnerSig !== eip3009Payload.signature + ? { ...eip3009Payload, signature: erc6492InnerSig } + : eip3009Payload; + const tx = await executeTransferWithAuthorization( signer, getAddress(requirements.asset), - eip3009Payload, + settlePayload, dataSuffix, ); @@ -356,9 +395,13 @@ export async function settleEIP3009( payer, }; } catch (error) { + // Preserve the raw revert text alongside the mapped code. The mapper collapses many + // distinct on-chain reverts into a single reason (e.g. ErrInvalidSignature), so without + // the original message the true cause is invisible to callers/operators. return { success: false, errorReason: parseEip3009TransferError(error), + errorMessage: error instanceof Error ? error.message : String(error), transaction: "", network: payload.accepted.network, payer, diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/errors.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/errors.ts index c71eb0ac..29bfbced 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/errors.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/errors.ts @@ -15,6 +15,8 @@ export const ErrInvalidSignature = "invalid_exact_evm_signature"; export const ErrValidBeforeExpired = "invalid_exact_evm_payload_authorization_valid_before"; export const ErrValidAfterInFuture = "invalid_exact_evm_payload_authorization_valid_after"; export const ErrInvalidAuthorizationValue = "invalid_exact_evm_authorization_value"; +export const ErrAuthorizationValueMismatch = + "invalid_exact_evm_payload_authorization_value_mismatch"; export const ErrUndeployedSmartWallet = "invalid_exact_evm_payload_undeployed_smart_wallet"; export const ErrTransactionFailed = "invalid_exact_evm_transaction_failed"; @@ -72,3 +74,4 @@ export const ErrEip2612DeadlineExpired = "eip2612_deadline_expired"; export const ErrUnsupportedPayloadType = "unsupported_payload_type"; export const ErrInvalidTransactionState = "invalid_transaction_state"; export const ErrFactoryNotAllowed = "eip6492_factory_not_allowed"; +export const ErrSmartWalletDeploymentFailed = "smart_wallet_deployment_failed"; diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts index a469f6e3..6e79fa20 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/permit2.ts @@ -5,6 +5,7 @@ import { SettleResponse, VerifyResponse, } from "@bankofai/x402-core/types"; +import { verifyTypedDataSignature } from "../../shared/verifySignature"; import { extractEip2612GasSponsoringInfo, extractErc20ApprovalGasSponsoringInfo, @@ -191,34 +192,24 @@ export async function verifyPermit2( }, }; - // Verify signature - // Note: verifyTypedData is implementation-dependent and pluggable on FacilitatorEvmSigner - // Some implementations only do EOA-style ECDSA recovery (e.g. viem/utils verifyTypedData, ethers.verifyTypedData) - // Viem's publicClient.verifyTypedData supports EOA and Smart Contract Account (ERC-1271 / ERC-6492) signature verification - let signatureValid = false; - try { - signatureValid = await signer.verifyTypedData({ - address: payer, - ...permit2TypedData, - signature: permit2Payload.signature, - }); - } catch { - signatureValid = false; - } + // Verify signature using a strict primitive that mirrors Permit2's + // on-chain SignatureVerification (libraries/SignatureVerification.sol): + // ecrecover when the address has no code, strict EIP-1271 isValidSignature + // when it does. No ECDSA fallback for addresses with code — that fallback + // would accept signatures Permit2 rejects on-chain (notably for ERC-7702 + // EOAs whose delegate doesn't accept raw owner ECDSA). + const signatureValid = await verifyTypedDataSignature(signer, { + address: payer, + ...permit2TypedData, + signature: permit2Payload.signature, + }); if (!signatureValid) { - // Check if the payer is a deployed smart contract - const bytecode = await signer.getCode({ address: payer }); - const isDeployedContract = bytecode && bytecode !== "0x"; - - if (!isDeployedContract) { - return { - isValid: false, - invalidReason: Errors.ErrPermit2InvalidSignature, - payer, - }; - } - // Deployed smart contract: fall through to simulation + return { + isValid: false, + invalidReason: Errors.ErrPermit2InvalidSignature, + payer, + }; } // If simulation is disabled, return early diff --git a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts index 3a93fa40..93a3219b 100644 --- a/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/facilitator/scheme.ts @@ -100,7 +100,14 @@ export class ExactEvmScheme implements SchemeNetworkFacilitator { } const eip3009Payload: ExactEIP3009Payload = rawPayload; - return verifyEIP3009(this.signer, payload, requirements, eip3009Payload); + return verifyEIP3009( + this.signer, + payload, + requirements, + eip3009Payload, + undefined, + this.config.eip6492AllowedFactories, + ); } /** diff --git a/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts index 6293eba8..57975f66 100644 --- a/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/server/scheme.ts @@ -6,7 +6,11 @@ import { SchemeNetworkServer, MoneyParser, } from "@bankofai/x402-core/types"; -import { convertToTokenAmount, numberToDecimalString, parseMoneyString } from "@bankofai/x402-core/utils"; +import { + convertToTokenAmount, + numberToDecimalString, + parseMoneyString, +} from "@bankofai/x402-core/utils"; import { getDefaultAsset, type ExactDefaultAssetInfo } from "../../shared/defaultAssets"; /** diff --git a/typescript/packages/mechanisms/evm/src/exact/v1/client/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/v1/client/scheme.ts index d0a9f73c..369f7250 100644 --- a/typescript/packages/mechanisms/evm/src/exact/v1/client/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/v1/client/scheme.ts @@ -46,7 +46,7 @@ export class ExactEvmSchemeV1 implements SchemeNetworkClient { from: this.signer.address, to: getAddress(selectedV1.payTo), value: selectedV1.maxAmountRequired, - validAfter: (now - 600).toString(), // 10 minutes before + validAfter: "0", validBefore: (now + selectedV1.maxTimeoutSeconds).toString(), nonce, }; diff --git a/typescript/packages/mechanisms/evm/src/exact/v1/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/exact/v1/facilitator/scheme.ts index 17683e09..99160ce1 100644 --- a/typescript/packages/mechanisms/evm/src/exact/v1/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/exact/v1/facilitator/scheme.ts @@ -12,6 +12,7 @@ import { PaymentRequirementsV1 } from "@bankofai/x402-core/types/v1"; import { getAddress, Hex, isAddressEqual, parseErc6492Signature } from "viem"; import { authorizationTypes } from "../../../constants"; import { FacilitatorEvmSigner } from "../../../signer"; +import { verifyTypedDataSignature, classifyErc6492Payer } from "../../../shared/verifySignature"; import { ExactEvmPayloadV1 } from "../../../types"; import { EvmNetworkV1, getEvmChainIdV1 } from "../../../v1"; import * as Errors from "../../facilitator/errors"; @@ -175,8 +176,24 @@ export class ExactEvmSchemeV1 implements SchemeNetworkFacilitator { data: factoryCalldata as Hex, }); - // Wait for deployment transaction - await this.signer.waitForTransactionReceipt({ hash: deployTx }); + // Wait for deployment and verify it actually succeeded. + const deployReceipt = await this.signer.waitForTransactionReceipt({ hash: deployTx }); + if (deployReceipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrSmartWalletDeploymentFailed, + transaction: "", + network: payloadV1.network, + payer: exactEvmPayload.authorization.from, + }; + } + + // Do NOT re-simulate the transfer here. The authoritative pre-check is the atomic + // deploy+transfer simulation in verify; a second standalone eth_call after the real + // deploy tx races the deploy's state propagation across load-balanced RPC nodes and + // false-rejected valid wallets. The on-chain transferWithAuthorization below is the + // definitive signature check; a genuinely unsupported inner signature reverts there + // and is classified by parseEip3009TransferError. } } @@ -275,7 +292,7 @@ export class ExactEvmSchemeV1 implements SchemeNetworkFacilitator { }; } - const { name, version } = requirements.extra; + const { name, version } = requirements.extra as { name: string; version: string }; const erc20Address = getAddress(requirements.asset); // Verify network matches @@ -307,57 +324,55 @@ export class ExactEvmSchemeV1 implements SchemeNetworkFacilitator { }, }; - // Verify signature (flatten EIP-6492 handling out of catch block) - let isValid = false; - try { - isValid = await this.signer.verifyTypedData({ - address: payer, - ...permitTypedData, - signature: exactEvmPayload.signature!, - }); - } catch { - isValid = false; - } - const signature = exactEvmPayload.signature!; - const sigLen = signature.startsWith("0x") ? signature.length - 2 : signature.length; - - // Extract EIP-6492 deployment info (factory address + calldata) if present - const erc6492Data = parseErc6492Signature(signature); - const hasDeploymentInfo = - erc6492Data.address && - erc6492Data.data && - !isAddressEqual(erc6492Data.address, "0x0000000000000000000000000000000000000000"); - - if (hasDeploymentInfo) { - eip6492Deployment = { - factoryAddress: erc6492Data.address!, - factoryCalldata: erc6492Data.data!, - }; - } - if (!isValid) { - const isSmartWallet = sigLen > 130; // 65 bytes = 130 hex chars for EOA + // Classify the payer (counterfactual vs deployed) and extract inner sig in one shot. + const { + isCounterfactual, + innerSignature, + eip6492Deployment: classification6492, + } = await classifyErc6492Payer(this.signer, signature, payer); - if (!isSmartWallet) { + if (classification6492) { + eip6492Deployment = classification6492; + } + + if (isCounterfactual) { + // Mirror settle's allowlist gate so verify predicts settle: a counterfactual payment whose + // factory is not allowlisted is rejected at settle, so reject it here too. + const factory = classification6492?.factoryAddress; + const factoryAllowed = + !!factory && + this.config.eip6492AllowedFactories.some( + a => a.trim().toLowerCase() === factory.toLowerCase(), + ); + if (!factoryAllowed) { return { isValid: false, - invalidReason: Errors.ErrInvalidSignature, + invalidReason: Errors.ErrFactoryNotAllowed, payer, }; } + } - const bytecode = await this.signer.getCode({ address: payer }); - const isDeployed = bytecode && bytecode !== "0x"; - - if (!isDeployed && !hasDeploymentInfo) { + if (!isCounterfactual) { + // Non-counterfactual path: verify using the strict primitive that mirrors + // on-chain SignatureChecker semantics (ecrecover for EOAs, strict EIP-1271 + // for any address with code). No ECDSA fallback for code addresses. + const isValid = await verifyTypedDataSignature(this.signer, { + address: payer, + ...permitTypedData, + signature: innerSignature, + }); + if (!isValid) { return { isValid: false, - invalidReason: Errors.ErrUndeployedSmartWallet, + invalidReason: Errors.ErrInvalidSignature, payer, }; } } + // Counterfactual path: defer to on-chain simulation which deploys + verifies atomically. // Verify payment recipient matches if (getAddress(exactEvmPayload.authorization.to) !== getAddress(requirements.payTo)) { diff --git a/typescript/packages/mechanisms/evm/src/index.ts b/typescript/packages/mechanisms/evm/src/index.ts index 6f0b5a6a..92f501f1 100644 --- a/typescript/packages/mechanisms/evm/src/index.ts +++ b/typescript/packages/mechanisms/evm/src/index.ts @@ -82,6 +82,24 @@ export { export { getDefaultAsset } from "./shared/defaultAssets"; export type { DefaultAssetInfo, ExactDefaultAssetInfo } from "./shared/defaultAssets"; +// ERC-7702 detection utilities (diagnostic — not used in routing). +// Verification is code-routed via {@link verifyTypedDataSignature}; 7702 is just +// "address has code" and routes to EIP-1271 like any other contract. +export { isERC7702Delegation, getERC7702DelegateAddress } from "./shared/erc7702"; + +// Strict signature verification primitive. Use this instead of +// `signer.verifyTypedData` (or viem's `publicClient.verifyTypedData`) inside a +// facilitator — those have an ECDSA fallback when EIP-1271 returns failure +// that diverges from on-chain SignatureChecker semantics for any address with +// code (most visibly ERC-7702 EOAs whose delegate rejects raw owner ECDSA). +export { + verifyTypedDataSignature, + verifyHashSignature, + verifyHashSignatureWithCode, + classifyErc6492Payer, +} from "./shared/verifySignature"; +export type { Erc6492Classification } from "./shared/verifySignature"; + // Extension helpers (client + facilitator) export { BUILDER_CODE_KEY, resolveDataSuffix, appendDataSuffix } from "./shared/extensions"; export type { DataSuffixContext, BuilderCodeFacilitatorExtension } from "./shared/extensions"; diff --git a/typescript/packages/mechanisms/evm/src/shared/erc7702.ts b/typescript/packages/mechanisms/evm/src/shared/erc7702.ts new file mode 100644 index 00000000..ba0ce551 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/shared/erc7702.ts @@ -0,0 +1,47 @@ +/** + * Detection utilities for the ERC-7702 delegation designation (`0xef0100 + 20-byte address`). + * + * NOTE: These helpers are diagnostic only. The signature-verification path does + * not branch on 7702 detection — it routes by `code.length` (matching on-chain + * SignatureChecker) and the delegate decides via `isValidSignature`. See + * {@link ./verifySignature.ts} for the verification primitive. + * + * Use these helpers for telemetry, logging, or surfacing wallet types in UIs. + */ + +const ERC7702_PREFIX = "0xef0100"; +const ERC7702_BYTECODE_LENGTH = 48; // "0x" + 6 hex chars (prefix) + 40 hex chars (address) + +/** + * Returns `true` if `bytecode` is a valid ERC-7702 delegation designation. + * + * The check is case-insensitive — `eth_getCode` casing is not normalized at the + * JSON-RPC layer, so callers using ethers, custom signers, or post-processed + * hex can pass uppercase variants. + * + * @param bytecode - Raw hex bytecode returned by `eth_getCode`. + * @returns `true` if the bytecode is an ERC-7702 delegation designation. + */ +export function isERC7702Delegation(bytecode: `0x${string}` | undefined | null): boolean { + if (!bytecode || bytecode === "0x") return false; + if (bytecode.length !== ERC7702_BYTECODE_LENGTH) return false; + return bytecode.toLowerCase().startsWith(ERC7702_PREFIX); +} + +/** + * Extracts the 20-byte delegate address from a 7702 delegation designation. + * Returns the address in **lowercase** hex with a `0x` prefix. + * The Go equivalent ({@link GetERC7702DelegateAddress}) returns a checksummed EIP-55 address. + * The Python equivalent returns lowercase hex. Normalise with `getAddress()` when comparing + * cross-SDK outputs or storing in a case-sensitive index. + * Returns `null` for non-7702 bytecode. + * + * @param bytecode - Raw hex bytecode returned by `eth_getCode`. + * @returns The lowercase `0x`-prefixed delegate address, or `null` if `bytecode` is not a 7702 designation. + */ +export function getERC7702DelegateAddress( + bytecode: `0x${string}` | undefined | null, +): `0x${string}` | null { + if (!isERC7702Delegation(bytecode)) return null; + return ("0x" + bytecode!.slice(8).toLowerCase()) as `0x${string}`; +} diff --git a/typescript/packages/mechanisms/evm/src/shared/permit2.ts b/typescript/packages/mechanisms/evm/src/shared/permit2.ts index d8020d6f..db75743e 100644 --- a/typescript/packages/mechanisms/evm/src/shared/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/shared/permit2.ts @@ -632,7 +632,7 @@ export async function createPermit2PayloadForProxy( const nonce = createPermit2Nonce(); // Lower time bound - allow some clock skew - const validAfter = (now - 600).toString(); + const validAfter = "0"; // Upper time bound is enforced by Permit2's deadline field const deadline = (now + paymentRequirements.maxTimeoutSeconds).toString(); diff --git a/typescript/packages/mechanisms/evm/src/shared/revert.ts b/typescript/packages/mechanisms/evm/src/shared/revert.ts new file mode 100644 index 00000000..c14de8e7 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/shared/revert.ts @@ -0,0 +1,17 @@ +/** + * Distinguishes an on-chain contract revert from a transport/RPC failure. + * + * Used by the post-deploy ERC-6492 simulation paths: after a successful factory deploy, a + * simulation that reverts means the deployed wallet's validator rejected the inner signature + * (deterministic, retry-with-standard-sig guidance applies). A transport/RPC failure is NOT a + * signature problem and must not be reported as one. + * + * Matches the revert-substring heuristic already used by `parseEip3009TransferError`. + * + * @param error - The error thrown by an `eth_call` / simulation. + * @returns `true` if the error looks like a contract revert, `false` for transport/RPC failures. + */ +export function isContractRevert(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /revert/i.test(message); +} diff --git a/typescript/packages/mechanisms/evm/src/shared/verifySignature.ts b/typescript/packages/mechanisms/evm/src/shared/verifySignature.ts new file mode 100644 index 00000000..3da9624d --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/shared/verifySignature.ts @@ -0,0 +1,254 @@ +import { + hashTypedData, + recoverAddress, + isAddressEqual, + getAddress, + parseErc6492Signature, +} from "viem"; +import type { TypedDataDomain } from "viem"; +import type { FacilitatorEvmSigner } from "../signer"; + +/** + * Parsed ERC-6492 classification for a payer address. + * + * `isCounterfactual` is true when the payment comes from an undeployed smart wallet + * (ERC-6492 wrapper present, no bytecode at the payer address yet). In this case + * pre-verification of the signature is deferred to on-chain simulation or settle. + */ +export type Erc6492Classification = { + isCounterfactual: boolean; + isDeployedAtPayer: boolean; + hasDeploymentInfo: boolean; + innerSignature: `0x${string}`; + eip6492Deployment?: { factoryAddress: `0x${string}`; factoryCalldata: `0x${string}` }; +}; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; + +/** + * Classify an ERC-6492 payer in one RPC round-trip: parse the sig wrapper, fetch code, + * and determine counterfactual vs deployed state. + * + * @param signer - Facilitator signer used to call `eth_getCode` on the payer address. + * @param signature - The full signature, which may be an ERC-6492 wrapper. + * @param payerAddress - The address whose bytecode is fetched to detect deployment state. + * @returns Classification result including counterfactual flag, deployment state, and inner signature. + */ +export async function classifyErc6492Payer( + signer: FacilitatorEvmSigner, + signature: `0x${string}`, + payerAddress: `0x${string}`, +): Promise { + const erc6492Data = parseErc6492Signature(signature); + const hasDeploymentInfo = !!( + erc6492Data.address && + erc6492Data.data && + !isAddressEqual(erc6492Data.address, ZERO_ADDRESS) + ); + const innerSignature = hasDeploymentInfo ? erc6492Data.signature : signature; + const eip6492Deployment = hasDeploymentInfo + ? { factoryAddress: erc6492Data.address!, factoryCalldata: erc6492Data.data! } + : undefined; + + let code: `0x${string}` | undefined; + try { + code = await signer.getCode({ address: payerAddress }); + } catch { + code = undefined; + } + const isDeployedAtPayer = !!(code && code !== "0x"); + const isCounterfactual = hasDeploymentInfo && !isDeployedAtPayer; + + return { + isCounterfactual, + isDeployedAtPayer, + hasDeploymentInfo, + innerSignature, + eip6492Deployment, + }; +} + +/** + * Strict signature verification primitive that mirrors on-chain SignatureChecker + * semantics exactly: + * + * if signer.code.length == 0: + * ecrecover(digest, sig) == signer + * else: + * IERC1271(signer).isValidSignature(digest, sig) == 0x1626ba7e + * + * This matches: + * - Permit2 (libraries/SignatureVerification.sol) + * - USDC v2.2 (Circle's util/SignatureChecker.sol) + * - x402BatchSettlement (uses OpenZeppelin SignatureChecker) + * + * It deliberately does NOT fall back to ECDSA when EIP-1271 returns failure. + * That fallback (which viem's `publicClient.verifyTypedData` performs) makes + * pre-verify accept signatures that on-chain rejects — most visibly for + * ERC-7702 delegated EOAs whose delegate's `isValidSignature` does not accept + * raw owner ECDSA. With this primitive, pre-verify outcome == on-chain outcome. + * + * Plain EOAs (no code) take the ecrecover path and behave identically to before. + * 7702-delegated EOAs take the EIP-1271 path because they have code; the delegate + * decides — same as on-chain. + */ +const ERC1271_MAGIC_VALUE = "0x1626ba7e" as const; + +const ERC1271_ABI = [ + { + name: "isValidSignature", + type: "function", + stateMutability: "view", + inputs: [ + { name: "hash", type: "bytes32" }, + { name: "signature", type: "bytes" }, + ], + outputs: [{ name: "", type: "bytes4" }], + }, +] as const; + +/** + * Verify a typed-data signature using strict on-chain SignatureChecker semantics. + * + * @param signer - Facilitator signer used for `eth_getCode` and `isValidSignature` calls. + * @param params - Typed-data verification parameters. + * @param params.address - The address that is expected to have signed the data. + * @param params.domain - EIP-712 domain. + * @param params.types - EIP-712 type definitions. + * @param params.primaryType - The primary type to hash. + * @param params.message - The typed-data message. + * @param params.signature - The signature to verify. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export async function verifyTypedDataSignature( + signer: FacilitatorEvmSigner, + params: { + address: `0x${string}`; + domain: TypedDataDomain; + types: Record; + primaryType: string; + message: Record; + signature: `0x${string}`; + }, +): Promise { + let digest: `0x${string}`; + try { + digest = hashTypedData({ + domain: params.domain, + types: params.types, + primaryType: params.primaryType, + message: params.message, + }); + } catch { + // Malformed typed data (e.g. non-checksummed address that viem rejects). + // Treat as an invalid signature rather than propagating the error. + return false; + } + return verifyHashSignature(signer, params.address, digest, params.signature); +} + +/** + * Lower-level variant of {@link verifyTypedDataSignature} for callers that already have the digest. + * + * @param signer - Facilitator signer used for `eth_getCode` and `isValidSignature` calls. + * @param address - The address that is expected to have produced the signature. + * @param digest - The EIP-191 / EIP-712 message hash to verify against. + * @param signature - The signature to verify. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export async function verifyHashSignature( + signer: FacilitatorEvmSigner, + address: `0x${string}`, + digest: `0x${string}`, + signature: `0x${string}`, +): Promise { + // getCode must be guarded: a transient RPC error should return false (invalid sig + // semantics) rather than propagating as an unhandled rejection. All callers rely on + // this function returning a boolean, never throwing. + let code: `0x${string}` | undefined; + try { + code = await signer.getCode({ address }); + } catch { + return false; + } + return verifyHashSignatureWithCode(signer, address, code, digest, signature); +} + +/** + * Like {@link verifyHashSignature} but accepts pre-fetched bytecode to avoid a + * redundant `eth_getCode` RPC when the caller already has it (e.g. after the + * ERC-6492 counterfactual check in {@link classifyErc6492Payer}). + * + * Pass `undefined` or `"0x"` for `code` to take the EOA (ecrecover) path. + * + * @param signer - Facilitator signer used for `isValidSignature` calls on deployed contracts. + * @param address - The address that is expected to have produced the signature. + * @param code - Pre-fetched bytecode at `address`; `undefined` or `"0x"` takes the ECDSA path. + * @param digest - The message hash to verify against. + * @param signature - The signature to verify. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export function verifyHashSignatureWithCode( + signer: FacilitatorEvmSigner, + address: `0x${string}`, + code: `0x${string}` | undefined, + digest: `0x${string}`, + signature: `0x${string}`, +): Promise { + if (!code || code === "0x") { + return verifyECDSA(address, digest, signature); + } + return verifyERC1271(signer, address, digest, signature); +} + +/** + * ecrecover path — used when the address has no code. + * + * @param address - The address expected to be recovered from the signature. + * @param digest - The message hash to recover from. + * @param signature - The compact 65-byte ECDSA signature. + * @returns `true` if ecrecover produces `address`, `false` otherwise. + */ +export async function verifyECDSA( + address: `0x${string}`, + digest: `0x${string}`, + signature: `0x${string}`, +): Promise { + const sigHex = signature.startsWith("0x") ? signature.slice(2) : signature; + if (sigHex.length !== 130) return false; + try { + const recovered = await recoverAddress({ hash: digest, signature }); + return isAddressEqual(getAddress(recovered), getAddress(address)); + } catch { + return false; + } +} + +/** + * Strict EIP-1271 path — returns false on revert or non-magic return, never falls back to ECDSA. + * + * @param signer - Facilitator signer used to call `isValidSignature` on the contract. + * @param address - The contract address whose `isValidSignature` is called. + * @param digest - The hash passed as the first argument to `isValidSignature`. + * @param signature - The signature bytes passed as the second argument. + * @returns `true` if `isValidSignature` returns the ERC-1271 magic value, `false` otherwise. + */ +export async function verifyERC1271( + signer: FacilitatorEvmSigner, + address: `0x${string}`, + digest: `0x${string}`, + signature: `0x${string}`, +): Promise { + try { + const result = (await signer.readContract({ + address, + abi: ERC1271_ABI, + functionName: "isValidSignature", + args: [digest, signature], + })) as `0x${string}` | undefined; + if (typeof result !== "string") return false; + return result.toLowerCase().startsWith(ERC1271_MAGIC_VALUE); + } catch { + return false; + } +} diff --git a/typescript/packages/mechanisms/evm/src/upto/client/permit2.ts b/typescript/packages/mechanisms/evm/src/upto/client/permit2.ts index 789b3b8b..60238107 100644 --- a/typescript/packages/mechanisms/evm/src/upto/client/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/upto/client/permit2.ts @@ -41,7 +41,7 @@ export async function createUptoPermit2Payload( const now = Math.floor(Date.now() / 1000); const nonce = createPermit2Nonce(); - const validAfter = (now - 600).toString(); + const validAfter = "0"; const deadline = (now + paymentRequirements.maxTimeoutSeconds).toString(); if (BigInt(deadline) <= BigInt(validAfter)) { diff --git a/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts b/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts index 2c1e6e7c..5b0be5dd 100644 --- a/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/upto/facilitator/permit2.ts @@ -5,6 +5,7 @@ import { SettleResponse, VerifyResponse, } from "@bankofai/x402-core/types"; +import { verifyTypedDataSignature } from "../../shared/verifySignature"; import { extractEip2612GasSponsoringInfo, extractErc20ApprovalGasSponsoringInfo, @@ -204,34 +205,21 @@ export async function verifyUptoPermit2( }, }; - // Verify signature - // Note: verifyTypedData is implementation-dependent and pluggable on FacilitatorEvmSigner - // Some implementations only do EOA-style ECDSA recovery (e.g. viem/utils verifyTypedData, ethers.verifyTypedData) - // Viem's publicClient.verifyTypedData supports EOA and Smart Contract Account (ERC-1271 / ERC-6492) signature verification - let signatureValid = false; - try { - signatureValid = await signer.verifyTypedData({ - address: payer, - ...permit2TypedData, - signature: permit2Payload.signature, - }); - } catch { - signatureValid = false; - } + // Verify signature using a strict primitive that mirrors Permit2's + // on-chain SignatureVerification: ecrecover when no code, strict EIP-1271 + // isValidSignature when code is present. No ECDSA fallback for code addresses. + const signatureValid = await verifyTypedDataSignature(signer, { + address: payer, + ...permit2TypedData, + signature: permit2Payload.signature, + }); if (!signatureValid) { - // Check if the payer is a deployed smart contract (ERC-1271 / ERC-6492) - const bytecode = await signer.getCode({ address: payer }); - const isDeployedContract = bytecode && bytecode !== "0x"; - - if (!isDeployedContract) { - return { - isValid: false, - invalidReason: "invalid_permit2_signature", - payer, - }; - } - // Deployed smart contract: fall through to simulation + return { + isValid: false, + invalidReason: "invalid_permit2_signature", + payer, + }; } // If simulation is disabled, return early diff --git a/typescript/packages/mechanisms/evm/src/upto/server/scheme.ts b/typescript/packages/mechanisms/evm/src/upto/server/scheme.ts index b925ddc7..80d9c58b 100644 --- a/typescript/packages/mechanisms/evm/src/upto/server/scheme.ts +++ b/typescript/packages/mechanisms/evm/src/upto/server/scheme.ts @@ -6,7 +6,11 @@ import { SchemeNetworkServer, MoneyParser, } from "@bankofai/x402-core/types"; -import { convertToTokenAmount, numberToDecimalString, parseMoneyString } from "@bankofai/x402-core/utils"; +import { + convertToTokenAmount, + numberToDecimalString, + parseMoneyString, +} from "@bankofai/x402-core/utils"; import { getAddress } from "viem"; import { getDefaultAsset } from "../../shared/defaultAssets"; diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/deposit-erc6492.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/deposit-erc6492.test.ts new file mode 100644 index 00000000..dd8288db --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/deposit-erc6492.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi } from "vitest"; +import { encodeAbiParameters, parseAbiParameters, concat } from "viem"; +import type { PaymentRequirements } from "@bankofai/x402-core/types"; +import { verifyEip3009DepositAuthorization } from "../../../src/batch-settlement/facilitator/deposit-eip3009"; +import * as Errors from "../../../src/batch-settlement/errors"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; +import type { + BatchSettlementDepositPayload, + ChannelConfig, +} from "../../../src/batch-settlement/types"; + +const ERC6492_MAGIC = "0x6492649264926492649264926492649264926492649264926492649264926492" as const; +const PAYER = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as `0x${string}`; +const ASSET = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`; +const FACTORY = "0xca11bde05977b3631167028862be2a173976ca11" as `0x${string}`; +const NETWORK = "eip155:84532"; + +function wrapErc6492( + factory: `0x${string}`, + factoryCalldata: `0x${string}`, + inner: `0x${string}`, +): `0x${string}` { + const encoded = encodeAbiParameters(parseAbiParameters("address, bytes, bytes"), [ + factory, + factoryCalldata, + inner, + ]); + return concat([encoded, ERC6492_MAGIC]); +} + +function buildChannelConfig(): ChannelConfig { + return { + payer: PAYER, + payerAuthorizer: "0x1111111111111111111111111111111111111111", + receiver: "0x9876543210987654321098765432109876543210", + receiverAuthorizer: "0x2222222222222222222222222222222222222222", + token: ASSET, + withdrawDelay: 900, + salt: "0x0000000000000000000000000000000000000000000000000000000000000000", + }; +} + +function buildCounterfactualPayload(): BatchSettlementDepositPayload { + const now = Math.floor(Date.now() / 1000); + const inner = ("0x" + "33".repeat(65)) as `0x${string}`; + return { + type: "deposit", + channelConfig: buildChannelConfig(), + voucher: { + channelId: ("0x" + "ab".repeat(32)) as `0x${string}`, + maxClaimableAmount: "1000", + signature: "0xcafebabe", + }, + deposit: { + amount: "1000", + authorization: { + erc3009Authorization: { + validAfter: String(now - 600), + validBefore: String(now + 3600), + salt: ("0x" + "22".repeat(32)) as `0x${string}`, + signature: wrapErc6492(FACTORY, "0xdeadbeef", inner), + }, + }, + }, + }; +} + +function requirements(): PaymentRequirements { + return { + scheme: "batch-settlement", + network: NETWORK, + amount: "1000", + asset: ASSET, + payTo: "0x9876543210987654321098765432109876543210", + maxTimeoutSeconds: 3600, + extra: { name: "USDC", version: "2" }, + } as PaymentRequirements; +} + +function buildSigner(code: `0x${string}` | undefined): FacilitatorEvmSigner { + return { + getAddresses: () => ["0xFAC11174700123456789012345678901234aBCDe"], + readContract: vi.fn().mockResolvedValue(undefined), + verifyTypedData: vi.fn().mockResolvedValue(false), + writeContract: vi.fn(), + sendTransaction: vi.fn(), + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), + getCode: vi.fn().mockResolvedValue(code), + } as unknown as FacilitatorEvmSigner; +} + +describe("verifyEip3009DepositAuthorization — ERC-6492 counterfactual", () => { + it("rejects an undeployed wallet whose factory is not allowlisted", async () => { + const signer = buildSigner("0x"); // undeployed + const result = await verifyEip3009DepositAuthorization( + signer, + buildCounterfactualPayload(), + requirements(), + 84532, + [], // empty allowlist + ); + expect(result.counterfactual).toBeNull(); + expect(result.response?.invalidReason).toBe(Errors.ErrFactoryNotAllowed); + }); + + it("defers to simulation for an undeployed wallet with an allowlisted factory", async () => { + const signer = buildSigner("0x"); // undeployed + const result = await verifyEip3009DepositAuthorization( + signer, + buildCounterfactualPayload(), + requirements(), + 84532, + [FACTORY], + ); + expect(result.response).toBeNull(); + expect(result.counterfactual).not.toBeNull(); + expect(result.counterfactual?.factory.toLowerCase()).toBe(FACTORY.toLowerCase()); + expect(result.counterfactual?.factoryCalldata).toBe("0xdeadbeef"); + }); + + it("validates the inner signature directly when the wrapped wallet is already deployed", async () => { + // Deployed wallet → EIP-1271 path. readContract isValidSignature returns the magic value + // so the inner signature is accepted without any factory deployment. + const signer = buildSigner("0x6080604052"); // has code + (signer.readContract as ReturnType).mockImplementation(args => + args.functionName === "isValidSignature" + ? Promise.resolve("0x1626ba7e") + : Promise.resolve(undefined), + ); + const result = await verifyEip3009DepositAuthorization( + signer, + buildCounterfactualPayload(), + requirements(), + 84532, + [], // allowlist irrelevant: wallet already deployed + ); + expect(result.response).toBeNull(); + expect(result.counterfactual).toBeNull(); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/facilitator.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/facilitator.test.ts index 8342e2ed..1a2b9c5b 100644 --- a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/facilitator.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/facilitator.test.ts @@ -98,7 +98,12 @@ function makeRequirements(overrides: Partial = {}): Payment function buildSigner(overrides: Partial = {}): FacilitatorEvmSigner { return { getAddresses: () => [FACILITATOR_ADDRESS], + // The strict signature primitive added in the 7702 fix calls readContract + // with functionName="isValidSignature". Return ERC-1271 magic by default so + // existing tests' mock placeholder signatures pass through. Tests that need + // an invalid signature override readContract to return "0xffffffff". readContract: vi.fn().mockImplementation(args => { + if (args.functionName === "isValidSignature") return Promise.resolve("0x1626ba7e"); if (args.functionName === "receivers") return Promise.resolve([2500n, 0n]); return Promise.resolve(undefined); }), @@ -106,7 +111,10 @@ function buildSigner(overrides: Partial = {}): Facilitator writeContract: vi.fn().mockResolvedValue("0xtxhash" as `0x${string}`), sendTransaction: vi.fn(), waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), - getCode: vi.fn(), + // Default: contract bytecode so the strict primitive takes the EIP-1271 path + // and uses the readContract mock above. Tests that need an EOA path can + // override getCode to return "0x". + getCode: vi.fn().mockResolvedValue("0x6080604052"), ...overrides, }; } @@ -191,6 +199,11 @@ describe("BatchSettlementEvmScheme (Facilitator) — construction & metadata", ( expect(scheme.getExtra(NETWORK)).toEqual({ receiverAuthorizer: authorizer.address }); }); + it("getExtra returns undefined when no authorizerSigner is configured", () => { + const scheme = new BatchSettlementEvmScheme(buildSigner()); + expect(scheme.getExtra(NETWORK)).toBeUndefined(); + }); + it("getSigners returns the facilitator addresses", () => { const scheme = new BatchSettlementEvmScheme(buildSigner(), authorizer); expect(scheme.getSigners(NETWORK)).toEqual([FACILITATOR_ADDRESS]); @@ -296,8 +309,14 @@ describe("BatchSettlementEvmScheme (Facilitator) — verifyVoucher", () => { expect(result.extra?.totalClaimed).toBe("0"); }); - it("returns InvalidVoucherSignature when verifyTypedData fails", async () => { - const signer = buildSigner({ verifyTypedData: vi.fn().mockResolvedValue(false) }); + it("returns InvalidVoucherSignature when isValidSignature returns failure", async () => { + const signer = buildSigner({ + readContract: vi.fn().mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return "0xffffffff"; + if (args.functionName === "receivers") return [2500n, 0n]; + return undefined; + }), + }); const scheme = new BatchSettlementEvmScheme(signer, authorizer); const config = buildChannelConfig({ payerAuthorizer: "0x0000000000000000000000000000000000000000", @@ -561,8 +580,14 @@ describe("BatchSettlementEvmScheme (Facilitator) — verifyDeposit", () => { expect(result.invalidReason).toBe(Errors.ErrInsufficientBalance); }); - it("returns InvalidReceiveAuthorizationSignature when verifyTypedData fails", async () => { - const signer = buildSigner({ verifyTypedData: vi.fn().mockResolvedValue(false) }); + it("returns InvalidReceiveAuthorizationSignature when isValidSignature returns failure", async () => { + const signer = buildSigner({ + readContract: vi.fn().mockImplementation(async (args: { functionName: string }) => { + if (args.functionName === "isValidSignature") return "0xffffffff"; + if (args.functionName === "receivers") return [2500n, 0n]; + return undefined; + }), + }); const scheme = new BatchSettlementEvmScheme(signer, authorizer); const { payload } = buildDeposit(); @@ -669,6 +694,7 @@ describe("BatchSettlementEvmScheme (Facilitator) — verifyDeposit", () => { it("accepts a Permit2 deposit and simulates with the Permit2 collector", async () => { const readContract = vi.fn(async ({ functionName }: { functionName: string }) => { + if (functionName === "isValidSignature") return "0x1626ba7e"; if (functionName === "allowance") return 1_000_000n; return undefined; }); @@ -749,6 +775,7 @@ describe("BatchSettlementEvmScheme (Facilitator) — verifyDeposit", () => { it("rejects Permit2 deposits without Permit2 allowance or sponsoring data", async () => { const readContract = vi.fn(async ({ functionName }: { functionName: string }) => { + if (functionName === "isValidSignature") return "0x1626ba7e"; if (functionName === "allowance") return 1n; return undefined; }); @@ -1196,6 +1223,121 @@ describe("BatchSettlementEvmScheme (Facilitator) — settle routing", () => { }); }); +describe("BatchSettlementEvmScheme (Facilitator) — no authorizer configured", () => { + it("returns AuthorizerNotConfigured for a claim without a client signature", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer); + const config = buildChannelConfig(); + const cp: BatchSettlementClaimPayload = { + type: "claim", + claims: [ + { + voucher: { channel: config, maxClaimableAmount: "1000" }, + signature: "0xcafe", + totalClaimed: "1000", + }, + ], + }; + + const result = await scheme.settle( + envelopeSettle(cp as unknown as Record), + makeRequirements(), + ); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrAuthorizerNotConfigured); + expect(signer.writeContract).not.toHaveBeenCalled(); + }); + + it("submits a claim that carries a server-supplied authorizer signature", async () => { + const signer = buildSigner(); + const scheme = new BatchSettlementEvmScheme(signer); + const config = buildChannelConfig(); + const cp: BatchSettlementClaimPayload = { + type: "claim", + claimAuthorizerSignature: "0xserversig" as `0x${string}`, + claims: [ + { + voucher: { channel: config, maxClaimableAmount: "1000" }, + signature: "0xcafe", + totalClaimed: "1000", + }, + ], + }; + + const result = await scheme.settle( + envelopeSettle(cp as unknown as Record), + makeRequirements(), + ); + + expect(result.success).toBe(true); + expect(signer.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "claimWithSignature" }), + ); + }); + + it("returns AuthorizerNotConfigured for a refund without a client signature", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [10000n, 0n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const rp: BatchSettlementEnrichedRefundPayload = { + type: "refund", + channelConfig: config, + voucher: { channelId, maxClaimableAmount: "0", signature: "0xdead" }, + amount: "9000", + refundNonce: "0", + claims: [], + }; + + const result = await scheme.settle( + envelopeSettle(rp as unknown as Record), + makeRequirements(), + ); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrAuthorizerNotConfigured); + expect(signer.writeContract).not.toHaveBeenCalled(); + }); + + it("submits a refund that carries a server-supplied authorizer signature", async () => { + const signer = buildSigner(); + mockedMulticall.mockResolvedValue([ + { status: "success", result: [10000n, 0n] }, + { status: "success", result: [0n, 0n] }, + { status: "success", result: 0n }, + ]); + const scheme = new BatchSettlementEvmScheme(signer); + const config = buildChannelConfig(); + const channelId = computeChannelId(config); + const rp: BatchSettlementEnrichedRefundPayload = { + type: "refund", + channelConfig: config, + voucher: { channelId, maxClaimableAmount: "0", signature: "0xdead" }, + amount: "9000", + refundNonce: "0", + refundAuthorizerSignature: "0xserversig" as `0x${string}`, + claims: [], + }; + + const result = await scheme.settle( + envelopeSettle(rp as unknown as Record), + makeRequirements(), + ); + + expect(result.success).toBe(true); + expect(result.amount).toBe("9000"); + expect(signer.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "refundWithSignature" }), + ); + }); +}); + describe("BatchSettlementEvmScheme (Facilitator) — handler contract constants", () => { it("exposes well-formed distinct contract addresses", () => { const addrs = [ diff --git a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/server.test.ts b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/server.test.ts index 345fab6f..0d0fc01b 100644 --- a/typescript/packages/mechanisms/evm/test/unit/batch-settlement/server.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/batch-settlement/server.test.ts @@ -396,6 +396,47 @@ describe("BatchSettlementEvmScheme — enhancePaymentRequirements", () => { }); }); +describe("BatchSettlementEvmScheme — validateFacilitatorSupport", () => { + const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"; + + function supportedKind(extra?: Record) { + return { x402Version: 2, scheme: "batch-settlement", network: NETWORK, extra }; + } + + it("returns a problem when no signer and the facilitator advertises no receiverAuthorizer", () => { + const server = new BatchSettlementEvmScheme(RECEIVER); + const problem = server.validateFacilitatorSupport(NETWORK, supportedKind(), []); + expect(problem).toMatch(/receiverAuthorizer/); + }); + + it("returns a problem when no signer and the facilitator advertises a zero receiverAuthorizer", () => { + const server = new BatchSettlementEvmScheme(RECEIVER); + const problem = server.validateFacilitatorSupport( + NETWORK, + supportedKind({ receiverAuthorizer: ZERO_ADDRESS }), + [], + ); + expect(problem).toMatch(/receiverAuthorizer/); + }); + + it("returns void when the server has its own receiver-authorizer signer", () => { + const server = new BatchSettlementEvmScheme(RECEIVER, { + receiverAuthorizerSigner: buildAuthorizerSigner(), + }); + expect(server.validateFacilitatorSupport(NETWORK, supportedKind(), [])).toBeUndefined(); + }); + + it("returns void when the facilitator advertises a valid receiverAuthorizer", () => { + const server = new BatchSettlementEvmScheme(RECEIVER); + const problem = server.validateFacilitatorSupport( + NETWORK, + supportedKind({ receiverAuthorizer: RECEIVER_AUTHORIZER }), + [], + ); + expect(problem).toBeUndefined(); + }); +}); + describe("BatchSettlementEvmScheme — onBeforeVerify", () => { let server: BatchSettlementEvmScheme; let storage: InMemoryChannelStorage; diff --git a/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts b/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts index 768cd066..b015d328 100644 --- a/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/exact/client.test.ts @@ -74,7 +74,7 @@ describe("ExactEvmScheme (Client)", () => { expect(result1.payload.authorization.nonce).toMatch(/^0x[0-9a-f]{64}$/i); }); - it("should set validAfter to 10 minutes before now", async () => { + it("should set validAfter to 0", async () => { const requirements: PaymentRequirements = { scheme: "exact", network: "eip155:8453", @@ -88,9 +88,7 @@ describe("ExactEvmScheme (Client)", () => { const now = Math.floor(Date.now() / 1000); const result = await client.createPaymentPayload(2, requirements); - const validAfter = parseInt(result.payload.authorization.validAfter); - expect(validAfter).toBeGreaterThanOrEqual(now - 600 - 2); // 10 min before, ±2s tolerance - expect(validAfter).toBeLessThanOrEqual(now - 600 + 2); + expect(result.payload.authorization.validAfter).toBe("0"); }); it("should set validBefore based on maxTimeoutSeconds", async () => { 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 2fd1f77e..b6491009 100644 --- a/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/exact/facilitator.test.ts @@ -31,6 +31,23 @@ const mockGetCodeEOAPayer = : ("0x" as `0x${string}`), ); +// Wraps a readContract mock so isValidSignature returns the ERC-1271 magic value +// while delegating other calls to `impl`. Keeps "default: valid sig" semantics +// for tests that override readContract for other purposes (nonce, allowance, etc.). +const sigValid = "0x1626ba7e"; +function rcWithSig( + impl: unknown | ((args: { address?: string; functionName?: string }) => unknown), + sigResponse: string = sigValid, +) { + return vi.fn().mockImplementation(async (args: { address?: string; functionName?: string }) => { + if (args?.functionName === "isValidSignature") return sigResponse; + if (typeof impl === "function") { + return (impl as (a: typeof args) => unknown)(args); + } + return impl; + }); +} + describe("ExactEvmScheme (Facilitator)", () => { let facilitator: ExactEvmScheme; let mockFacilitatorSigner: FacilitatorEvmSigner; @@ -46,11 +63,15 @@ describe("ExactEvmScheme (Facilitator)", () => { }; client = new ClientExactEvmScheme(mockClientSigner); - // Create mock facilitator signer + // Create mock facilitator signer. readContract returns the ERC-1271 magic value for + // isValidSignature (contract-account path) and 0n for everything else (nonce, etc.). mockFacilitatorSigner = { - getAddresses: vi.fn().mockReturnValue(["0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0"]), - readContract: vi.fn().mockResolvedValue(0n), // Mock nonce state - verifyTypedData: vi.fn().mockResolvedValue(true), // Mock signature verification + getAddresses: vi.fn().mockReturnValue(["0x742D35CC6634c0532925A3b844BC9E7595F0BEb0"]), + readContract: vi.fn().mockImplementation(async (args: { functionName: string }) => { + if (args?.functionName === "isValidSignature") return "0x1626ba7e"; + return 0n; + }), + verifyTypedData: vi.fn().mockResolvedValue(true), writeContract: vi.fn().mockResolvedValue("0xtxhash"), sendTransaction: vi.fn().mockResolvedValue("0xtxhash"), waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), @@ -69,13 +90,13 @@ describe("ExactEvmScheme (Facilitator)", () => { }); describe("verify", () => { - it("should call verifyTypedData for signature verification", async () => { + it("should run signature verification through the strict primitive (getCode + isValidSignature for contract addresses)", async () => { const requirements: PaymentRequirements = { scheme: "exact", network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", @@ -94,8 +115,15 @@ describe("ExactEvmScheme (Facilitator)", () => { await facilitator.verify(fullPayload, requirements); - // Should have called verifyTypedData - expect(mockFacilitatorSigner.verifyTypedData).toHaveBeenCalled(); + // Signature verification now mirrors on-chain SignatureChecker: + // it calls getCode on the payer; for addresses with code (the default mock + // returns deployed bytecode) it calls readContract({ functionName: "isValidSignature" }). + expect(mockFacilitatorSigner.getCode).toHaveBeenCalledWith( + expect.objectContaining({ address: mockClientSigner.address }), + ); + expect(mockFacilitatorSigner.readContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "isValidSignature" }), + ); }); it("should reject if scheme doesn't match", async () => { @@ -104,7 +132,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2" }, }; @@ -138,7 +166,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: {}, // Missing name and version }; @@ -166,7 +194,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2" }, }; @@ -193,7 +221,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2" }, }; @@ -224,7 +252,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2" }, }; @@ -255,7 +283,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2" }, }; @@ -281,13 +309,13 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, }; // Simulation of settle() on the proxy succeeds (readContract doesn't throw) - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const permit2Payload: PaymentPayload = { x402Version: 2, @@ -324,36 +352,34 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, }; // Simulation fails (settle throws), diagnostic multicall returns proxy OK, balance OK, allowance 0 - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === x402ExactPermit2ProxyAddress) { - return Promise.reject(new Error("execution reverted")); - } - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { - success: true, - returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", - }, - { - success: true, - returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", - }, - { - success: true, - returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", - }, - ]); - } - return Promise.resolve(BigInt(0)); - }); + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === x402ExactPermit2ProxyAddress) { + return Promise.reject(new Error("execution reverted")); + } + if (address === MULTICALL3_ADDRESS) { + return Promise.resolve([ + { + success: true, + returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", + }, + { + success: true, + returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", + }, + { + success: true, + returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + ]); + } + return Promise.resolve(BigInt(0)); + }); const permit2Payload: PaymentPayload = { x402Version: 2, @@ -391,7 +417,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, }; @@ -432,7 +458,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, }; @@ -473,7 +499,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, }; @@ -516,13 +542,13 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, }; // settle's re-verify has simulate=false (default), so no simulation readContract needed - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const permit2Payload: PaymentPayload = { x402Version: 2, @@ -561,7 +587,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2", assetTransferMethod: "permit2" }, }; @@ -610,7 +636,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2" }, }; @@ -671,14 +697,14 @@ describe("ExactEvmScheme (Facilitator)", () => { describe("EIP-2612 Gas Sponsoring - Verify", () => { it("should accept valid EIP-2612 extension when settleWithPermit simulation succeeds", async () => { // Simulation of settleWithPermit on proxy succeeds - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const permit2Requirements: PaymentRequirements = { scheme: "exact", network: "eip155:84532", amount: "1000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 60, extra: { assetTransferMethod: "permit2", name: "USDC", version: "2" }, }; @@ -723,37 +749,35 @@ describe("ExactEvmScheme (Facilitator)", () => { it("should reject when simulation fails and no extension present (allowance insufficient)", async () => { // Simulation fails, diagnostic multicall returns low allowance - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === x402ExactPermit2ProxyAddress) { - return Promise.reject(new Error("execution reverted")); - } - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { - success: true, - returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", - }, - { - success: true, - returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", - }, - { - success: true, - returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", - }, - ]); - } - return Promise.resolve(BigInt(0)); - }); + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === x402ExactPermit2ProxyAddress) { + return Promise.reject(new Error("execution reverted")); + } + if (address === MULTICALL3_ADDRESS) { + return Promise.resolve([ + { + success: true, + returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", + }, + { + success: true, + returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", + }, + { + success: true, + returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + ]); + } + return Promise.resolve(BigInt(0)); + }); const permit2Requirements: PaymentRequirements = { scheme: "exact", network: "eip155:84532", amount: "1000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 60, extra: { assetTransferMethod: "permit2", name: "USDC", version: "2" }, }; @@ -778,14 +802,14 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject EIP-2612 extension with wrong spender", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const permit2Requirements: PaymentRequirements = { scheme: "exact", network: "eip155:84532", amount: "1000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 60, extra: { assetTransferMethod: "permit2", name: "USDC", version: "2" }, }; @@ -847,7 +871,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2" }, }; @@ -877,24 +901,48 @@ describe("ExactEvmScheme (Facilitator)", () => { }; } + // Verify now mirrors settle's allowlist gate, so the simulation-path tests below must + // construct a facilitator that allowlists `factory` (an undeployed payer whose factory is + // not allowlisted is rejected before simulation — covered by its own test). + let cfFacilitator: ExactEvmScheme; + beforeEach(() => { + cfFacilitator = new ExactEvmScheme(mockFacilitatorSigner, { + eip6492AllowedFactories: [factory], + }); + }); + + it("rejects a counterfactual payment whose factory is not allowlisted (verify mirrors settle)", async () => { + mockFacilitatorSigner.getCode = vi + .fn() + .mockImplementation(mockGetCodeEOAPayer("0x036CbD53842c5426634e7929541eC2318f3dCF7e")); + + // Default `facilitator` has an empty allowlist. + const result = await facilitator.verify(makeERC6492Payload(erc6492Sig), erc6492Requirements); + + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe(Errors.ErrFactoryNotAllowed); + expect(result.payer).toBe(erc6492Payer); + }); + it("should accept ERC-6492 when verifyTypedData returns true and simulation passes", async () => { mockFacilitatorSigner.verifyTypedData = vi.fn().mockResolvedValue(true); mockFacilitatorSigner.getCode = vi .fn() .mockImplementation(mockGetCodeEOAPayer("0x036CbD53842c5426634e7929541eC2318f3dCF7e")); - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { success: true, returnData: "0x" }, - { success: true, returnData: "0x" }, - ]); - } - return Promise.resolve(BigInt("10000000")); - }); + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === MULTICALL3_ADDRESS) { + return Promise.resolve([ + { success: true, returnData: "0x" }, + { success: true, returnData: "0x" }, + ]); + } + return Promise.resolve(BigInt("10000000")); + }); - const result = await facilitator.verify(makeERC6492Payload(erc6492Sig), erc6492Requirements); + const result = await cfFacilitator.verify( + makeERC6492Payload(erc6492Sig), + erc6492Requirements, + ); expect(result.isValid).toBe(true); expect(result.payer).toBe(erc6492Payer); @@ -905,19 +953,20 @@ describe("ExactEvmScheme (Facilitator)", () => { mockFacilitatorSigner.getCode = vi .fn() .mockImplementation(mockGetCodeEOAPayer("0x036CbD53842c5426634e7929541eC2318f3dCF7e")); - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { success: true, returnData: "0x" }, - { success: true, returnData: "0x" }, - ]); - } - return Promise.resolve(BigInt("10000000")); - }); + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === MULTICALL3_ADDRESS) { + return Promise.resolve([ + { success: true, returnData: "0x" }, + { success: true, returnData: "0x" }, + ]); + } + return Promise.resolve(BigInt("10000000")); + }); - const result = await facilitator.verify(makeERC6492Payload(erc6492Sig), erc6492Requirements); + const result = await cfFacilitator.verify( + makeERC6492Payload(erc6492Sig), + erc6492Requirements, + ); expect(result.isValid).toBe(true); expect(result.payer).toBe(erc6492Payer); @@ -930,19 +979,20 @@ describe("ExactEvmScheme (Facilitator)", () => { mockFacilitatorSigner.getCode = vi .fn() .mockImplementation(mockGetCodeEOAPayer("0x036CbD53842c5426634e7929541eC2318f3dCF7e")); - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { success: true, returnData: "0x" }, - { success: true, returnData: "0x" }, - ]); - } - return Promise.resolve(BigInt("10000000")); - }); + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === MULTICALL3_ADDRESS) { + return Promise.resolve([ + { success: true, returnData: "0x" }, + { success: true, returnData: "0x" }, + ]); + } + return Promise.resolve(BigInt("10000000")); + }); - const result = await facilitator.verify(makeERC6492Payload(erc6492Sig), erc6492Requirements); + const result = await cfFacilitator.verify( + makeERC6492Payload(erc6492Sig), + erc6492Requirements, + ); expect(result.isValid).toBe(true); expect(result.payer).toBe(erc6492Payer); @@ -953,30 +1003,31 @@ describe("ExactEvmScheme (Facilitator)", () => { mockFacilitatorSigner.getCode = vi .fn() .mockImplementation(mockGetCodeEOAPayer("0x036CbD53842c5426634e7929541eC2318f3dCF7e")); - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { success: true, returnData: "0x" }, - { success: false, returnData: "0x" }, - ]); - } + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === MULTICALL3_ADDRESS) { return Promise.resolve([ - { - success: true, - returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", - }, { success: true, returnData: "0x" }, - { success: true, returnData: "0x" }, - { - success: true, - returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", - }, + { success: false, returnData: "0x" }, ]); - }); + } + return Promise.resolve([ + { + success: true, + returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", + }, + { success: true, returnData: "0x" }, + { success: true, returnData: "0x" }, + { + success: true, + returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + ]); + }); - const result = await facilitator.verify(makeERC6492Payload(erc6492Sig), erc6492Requirements); + const result = await cfFacilitator.verify( + makeERC6492Payload(erc6492Sig), + erc6492Requirements, + ); expect(result.isValid).toBe(false); }); @@ -986,38 +1037,102 @@ describe("ExactEvmScheme (Facilitator)", () => { mockFacilitatorSigner.getCode = vi .fn() .mockImplementation(mockGetCodeEOAPayer("0x036CbD53842c5426634e7929541eC2318f3dCF7e")); - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { success: true, returnData: "0x" }, - { success: false, returnData: "0x" }, - ]); - } + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === MULTICALL3_ADDRESS) { return Promise.resolve([ - { - success: true, - returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", - }, { success: true, returnData: "0x" }, - { success: true, returnData: "0x" }, - { - success: true, - returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", - }, + { success: false, returnData: "0x" }, ]); - }); + } + return Promise.resolve([ + { + success: true, + returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", + }, + { success: true, returnData: "0x" }, + { success: true, returnData: "0x" }, + { + success: true, + returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + ]); + }); - const result = await facilitator.verify(makeERC6492Payload(erc6492Sig), erc6492Requirements); + const result = await cfFacilitator.verify( + makeERC6492Payload(erc6492Sig), + erc6492Requirements, + ); expect(result.isValid).toBe(false); expect(result.payer).toBe(erc6492Payer); }); - it("should reject undeployed smart wallet without ERC-6492 deployment info", async () => { + // 66-byte inner sig avoids the ECDSA (65-byte) branch, so executeTransferWithAuthorization + // takes the bytes-overload path (writeContract receives the inner signature directly). + const nonEcdsaInnerSig = ("0x" + "cc".repeat(66)) as `0x${string}`; + const nonEcdsaErc6492Sig = makeERC6492Sig(factory, factoryCalldata, nonEcdsaInnerSig); + + it("settle submits the transfer after a successful deploy (no post-deploy simulation gate)", async () => { + // The on-chain transfer is the authoritative signature check: after deploying the + // wallet via the allowlisted factory, settle submits transferWithAuthorization with + // the inner signature rather than pre-simulating it (which raced the deploy's state + // and false-rejected valid wallets, e.g. Coinbase Smart Wallet). + const facilitatorWithFactory = new ExactEvmScheme(mockFacilitatorSigner, { + eip6492AllowedFactories: [factory], + }); + // payer undeployed ("0x"), asset deployed (so verify's asset-code check passes). + mockFacilitatorSigner.getCode = vi + .fn() + .mockImplementation(mockGetCodeEOAPayer(erc6492Requirements.asset)); + mockFacilitatorSigner.sendTransaction = vi.fn().mockResolvedValue("0xdeploytx"); + mockFacilitatorSigner.writeContract = vi.fn().mockResolvedValue("0xtransfertx"); + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockResolvedValue({ status: "success" }); + + const result = await facilitatorWithFactory.settle( + makeERC6492Payload(nonEcdsaErc6492Sig), + erc6492Requirements, + ); + + expect(result.success).toBe(true); + // Deploy tx was sent, then the transfer was submitted with the inner signature. + expect(mockFacilitatorSigner.sendTransaction).toHaveBeenCalled(); + expect(mockFacilitatorSigner.writeContract).toHaveBeenCalled(); + expect(result.transaction).toBe("0xtransfertx"); + }); + + it("settle classifies a post-deploy transfer revert (deployed wallet rejects inner sig)", async () => { + // A wallet whose deployed validator genuinely rejects the inner signature surfaces as + // a reverted transferWithAuthorization, classified via parseEip3009TransferError — + // no separate pre-transfer gate is needed. + const facilitatorWithFactory = new ExactEvmScheme(mockFacilitatorSigner, { + eip6492AllowedFactories: [factory], + }); + mockFacilitatorSigner.getCode = vi + .fn() + .mockImplementation(mockGetCodeEOAPayer(erc6492Requirements.asset)); + mockFacilitatorSigner.sendTransaction = vi.fn().mockResolvedValue("0xdeploytx"); + mockFacilitatorSigner.waitForTransactionReceipt = vi + .fn() + .mockResolvedValue({ status: "success" }); + // The real transfer reverts because the deployed wallet rejects the inner signature. + mockFacilitatorSigner.writeContract = vi + .fn() + .mockRejectedValue(new Error("execution reverted: invalid signature")); + + const result = await facilitatorWithFactory.settle( + makeERC6492Payload(nonEcdsaErc6492Sig), + erc6492Requirements, + ); + + expect(result.success).toBe(false); + expect(result.errorReason).toBe(Errors.ErrInvalidSignature); + expect(result.transaction).toBe(""); + }); + + it("should reject non-ERC-6492 long signature against undeployed wallet", async () => { const longNonERC6492Sig = ("0x" + "ab".repeat(100)) as `0x${string}`; - mockFacilitatorSigner.verifyTypedData = vi.fn().mockResolvedValue(false); mockFacilitatorSigner.getCode = vi .fn() .mockImplementation(mockGetCodeEOAPayer("0x036CbD53842c5426634e7929541eC2318f3dCF7e")); @@ -1027,43 +1142,56 @@ describe("ExactEvmScheme (Facilitator)", () => { erc6492Requirements, ); + // Strict primitive: payer has no code → ECDSA path → 100-byte sig is not a + // valid ECDSA signature → rejected as invalid_signature. Previously this + // returned ErrUndeployedSmartWallet because the OLD heuristic treated any + // sig > 65 bytes as a smart-wallet sig and routed via getCode, which then + // saw no code and no factory info. The new behavior is closer to on-chain: + // a long sig sent to an EOA address is just an invalid signature. expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe("invalid_exact_evm_payload_undeployed_smart_wallet"); + expect(result.invalidReason).toBe(Errors.ErrInvalidSignature); expect(result.payer).toBe(erc6492Payer); }); it("should accept deployed smart wallet when verifyTypedData fails but simulation passes (ERC-1271)", async () => { mockFacilitatorSigner.verifyTypedData = vi.fn().mockResolvedValue(false); mockFacilitatorSigner.getCode = vi.fn().mockResolvedValue("0x6080604052"); - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { success: true, returnData: "0x" }, - { success: true, returnData: "0x" }, - ]); - } - return Promise.resolve(undefined); - }); + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === MULTICALL3_ADDRESS) { + return Promise.resolve([ + { success: true, returnData: "0x" }, + { success: true, returnData: "0x" }, + ]); + } + return Promise.resolve(undefined); + }); - const result = await facilitator.verify(makeERC6492Payload(erc6492Sig), erc6492Requirements); + const result = await cfFacilitator.verify( + makeERC6492Payload(erc6492Sig), + erc6492Requirements, + ); expect(result.isValid).toBe(true); expect(result.payer).toBe(erc6492Payer); }); - it("should reject deployed smart wallet when both verifyTypedData and simulation fail", async () => { - mockFacilitatorSigner.verifyTypedData = vi.fn().mockResolvedValue(false); + it("should reject deployed wallet when isValidSignature reverts (REGRESSION: was ErrEip3009SimulationFailed)", async () => { mockFacilitatorSigner.getCode = vi.fn().mockResolvedValue("0x6080604052"); + // Every readContract call throws — including isValidSignature. mockFacilitatorSigner.readContract = vi .fn() .mockRejectedValue(new Error("execution reverted")); - const result = await facilitator.verify(makeERC6492Payload(erc6492Sig), erc6492Requirements); + const result = await cfFacilitator.verify( + makeERC6492Payload(erc6492Sig), + erc6492Requirements, + ); + // The strict primitive treats a reverted isValidSignature call as "rejected" + // (no ECDSA fallback, no simulation second-chance). Pre-verify outcome now + // matches what on-chain SignatureChecker.isValidSignatureNow would return. expect(result.isValid).toBe(false); - expect(result.invalidReason).toBe(Errors.ErrEip3009SimulationFailed); + expect(result.invalidReason).toBe(Errors.ErrInvalidSignature); }); }); @@ -1073,7 +1201,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 60, extra: { assetTransferMethod: "permit2", name: "USDC", version: "2" }, }; @@ -1127,7 +1255,7 @@ describe("ExactEvmScheme (Facilitator)", () => { it("should call settleWithPermit when EIP-2612 extension is present", async () => { // settle's re-verify has simulate=false, so readContract is not called for simulation - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const payload = makePermit2Payload(makeEip2612Extension()); const result = await facilitator.settle(payload, permit2Requirements); @@ -1142,7 +1270,7 @@ describe("ExactEvmScheme (Facilitator)", () => { it("should call settle (not settleWithPermit) when no EIP-2612 extension", async () => { // settle's re-verify has simulate=false - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const payload = makePermit2Payload(); const result = await facilitator.settle(payload, permit2Requirements); @@ -1156,7 +1284,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should map Permit2612AmountMismatch contract revert to permit2_2612_amount_mismatch", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); mockFacilitatorSigner.writeContract = vi .fn() .mockRejectedValue(new Error("execution reverted: Permit2612AmountMismatch()")); @@ -1169,7 +1297,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should map InvalidAmount contract revert to permit2_invalid_amount", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); mockFacilitatorSigner.writeContract = vi .fn() .mockRejectedValue(new Error("execution reverted: InvalidAmount()")); @@ -1182,7 +1310,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should map InvalidNonce contract revert to permit2_invalid_nonce", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); mockFacilitatorSigner.writeContract = vi .fn() .mockRejectedValue(new Error("execution reverted: InvalidNonce()")); @@ -1196,7 +1324,7 @@ describe("ExactEvmScheme (Facilitator)", () => { it("should pass correct EIP-2612 permit struct to settleWithPermit", async () => { // settle's re-verify has simulate=false - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const extensions = makeEip2612Extension(); const payload = makePermit2Payload(extensions); @@ -1231,7 +1359,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000", asset: TOKEN_ADDRESS, - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 60, extra: { assetTransferMethod: "permit2" }, }; @@ -1294,30 +1422,28 @@ describe("ExactEvmScheme (Facilitator)", () => { it("should reject when simulation fails and no ERC-20 extension (no context)", async () => { // Simulation of settle() fails, diagnostic multicall shows low allowance - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === x402ExactPermit2ProxyAddress) { - return Promise.reject(new Error("execution reverted")); - } - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { - success: true, - returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", - }, - { - success: true, - returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", - }, - { - success: true, - returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", - }, - ]); - } - return Promise.resolve(BigInt(0)); - }); + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === x402ExactPermit2ProxyAddress) { + return Promise.reject(new Error("execution reverted")); + } + if (address === MULTICALL3_ADDRESS) { + return Promise.resolve([ + { + success: true, + returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", + }, + { + success: true, + returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", + }, + { + success: true, + returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + ]); + } + return Promise.resolve(BigInt(0)); + }); const payload = makeErc20Permit2Payload(); const result = await facilitator.verify(payload, erc20Requirements); @@ -1327,7 +1453,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject when ERC-20 extension has invalid format (bad address)", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const payload = makeErc20Permit2Payload({ erc20ApprovalGasSponsoring: { @@ -1350,7 +1476,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject when ERC-20 extension `from` doesn't match payer", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const payload = makeErc20Permit2Payload({ erc20ApprovalGasSponsoring: { @@ -1373,7 +1499,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject when ERC-20 extension `asset` doesn't match token", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const payload = makeErc20Permit2Payload({ erc20ApprovalGasSponsoring: { @@ -1396,7 +1522,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject when ERC-20 extension spender is not PERMIT2_ADDRESS", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const payload = makeErc20Permit2Payload({ erc20ApprovalGasSponsoring: { @@ -1420,23 +1546,21 @@ describe("ExactEvmScheme (Facilitator)", () => { it("should accept when valid ERC-20 extension present and prerequisites pass", async () => { // checkPermit2Prerequisites multicall: proxy deployed + sufficient token balance - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { - success: true, - returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", - }, - { - success: true, - returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", - }, - ]); - } - return Promise.resolve(undefined); - }); + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === MULTICALL3_ADDRESS) { + return Promise.resolve([ + { + success: true, + returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", + }, + { + success: true, + returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", + }, + ]); + } + return Promise.resolve(undefined); + }); const { parseTransaction, recoverTransactionAddress } = await import("viem"); vi.mocked(parseTransaction).mockReturnValue({ @@ -1454,7 +1578,7 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should reject when calldata targets wrong address (not PERMIT2_ADDRESS)", async () => { - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const wrongSpenderCalldata = "0x095ea7b3" + @@ -1483,7 +1607,7 @@ describe("ExactEvmScheme (Facilitator)", () => { } as any); vi.mocked(recoverTransactionAddress).mockResolvedValue(PAYER); - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const mockSimulateTransactions = vi.fn().mockResolvedValue(true); @@ -1522,28 +1646,26 @@ describe("ExactEvmScheme (Facilitator)", () => { } as any); vi.mocked(recoverTransactionAddress).mockResolvedValue(PAYER); - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === MULTICALL3_ADDRESS) { - // diagnostic multicall: proxy deployed, balance insufficient - return Promise.resolve([ - { - success: true, - returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", - }, - { - success: true, - returnData: "0x0000000000000000000000000000000000000000000000000000000000000001", - }, - { - success: true, - returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", - }, - ]); - } - return Promise.resolve(undefined); - }); + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === MULTICALL3_ADDRESS) { + // diagnostic multicall: proxy deployed, balance insufficient + return Promise.resolve([ + { + success: true, + returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", + }, + { + success: true, + returnData: "0x0000000000000000000000000000000000000000000000000000000000000001", + }, + { + success: true, + returnData: "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + ]); + } + return Promise.resolve(undefined); + }); const mockSimulateTransactions = vi.fn().mockResolvedValue(false); @@ -1579,23 +1701,21 @@ describe("ExactEvmScheme (Facilitator)", () => { vi.mocked(recoverTransactionAddress).mockResolvedValue(PAYER); // prerequisites pass: proxy deployed + sufficient token balance - mockFacilitatorSigner.readContract = vi - .fn() - .mockImplementation(({ address }: { address: string }) => { - if (address === MULTICALL3_ADDRESS) { - return Promise.resolve([ - { - success: true, - returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", - }, - { - success: true, - returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", - }, - ]); - } - return Promise.resolve(undefined); - }); + mockFacilitatorSigner.readContract = rcWithSig(({ address }: { address: string }) => { + if (address === MULTICALL3_ADDRESS) { + return Promise.resolve([ + { + success: true, + returnData: "0x000000000000000000000000000000000022D473030F116dDEE9F6B43aC78BA3", + }, + { + success: true, + returnData: "0x00000000000000000000000000000000000000000000000000000000000f4240", + }, + ]); + } + return Promise.resolve(undefined); + }); // signer has sendTransactions but no simulateTransactions (legacy) const mockContext = { @@ -1634,7 +1754,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000", asset: TOKEN_ADDRESS, - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 60, extra: { assetTransferMethod: "permit2" }, }; @@ -1692,7 +1812,7 @@ describe("ExactEvmScheme (Facilitator)", () => { vi.mocked(recoverTransactionAddress).mockResolvedValue(PAYER); // settle's re-verify has simulate=false, so no simulation calls - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const SETTLE_TX_HASH = "0xsettle_tx_hash_mock" as `0x${string}`; const mockSendTransactions = vi.fn().mockResolvedValue([SETTLE_TX_HASH]); @@ -1745,7 +1865,7 @@ describe("ExactEvmScheme (Facilitator)", () => { vi.mocked(recoverTransactionAddress).mockResolvedValue(PAYER); // settle's re-verify has simulate=false - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockFacilitatorSigner.readContract = rcWithSig(undefined); const selectedSignerSendTransactions = vi .fn() @@ -1804,7 +1924,7 @@ describe("ExactEvmScheme (Facilitator)", () => { network: "eip155:84532", amount: "1000000", asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - payTo: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + payTo: "0x742D35CC6634c0532925A3b844BC9E7595F0BEb0", maxTimeoutSeconds: 300, extra: { name: "USDC", version: "2" }, }; @@ -1841,7 +1961,7 @@ describe("ExactEvmScheme (Facilitator)", () => { beforeEach(() => { mockFacilitatorSigner.verifyTypedData = vi.fn().mockResolvedValue(true); - mockFacilitatorSigner.readContract = vi.fn().mockResolvedValue(0n); + mockFacilitatorSigner.readContract = rcWithSig(0n); mockFacilitatorSigner.writeContract = vi.fn().mockResolvedValue("0xsettletxhash"); mockFacilitatorSigner.sendTransaction = vi.fn().mockResolvedValue("0xdeploytxhash"); mockFacilitatorSigner.waitForTransactionReceipt = vi @@ -1868,9 +1988,21 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should deploy and settle when factory is in allowlist", async () => { + // After sendTransaction (factory deploy), getCode must return deployed bytecode + // so the polling loop exits. Track deploy state via sendTransaction call count. + let deployed = false; + mockFacilitatorSigner.sendTransaction = vi.fn().mockImplementation(async () => { + deployed = true; + return "0xdeploytxhash"; + }); mockFacilitatorSigner.getCode = vi .fn() - .mockImplementation(mockGetCodeEOAPayer("0x036CbD53842c5426634e7929541eC2318f3dCF7e")); + .mockImplementation(({ address }: { address: string }) => { + const assetAddr = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + if (address.toLowerCase() === assetAddr.toLowerCase()) + return Promise.resolve("0x6080604052"); + return Promise.resolve(deployed ? "0x6080604052" : "0x"); + }); const scheme = new ExactEvmScheme(mockFacilitatorSigner, { eip6492AllowedFactories: [SETTLE_FACTORY], }); @@ -1886,9 +2018,19 @@ describe("ExactEvmScheme (Facilitator)", () => { }); it("should match factory address case-insensitively", async () => { + let deployed = false; + mockFacilitatorSigner.sendTransaction = vi.fn().mockImplementation(async () => { + deployed = true; + return "0xdeploytxhash"; + }); mockFacilitatorSigner.getCode = vi .fn() - .mockImplementation(mockGetCodeEOAPayer("0x036CbD53842c5426634e7929541eC2318f3dCF7e")); + .mockImplementation(({ address }: { address: string }) => { + const assetAddr = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"; + if (address.toLowerCase() === assetAddr.toLowerCase()) + return Promise.resolve("0x6080604052"); + return Promise.resolve(deployed ? "0x6080604052" : "0x"); + }); const scheme = new ExactEvmScheme(mockFacilitatorSigner, { eip6492AllowedFactories: [SETTLE_FACTORY.toUpperCase() as `0x${string}`], }); @@ -1936,14 +2078,21 @@ describe("ExactEvmScheme (Facilitator)", () => { expect(mockFacilitatorSigner.writeContract).toHaveBeenCalled(); }); - it("should not call factory deployment for EOA payer", async () => { + it("should not call factory deployment for EOA payer (no 6492 wrapper)", async () => { + // Payer is an EOA (mockGetCodeEOAPayer returns "0x" for non-asset addresses). + // Sign with a real 65-byte ECDSA signature so the strict primitive's ECDSA + // path can succeed; we want to verify settle does NOT call sendTransaction + // for factory deployment regardless of the signature outcome — there's no + // 6492 wrapper, so deployment can't be triggered. mockFacilitatorSigner.getCode = vi .fn() .mockImplementation(mockGetCodeEOAPayer("0x036CbD53842c5426634e7929541eC2318f3dCF7e")); - const eoaSig = ("0x" + "aa".repeat(66)) as `0x${string}`; const scheme = new ExactEvmScheme(mockFacilitatorSigner, { eip6492AllowedFactories: [], }); + // 65-byte sig fixture — strict primitive will attempt ecrecover. The + // recovered address won't match SETTLE_PAYER, so sig will be invalid. + const eoaSig = ("0x" + "aa".repeat(65)) as `0x${string}`; const eoaPayload: PaymentPayload = { ...makeSettlePayload(eoaSig), payload: { @@ -1959,9 +2108,9 @@ describe("ExactEvmScheme (Facilitator)", () => { }, }; - const result = await scheme.settle(eoaPayload, settleRequirements); + await scheme.settle(eoaPayload, settleRequirements); - expect(result.success).toBe(true); + // Regardless of the signature outcome, we never deploy a factory for an EOA. expect(mockFacilitatorSigner.sendTransaction).not.toHaveBeenCalled(); }); }); diff --git a/typescript/packages/mechanisms/evm/test/unit/shared/erc7702.test.ts b/typescript/packages/mechanisms/evm/test/unit/shared/erc7702.test.ts new file mode 100644 index 00000000..919fe9f0 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/shared/erc7702.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { isERC7702Delegation, getERC7702DelegateAddress } from "../../../src/shared/erc7702"; + +const ADDR_LOWER = "1234567890abcdef1234567890abcdef12345678"; +const ADDR_MIXED = "1234567890abCDEF1234567890ABcdef12345678"; + +describe("isERC7702Delegation", () => { + it("returns true for canonical lowercase delegation", () => { + expect(isERC7702Delegation(`0xef0100${ADDR_LOWER}` as `0x${string}`)).toBe(true); + }); + + it("returns true for uppercase prefix (case-insensitive — JSON-RPC casing not normalized)", () => { + expect(isERC7702Delegation(`0xEF0100${ADDR_LOWER}` as `0x${string}`)).toBe(true); + expect(isERC7702Delegation(`0xEf0100${ADDR_LOWER}` as `0x${string}`)).toBe(true); + }); + + it("returns true for mixed-case address suffix", () => { + expect(isERC7702Delegation(`0xef0100${ADDR_MIXED}` as `0x${string}`)).toBe(true); + }); + + it("returns false for undefined / null / empty", () => { + expect(isERC7702Delegation(undefined)).toBe(false); + expect(isERC7702Delegation(null)).toBe(false); + expect(isERC7702Delegation("0x")).toBe(false); + }); + + it("returns false for wrong prefix bytes", () => { + expect(isERC7702Delegation(`0xef0200${ADDR_LOWER}` as `0x${string}`)).toBe(false); + expect(isERC7702Delegation(`0xef0000${ADDR_LOWER}` as `0x${string}`)).toBe(false); + }); + + it("returns false for too-short bytecode", () => { + expect(isERC7702Delegation("0xef01001234" as `0x${string}`)).toBe(false); + }); + + it("returns false for too-long bytecode", () => { + expect(isERC7702Delegation(`0xef0100${ADDR_LOWER}00` as `0x${string}`)).toBe(false); + }); + + it("returns false for regular contract bytecode", () => { + expect(isERC7702Delegation("0x6080604052" as `0x${string}`)).toBe(false); + }); +}); + +describe("getERC7702DelegateAddress", () => { + it("extracts address from valid lowercase delegation", () => { + expect(getERC7702DelegateAddress(`0xef0100${ADDR_LOWER}` as `0x${string}`)).toBe( + `0x${ADDR_LOWER}`, + ); + }); + + it("normalizes uppercase prefix and mixed-case address to lowercase", () => { + expect(getERC7702DelegateAddress(`0xEF0100${ADDR_MIXED}` as `0x${string}`)).toBe( + `0x${ADDR_LOWER}`, + ); + }); + + it("returns null for non-7702 bytecode", () => { + expect(getERC7702DelegateAddress("0x6080" as `0x${string}`)).toBeNull(); + expect(getERC7702DelegateAddress(undefined)).toBeNull(); + expect(getERC7702DelegateAddress(null)).toBeNull(); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/shared/verifySignature.test.ts b/typescript/packages/mechanisms/evm/test/unit/shared/verifySignature.test.ts new file mode 100644 index 00000000..7064d680 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/shared/verifySignature.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from "vitest"; +import { hashTypedData } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { + verifyTypedDataSignature, + verifyHashSignature, + verifyECDSA, + verifyERC1271, +} from "../../../src/shared/verifySignature"; +import type { FacilitatorEvmSigner } from "../../../src/signer"; + +const ECDSA_KEY = "0x4df93dc5e721ad24d04da311f073184b4c6cd036ba08956aeff970a2a43d7401" as const; +const ECDSA_ADDR = "0xabcA8d06A3925a6C06D142788a1A90ae431ccB00" as const; + +const SAMPLE_DOMAIN = { + name: "USDC", + version: "2", + chainId: 84532, + verifyingContract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as const, +}; +const SAMPLE_TYPES = { + 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" }, + ], +} as const; +const SAMPLE_MESSAGE = { + from: ECDSA_ADDR, + to: "0x122F8Fcaf2152420445Aa424E1D8C0306935B5c9", + value: 1000n, + validAfter: 0n, + validBefore: 9999999999n, + nonce: ("0x" + "a".repeat(64)) as `0x${string}`, +}; + +const ERC1271_MAGIC = "0x1626ba7e"; +const ERC1271_FAIL = "0xffffffff"; + +function mockSigner(opts: { + code: `0x${string}` | undefined; + isValidSignatureResult?: `0x${string}`; + isValidSignatureThrows?: boolean; +}): FacilitatorEvmSigner { + return { + getAddresses: () => [], + readContract: vi.fn(async () => { + if (opts.isValidSignatureThrows) throw new Error("revert"); + return opts.isValidSignatureResult ?? ERC1271_FAIL; + }), + verifyTypedData: vi.fn(), + writeContract: vi.fn(), + sendTransaction: vi.fn(), + waitForTransactionReceipt: vi.fn(), + getCode: vi.fn(async () => opts.code), + }; +} + +async function buildSignatureFor( + account: ReturnType, +): Promise<{ digest: `0x${string}`; signature: `0x${string}` }> { + const digest = hashTypedData({ + domain: SAMPLE_DOMAIN, + types: SAMPLE_TYPES, + primaryType: "TransferWithAuthorization", + message: SAMPLE_MESSAGE, + }); + const signature = await account.signTypedData({ + domain: SAMPLE_DOMAIN, + types: SAMPLE_TYPES, + primaryType: "TransferWithAuthorization", + message: SAMPLE_MESSAGE, + }); + return { digest, signature }; +} + +describe("verifyECDSA", () => { + it("accepts a valid 65-byte signature from the address's owner", async () => { + const account = privateKeyToAccount(ECDSA_KEY); + const { digest, signature } = await buildSignatureFor(account); + expect(await verifyECDSA(account.address, digest, signature)).toBe(true); + }); + + it("rejects a signature for the wrong address", async () => { + const account = privateKeyToAccount(ECDSA_KEY); + const { digest, signature } = await buildSignatureFor(account); + const otherAddr = "0x0000000000000000000000000000000000000001" as `0x${string}`; + expect(await verifyECDSA(otherAddr, digest, signature)).toBe(false); + }); + + it("rejects a signature that isn't 65 bytes", async () => { + const account = privateKeyToAccount(ECDSA_KEY); + const { digest } = await buildSignatureFor(account); + expect(await verifyECDSA(account.address, digest, "0xdeadbeef")).toBe(false); + }); +}); + +describe("verifyERC1271", () => { + it("accepts when isValidSignature returns the magic value", async () => { + const signer = mockSigner({ code: "0x6080604052", isValidSignatureResult: ERC1271_MAGIC }); + const result = await verifyERC1271( + signer, + "0x1234567890123456789012345678901234567890", + ("0x" + "0".repeat(64)) as `0x${string}`, + ("0x" + "f".repeat(130)) as `0x${string}`, + ); + expect(result).toBe(true); + }); + + it("rejects when isValidSignature returns the failure value", async () => { + const signer = mockSigner({ code: "0x6080", isValidSignatureResult: ERC1271_FAIL }); + const result = await verifyERC1271( + signer, + "0x1234567890123456789012345678901234567890", + ("0x" + "0".repeat(64)) as `0x${string}`, + ("0x" + "f".repeat(130)) as `0x${string}`, + ); + expect(result).toBe(false); + }); + + it("rejects (does NOT fall back to ECDSA) when isValidSignature reverts", async () => { + const signer = mockSigner({ code: "0x6080", isValidSignatureThrows: true }); + const result = await verifyERC1271( + signer, + "0x1234567890123456789012345678901234567890", + ("0x" + "0".repeat(64)) as `0x${string}`, + ("0x" + "f".repeat(130)) as `0x${string}`, + ); + expect(result).toBe(false); + }); +}); + +describe("verifyTypedDataSignature (code-routed)", () => { + it("plain EOA + valid sig → true via ECDSA path", async () => { + const account = privateKeyToAccount(ECDSA_KEY); + const signer = mockSigner({ code: undefined }); + const { signature } = await buildSignatureFor(account); + const ok = await verifyTypedDataSignature(signer, { + address: account.address, + domain: SAMPLE_DOMAIN, + types: SAMPLE_TYPES, + primaryType: "TransferWithAuthorization", + message: SAMPLE_MESSAGE, + signature, + }); + expect(ok).toBe(true); + expect(signer.readContract).not.toHaveBeenCalled(); + }); + + it("plain EOA + invalid sig → false (no 1271 fallback)", async () => { + const signer = mockSigner({ code: undefined }); + const ok = await verifyTypedDataSignature(signer, { + address: "0x1234567890123456789012345678901234567890", + domain: SAMPLE_DOMAIN, + types: SAMPLE_TYPES, + primaryType: "TransferWithAuthorization", + message: SAMPLE_MESSAGE, + signature: ("0x" + "f".repeat(130)) as `0x${string}`, + }); + expect(ok).toBe(false); + expect(signer.readContract).not.toHaveBeenCalled(); + }); + + it("contract that returns ERC-1271 magic → true", async () => { + const signer = mockSigner({ code: "0x6080604052", isValidSignatureResult: ERC1271_MAGIC }); + const account = privateKeyToAccount(ECDSA_KEY); + const { signature } = await buildSignatureFor(account); + const ok = await verifyTypedDataSignature(signer, { + address: "0x1234567890123456789012345678901234567890", + domain: SAMPLE_DOMAIN, + types: SAMPLE_TYPES, + primaryType: "TransferWithAuthorization", + message: SAMPLE_MESSAGE, + signature, + }); + expect(ok).toBe(true); + }); + + it("REGRESSION: 7702/contract whose 1271 rejects → false (must NOT fall back to ECDSA)", async () => { + // This is the key regression case the PR exists to fix. + // A 7702-delegated EOA whose delegate's isValidSignature returns failure. + // ECDSA recovery WOULD succeed (sig was made by the underlying owner key) but + // on-chain Permit2 / USDC SignatureChecker calls isValidSignature which rejects. + // The strict primitive must mirror that. + const erc7702Bytecode = "0xef01001234567890abcdef1234567890abcdef12345678" as `0x${string}`; + const signer = mockSigner({ + code: erc7702Bytecode, + isValidSignatureResult: ERC1271_FAIL, + }); + const account = privateKeyToAccount(ECDSA_KEY); + const { signature } = await buildSignatureFor(account); + const ok = await verifyTypedDataSignature(signer, { + // Use the EOA's address — ECDSA recovery would match this if we fell back. + address: account.address, + domain: SAMPLE_DOMAIN, + types: SAMPLE_TYPES, + primaryType: "TransferWithAuthorization", + message: SAMPLE_MESSAGE, + signature, + }); + expect(ok).toBe(false); + }); + + it("contract whose 1271 reverts → false (no ECDSA fallback)", async () => { + const signer = mockSigner({ code: "0x6080", isValidSignatureThrows: true }); + const account = privateKeyToAccount(ECDSA_KEY); + const { signature } = await buildSignatureFor(account); + const ok = await verifyTypedDataSignature(signer, { + address: account.address, + domain: SAMPLE_DOMAIN, + types: SAMPLE_TYPES, + primaryType: "TransferWithAuthorization", + message: SAMPLE_MESSAGE, + signature, + }); + expect(ok).toBe(false); + }); +}); + +describe("verifyHashSignature", () => { + it("works on a raw 32-byte digest (no typed-data wrapper)", async () => { + const signer = mockSigner({ code: undefined }); + const account = privateKeyToAccount(ECDSA_KEY); + const { digest, signature } = await buildSignatureFor(account); + const ok = await verifyHashSignature(signer, account.address, digest, signature); + expect(ok).toBe(true); + }); +}); diff --git a/typescript/packages/mechanisms/evm/test/unit/upto/client.test.ts b/typescript/packages/mechanisms/evm/test/unit/upto/client.test.ts index 6963b73f..8686f580 100644 --- a/typescript/packages/mechanisms/evm/test/unit/upto/client.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/upto/client.test.ts @@ -141,15 +141,14 @@ describe("UptoEvmScheme (Client)", () => { } }); - it("should set validAfter to 10 minutes before now", async () => { + it("should set validAfter to 0", async () => { const fakeNow = 1700000000000; vi.useFakeTimers(); vi.setSystemTime(fakeNow); try { const result = await client.createPaymentPayload(2, makeRequirements()); - // now (1700000000s) - 600s clock-skew allowance. - expect(result.payload.permit2Authorization.witness.validAfter).toBe("1699999400"); + expect(result.payload.permit2Authorization.witness.validAfter).toBe("0"); } finally { vi.useRealTimers(); } 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 d22c4e32..f9182ca6 100644 --- a/typescript/packages/mechanisms/evm/test/unit/upto/facilitator.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/upto/facilitator.test.ts @@ -87,6 +87,25 @@ const mockGetCodeEOAPayer = : ("0x" as `0x${string}`), ); +// Wraps a per-test readContract impl so isValidSignature returns the ERC-1271 +// magic value. The strict signature primitive added in the 7702 fix calls +// readContract for ERC-1271 verification; this helper preserves the previous +// "default: signature accepted" semantics for tests that mock readContract for +// other purposes (allowance, nonce, multicall). +const sigValid = "0x1626ba7e"; +function rcWithSig( + impl: unknown | ((args: { address?: string; functionName?: string }) => unknown), + sigResponse: string = sigValid, +) { + return vi.fn().mockImplementation(async (args: { address?: string; functionName?: string }) => { + if (args?.functionName === "isValidSignature") return sigResponse; + if (typeof impl === "function") { + return (impl as (a: typeof args) => unknown)(args); + } + return impl; + }); +} + describe("UptoEvmScheme (Facilitator)", () => { let mockSigner: FacilitatorEvmSigner; let scheme: UptoEvmScheme; @@ -94,7 +113,7 @@ describe("UptoEvmScheme (Facilitator)", () => { beforeEach(() => { mockSigner = { getAddresses: () => [FACILITATOR_ADDRESS], - readContract: vi.fn().mockResolvedValue(BigInt("999999999999999999")), + readContract: rcWithSig(BigInt("999999999999999999")), verifyTypedData: vi.fn().mockResolvedValue(true), writeContract: vi.fn().mockResolvedValue("0xtxhash1234" as `0x${string}`), sendTransaction: vi.fn(), @@ -126,19 +145,12 @@ describe("UptoEvmScheme (Facilitator)", () => { expect(result.isValid).toBe(true); expect(result.payer).toBe("0x1234567890123456789012345678901234567890"); - expect(mockSigner.verifyTypedData).toHaveBeenCalled(); - }); - - it("should verify with uptoPermit2WitnessTypes containing facilitator", async () => { - await scheme.verify(makePayload(), makeRequirements()); - - const callArgs = (mockSigner.verifyTypedData as ReturnType).mock.calls[0][0]; - const witnessType = callArgs.types.Witness; - expect(witnessType).toEqual([ - { name: "to", type: "address" }, - { name: "facilitator", type: "address" }, - { name: "validAfter", type: "uint256" }, - ]); + // The strict primitive checks code.length first then either ecrecovers or + // calls isValidSignature. Default mock returns deployed bytecode, so we + // expect an isValidSignature ERC-1271 readContract call. + expect(mockSigner.readContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "isValidSignature" }), + ); }); it("should reject if scheme is not upto", async () => { @@ -398,10 +410,11 @@ describe("UptoEvmScheme (Facilitator)", () => { expect(result.success).toBe(true); - // Verify that verifyTypedData was called with the ceiling amount, - // not the metered amount — this is the swap convention. - const verifyCall = (mockSigner.verifyTypedData as ReturnType).mock.calls[0][0]; - expect(verifyCall.message.permitted.amount).toBe(BigInt(ceiling)); + // Default mock returns deployed bytecode → ERC-1271 path; readContract + // is called with isValidSignature to verify the ceiling-amount typed data. + expect(mockSigner.readContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "isValidSignature" }), + ); }); it("should transfer the metered amount on-chain, not the ceiling", async () => { @@ -428,7 +441,7 @@ describe("UptoEvmScheme (Facilitator)", () => { for (const metered of testAmounts) { vi.clearAllMocks(); - mockSigner.readContract = vi.fn().mockResolvedValue(BigInt("999999999999999999")); + mockSigner.readContract = rcWithSig(BigInt("999999999999999999")); mockSigner.verifyTypedData = vi.fn().mockResolvedValue(true); mockSigner.writeContract = vi.fn().mockResolvedValue("0xtxhash1234" as `0x${string}`); mockSigner.waitForTransactionReceipt = vi.fn().mockResolvedValue({ status: "success" }); @@ -655,7 +668,7 @@ describe("UptoEvmScheme (Facilitator)", () => { } it("should call settleWithPermit when EIP-2612 extension is present", async () => { - mockSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockSigner.readContract = rcWithSig(undefined); const payload = makePayloadWithExtensions(makeEip2612Extension()); const result = await scheme.settle(payload, eip2612Requirements); @@ -668,7 +681,7 @@ describe("UptoEvmScheme (Facilitator)", () => { }); it("should call settle (not settleWithPermit) when no EIP-2612 extension", async () => { - mockSigner.readContract = vi.fn().mockResolvedValue(BigInt("999999999999999999")); + mockSigner.readContract = rcWithSig(BigInt("999999999999999999")); const payload = makePayloadWithExtensions(); const result = await scheme.settle(payload, eip2612Requirements); @@ -680,7 +693,7 @@ describe("UptoEvmScheme (Facilitator)", () => { }); it("should pass correct EIP-2612 permit struct to settleWithPermit", async () => { - mockSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockSigner.readContract = rcWithSig(undefined); const payload = makePayloadWithExtensions(makeEip2612Extension()); await scheme.settle(payload, eip2612Requirements); @@ -698,7 +711,7 @@ describe("UptoEvmScheme (Facilitator)", () => { }); it("should include settlement amount in settleWithPermit args", async () => { - mockSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockSigner.readContract = rcWithSig(undefined); const payload = makePayloadWithExtensions(makeEip2612Extension()); await scheme.settle(payload, makeRequirements({ amount: "500000" })); @@ -786,7 +799,7 @@ describe("UptoEvmScheme (Facilitator)", () => { } it("should reject when ERC-20 extension has invalid format (bad address)", async () => { - mockSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockSigner.readContract = rcWithSig(undefined); const payload = makeErc20UptoPayload({ erc20ApprovalGasSponsoring: { @@ -809,7 +822,7 @@ describe("UptoEvmScheme (Facilitator)", () => { }); it("should reject when ERC-20 extension from doesn't match payer", async () => { - mockSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockSigner.readContract = rcWithSig(undefined); const payload = makeErc20UptoPayload({ erc20ApprovalGasSponsoring: { @@ -832,7 +845,7 @@ describe("UptoEvmScheme (Facilitator)", () => { }); it("should accept when valid ERC-20 extension present and simulation succeeds", async () => { - mockSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockSigner.readContract = rcWithSig(undefined); const { parseTransaction, recoverTransactionAddress } = await import("viem"); vi.mocked(parseTransaction).mockReturnValue({ @@ -970,7 +983,7 @@ describe("UptoEvmScheme (Facilitator)", () => { } as any); vi.mocked(recoverTransactionAddress).mockResolvedValue(PAYER); - mockSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockSigner.readContract = rcWithSig(undefined); const { mockContext, mockSendTransactions } = makeErc20SettleContext(); @@ -999,7 +1012,7 @@ describe("UptoEvmScheme (Facilitator)", () => { } as any); vi.mocked(recoverTransactionAddress).mockResolvedValue(PAYER); - mockSigner.readContract = vi.fn().mockResolvedValue(undefined); + mockSigner.readContract = rcWithSig(undefined); const { mockContext } = makeErc20SettleContext(); diff --git a/typescript/packages/mechanisms/evm/test/unit/v1/client.test.ts b/typescript/packages/mechanisms/evm/test/unit/v1/client.test.ts index 15ec904a..767ed1e7 100644 --- a/typescript/packages/mechanisms/evm/test/unit/v1/client.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/v1/client.test.ts @@ -92,7 +92,7 @@ describe("ExactEvmSchemeV1", () => { expect(payload.payload.authorization.nonce).toMatch(/^0x[0-9a-f]{64}$/); }); - it("should set validAfter to 10 minutes before now", async () => { + it("should set validAfter to 0", async () => { const client = new ExactEvmSchemeV1(mockSigner); const now = Math.floor(Date.now() / 1000); @@ -110,10 +110,7 @@ describe("ExactEvmSchemeV1", () => { }; const payload = await client.createPaymentPayload(1, requirements as never); - const validAfter = parseInt(payload.payload.authorization.validAfter); - - expect(validAfter).toBeGreaterThanOrEqual(now - 600 - 2); // Allow 2 second tolerance - expect(validAfter).toBeLessThanOrEqual(now - 600 + 2); + expect(payload.payload.authorization.validAfter).toBe("0"); }); it("should set validBefore based on maxTimeoutSeconds", async () => { diff --git a/typescript/packages/mechanisms/evm/test/unit/v1/facilitator.test.ts b/typescript/packages/mechanisms/evm/test/unit/v1/facilitator.test.ts index e87410b1..3eefb40a 100644 --- a/typescript/packages/mechanisms/evm/test/unit/v1/facilitator.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/v1/facilitator.test.ts @@ -5,17 +5,38 @@ import type { PaymentRequirementsV1 } from "@bankofai/x402-core/types/v1"; import type { PaymentPayloadV1 } from "@bankofai/x402-core/types/v1"; import * as Errors from "../../../src/exact/facilitator/errors"; +// Wraps a per-test readContract impl so isValidSignature returns the ERC-1271 +// magic value. The strict signature primitive added in the 7702 fix calls +// readContract for ERC-1271 verification. +const sigValid = "0x1626ba7e"; +function rcWithSig( + impl: unknown | ((args: { address?: string; functionName?: string }) => unknown), + sigResponse: string = sigValid, +) { + return vi.fn().mockImplementation(async (args: { address?: string; functionName?: string }) => { + if (args?.functionName === "isValidSignature") return sigResponse; + if (typeof impl === "function") { + return (impl as (a: typeof args) => unknown)(args); + } + return impl; + }); +} + describe("ExactEvmSchemeV1", () => { let mockSigner: FacilitatorEvmSigner; beforeEach(() => { mockSigner = { address: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", - readContract: vi.fn().mockResolvedValue(BigInt("10000000")), // 10 USDC + // Default readContract returns BigInt("10000000") for nonce/balance/etc. + // and the ERC-1271 magic value for isValidSignature (mock placeholder sigs). + readContract: rcWithSig(BigInt("10000000")), verifyTypedData: vi.fn().mockResolvedValue(true), writeContract: vi.fn().mockResolvedValue("0xtxhash"), waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), - getCode: vi.fn().mockResolvedValue("0x"), + // Default: deployed contract so ERC-1271 path is taken (matches the previous + // verifyTypedData=true behavior; tests with real ECDSA sigs override this). + getCode: vi.fn().mockResolvedValue("0x6080604052"), }; }); @@ -80,7 +101,7 @@ describe("ExactEvmSchemeV1", () => { value: "100000", validAfter: "0", validBefore: "999999999999", - nonce: "0x00", + nonce: "0x0000000000000000000000000000000000000000000000000000000000000000", }, }, }; @@ -116,7 +137,7 @@ describe("ExactEvmSchemeV1", () => { value: "100000", validAfter: (Math.floor(Date.now() / 1000) - 300).toString(), validBefore: (Math.floor(Date.now() / 1000) + 3600).toString(), - nonce: "0x00", + nonce: "0x0000000000000000000000000000000000000000000000000000000000000000", }, }, }; @@ -152,7 +173,7 @@ describe("ExactEvmSchemeV1", () => { value: "50000", // Less than required validAfter: (Math.floor(Date.now() / 1000) - 300).toString(), validBefore: (Math.floor(Date.now() / 1000) + 3600).toString(), - nonce: "0x00", + nonce: "0x0000000000000000000000000000000000000000000000000000000000000000", }, }, }; @@ -175,7 +196,10 @@ describe("ExactEvmSchemeV1", () => { it("should reject if balance is insufficient", async () => { // Simulation fails (transfer would revert due to insufficient balance) - mockSigner.readContract = vi.fn().mockRejectedValue(new Error("simulation reverted")); + // Simulation reverts on every readContract — but isValidSignature must still + // succeed (otherwise we never reach simulation). Wrap in rcWithSig so the + // ERC-1271 sig check passes, then everything else throws. + mockSigner.readContract = rcWithSig(() => Promise.reject(new Error("simulation reverted"))); const facilitator = new ExactEvmSchemeV1(mockSigner); @@ -191,7 +215,7 @@ describe("ExactEvmSchemeV1", () => { value: "100000", validAfter: (Math.floor(Date.now() / 1000) - 300).toString(), validBefore: (Math.floor(Date.now() / 1000) + 3600).toString(), - nonce: "0x00", + nonce: "0x0000000000000000000000000000000000000000000000000000000000000000", }, }, }; @@ -227,7 +251,7 @@ describe("ExactEvmSchemeV1", () => { value: "100000", validAfter: (Math.floor(Date.now() / 1000) - 300).toString(), validBefore: (Math.floor(Date.now() / 1000) + 3600).toString(), - nonce: "0x00", + nonce: "0x0000000000000000000000000000000000000000000000000000000000000000", }, }, }; @@ -262,7 +286,7 @@ describe("ExactEvmSchemeV1", () => { value: "100000", validAfter: "0", validBefore: "999999999999", - nonce: "0x00", + nonce: "0x0000000000000000000000000000000000000000000000000000000000000000", }, }, }; @@ -327,7 +351,9 @@ describe("ExactEvmSchemeV1", () => { }); it("should fail settlement if verification fails", async () => { - mockSigner.verifyTypedData = vi.fn().mockResolvedValue(false); + // Make the strict primitive's ERC-1271 path return the failure value so + // signature verification is rejected. + mockSigner.readContract = rcWithSig(BigInt("10000000"), "0xffffffff"); const facilitator = new ExactEvmSchemeV1(mockSigner); @@ -343,7 +369,7 @@ describe("ExactEvmSchemeV1", () => { value: "100000", validAfter: (Math.floor(Date.now() / 1000) - 300).toString(), validBefore: (Math.floor(Date.now() / 1000) + 3600).toString(), - nonce: "0x00", + nonce: "0x0000000000000000000000000000000000000000000000000000000000000000", }, }, }; diff --git a/typescript/packages/mechanisms/tron/README.md b/typescript/packages/mechanisms/tron/README.md index 6931bf8d..00fe27e2 100644 --- a/typescript/packages/mechanisms/tron/README.md +++ b/typescript/packages/mechanisms/tron/README.md @@ -20,16 +20,11 @@ It plugs into `@bankofai/x402-core` via the `tron:*` CAIP family — no core cha ### Fee behavior -The `exact` and `upto` schemes do not advertise or collect a facilitator fee: -their deployed proxies transfer exactly the payment amount. Facilitator fees are -supported only by `exact_gasfree`, where the relayer deducts the configured fee -from the GasFree wallet. - -When upgrading from the advisory-fee API, remove the `fee` property from -`TronFacilitatorConfig` and stop passing a fee configuration as the second -argument to the `exact` or `upto` facilitator scheme constructors. Pass the fee -configuration only when registering or constructing an `exact_gasfree` -facilitator. +None of the TRON schemes (`exact`, `upto`, `exact_gasfree`) advertise or collect +a facilitator service fee via the SDK public API. The deployed proxies transfer +exactly the payment amount. GasFree provider-driven fees (`transferFee`, +`activateFee`) are inherent to the relayer protocol and remain unchanged — they +are read from the relayer API, not configured via the SDK. ## Two transfer paths (and why TRON is effectively Permit2) @@ -176,7 +171,7 @@ The suite is fully offline (no network, no keys): - `permit2-digest.test.ts` — reproduces the exact on-chain Permit2 digest and recovers the signer two independent ways (the facilitator verify path and a manual contract-style reconstruction), guaranteeing TIP-712 hashing matches the deployed proxy. - `gasfree-digest.test.ts` / `gasfree-flow.test.ts` — GasFree TIP-712 sign↔verify round-trip plus client/facilitator term-validation and settle flow against a mocked relayer. -- `tokens.test.ts`, `fee.test.ts`, `selection.test.ts`, `signer-wallet.test.ts` — token registry, fee policy, token selection / balance filtering, and the ClientTronWallet abstraction. +- `tokens.test.ts`, `selection.test.ts`, `signer-wallet.test.ts` — token registry, token selection / balance filtering, and the ClientTronWallet abstraction. ## Notes & caveats diff --git a/typescript/packages/mechanisms/tron/src/batch-settlement/client/eip3009.ts b/typescript/packages/mechanisms/tron/src/batch-settlement/client/eip3009.ts index 5c44a859..01423437 100644 --- a/typescript/packages/mechanisms/tron/src/batch-settlement/client/eip3009.ts +++ b/typescript/packages/mechanisms/tron/src/batch-settlement/client/eip3009.ts @@ -47,7 +47,7 @@ export async function createBatchSettlementEIP3009DepositPayload( const erc3009Nonce = buildErc3009DepositNonce(channelId, salt); const collector = getErc3009DepositCollectorAddress(paymentRequirements.network); - const validAfter = (now - 600).toString(); + const validAfter = "0"; const validBefore = (now + paymentRequirements.maxTimeoutSeconds).toString(); const signature = await signer.signTypedData({ diff --git a/typescript/packages/mechanisms/tron/src/batch-settlement/errors.ts b/typescript/packages/mechanisms/tron/src/batch-settlement/errors.ts index 4b0ac71e..d69c904b 100644 --- a/typescript/packages/mechanisms/tron/src/batch-settlement/errors.ts +++ b/typescript/packages/mechanisms/tron/src/batch-settlement/errors.ts @@ -34,6 +34,7 @@ export const ErrReceiverAuthorizerMismatch = export const ErrWithdrawDelayMismatch = "invalid_batch_settlement_tron_withdraw_delay_mismatch"; export const ErrAuthorizerAddressMismatch = "invalid_batch_settlement_tron_authorizer_address_mismatch"; +export const ErrAuthorizerNotConfigured = "invalid_batch_settlement_tron_authorizer_not_configured"; export const ErrDepositSimulationFailed = "invalid_batch_settlement_tron_deposit_simulation_failed"; export const ErrClaimSimulationFailed = "invalid_batch_settlement_tron_claim_simulation_failed"; export const ErrSettleSimulationFailed = "invalid_batch_settlement_tron_settle_simulation_failed"; diff --git a/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/claim.ts b/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/claim.ts index 058a671f..99dab260 100644 --- a/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/claim.ts +++ b/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/claim.ts @@ -38,19 +38,28 @@ export function buildVoucherClaimArgs(claims: BatchSettlementClaimPayload["claim * @param payload - Claim payload containing voucher claims and optional authorizer signature. * @param requirements - Payment requirements for network identification. * @param authorizerSigner - Dedicated key for producing `ClaimBatch` TIP-712 signatures. + * When omitted, the payload must already carry a `claimAuthorizerSignature`. * @returns A {@link SettleResponse} with the transaction hash on success. */ export async function executeClaimWithSignature( signer: FacilitatorTronSigner, payload: BatchSettlementClaimPayload, requirements: PaymentRequirements, - authorizerSigner: TronAuthorizerSigner, + authorizerSigner: TronAuthorizerSigner | undefined, ): Promise { const network = requirements.network; const claimArgs = buildVoucherClaimArgs(payload.claims); let sig = payload.claimAuthorizerSignature; if (!sig) { + if (!authorizerSigner) { + return { + success: false, + errorReason: Errors.ErrAuthorizerNotConfigured, + transaction: "", + network, + }; + } for (const claim of payload.claims) { if ( normalizeAddressForSigning(claim.voucher.channel.receiverAuthorizer) !== diff --git a/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/refund.ts b/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/refund.ts index c9ee11db..74a19671 100644 --- a/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/refund.ts +++ b/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/refund.ts @@ -105,13 +105,14 @@ function buildRefundExtra( * @param payload - Refund payload with optional signatures, amount, and nonce. * @param requirements - Payment requirements for network identification. * @param authorizerSigner - Dedicated key for producing TIP-712 signatures. + * When omitted, the payload must already carry the required authorizer signatures. * @returns A {@link SettleResponse} with the transaction hash on success. */ export async function executeRefundWithSignature( signer: FacilitatorTronSigner, payload: BatchSettlementEnrichedRefundPayload, requirements: PaymentRequirements, - authorizerSigner: TronAuthorizerSigner, + authorizerSigner: TronAuthorizerSigner | undefined, ): Promise { const network = requirements.network; const address = getBatchSettlementAddress(network); @@ -132,9 +133,20 @@ export async function executeRefundWithSignature( } const hasClientSig = payload.refundAuthorizerSignature !== undefined; - const authorizerMismatch = - normalizeAddressForSigning(payload.channelConfig.receiverAuthorizer) !== - normalizeAddressForSigning(authorizerSigner.address); + const authorizerMismatch = authorizerSigner + ? normalizeAddressForSigning(payload.channelConfig.receiverAuthorizer) !== + normalizeAddressForSigning(authorizerSigner.address) + : false; + + if (!hasClientSig && !authorizerSigner) { + return { + success: false, + errorReason: Errors.ErrAuthorizerNotConfigured, + transaction: "", + network, + }; + } + if (!hasClientSig && authorizerMismatch) { return { success: false, @@ -146,7 +158,15 @@ export async function executeRefundWithSignature( const refundSig = payload.refundAuthorizerSignature ?? - (await signRefund(authorizerSigner, channelId, payload.amount, payload.refundNonce, network)); + (authorizerSigner + ? await signRefund( + authorizerSigner, + channelId, + payload.amount, + payload.refundNonce, + network, + ) + : undefined); const contractConfig = toContractChannelConfig(payload.channelConfig); const refundArgs = [ @@ -160,7 +180,17 @@ export async function executeRefundWithSignature( if (payload.claims.length > 0) { const claimSig = payload.claimAuthorizerSignature ?? - (await signClaimBatch(authorizerSigner, payload.claims, network)); + (authorizerSigner + ? await signClaimBatch(authorizerSigner, payload.claims, network) + : undefined); + if (!claimSig) { + return { + success: false, + errorReason: Errors.ErrAuthorizerNotConfigured, + transaction: "", + network, + }; + } const claimCalldata = encodeFunctionData(abi, "claimWithSignature", [ buildVoucherClaimArgs(payload.claims), diff --git a/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/scheme.ts b/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/scheme.ts index 2aaf5014..834c7c8f 100644 --- a/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/batch-settlement/facilitator/scheme.ts @@ -38,22 +38,27 @@ export class BatchSettlementTronScheme implements SchemeNetworkFacilitator { * Creates a facilitator scheme for verifying and settling batch-settlement payments. * * @param signer - Facilitator TRON signer used for tx submission and onchain reads. - * @param authorizerSigner - Dedicated key that provides TIP-712 signatures for - * `claimWithSignature` / `refundWithSignature` when the server omits them. + * @param authorizerSigner - Optional dedicated key that provides TIP-712 signatures for + * `claimWithSignature` / `refundWithSignature` when the server omits them. When omitted, + * no `receiverAuthorizer` is advertised and servers must supply their own signatures. */ constructor( private readonly signer: FacilitatorTronSigner, - private readonly authorizerSigner: TronAuthorizerSigner, + private readonly authorizerSigner?: TronAuthorizerSigner, ) {} /** * Returns facilitator-specific extra fields to be merged into payment requirements. + * Returns `undefined` when no authorizer signer is configured. * * @param _ - Network identifier (unused). - * @returns Extra fields containing `receiverAuthorizer`. + * @returns Extra fields containing `receiverAuthorizer`, or `undefined`. */ getExtra(_: string): { receiverAuthorizer: string } | undefined { void _; + if (!this.authorizerSigner) { + return undefined; + } return { receiverAuthorizer: this.authorizerSigner.address }; } diff --git a/typescript/packages/mechanisms/tron/src/batch-settlement/server/scheme.ts b/typescript/packages/mechanisms/tron/src/batch-settlement/server/scheme.ts index 166f0a34..76e21619 100644 --- a/typescript/packages/mechanisms/tron/src/batch-settlement/server/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/batch-settlement/server/scheme.ts @@ -7,6 +7,7 @@ import { SchemeNetworkServer, SchemeServerHooks, MoneyParser, + SupportedKind, } from "@bankofai/x402-core/types"; import type { DeepReadonly } from "@bankofai/x402-core/types"; import type { SettleContext, SettleResultContext } from "@bankofai/x402-core/server"; @@ -342,6 +343,35 @@ export class BatchSettlementTronScheme implements SchemeNetworkServer { return this.receiverAuthorizerSigner; } + /** + * Fails server startup when this scheme delegates the receiver-authorizer role + * but the facilitator does not advertise a usable `receiverAuthorizer`. + * + * @param network - The network identifier being validated. + * @param supportedKind - The facilitator's advertised kind for this scheme/network. + * @param _ - Extensions advertised by the facilitator (unused). + * @returns A problem message when delegation is impossible, or void when valid. + */ + validateFacilitatorSupport( + network: Network, + supportedKind: SupportedKind, + _: string[], + ): string | void { + if (this.receiverAuthorizerSigner) return; + + const advertised = supportedKind.extra?.receiverAuthorizer; + const hasValid = + typeof advertised === "string" && normalizeAddressForSigning(advertised) !== ZERO_ADDRESS; + + if (!hasValid) { + return ( + `no receiverAuthorizerSigner is configured and the facilitator does not advertise a ` + + `receiverAuthorizer on ${network}. Configure a receiverAuthorizerSigner or use a ` + + `facilitator that advertises one.` + ); + } + } + /** * Creates a {@link BatchSettlementChannelManager} pre-configured with this scheme's * receiver, default token for the given network, and the provided facilitator. diff --git a/typescript/packages/mechanisms/tron/src/constants.ts b/typescript/packages/mechanisms/tron/src/constants.ts index 7d87725f..c455b5da 100644 --- a/typescript/packages/mechanisms/tron/src/constants.ts +++ b/typescript/packages/mechanisms/tron/src/constants.ts @@ -114,6 +114,7 @@ export const uptoPermit2WitnessTypes = { export const PERMIT2_ADDRESSES: Record = { "tron:0x2b6653dc": "TTJxU3P8rHycAyFY4kVtGNfmnMH4ezcuM9", "tron:0xcd8690dc": "TYQuuhGbEMxF7nZxUHV3uHJxAVVAegNU9h", + "tron:0x94a9059e": "TJMkP7a3ucTMkvi17p7ChhTCw6zriFX3tg", }; /** @@ -123,6 +124,7 @@ export const PERMIT2_ADDRESSES: Record = { export const X402_PERMIT2_PROXY_ADDRESSES: Record = { "tron:0x2b6653dc": "TN49yaJmZMZoEdDCqjB4uPzQLHvYkGw95m", "tron:0xcd8690dc": "TFGoaq2KjizijgjtkVxT7yjffW1A5T1j6F", + "tron:0x94a9059e": "TGZkC38n14f2GpBWPMQLF2BpmcpWW3QNhg", }; /** @@ -132,6 +134,7 @@ export const X402_PERMIT2_PROXY_ADDRESSES: Record = { export const X402_UPTO_PERMIT2_PROXY_ADDRESSES: Record = { "tron:0x2b6653dc": "TBLeFPkfDiweBbYmAPqnakaFBPDt9p93sR", "tron:0xcd8690dc": "TKvcqQ7S2bYyys5ZZNpjj9xGiPhiwzHq1K", + "tron:0x94a9059e": "TMxpieW75DQiA9QaoTB1ifJWeQpuppSB1g", }; /** diff --git a/typescript/packages/mechanisms/tron/src/exact/client/eip3009.ts b/typescript/packages/mechanisms/tron/src/exact/client/eip3009.ts index ef7d4ac7..e69a301d 100644 --- a/typescript/packages/mechanisms/tron/src/exact/client/eip3009.ts +++ b/typescript/packages/mechanisms/tron/src/exact/client/eip3009.ts @@ -28,7 +28,7 @@ export async function createEIP3009Payload( from: fromAddress, to: toAddress, value: paymentRequirements.amount, - validAfter: (now - 600).toString(), + validAfter: "0", validBefore: (now + paymentRequirements.maxTimeoutSeconds).toString(), nonce, }; diff --git a/typescript/packages/mechanisms/tron/src/exact/client/permit2.ts b/typescript/packages/mechanisms/tron/src/exact/client/permit2.ts index d3fec566..8b08a10f 100644 --- a/typescript/packages/mechanisms/tron/src/exact/client/permit2.ts +++ b/typescript/packages/mechanisms/tron/src/exact/client/permit2.ts @@ -37,7 +37,7 @@ export async function createPermit2Payload( throw new Error(`No x402Permit2Proxy contract address configured for network ${network}`); } - const validAfter = (now - 600).toString(); + const validAfter = "0"; const deadline = (now + paymentRequirements.maxTimeoutSeconds).toString(); const fromAddress = normalizeAddressForSigning(signer.address); @@ -62,11 +62,12 @@ export async function createPermit2Payload( // Ensure the one-time Permit2 allowance before signing (mirrors the Python // client). No-op when the signer can't broadcast (sign-only wallet) or when - // the allowance already covers the payment. TRON's mainstream tokens + // the allowance already covers payment + fee. TRON's mainstream tokens // (USDT/USDD) lack ERC-3009, so this approve is required on first use. + const totalRequired = BigInt(paymentRequirements.amount); await signer.ensureAllowance?.({ token: paymentRequirements.asset, - amount: BigInt(paymentRequirements.amount), + amount: totalRequired, network, }); diff --git a/typescript/packages/mechanisms/tron/src/gasfree/client/scheme.ts b/typescript/packages/mechanisms/tron/src/gasfree/client/scheme.ts index 294d3f54..feff2426 100644 --- a/typescript/packages/mechanisms/tron/src/gasfree/client/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/gasfree/client/scheme.ts @@ -8,7 +8,6 @@ import { ClientTronSigner } from "../../signer"; import { ExactGasFreePayload, GasFreeMessage } from "../../types"; import { getDecimals } from "../../shared/tokens"; import { log } from "@bankofai/x402-core"; -import { readFeeFromExtra, type FeeInfo } from "../../shared/fee"; import { transferWithAuthorizationABI } from "../../constants"; import { GasFreeAPIClient, @@ -97,6 +96,23 @@ export class ExactGasFreeTronScheme implements SchemeNetworkClient { return this.readGasFreeBalance(asset, accountInfo.gasFreeAddress); } + /** + * Estimate the total deduction for a GasFree payment: amount + maxFee + * (transferFee, plus activateFee when the account is inactive). Used by + * filterAffordableRequirements so options whose amount alone is affordable + * but whose amount + provider fee is not are excluded before selection. + * + * @param requirements - The payment requirements. + * @returns The estimated total cost (amount + maxFee) in smallest units. + */ + async estimateCost(requirements: PaymentRequirements): Promise { + const api = this.getApiClient(requirements.network); + const accountInfo = await api.getAddressInfo(this.signer.address); + const asset = accountInfo.assets.find(a => a.tokenAddress === requirements.asset); + const maxFee = this.computeMaxFee(requirements, accountInfo, asset); + return BigInt(requirements.amount) + maxFee; + } + /** * Create the `exact_gasfree` payment payload. * @@ -124,11 +140,10 @@ export class ExactGasFreeTronScheme implements SchemeNetworkClient { throw new Error(`GasFree account for ${user} (${gasfreeAddress}) is not activated.`); } - const fee = readFeeFromExtra(requirements.extra); - const serviceProvider = await this.resolveProvider(api, fee); + const serviceProvider = await this.resolveProvider(api); const asset = accountInfo.assets.find(a => a.tokenAddress === requirements.asset); - const maxFee = this.computeMaxFee(requirements, accountInfo, asset, fee); + const maxFee = this.computeMaxFee(requirements, accountInfo, asset); const skipBalanceCheck = (context?.extensions?.skipBalanceCheck as boolean) ?? false; if (!skipBalanceCheck) { @@ -207,16 +222,12 @@ export class ExactGasFreeTronScheme implements SchemeNetworkClient { } /** - * Resolve the service provider: the requirement's fee.feeTo, or a relayer pick. + * Resolve the service provider by picking from the relayer's provider list. * * @param api - The GasFree relayer client. - * @param fee - The fee terms from the requirement, if any. * @returns The selected service provider address. */ - private async resolveProvider(api: GasFreeAPIClient, fee: FeeInfo | undefined): Promise { - if (fee?.feeTo) { - return fee.feeTo; - } + private async resolveProvider(api: GasFreeAPIClient): Promise { const providers = await api.getProviders(); if (!providers || providers.length === 0) { throw new Error("No GasFree service providers available"); @@ -225,29 +236,26 @@ export class ExactGasFreeTronScheme implements SchemeNetworkClient { } /** - * Compute the maxFee: max(account transferFee, facilitator fee), plus - * activateFee when inactive, falling back to one token when nothing is known. + * Compute the maxFee: account transferFee, plus activateFee when inactive, + * falling back to one token when nothing is known. * * @param requirements - The payment requirements. * @param accountInfo - The GasFree account info. * @param asset - The GasFree per-token fee entry, if present. - * @param fee - The fee terms from the requirement, if any. * @returns The computed maxFee in smallest units. */ private computeMaxFee( requirements: PaymentRequirements, accountInfo: GasFreeAddressInfo, asset: GasFreeAsset | undefined, - fee: FeeInfo | undefined, ): bigint { const transferFee = BigInt(asset?.transferFee ?? "0"); const activateFee = BigInt(asset?.activateFee ?? "0"); - const facilitatorFee = BigInt(fee?.feeAmount ?? "0"); - let maxFee = transferFee > facilitatorFee ? transferFee : facilitatorFee; + let maxFee = transferFee; - // Fallback: nothing known and no facilitator fee → default to 1 token. - if (maxFee === 0n && !fee) { + // Fallback: nothing known → default to 1 token. + if (maxFee === 0n) { const decimals = getDecimals(requirements.network, requirements.asset); maxFee = BigInt(10) ** BigInt(decimals); } diff --git a/typescript/packages/mechanisms/tron/src/gasfree/facilitator/errors.ts b/typescript/packages/mechanisms/tron/src/gasfree/facilitator/errors.ts index 2095e77f..c977f919 100644 --- a/typescript/packages/mechanisms/tron/src/gasfree/facilitator/errors.ts +++ b/typescript/packages/mechanisms/tron/src/gasfree/facilitator/errors.ts @@ -2,13 +2,10 @@ export const INVALID_SCHEME = "invalid_exact_gasfree_scheme"; export const NETWORK_MISMATCH = "invalid_exact_gasfree_network_mismatch"; export const MISSING_PAYLOAD = "missing_gasfree_payload"; -export const TOKEN_NOT_ALLOWED = "gasfree_token_not_allowed"; export const TOKEN_MISMATCH = "gasfree_token_mismatch"; export const AMOUNT_MISMATCH = "gasfree_amount_mismatch"; export const PAYTO_MISMATCH = "gasfree_payto_mismatch"; export const FEE_TO_MISMATCH = "gasfree_fee_to_mismatch"; -export const FEE_AMOUNT_TOO_LOW = "gasfree_fee_amount_too_low"; -export const UNSUPPORTED_TOKEN = "gasfree_unsupported_token"; export const EXPIRED = "gasfree_expired"; export const INVALID_SIGNATURE = "invalid_gasfree_signature"; export const INSUFFICIENT_FUNDS = "insufficient_funds"; diff --git a/typescript/packages/mechanisms/tron/src/gasfree/facilitator/register.ts b/typescript/packages/mechanisms/tron/src/gasfree/facilitator/register.ts index 823fbf22..c92cd3fd 100644 --- a/typescript/packages/mechanisms/tron/src/gasfree/facilitator/register.ts +++ b/typescript/packages/mechanisms/tron/src/gasfree/facilitator/register.ts @@ -1,7 +1,6 @@ import { x402Facilitator } from "@bankofai/x402-core/facilitator"; import { Network } from "@bankofai/x402-core/types"; import { FacilitatorTronSigner } from "../../signer"; -import { ExactTronFeeConfig } from "../../shared/fee"; import { GasFreeAPIClient, createGasFreeApiClients } from "../../shared/gasfree/api"; import { GASFREE_API_BASE_URLS } from "../../shared/gasfree/config"; import { ExactGasFreeTronScheme } from "./scheme"; @@ -16,8 +15,6 @@ export interface TronGasFreeFacilitatorConfig { apiClients?: Record; /** GasFree relayer base URLs keyed by network (used when apiClients omitted). */ apiBaseUrls?: Record; - /** Optional facilitator fee policy. */ - fee?: ExactTronFeeConfig; } /** @@ -33,9 +30,6 @@ export function registerExactGasFreeTronScheme( ): x402Facilitator { const apiClients = config.apiClients ?? createGasFreeApiClients(config.apiBaseUrls ?? GASFREE_API_BASE_URLS); - facilitator.register( - config.networks, - new ExactGasFreeTronScheme(config.signer, apiClients, config.fee), - ); + facilitator.register(config.networks, new ExactGasFreeTronScheme(config.signer, apiClients)); return facilitator; } diff --git a/typescript/packages/mechanisms/tron/src/gasfree/facilitator/scheme.ts b/typescript/packages/mechanisms/tron/src/gasfree/facilitator/scheme.ts index eee47641..6d0a7eaf 100644 --- a/typescript/packages/mechanisms/tron/src/gasfree/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/gasfree/facilitator/scheme.ts @@ -9,7 +9,6 @@ import { import { FacilitatorTronSigner } from "../../signer"; import { ExactGasFreePayload } from "../../types"; import { normalizeAddressForSigning } from "../../utils"; -import { resolveBaseFee, isTokenAllowed, type ExactTronFeeConfig } from "../../shared/fee"; import { GasFreeAPIClient } from "../../shared/gasfree/api"; import { assembleGasFreeTransaction } from "../../shared/gasfree/assemble"; import * as errors from "./errors"; @@ -29,40 +28,21 @@ export class ExactGasFreeTronScheme implements SchemeNetworkFacilitator { * * @param signer - The TRON facilitator signer (verify + balance reads). * @param apiClients - GasFree relayer clients keyed by CAIP-2 network. - * @param feeConfig - Optional fee policy (base fees, token allowlist, feeTo). */ constructor( private readonly signer: FacilitatorTronSigner, private readonly apiClients: Record, - private readonly feeConfig: ExactTronFeeConfig = {}, ) {} /** - * Advertise the fee configuration so the server can attach fee terms. + * Returns undefined — the GasFree facilitator advertises no extra config. * * @param _network - The network identifier (unused). - * @returns Extra data, or undefined when no fee is configured. + * @returns undefined. */ getExtra(_network: string): Record | undefined { void _network; - if (!this.feeConfig.baseFee) return undefined; - return { - feeConfig: { - // GasFree feeTo MUST be a registered relayer provider — verify rejects - // anything else with `gasfree_fee_to_mismatch`. NEVER default to the - // facilitator's own address (it is not a provider; that produced the - // mismatch in production). Advertise feeTo only when explicitly - // configured to a real provider; otherwise omit it so the client picks - // one from the relayer's provider list. This mirrors the Python - // facilitator's `fee_quote`, which selects a provider rather than the - // facilitator address. (`exact` differs: there the facilitator IS the - // fee recipient, so its scheme keeps the self-address default.) - ...(this.feeConfig.feeTo ? { feeTo: this.feeConfig.feeTo } : {}), - ...(this.feeConfig.caller ? { caller: this.feeConfig.caller } : {}), - baseFee: this.feeConfig.baseFee, - ...(this.feeConfig.allowedTokens ? { allowedTokens: this.feeConfig.allowedTokens } : {}), - }, - }; + return undefined; } /** @@ -226,9 +206,6 @@ export class ExactGasFreeTronScheme implements SchemeNetworkFacilitator { const norm = (a: string) => normalizeAddressForSigning(a); const m = gf.gasfree; - if (!isTokenAllowed(this.feeConfig, m.token)) { - return errors.TOKEN_NOT_ALLOWED; - } if (norm(m.token) !== norm(requirements.asset)) { return errors.TOKEN_MISMATCH; } @@ -239,14 +216,12 @@ export class ExactGasFreeTronScheme implements SchemeNetworkFacilitator { return errors.PAYTO_MISMATCH; } - // Fee policy: feeTo must be an active provider, or match configured feeTo. - const feeToError = await this.validateFeeTo(m.serviceProvider, requirements.network); - if (feeToError) return feeToError; - - const baseFee = resolveBaseFee(this.feeConfig, requirements.network, m.token); - if (baseFee !== null && BigInt(m.maxFee) < baseFee) { - return errors.FEE_AMOUNT_TOO_LOW; - } + // The serviceProvider must be an active relayer provider. + const providerError = await this.validateServiceProvider( + m.serviceProvider, + requirements.network, + ); + if (providerError) return providerError; const now = Math.floor(Date.now() / 1000); if (BigInt(m.deadline) < BigInt(now)) { @@ -256,14 +231,16 @@ export class ExactGasFreeTronScheme implements SchemeNetworkFacilitator { } /** - * Validate the fee collector: must be an active relayer provider, or match - * the configured feeTo when the provider list is unavailable. + * Validate the serviceProvider: must be an active relayer provider. * - * @param serviceProvider - The fee collector / provider address. + * @param serviceProvider - The relayer provider address. * @param network - CAIP-2 network identifier. * @returns An error reason string, or null when valid. */ - private async validateFeeTo(serviceProvider: string, network: string): Promise { + private async validateServiceProvider( + serviceProvider: string, + network: string, + ): Promise { const norm = (a: string) => normalizeAddressForSigning(a); try { const providers = await this.getApiClient(network).getProviders(); @@ -272,10 +249,7 @@ export class ExactGasFreeTronScheme implements SchemeNetworkFacilitator { return allowed.has(norm(serviceProvider)) ? null : errors.FEE_TO_MISMATCH; } } catch { - // Fall back to configured feeTo when the provider list is unavailable. - } - if (this.feeConfig.feeTo && norm(serviceProvider) !== norm(this.feeConfig.feeTo)) { - return errors.FEE_TO_MISMATCH; + // Provider list unavailable — cannot validate. } return null; } diff --git a/typescript/packages/mechanisms/tron/src/gasfree/server/scheme.ts b/typescript/packages/mechanisms/tron/src/gasfree/server/scheme.ts index e95943a4..daffc9e6 100644 --- a/typescript/packages/mechanisms/tron/src/gasfree/server/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/gasfree/server/scheme.ts @@ -12,7 +12,6 @@ import { findByAddress, parsePrice as parseTokenPrice, } from "../../shared/tokens"; -import { buildFeeInfo, type ExactTronFeeConfig } from "../../shared/fee"; /** * TRON server implementation for the `exact_gasfree` scheme. @@ -66,7 +65,7 @@ export class ExactGasFreeTronScheme implements SchemeNetworkServer { * @param supportedKind.x402Version - The x402 version. * @param supportedKind.scheme - The payment scheme. * @param supportedKind.network - The network identifier. - * @param supportedKind.extra - Optional facilitator extra (feeConfig). + * @param supportedKind.extra - Optional facilitator extra. * @param extensionKeys - Facilitator extension keys (unused). * @returns The enhanced payment requirements. */ @@ -83,18 +82,6 @@ export class ExactGasFreeTronScheme implements SchemeNetworkServer { void extensionKeys; const token = findByAddress(supportedKind.network, paymentRequirements.asset); - const feeConfig = supportedKind.extra?.feeConfig as ExactTronFeeConfig | undefined; - const existingFee = paymentRequirements.extra?.fee; - const fee = - existingFee ?? - (feeConfig - ? buildFeeInfo( - feeConfig, - supportedKind.network, - paymentRequirements.asset, - feeConfig.feeTo ?? "", - ) - : undefined); return Promise.resolve({ ...paymentRequirements, @@ -103,7 +90,6 @@ export class ExactGasFreeTronScheme implements SchemeNetworkServer { ...(token ? { name: token.name, ...(token.version ? { version: token.version } : {}) } : {}), - ...(fee ? { fee } : {}), }, }); } diff --git a/typescript/packages/mechanisms/tron/src/index.ts b/typescript/packages/mechanisms/tron/src/index.ts index 63e643d2..46f4eaa5 100644 --- a/typescript/packages/mechanisms/tron/src/index.ts +++ b/typescript/packages/mechanisms/tron/src/index.ts @@ -75,9 +75,6 @@ export { type TokenInfo, } from "./shared/tokens"; -// Fee -export { type FeeInfo, type ExactTronFeeConfig, type GasFreeTronFeeConfig } from "./shared/fee"; - // Token selection + balance-aware selection export { CheapestTokenSelectionStrategy, diff --git a/typescript/packages/mechanisms/tron/src/shared/balance.ts b/typescript/packages/mechanisms/tron/src/shared/balance.ts index 54c1234d..13ebcf44 100644 --- a/typescript/packages/mechanisms/tron/src/shared/balance.ts +++ b/typescript/packages/mechanisms/tron/src/shared/balance.ts @@ -1,6 +1,5 @@ import type { PaymentRequirements } from "@bankofai/x402-core/types"; import { CheapestTokenSelectionStrategy, type TokenSelectionStrategy } from "./tokenSelection"; -import { readFeeFromExtra } from "./fee"; /** * Balance-aware payment selection for TRON. @@ -20,17 +19,27 @@ export interface BalanceCheckable { * @returns The balance in smallest units. */ checkBalance(asset: string, network: string): Promise; + + /** + * Estimate the total deduction required to fulfill a requirement, + * including scheme-specific fees (e.g. GasFree transferFee/activateFee). + * + * When omitted, the cost defaults to req.amount (no extra fees). + * + * @param req - The payment requirement. + * @returns The estimated total cost in smallest units. + */ + estimateCost?(req: PaymentRequirements): Promise; } /** * Filter out requirements the payer cannot afford. * * Requirements whose balance cannot be determined are kept, deferring the - * decision to createPaymentPayload. Only `exact_gasfree` adds its enforced - * relayer fee to the required amount; `exact` and `upto` proxies transfer - * exactly `amount` and carry no fee, so stale `extra.fee` metadata is ignored. + * decision to createPaymentPayload. * - * @param scheme - A client scheme exposing checkBalance. + * @param scheme - A client scheme exposing checkBalance (and optionally + * estimateCost for schemes with extra on-chain fees, e.g. GasFree). * @param accepts - The candidate payment requirements. * @returns The affordable subset (possibly empty). */ @@ -49,10 +58,13 @@ export async function filterAffordableRequirements( continue; } let needed = BigInt(req.amount); - if (req.scheme === "exact_gasfree") { - const fee = readFeeFromExtra(req.extra); - if (fee) { - needed += BigInt(fee.feeAmount); + if (scheme.estimateCost) { + try { + needed = await scheme.estimateCost(req); + } catch { + // Cannot estimate scheme-specific cost; defer to createPaymentPayload. + affordable.push(req); + continue; } } if (balance >= needed) { diff --git a/typescript/packages/mechanisms/tron/src/shared/batch-settlement/constants.ts b/typescript/packages/mechanisms/tron/src/shared/batch-settlement/constants.ts index 8b398f17..cea3c6b8 100644 --- a/typescript/packages/mechanisms/tron/src/shared/batch-settlement/constants.ts +++ b/typescript/packages/mechanisms/tron/src/shared/batch-settlement/constants.ts @@ -17,6 +17,7 @@ export const BATCH_SETTLEMENT_SCHEME = "batch-settlement" as const; export const BATCH_SETTLEMENT_ADDRESSES: Record = { "tron:0x2b6653dc": "TW9yNhTySkEHYfjnGQU2u4NAsdb1tW4fbm", "tron:0xcd8690dc": "TWBwWHZWwH8TzrZnbxit1J645VGYY1K2fA", + "tron:0x94a9059e": "TA3MZHMLsgi8JMU1DL8H4gKp1YjJKATibf", }; /** @@ -25,6 +26,7 @@ export const BATCH_SETTLEMENT_ADDRESSES: Record = { export const ERC3009_DEPOSIT_COLLECTOR_ADDRESSES: Record = { "tron:0x2b6653dc": "TTWA7aWMdx4jfcbp8XRAS2JAd2sUhyF9qj", "tron:0xcd8690dc": "TJUQ3BQt4YFg8EeevjiUa5LbfSGz5BxzRW", + "tron:0x94a9059e": "TRd1KBfy1iUs6R45oZrtbLUjtcSKzXAvPG", }; /** @@ -33,6 +35,7 @@ export const ERC3009_DEPOSIT_COLLECTOR_ADDRESSES: Record = { export const PERMIT2_DEPOSIT_COLLECTOR_ADDRESSES: Record = { "tron:0x2b6653dc": "TAg5qqp1K9x5KeSTWnRa8LT79B5HUjzSHY", "tron:0xcd8690dc": "TEp6bCqSEKAr99sCiqANC84RtRwx7xGbA4", + "tron:0x94a9059e": "TNmfrxbKCHqPUTj9zHVfg4Dq8WNZXPyf1x", }; /** diff --git a/typescript/packages/mechanisms/tron/src/shared/fee.ts b/typescript/packages/mechanisms/tron/src/shared/fee.ts deleted file mode 100644 index a4e6b6a7..00000000 --- a/typescript/packages/mechanisms/tron/src/shared/fee.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Network } from "@bankofai/x402-core/types"; -import { findByAddress } from "./tokens"; - -/** - * Facilitator fee model for TRON schemes. - * - * On TRON, energy (gas) for a TRC-20 settlement costs real value, so a - * facilitator must recoup it from the payment token — unlike Coinbase's Base - * deployment where gas is negligible and the protocol carries no fee. This - * module holds the fee data model and helpers used by `exact_gasfree`, where the - * relayer actually deducts `feeAmount` from the GasFree wallet. The `exact` and - * `upto` schemes carry no fee (the proxy transfers exactly `amount`). - */ - -/** - * Fee terms attached to a payment requirement via `requirements.extra.fee`. - */ -export interface FeeInfo { - /** Address that collects the facilitator fee (Base58Check). */ - feeTo: string; - /** Fee amount in the token's smallest unit. */ - feeAmount: string; - /** Optional caller the on-chain contract / relayer requires (e.g. provider). */ - caller?: string; -} - -/** - * Facilitator-side fee configuration. - * - * The fee is **enforced** on `exact_gasfree` — the relayer actually deducts - * `feeAmount` from the GasFree wallet. The `exact` and `upto` schemes carry no - * fee (their proxies transfer exactly `amount` and do not split a fee). - */ -export interface ExactTronFeeConfig { - /** Address that collects the fee. Defaults to the facilitator signer address. */ - feeTo?: string; - /** Optional caller address required by the settling contract / relayer. */ - caller?: string; - /** - * Per-symbol base fee in smallest units (symbol uppercased before lookup). - * Tokens absent from this map have no configured fee. - */ - baseFee?: Record; - /** - * Optional token allowlist (Base58Check addresses, matched case-insensitively). - * When set, only listed tokens are accepted; when undefined, all tokens pass. - */ - allowedTokens?: ReadonlyArray; -} - -/** - * Alias reflecting the current semantics: this config drives the enforced - * relayer fee on `exact_gasfree` only. `ExactTronFeeConfig` is retained for - * backward compatibility. - */ -export type GasFreeTronFeeConfig = ExactTronFeeConfig; - -/** - * Resolve the configured base fee for an asset on a network. - * - * @param config - The facilitator fee configuration. - * @param network - CAIP-2 network identifier. - * @param asset - TRC-20 contract address. - * @returns The base fee in smallest units, or null when no fee is configured - * for the asset (token unknown or symbol absent from `baseFee`). - */ -export function resolveBaseFee( - config: ExactTronFeeConfig, - network: Network, - asset: string, -): bigint | null { - if (!config.baseFee) return null; - const token = findByAddress(network, asset); - if (!token) return null; - const raw = config.baseFee[token.symbol.toUpperCase()]; - if (raw === undefined) return null; - return BigInt(raw); -} - -/** - * Check whether an asset is permitted by the fee config's token allowlist. - * - * @param config - The facilitator fee configuration. - * @param asset - TRC-20 contract address. - * @returns True when no allowlist is set or the asset is listed. - */ -export function isTokenAllowed(config: ExactTronFeeConfig, asset: string): boolean { - if (!config.allowedTokens) return true; - const lower = asset.toLowerCase(); - return config.allowedTokens.some(a => a.toLowerCase() === lower); -} - -/** - * Build the {@link FeeInfo} a facilitator advertises for an asset, if any. - * - * @param config - The facilitator fee configuration. - * @param network - CAIP-2 network identifier. - * @param asset - TRC-20 contract address. - * @param defaultFeeTo - Fallback fee collector (typically the signer address). - * @returns The fee terms, or undefined when no fee applies to the asset. - */ -export function buildFeeInfo( - config: ExactTronFeeConfig, - network: Network, - asset: string, - defaultFeeTo: string, -): FeeInfo | undefined { - if (!isTokenAllowed(config, asset)) return undefined; - const baseFee = resolveBaseFee(config, network, asset); - if (baseFee === null) return undefined; - const feeTo = config.feeTo ?? defaultFeeTo; - return { - feeTo, - feeAmount: baseFee.toString(), - ...(config.caller ? { caller: config.caller } : {}), - }; -} - -/** - * Extract a {@link FeeInfo} from a requirement/supported-kind `extra` object. - * - * @param extra - An `extra` record that may carry a `fee` field. - * @returns The fee terms when present and well-formed, otherwise undefined. - */ -export function readFeeFromExtra(extra: Record | undefined): FeeInfo | undefined { - const fee = extra?.fee as Partial | undefined; - if (!fee || typeof fee.feeTo !== "string" || typeof fee.feeAmount !== "string") { - return undefined; - } - return { - feeTo: fee.feeTo, - feeAmount: fee.feeAmount, - ...(typeof fee.caller === "string" ? { caller: fee.caller } : {}), - }; -} diff --git a/typescript/packages/mechanisms/tron/src/shared/tokens.ts b/typescript/packages/mechanisms/tron/src/shared/tokens.ts index 96528fd3..ccbc2ca4 100644 --- a/typescript/packages/mechanisms/tron/src/shared/tokens.ts +++ b/typescript/packages/mechanisms/tron/src/shared/tokens.ts @@ -38,9 +38,7 @@ export interface TokenInfo { * Built-in TRC-20 tokens indexed by CAIP-2 network and uppercased symbol. * * Note: `tron:0x2b6653dc` and `tron:0xcd8690dc` have Permit2 + x402Permit2Proxy - * deployments, so their tokens default to `permit2`. `tron:0x94a9059e` has no - * Permit2 deployment, so USDT there falls back to the TIP-712 - * TransferWithAuthorization (`eip3009`) path. + * deployments, so their tokens default to `permit2`. */ const TOKENS: Record> = { "tron:0x2b6653dc": { @@ -86,6 +84,7 @@ const TOKENS: Record> = { name: "Tether USD", symbol: "USDT", version: "1", + assetTransferMethod: "permit2", }, }, }; diff --git a/typescript/packages/mechanisms/tron/src/upto/client/permit2.ts b/typescript/packages/mechanisms/tron/src/upto/client/permit2.ts index 8d2b441b..5e142822 100644 --- a/typescript/packages/mechanisms/tron/src/upto/client/permit2.ts +++ b/typescript/packages/mechanisms/tron/src/upto/client/permit2.ts @@ -40,8 +40,9 @@ export async function createUptoPermit2Payload( throw new Error(`No x402UptoPermit2Proxy contract address configured for network ${network}`); } - const facilitatorAddress = (paymentRequirements.extra?.permit2FacilitatorAddress ?? - paymentRequirements.extra?.facilitatorAddress) as string | undefined; + const facilitatorAddress = paymentRequirements.extra?.permit2FacilitatorAddress as + | string + | undefined; if (!facilitatorAddress) { throw new Error( "upto scheme requires permit2FacilitatorAddress in paymentRequirements.extra. " + @@ -49,7 +50,7 @@ export async function createUptoPermit2Payload( ); } - const validAfter = (now - 600).toString(); + const validAfter = "0"; const deadline = (now + paymentRequirements.maxTimeoutSeconds).toString(); if (BigInt(deadline) <= BigInt(validAfter)) { @@ -83,13 +84,14 @@ export async function createUptoPermit2Payload( // Ensure the one-time Permit2 allowance before signing (mirrors the exact // client). No-op when the signer can't broadcast (sign-only wallet) or when - // the allowance already covers the authorized maximum. TRON's mainstream + // the allowance already covers the authorized maximum + fee. TRON's mainstream // tokens (USDT/USDD) lack ERC-3009, so this approve is required on first use. // `permitted.amount` is the upto ceiling — approve at least that much, since // the facilitator may settle for any amount up to it. + const totalRequired = BigInt(paymentRequirements.amount); await signer.ensureAllowance?.({ token: paymentRequirements.asset, - amount: BigInt(paymentRequirements.amount), + amount: totalRequired, network, }); diff --git a/typescript/packages/mechanisms/tron/src/upto/facilitator/scheme.ts b/typescript/packages/mechanisms/tron/src/upto/facilitator/scheme.ts index a0fad4bd..23368735 100644 --- a/typescript/packages/mechanisms/tron/src/upto/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/upto/facilitator/scheme.ts @@ -42,11 +42,10 @@ export class UptoTronScheme implements SchemeNetworkFacilitator { if (signers.length === 0) { return undefined; } - const facilitatorAddress = signers[Math.floor(Math.random() * signers.length)]; + const permit2FacilitatorAddress = signers[Math.floor(Math.random() * signers.length)]; return { assetTransferMethod: "permit2", - facilitatorAddress, - permit2FacilitatorAddress: facilitatorAddress, + permit2FacilitatorAddress, }; } diff --git a/typescript/packages/mechanisms/tron/src/upto/server/scheme.ts b/typescript/packages/mechanisms/tron/src/upto/server/scheme.ts index 342174fb..6d8ad255 100644 --- a/typescript/packages/mechanisms/tron/src/upto/server/scheme.ts +++ b/typescript/packages/mechanisms/tron/src/upto/server/scheme.ts @@ -103,20 +103,16 @@ export class UptoTronScheme implements SchemeNetworkServer { delete extra.fee; const requirementsWithoutFee = { ...paymentRequirements, extra }; - const facilitatorAddress = + const permit2FacilitatorAddress = (paymentRequirements.extra?.permit2FacilitatorAddress as string | undefined) ?? - (paymentRequirements.extra?.facilitatorAddress as string | undefined) ?? - (supportedKind.extra?.permit2FacilitatorAddress as string | undefined) ?? - (supportedKind.extra?.facilitatorAddress as string | undefined); + (supportedKind.extra?.permit2FacilitatorAddress as string | undefined); return Promise.resolve({ ...requirementsWithoutFee, extra: { ...extra, assetTransferMethod: "permit2", - ...(facilitatorAddress - ? { facilitatorAddress, permit2FacilitatorAddress: facilitatorAddress } - : {}), + ...(permit2FacilitatorAddress ? { permit2FacilitatorAddress } : {}), }, }); } diff --git a/typescript/packages/mechanisms/tron/test/unit/fee.test.ts b/typescript/packages/mechanisms/tron/test/unit/fee.test.ts deleted file mode 100644 index fd0c2abb..00000000 --- a/typescript/packages/mechanisms/tron/test/unit/fee.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - resolveBaseFee, - isTokenAllowed, - buildFeeInfo, - readFeeFromExtra, - type ExactTronFeeConfig, -} from "../../src/shared/fee"; - -/** - * Offline unit tests for the TRON facilitator fee layer (F3). - */ - -const NETWORK = "tron:0xcd8690dc"; -const USDT = "TXYZopYRdj2D9XRtbG411XZZ3kM5VkAeBf"; -const USDD = "TGjgvdTWWrybVLaVeFqSyVqJQWjxqRYbaK"; -const FEE_TO = "TJRyWwFs9wTFGZg3JbrVriFbNfCug5tDeC"; - -const config: ExactTronFeeConfig = { - feeTo: FEE_TO, - baseFee: { USDT: "10000", USDD: "1000000000000000000" }, -}; - -describe("resolveBaseFee", () => { - it("returns the configured base fee per symbol", () => { - expect(resolveBaseFee(config, NETWORK, USDT)).toBe(10000n); - expect(resolveBaseFee(config, NETWORK, USDD)).toBe(1000000000000000000n); - }); - - it("returns null when no baseFee map is configured", () => { - expect(resolveBaseFee({}, NETWORK, USDT)).toBeNull(); - }); - - it("returns null for unknown tokens or unconfigured symbols", () => { - expect(resolveBaseFee(config, NETWORK, "TUnknown")).toBeNull(); - expect(resolveBaseFee({ baseFee: { USDT: "1" } }, NETWORK, USDD)).toBeNull(); - }); -}); - -describe("isTokenAllowed", () => { - it("allows all tokens when no allowlist is set", () => { - expect(isTokenAllowed({}, USDT)).toBe(true); - }); - - it("enforces the allowlist case-insensitively", () => { - const c: ExactTronFeeConfig = { allowedTokens: [USDT.toLowerCase()] }; - expect(isTokenAllowed(c, USDT)).toBe(true); - expect(isTokenAllowed(c, USDD)).toBe(false); - }); -}); - -describe("buildFeeInfo", () => { - it("builds fee terms for a configured token", () => { - expect(buildFeeInfo(config, NETWORK, USDT, "TFallback")).toEqual({ - feeTo: FEE_TO, - feeAmount: "10000", - }); - }); - - it("falls back to defaultFeeTo when config.feeTo is unset", () => { - const c: ExactTronFeeConfig = { baseFee: { USDT: "5000" }, caller: "TCaller" }; - expect(buildFeeInfo(c, NETWORK, USDT, "TSigner")).toEqual({ - feeTo: "TSigner", - feeAmount: "5000", - caller: "TCaller", - }); - }); - - it("returns undefined for disallowed or unconfigured tokens", () => { - expect(buildFeeInfo({ ...config, allowedTokens: [USDD] }, NETWORK, USDT, "T")).toBeUndefined(); - expect(buildFeeInfo({}, NETWORK, USDT, "T")).toBeUndefined(); - }); -}); - -describe("readFeeFromExtra", () => { - it("extracts a well-formed fee", () => { - expect(readFeeFromExtra({ fee: { feeTo: FEE_TO, feeAmount: "100", caller: "TC" } })).toEqual({ - feeTo: FEE_TO, - feeAmount: "100", - caller: "TC", - }); - }); - - it("returns undefined for missing or malformed fee", () => { - expect(readFeeFromExtra(undefined)).toBeUndefined(); - expect(readFeeFromExtra({})).toBeUndefined(); - expect(readFeeFromExtra({ fee: { feeTo: FEE_TO } })).toBeUndefined(); - expect(readFeeFromExtra({ fee: { feeAmount: "1" } })).toBeUndefined(); - }); -}); diff --git a/typescript/packages/mechanisms/tron/test/unit/flow-integration.test.ts b/typescript/packages/mechanisms/tron/test/unit/flow-integration.test.ts index 9570e2ca..3794c5af 100644 --- a/typescript/packages/mechanisms/tron/test/unit/flow-integration.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/flow-integration.test.ts @@ -167,23 +167,20 @@ async function pay( } describe("GasFree end-to-end (in-process)", () => { - const feeConfig = { feeTo: PROVIDER, baseFee: { USDT: "10000" } }; - async function wire(chain: Chain) { const api = { [NETWORK]: relayer() as never }; return { server: new GasFreeServer(), - facilitator: new GasFreeFacilitator(await facilitatorSigner(chain), api, feeConfig), + facilitator: new GasFreeFacilitator(await facilitatorSigner(chain), api), client: new GasFreeClient(await clientSigner(chain), { apiClients: api }), }; } - it("negotiates fee, signs, verifies, and settles", async () => { + it("negotiates, signs, verifies, and settles", async () => { const chain: Chain = { balance: 10_000_000n, allowance: 0n }; const { server, facilitator, client } = await wire(chain); const req = await negotiate(server, facilitator, "exact_gasfree"); - expect((req.extra?.fee as { feeTo: string }).feeTo).toBe(PROVIDER); expect(req.extra?.name).toBe("Tether USD"); // server injects token metadata const payload = await pay(client, req); diff --git a/typescript/packages/mechanisms/tron/test/unit/gasfree-digest.test.ts b/typescript/packages/mechanisms/tron/test/unit/gasfree-digest.test.ts index 9c7ec222..edb69243 100644 --- a/typescript/packages/mechanisms/tron/test/unit/gasfree-digest.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/gasfree-digest.test.ts @@ -87,7 +87,7 @@ describe("GasFree TIP-712 digest round-trip", () => { amount: "1000", payTo: PAY_TO, maxTimeoutSeconds: 600, - extra: { fee: { feeTo: PROVIDER, feeAmount: "10000" } }, + extra: {}, } as never; const result = await client.createPaymentPayload(2, requirements, { @@ -101,14 +101,9 @@ describe("GasFree TIP-712 digest round-trip", () => { }; vi.mocked(buildTronWeb).mockReturnValue(tronWeb); const facSigner = await createFacilitatorTronSigner(facWallet, { network: NETWORK }); - facilitator = new FacilitatorScheme( - facSigner, - { [NETWORK]: mockApi(account, providers) as never }, - { - feeTo: PROVIDER, - baseFee: { USDT: "10000" }, - }, - ); + facilitator = new FacilitatorScheme(facSigner, { + [NETWORK]: mockApi(account, providers) as never, + }); }); it("produces a self-contained GasFree message", () => { diff --git a/typescript/packages/mechanisms/tron/test/unit/gasfree-feeto.test.ts b/typescript/packages/mechanisms/tron/test/unit/gasfree-feeto.test.ts deleted file mode 100644 index e0e4083d..00000000 --- a/typescript/packages/mechanisms/tron/test/unit/gasfree-feeto.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ExactGasFreeTronScheme as GasFreeFacilitator } from "../../src/gasfree/facilitator/scheme"; -import type { FacilitatorTronSigner } from "../../src/signer"; - -/** - * Regression: the GasFree facilitator must NOT advertise its own address as - * `feeTo`. - * - * GasFree's `feeTo` is the relayer's service provider — `verify` rejects any - * other address with `gasfree_fee_to_mismatch`. The facilitator's own wallet is - * not a registered provider, so defaulting to it (the prior behavior) made every - * gasfree payment fail. getExtra now omits `feeTo` unless it is explicitly - * configured to a real provider, letting the client pick one from the relayer's - * provider list (mirroring the Python facilitator's `fee_quote`). - */ - -const NETWORK = "tron:0xcd8690dc"; -const SIGNER_ADDR = "TJRyWwFs9wTFGZg3JbrVriFbNfCug5tDeC"; // facilitator wallet (NOT a provider) -const PROVIDER = "TKtWbdzEq5ss9vTS9kwRhBp5mXmBfBns3E"; // a registered relayer provider - -function mockSigner(): FacilitatorTronSigner { - return { - getAddresses: () => [SIGNER_ADDR], - readContract: async () => 0n, - verifyTypedData: async () => true, - writeContract: async () => "0x", - waitForTransactionReceipt: async () => ({ status: "success" }), - } as unknown as FacilitatorTronSigner; -} - -describe("GasFree facilitator getExtra feeTo advertisement", () => { - it("omits feeConfig entirely when no baseFee is configured", () => { - const extra = new GasFreeFacilitator(mockSigner(), {}).getExtra(NETWORK); - expect(extra?.feeConfig).toBeUndefined(); - }); - - it("advertises baseFee WITHOUT feeTo when feeTo is not configured", () => { - const f = new GasFreeFacilitator(mockSigner(), {}, { baseFee: { USDT: "10000" } }); - const feeConfig = f.getExtra(NETWORK)?.feeConfig as Record; - - expect(feeConfig).toEqual({ baseFee: { USDT: "10000" } }); - // The fix: never fall back to the facilitator's own address. - expect(feeConfig).not.toHaveProperty("feeTo"); - expect(feeConfig.feeTo).not.toBe(SIGNER_ADDR); - }); - - it("advertises feeTo only when explicitly configured to a provider", () => { - const f = new GasFreeFacilitator( - mockSigner(), - {}, - { feeTo: PROVIDER, baseFee: { USDT: "10000" } }, - ); - const feeConfig = f.getExtra(NETWORK)?.feeConfig as Record; - - expect(feeConfig.feeTo).toBe(PROVIDER); - expect(feeConfig.baseFee).toEqual({ USDT: "10000" }); - }); - - it("passes through caller and allowedTokens when configured", () => { - const f = new GasFreeFacilitator( - mockSigner(), - {}, - { feeTo: PROVIDER, caller: PROVIDER, baseFee: { USDT: "10000" }, allowedTokens: ["USDT"] }, - ); - const feeConfig = f.getExtra(NETWORK)?.feeConfig as Record; - - expect(feeConfig).toEqual({ - feeTo: PROVIDER, - caller: PROVIDER, - baseFee: { USDT: "10000" }, - allowedTokens: ["USDT"], - }); - }); -}); diff --git a/typescript/packages/mechanisms/tron/test/unit/gasfree-flow.test.ts b/typescript/packages/mechanisms/tron/test/unit/gasfree-flow.test.ts index 04a8de76..15e0bf1f 100644 --- a/typescript/packages/mechanisms/tron/test/unit/gasfree-flow.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/gasfree-flow.test.ts @@ -5,6 +5,7 @@ import { ExactGasFreeTronScheme as FacilitatorScheme } from "../../src/gasfree/f import type { ClientTronSigner, FacilitatorTronSigner } from "../../src/signer"; import type { ExactGasFreePayload } from "../../src/types"; import type { GasFreeAddressInfo, GasFreeProvider } from "../../src/shared/gasfree/api"; +import { filterAffordableRequirements } from "../../src/shared/balance"; /** * GasFree client/facilitator behavior with mocked signers and relayer (F1). @@ -87,22 +88,20 @@ function requirements(extra: Record = {}) { } describe("GasFree client createPaymentPayload", () => { - it("uses fee.feeTo as provider and fee.feeAmount as maxFee floor", async () => { + it("picks a provider from the relayer list and uses transferFee as maxFee", async () => { const a = api(account()); const client = new ClientScheme(clientSigner(), { apiClients: { [NETWORK]: a as never } }); - const result = await client.createPaymentPayload( - 2, - requirements({ fee: { feeTo: PROVIDER, feeAmount: "20000" } }), - { extensions: { skipBalanceCheck: true } }, - ); + const result = await client.createPaymentPayload(2, requirements(), { + extensions: { skipBalanceCheck: true }, + }); const p = result.payload as ExactGasFreePayload; expect(p.gasfree.serviceProvider).toBe(PROVIDER); - // max(transferFee 10000, facilitatorFee 20000) = 20000 - expect(p.gasfree.maxFee).toBe("20000"); - expect(a.getProviders).not.toHaveBeenCalled(); + // transferFee 10000 + expect(p.gasfree.maxFee).toBe("10000"); + expect(a.getProviders).toHaveBeenCalled(); }); - it("falls back to a relayer provider when no fee is present", async () => { + it("defaults maxFee to 1 token when transferFee is unknown", async () => { const a = api(account({ assets: [] })); const client = new ClientScheme(clientSigner(), { apiClients: { [NETWORK]: a as never } }); const result = await client.createPaymentPayload(2, requirements(), { @@ -110,7 +109,7 @@ describe("GasFree client createPaymentPayload", () => { }); const p = result.payload as ExactGasFreePayload; expect(p.gasfree.serviceProvider).toBe(PROVIDER); - // no transferFee, no facilitator fee → default 1 token (10^6) + // no transferFee → default 1 token (10^6) expect(p.gasfree.maxFee).toBe("1000000"); expect(a.getProviders).toHaveBeenCalled(); }); @@ -133,11 +132,9 @@ describe("GasFree client createPaymentPayload", () => { const client = new ClientScheme(clientSigner(), { apiClients: { [NETWORK]: api(acct) as never }, }); - const result = await client.createPaymentPayload( - 2, - requirements({ fee: { feeTo: PROVIDER, feeAmount: "10000" } }), - { extensions: { skipBalanceCheck: true } }, - ); + const result = await client.createPaymentPayload(2, requirements(), { + extensions: { skipBalanceCheck: true }, + }); const p = result.payload as ExactGasFreePayload; expect(p.gasfree.maxFee).toBe("15000"); // 10000 + 5000 activate }); @@ -147,24 +144,16 @@ describe("GasFree client createPaymentPayload", () => { const client = new ClientScheme(clientSigner(), { apiClients: { [NETWORK]: api(acct) as never }, }); - await expect( - client.createPaymentPayload( - 2, - requirements({ fee: { feeTo: PROVIDER, feeAmount: "10000" } }), - ), - ).rejects.toThrow(/not activated/); + await expect(client.createPaymentPayload(2, requirements())).rejects.toThrow(/not activated/); }); it("throws on insufficient GasFree wallet balance", async () => { const client = new ClientScheme(clientSigner(500n), { apiClients: { [NETWORK]: api(account()) as never }, }); - await expect( - client.createPaymentPayload( - 2, - requirements({ fee: { feeTo: PROVIDER, feeAmount: "10000" } }), - ), - ).rejects.toThrow(/Insufficient balance/); + await expect(client.createPaymentPayload(2, requirements())).rejects.toThrow( + /Insufficient balance/, + ); }); it("uses the maximum window deadline when none is requested", async () => { @@ -253,14 +242,7 @@ describe("GasFree facilitator verify term validation", () => { } const fac = () => - new FacilitatorScheme( - facilitatorSigner(), - { [NETWORK]: api(account()) as never }, - { - feeTo: PROVIDER, - baseFee: { USDT: "10000" }, - }, - ); + new FacilitatorScheme(facilitatorSigner(), { [NETWORK]: api(account()) as never }); it("accepts a well-formed payload", async () => { const p = payload(); @@ -280,12 +262,6 @@ describe("GasFree facilitator verify term validation", () => { expect(r.invalidReason).toBe("gasfree_payto_mismatch"); }); - it("rejects fee below base fee", async () => { - const p = payload({ maxFee: "9999" }); - const r = await fac().verify({ accepted: requirements(), payload: p } as never, requirements()); - expect(r.invalidReason).toBe("gasfree_fee_amount_too_low"); - }); - it("rejects an expired permit", async () => { const p = payload({ deadline: String(Math.floor(Date.now() / 1000) - 10) }); const r = await fac().verify({ accepted: requirements(), payload: p } as never, requirements()); @@ -320,14 +296,7 @@ describe("GasFree facilitator settle", () => { it("submits to the relayer and returns the tx hash", async () => { const a = api(account()); - const fac = new FacilitatorScheme( - facilitatorSigner(), - { [NETWORK]: a as never }, - { - feeTo: PROVIDER, - baseFee: { USDT: "10000" }, - }, - ); + const fac = new FacilitatorScheme(facilitatorSigner(), { [NETWORK]: a as never }); const r = await fac.settle( { accepted: requirements(), payload: goodPayload() } as never, requirements(), @@ -338,11 +307,9 @@ describe("GasFree facilitator settle", () => { }); it("fails settle when verification fails", async () => { - const fac = new FacilitatorScheme( - facilitatorSigner({ verify: false }), - { [NETWORK]: api(account()) as never }, - { feeTo: PROVIDER, baseFee: { USDT: "10000" } }, - ); + const fac = new FacilitatorScheme(facilitatorSigner({ verify: false }), { + [NETWORK]: api(account()) as never, + }); const r = await fac.settle( { accepted: requirements(), payload: goodPayload() } as never, requirements(), @@ -352,11 +319,9 @@ describe("GasFree facilitator settle", () => { }); it("fails settle on insufficient GasFree wallet balance", async () => { - const fac = new FacilitatorScheme( - facilitatorSigner({ balance: 100n }), - { [NETWORK]: api(account()) as never }, - { feeTo: PROVIDER, baseFee: { USDT: "10000" } }, - ); + const fac = new FacilitatorScheme(facilitatorSigner({ balance: 100n }), { + [NETWORK]: api(account()) as never, + }); const r = await fac.settle( { accepted: requirements(), payload: goodPayload() } as never, requirements(), @@ -365,3 +330,45 @@ describe("GasFree facilitator settle", () => { expect(r.errorReason).toBe("insufficient_funds"); }); }); + +describe("GasFree estimateCost and affordability", () => { + it("estimateCost returns amount + transferFee for an active account", async () => { + const a = api(account()); + const client = new ClientScheme(clientSigner(), { apiClients: { [NETWORK]: a as never } }); + // amount 1000 + transferFee 10000 = 11000 + const cost = await client.estimateCost(requirements() as never); + expect(cost).toBe(11000n); + }); + + it("estimateCost includes activateFee when the account is inactive", async () => { + const acct = account({ + active: false, + assets: [{ ...account().assets[0]!, activateFee: "5000" }], + }); + const a = api(acct); + const client = new ClientScheme(clientSigner(), { apiClients: { [NETWORK]: a as never } }); + // amount 1000 + transferFee 10000 + activateFee 5000 = 16000 + const cost = await client.estimateCost(requirements() as never); + expect(cost).toBe(16000n); + }); + + it("filterAffordableRequirements excludes borderline GasFree option via estimateCost", async () => { + // balance 10999 < amount(1000) + transferFee(10000) = 11000 → excluded + const a = api(account()); + const client = new ClientScheme(clientSigner(10_999n), { + apiClients: { [NETWORK]: a as never }, + }); + const out = await filterAffordableRequirements(client, [requirements() as never]); + expect(out).toEqual([]); + }); + + it("filterAffordableRequirements keeps when balance covers amount + transferFee", async () => { + // balance 11000 >= amount(1000) + transferFee(10000) = 11000 → kept + const a = api(account()); + const client = new ClientScheme(clientSigner(11_000n), { + apiClients: { [NETWORK]: a as never }, + }); + const out = await filterAffordableRequirements(client, [requirements() as never]); + expect(out).toHaveLength(1); + }); +}); diff --git a/typescript/packages/mechanisms/tron/test/unit/selection.test.ts b/typescript/packages/mechanisms/tron/test/unit/selection.test.ts index 3a6a4036..433e3508 100644 --- a/typescript/packages/mechanisms/tron/test/unit/selection.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/selection.test.ts @@ -25,7 +25,7 @@ function req( extra: Record = {}, ): PaymentRequirements { return { - scheme: "exact" as string, + scheme: "exact", network: NETWORK, asset, amount, @@ -35,17 +35,6 @@ function req( } as unknown as PaymentRequirements; } -function gasfreeReq( - asset: string, - amount: string, - extra: Record = {}, -): PaymentRequirements { - return { - ...req(asset, amount, extra), - scheme: "exact_gasfree", - } as unknown as PaymentRequirements; -} - describe("CheapestTokenSelectionStrategy", () => { const strat = new CheapestTokenSelectionStrategy(); @@ -73,45 +62,26 @@ describe("createCheapestTokenSelector", () => { }); describe("filterAffordableRequirements", () => { - it("ignores stale extra.fee for exact scheme — only amount matters", async () => { + it("keeps only options the payer can afford", async () => { const scheme: BalanceCheckable = { checkBalance: vi.fn(async (asset: string) => (asset === USDT ? 1_500_000n : 0n)), }; const accepts = [ - req(USDT, "1000000", { fee: { feeTo: "T", feeAmount: "100000" } }), // stale fee ignored; need 1.0, have 1.5 → ok + req(USDT, "1000000"), // need 1.0 USDT, have 1.5 → ok req(USDD, "1000000000000000000"), // have 0 → out ]; const out = await filterAffordableRequirements(scheme, accepts); expect(out.map(r => r.asset)).toEqual([USDT]); }); - it("keeps an exact option when balance covers amount but not stale fee", async () => { - const scheme: BalanceCheckable = { checkBalance: vi.fn(async () => 1_050_000n) }; + it("excludes when balance is below amount", async () => { + const scheme: BalanceCheckable = { checkBalance: vi.fn(async () => 500_000n) }; const out = await filterAffordableRequirements(scheme, [ - req(USDT, "1000000", { fee: { feeTo: "T", feeAmount: "100000" } }), // need 1.1, have 1.05 - ]); - // exact ignores the stale fee: 1.05 >= 1.0 → affordable - expect(out.map(r => r.asset)).toEqual([USDT]); - }); - - it("includes fee for exact_gasfree — enforced relayer fee", async () => { - const scheme: BalanceCheckable = { checkBalance: vi.fn(async () => 1_050_000n) }; - // GasFree enforces fee: need 1.1, have 1.05 → excluded - const out = await filterAffordableRequirements(scheme, [ - gasfreeReq(USDT, "1000000", { fee: { feeTo: "T", feeAmount: "100000" } }), + req(USDT, "1000000"), // need 1.0, have 0.5 ]); expect(out).toEqual([]); }); - it("keeps exact_gasfree when balance covers amount + fee", async () => { - const scheme: BalanceCheckable = { checkBalance: vi.fn(async () => 1_200_000n) }; - // GasFree: need 1.1, have 1.2 → ok - const out = await filterAffordableRequirements(scheme, [ - gasfreeReq(USDT, "1000000", { fee: { feeTo: "T", feeAmount: "100000" } }), - ]); - expect(out.map(r => r.asset)).toEqual([USDT]); - }); - it("keeps a requirement when checkBalance throws", async () => { const scheme: BalanceCheckable = { checkBalance: vi.fn(async () => { @@ -141,3 +111,36 @@ describe("selectAffordableRequirement", () => { expect(chosen).toBeUndefined(); }); }); + +describe("filterAffordableRequirements with estimateCost", () => { + it("excludes when balance covers amount but not amount + fee", async () => { + const scheme: BalanceCheckable = { + checkBalance: vi.fn(async () => 1_050_000n), + estimateCost: vi.fn(async r => BigInt(r.amount) + 100_000n), + }; + // balance 1.05M < cost 1.1M (1.0M + 0.1M fee) → excluded + const out = await filterAffordableRequirements(scheme, [req(USDT, "1000000")]); + expect(out).toEqual([]); + expect(scheme.estimateCost).toHaveBeenCalledWith(expect.objectContaining({ asset: USDT })); + }); + + it("keeps when balance covers the estimated cost", async () => { + const scheme: BalanceCheckable = { + checkBalance: vi.fn(async () => 1_200_000n), + estimateCost: vi.fn(async r => BigInt(r.amount) + 100_000n), + }; + const out = await filterAffordableRequirements(scheme, [req(USDT, "1000000")]); + expect(out.map(r => r.asset)).toEqual([USDT]); + }); + + it("defers to createPaymentPayload when estimateCost throws", async () => { + const scheme: BalanceCheckable = { + checkBalance: vi.fn(async () => 1_500_000n), + estimateCost: vi.fn(async () => { + throw new Error("relayer unavailable"); + }), + }; + const out = await filterAffordableRequirements(scheme, [req(USDT, "1000000")]); + expect(out.map(r => r.asset)).toEqual([USDT]); + }); +}); diff --git a/typescript/packages/mechanisms/tron/test/unit/tokens.test.ts b/typescript/packages/mechanisms/tron/test/unit/tokens.test.ts index 07ffdb53..01d05e94 100644 --- a/typescript/packages/mechanisms/tron/test/unit/tokens.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/tokens.test.ts @@ -42,10 +42,10 @@ describe("token registry lookups", () => { expect(getNetworkTokens("tron:unknown")).toEqual({}); }); - it("mainnet and nile USDT default to permit2, shasta USDT does not", () => { + it("mainnet, nile, and shasta USDT all default to permit2", () => { expect(getToken("tron:0x2b6653dc", "USDT")?.assetTransferMethod).toBe("permit2"); expect(getToken("tron:0xcd8690dc", "USDT")?.assetTransferMethod).toBe("permit2"); - expect(getToken("tron:0x94a9059e", "USDT")?.assetTransferMethod).toBeUndefined(); + expect(getToken("tron:0x94a9059e", "USDT")?.assetTransferMethod).toBe("permit2"); }); }); @@ -67,10 +67,10 @@ describe("buildAssetExtra", () => { expect(extra).toEqual({ assetTransferMethod: "permit2" }); }); - it("includes name/version for eip3009 tokens (no transfer method)", () => { + it("shasta USDT is permit2 (name/version omitted)", () => { const shasta = getToken("tron:0x94a9059e", "USDT")!; const extra = buildAssetExtra(shasta); - expect(extra).toEqual({ name: "Tether USD", version: "1" }); + expect(extra).toEqual({ assetTransferMethod: "permit2" }); }); it("includes name/version for eip2612-capable permit2 tokens", () => { diff --git a/typescript/packages/mechanisms/tron/test/unit/upto-flow.test.ts b/typescript/packages/mechanisms/tron/test/unit/upto-flow.test.ts index 5ac3eb75..d3a0f147 100644 --- a/typescript/packages/mechanisms/tron/test/unit/upto-flow.test.ts +++ b/typescript/packages/mechanisms/tron/test/unit/upto-flow.test.ts @@ -135,7 +135,7 @@ describe("upto Permit2 end-to-end (in-process)", () => { const req = await negotiate(server, facilitator); expect(req.extra?.assetTransferMethod).toBe("permit2"); - expect(req.extra?.facilitatorAddress).toBe(addr(FAC_PK)); + expect(req.extra?.permit2FacilitatorAddress).toBe(addr(FAC_PK)); const payload = await pay(client, req); const p = payload.payload as UptoPermit2Payload; @@ -235,7 +235,6 @@ describe("upto Permit2 end-to-end (in-process)", () => { ...req, extra: { ...req.extra, - facilitatorAddress: OTHER_ADDR, permit2FacilitatorAddress: OTHER_ADDR, }, } as PaymentRequirements;