From 9e836aa935d64ba82bb4d7ccb13885b8f9fc9cac Mon Sep 17 00:00:00 2001 From: Eriks Reks Date: Wed, 30 Sep 2026 09:45:53 -0400 Subject: [PATCH 1/2] feat(sdk): add multi-network EVM x402 buyer --- .changeset/evm-multinetwork-buyer.md | 5 + packages/sdk/README.md | 87 ++- .../examples/agent-buyer/multi-network.mjs | 25 + packages/sdk/scripts/imports.test.mjs | 20 + packages/sdk/src/client/buyer.ts | 686 ++++++++++++++++++ packages/sdk/src/client/evm.ts | 139 ++++ packages/sdk/src/client/index.ts | 649 +---------------- packages/sdk/src/networks.ts | 2 +- packages/sdk/src/receipt.ts | 4 +- packages/sdk/src/settlement.ts | 2 +- packages/sdk/test/client-multinetwork.test.ts | 292 ++++++++ 11 files changed, 1271 insertions(+), 640 deletions(-) create mode 100644 .changeset/evm-multinetwork-buyer.md create mode 100644 packages/sdk/examples/agent-buyer/multi-network.mjs create mode 100644 packages/sdk/src/client/buyer.ts create mode 100644 packages/sdk/src/client/evm.ts create mode 100644 packages/sdk/test/client-multinetwork.test.ts diff --git a/.changeset/evm-multinetwork-buyer.md b/.changeset/evm-multinetwork-buyer.md new file mode 100644 index 0000000..81e5550 --- /dev/null +++ b/.changeset/evm-multinetwork-buyer.md @@ -0,0 +1,5 @@ +--- +"radius-sdk": minor +--- + +Add createEvmFetch for x402 purchases across explicitly configured EVM networks and ERC-20 assets, with independent spending caps, chain-specific signers and settlement reconciliation. Unsponsored Permit2 approval is opt-in. Preserve createRadiusFetch and its Radius wallet helpers; reject receipts that name a different payment network. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 38c35b2..3a5f1e8 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1,7 +1,7 @@ # radius-sdk -Accept and make [Radius](https://radiustech.xyz) payments over standard [x402 v2](https://x402.org). -Hono and Cloudflare Workers first. SBC is the default currency, mainnet the default network. +Accept [Radius](https://radiustech.xyz) payments and buy resources across EVM networks over standard [x402 v2](https://x402.org). +Hono and Cloudflare Workers first. Radius APIs default to SBC on mainnet; the multi-network buyer requires explicit chains and assets. Pre-1.0: minor versions may change the API. Release notes are in [CHANGELOG.md](./CHANGELOG.md). @@ -9,7 +9,7 @@ Pre-1.0: minor versions may change the API. Release notes are in [CHANGELOG.md]( | --- | --- | --- | | `radius-sdk` | networks, amounts, receipts, errors, `radiusEnv` (no viem at runtime) | — | | `radius-sdk/hono` | `radiusPayments()` seller middleware | `hono` | -| `radius-sdk/client` | `createRadiusFetch()` paying fetch, balance and settlement actions | `viem` | +| `radius-sdk/client` | `createRadiusFetch()`, `createEvmFetch()`, balance and settlement actions | `viem` | ## Install @@ -130,6 +130,87 @@ const receipt = getPaymentReceipt(res, payFetch.network); // { success, transa `RADIUS_RPC_URL`, `RADIUS_FACILITATOR_URL`, `RADIUS_ASSET_ADDRESS` (alias `RADIUS_SBC_ADDRESS`), `RADIUS_PRIVATE_KEY`, `RADIUS_MAX_PER_REQUEST`; on Workers pass `c.env`. +## Buy across EVM networks + +`createEvmFetch` accepts any viem EVM `Chain`: Base, Arbitrum, Polygon, Monad, Arc, +Radius, or a custom `defineChain(...)`. Configure the ERC-20 assets you allow on each +chain with their complete token metadata and independent caps. There is no default +chain, asset, facilitator, or assumption about how a chain pays gas. + +```ts +import { base, arbitrum } from 'viem/chains'; +import { radiusMainnet, SBC } from 'radius-sdk'; +import { createEvmFetch } from 'radius-sdk/client'; + +const payFetch = createEvmFetch({ + signer, // viem local account, external typed-data signer, or private key from your key store + networks: [ + { chain: radiusMainnet.chain, assets: [{ asset: SBC, maxPerRequest: '0.05' }] }, + { + chain: base, + assets: [{ + asset: { address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', symbol: 'USDC', decimals: 6, name: 'USD Coin', version: '2' }, + maxPerRequest: '0.05', + }], + }, + { + chain: arbitrum, + assets: [{ + asset: { address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', symbol: 'USDC', decimals: 6, name: 'USD Coin', version: '2' }, + maxPerRequest: { amount: '50000' }, + }], + }, + ], + onPaymentRequired: offer => allowedRecipients.has(offer.payTo.toLowerCase()), + onPaid: (receipt, offer) => recordPurchase(receipt, offer), +}); +const response = await payFetch('https://provider.example/lookup'); +``` + +The buyer scans the server's `accepts` in order and selects the first supported +network/asset/scheme within that asset's cap. It skips unconfigured networks (including +non-EVM offers), unconfigured tokens, unsupported schemes and offers above their cap. +`exact` v2 supports EIP-3009 and Permit2; `upto` v2 supports Permit2. Legacy `exact` +v1 is supported with CAIP-2 `eip155:` identifiers; named v1 aliases such as +`base` are not translated. A policy decline, signing failure, or paid rejection stops +the request; the buyer never purchases an alternative after attempting payment. + +A string cap is in **that token's display units**, and `{ amount }` is in its atomic +units. Caps do not compare exchange rates or accumulate across requests or networks. +An `upto` offer must fit the cap at its full authorized maximum. Apply daily budgets +and any account/provider policy in your payment service before allowing the signature. + +Each network accepts `rpcUrl`, an optional `signer` override, and `permit2Approval`. +A `WalletClient` with a configured chain must match that network; supply separate +clients for separate chains. A local or external typed-data signer can serve several +chains. RPC reads, approvals and settlement reconciliation use the selected network; +Radius's faucet and aggregate-balance helpers stay on `createRadiusFetch`. + +`permit2Approval` defaults to **`'never'`** here. Existing allowance and sponsored +approvals still work. Explicitly setting `'auto'` allows an unlimited ERC-20 approval; +`onApprovalRequired` can veto it and receives the selected offer. The wallet needs +the chosen chain's gas currency for an unsponsored approval. For operator-managed +approvals, use the exported ERC-20 actions on a viem wallet for that chain. + +`payFetch.routes` contains each network/asset pair's payer address, atomic cap and +`getSettlement(txHash)`. Choose the route matching the offer to reconcile transfers +of that asset on the correct chain. `onPaid` reports the decoded server receipt, +including failed settlements; an HTTP success without a receipt does not call it. +A receipt naming another network throws `invalid_receipt`. A decoded receipt is not +independent proof of settlement. For `upto`, a reported amount is checked against +the authorized maximum; when omitted, the existing buyer behavior records the maximum. + +The SDK does not discover funded chains or check facilitator availability. Confirm the +provider's settlement support and deployed token/Permit2 contracts for each enabled +chain. Arc configuration uses its ERC-20 token decimals, which can differ from its +native gas representation. Consult [x402 network and token support](https://docs.x402.org/core-concepts/network-and-token-support), +[Circle's USDC contracts](https://developers.circle.com/stablecoins/usdc-contract-addresses), +and the chosen chain's docs for current metadata. The test suite covers wire payloads +for all named chains, with local EVM execution for approval and reconciliation; +it does not establish live facilitator support on those networks. + +Runnable Radius + Base example: [multi-network.mjs](./examples/agent-buyer/multi-network.mjs). + ## ERC-20 interactions Metadata, allowance, `approve`, `transfer`, `transferFrom` and `Transfer` events as viem actions. diff --git a/packages/sdk/examples/agent-buyer/multi-network.mjs b/packages/sdk/examples/agent-buyer/multi-network.mjs new file mode 100644 index 0000000..1c8aab0 --- /dev/null +++ b/packages/sdk/examples/agent-buyer/multi-network.mjs @@ -0,0 +1,25 @@ +// Run after building the workspace. Buys at most one resource on Radius or Base. +// Set BUYER_PRIVATE_KEY via your secret store, PAID_URL and EXPECTED_PAY_TO explicitly. +import { base } from 'viem/chains'; +import { privateKeyToAccount } from 'viem/accounts'; +import { radiusMainnet, SBC } from 'radius-sdk'; +import { createEvmFetch } from 'radius-sdk/client'; + +const { BUYER_PRIVATE_KEY, PAID_URL, EXPECTED_PAY_TO } = process.env; +if (!BUYER_PRIVATE_KEY || !PAID_URL || !EXPECTED_PAY_TO) { + throw new Error('Set BUYER_PRIVATE_KEY, PAID_URL and EXPECTED_PAY_TO to run this paid example'); +} +const pay = createEvmFetch({ + signer: privateKeyToAccount(BUYER_PRIVATE_KEY), + networks: [ + { chain: radiusMainnet.chain, assets: [{ asset: SBC, maxPerRequest: '0.05' }] }, + { chain: base, assets: [{ + asset: { address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', symbol: 'USDC', decimals: 6, name: 'USD Coin', version: '2' }, + maxPerRequest: '0.05', + }] }, + ], + onPaymentRequired: offer => offer.payTo.toLowerCase() === EXPECTED_PAY_TO.toLowerCase(), + onPaid: (receipt, offer) => console.log({ network: offer.network, asset: offer.asset, receipt }), +}); +const response = await pay(PAID_URL); +console.log({ status: response.status, body: await response.text() }); diff --git a/packages/sdk/scripts/imports.test.mjs b/packages/sdk/scripts/imports.test.mjs index 205dcaa..2dbbc1e 100644 --- a/packages/sdk/scripts/imports.test.mjs +++ b/packages/sdk/scripts/imports.test.mjs @@ -65,3 +65,23 @@ test('buyer initializes with the installed viem peer', () => { assert.equal(payFetch.maxPerRequest, 1000n); `); }); + +test('multi-network buyer imports and runs with explicit viem chains', () => { + run(` + import assert from 'node:assert/strict'; + import { base } from 'viem/chains'; + import { privateKeyToAccount } from 'viem/accounts'; + import { createEvmFetch } from 'radius-sdk/client'; + const pay = createEvmFetch({ + signer: privateKeyToAccount('0x' + '01'.repeat(32)), + networks: [{ chain: base, assets: [{ + asset: { address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', symbol: 'USDC', decimals: 6, name: 'USD Coin', version: '2' }, + maxPerRequest: '0.05', + }] }], + fetch: async () => new Response('free'), + }); + assert.equal(pay.routes[0].network, 'eip155:8453'); + assert.equal(pay.routes[0].maxPerRequest, 50000n); + assert.equal(await (await pay('https://example.com/free')).text(), 'free'); + `); +}); diff --git a/packages/sdk/src/client/buyer.ts b/packages/sdk/src/client/buyer.ts new file mode 100644 index 0000000..44caec8 --- /dev/null +++ b/packages/sdk/src/client/buyer.ts @@ -0,0 +1,686 @@ +import { x402Client, x402HTTPClient } from '@x402/core/client'; +import type { PaymentPayloadResult, PaymentRequired, PaymentRequirements, PaymentRequirementsV1, SchemeNetworkClient } from '@x402/core/types'; +import { ExactEvmScheme, UptoEvmScheme, toClientEvmSigner, type ClientEvmSigner } from '@x402/evm'; +import { createPublicClient, createWalletClient, http, isAddress, maxUint256, type Account, type PublicClient, type WalletClient } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { formatAmount, resolvePrice, type Price } from '../amounts.js'; +import { getBalances, type AccountBalances } from '../balances.js'; +import { toTokenAtomic, type TokenAmount, type TxResult } from '../erc20.js'; +import { RadiusPaymentError } from '../errors.js'; +import { describeSupportedSchemes } from '../schemes.js'; +import { PERMIT2_ADDRESS, resolveNetwork, type Address, type NetworkInput, type NetworkOverrides, type RadiusNetwork } from '../networks.js'; +import { decodePaymentReceipt, parseUptoSettlementAmount, type PaymentReceipt } from '../receipt.js'; +import { getSettlement, type Settlement } from '../settlement.js'; + +/** + * Who pays: a private key, a viem local account (or any `{ address, signTypedData }`), + * or a viem WalletClient with an account (e.g. MetaMask via `custom(window.ethereum)`). + */ +export type RadiusSigner = `0x${string}` | ClientEvmSigner | WalletClient; + +/** x402 payment schemes this client can pay. */ +export type PaymentScheme = 'exact' | 'upto'; + +/** A challenge entry from either protocol version (v1 prices in `maxAmountRequired`, v2 in `amount`). */ +export type AnyPaymentRequirements = PaymentRequirements | PaymentRequirementsV1; + +/** What a server is asking for, presented to `onPaymentRequired` before anything is signed. */ +export interface PaymentOffer { + /** x402 protocol version of the challenge: v1 pays with `X-PAYMENT`, v2 with `PAYMENT-SIGNATURE`. */ + x402Version: 1 | 2; + /** `exact`: pay exactly `amount`. `upto`: authorise up to `amount`; the facilitator charges what was used. */ + scheme: PaymentScheme; + /** Atomic amount, e.g. "10000". For `upto` this is the authorised maximum, not what will be charged. */ + amount: string; + /** Display amount, e.g. "0.01 SBC". */ + amountFormatted: string; + asset: Address; + payTo: Address; + network: string; + resource: { url: string; description?: string; mimeType?: string }; + /** Untouched requirement chosen from the 402 (a v1 entry when `x402Version` is 1). */ + requirements: AnyPaymentRequirements; + /** How the asset moves: `permit2` needs a one-time ERC-20 approval (unless sponsored), `eip3009` does not. */ + transferMethod: 'permit2' | 'eip3009'; + /** True when the server's facilitator will sponsor the one-time Permit2 approval. */ + gasSponsored: boolean; +} + +/** + * An allowance change the client is about to make, handed to `onApprovalRequired` before anything + * is signed. Every path that changes an allowance goes through it: the automatic Permit2 approval + * during a payment (`reason: 'payment'`, with the `offer`), an explicit `approvePermit2()` + * (`'approvePermit2'`), and `approve(spender, amount)` (`'approve'`). The request is also the + * `details` of the `declined` error, so a policy layer has one auditable object either way. + */ +export interface ApprovalRequest { + /** Which call is asking. */ + reason: 'payment' | 'approvePermit2' | 'approve'; + asset: Address; + spender: Address; + /** Amount to approve: unlimited for Permit2 (the x402 "one-time gas approval" model), the caller's amount for `approve`. */ + amount: bigint; + currentAllowance: bigint; + /** The payment that needs the approval; only for `reason: 'payment'`. */ + offer?: PaymentOffer; +} + +/** `details` of a `payment_rejected` error: the server's second 402, unread. */ +export interface PaymentRejectedDetails { + response: Response; + /** `error` from the decoded `PAYMENT-REQUIRED` header, when the server sent one. */ + error?: string; + challenge?: PaymentRequired; +} + +/** `details` of an `invalid_challenge` error raised while parsing a 402. */ +export interface InvalidChallengeDetails { + response: Response; + /** Body text, when it had to be read to look for a challenge. */ + body?: string; + cause: unknown; +} + +export interface RadiusFetchOptions extends NetworkOverrides { + /** 'mainnet' (default), 'testnet', a preset, or a custom instance. */ + network?: NetworkInput; + signer: RadiusSigner; + /** + * Hard ceiling per request, e.g. "$0.05" or { amount: "50000" }. Required. + * This is NOT a cumulative budget: an agent looping over requests can exceed + * any total unless you enforce one outside the SDK. + */ + maxPerRequest: Price; + /** Approve or decline an offer before signing. Return false to decline. */ + onPaymentRequired?: (offer: PaymentOffer) => boolean | Promise; + /** + * Permit2 needs a one-time ERC-20 approval. When the server's facilitator sponsors it + * (`eip2612GasSponsoring`) nothing is sent on-chain. Otherwise: 'auto' (default) sends an + * unlimited approval transaction from the signer (gas via Turnstile from SBC, so the wallet + * needs ~0.01 SBC spare on Radius); 'never' throws `approval_required` instead. + */ + permit2Approval?: 'auto' | 'never'; + /** + * Approve or decline an allowance change before it is signed: the automatic Permit2 approval of + * a payment, `approvePermit2()` and `approve()` all pass through here (`request.reason` says + * which). Return false to decline (`declined` error carrying the request). + */ + onApprovalRequired?: (request: ApprovalRequest) => boolean | Promise; + /** Called with the decoded receipt after a paid response. */ + onPaid?: (receipt: PaymentReceipt, offer: PaymentOffer) => void | Promise; + /** Underlying fetch (defaults to globalThis.fetch). */ + fetch?: typeof globalThis.fetch; +} + +export type { TxResult } from '../erc20.js'; + +export interface FaucetResult { + success: boolean; + /** Display amount dripped, e.g. "0.5". */ + amount?: string; + txHash?: `0x${string}`; + raw: unknown; +} + +export interface RadiusFetch { + (input: RequestInfo | URL, init?: RequestInit): Promise; + readonly address: Address; + readonly network: RadiusNetwork; + /** Atomic cap per request. */ + readonly maxPerRequest: bigint; + /** Payment-asset (SBC) balance of the signer: a raw ERC-20 `balanceOf`, nothing aggregated. */ + balance(): Promise<{ atomic: bigint; formatted: string }>; + /** + * Native RUSD, payment-asset and aggregate balances of the signer, reported separately. + * On Radius `eth_getBalance` is native plus convertible stablecoins; see `getBalances`. + */ + balances(): Promise; + /** Current ERC-20 allowance granted to Permit2 for the payment asset. */ + permit2Allowance(): Promise; + /** Send an unlimited Permit2 approval now (rather than lazily on first unsponsored payment). Subject to `onApprovalRequired`. */ + approvePermit2(): Promise; + /** Transfer the payment asset. Needs a transaction-capable signer (private key or viem local account). */ + send(to: Address, amount: Price): Promise; + /** Payment-asset allowance the signer has granted to `spender` (atomic units). */ + allowance(spender: Address): Promise; + /** + * Approve `spender` for `amount` of the payment asset (bigint atomic, or "1.5" in display units). + * Subject to `onApprovalRequired` (`reason: 'approve'`), like every allowance change this client makes. + */ + approve(spender: Address, amount: TokenAmount): Promise; + /** Reconcile a settlement transaction on-chain (undefined while unknown to the node). */ + getSettlement(txHash: `0x${string}`): Promise; + /** Request a faucet drip for this wallet (testnet ~0.5 SBC; mainnet ~0.01 SBC/day). */ + fund(): Promise; + /** Escape hatch to the underlying x402 client. */ + readonly client: x402Client; +} + +/** Chain and asset context used by the shared buyer engine. */ +export type BuyerNetwork = Pick; +interface SingleNetworkBuyer extends Pick { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; + chooseOffer(challenge: PaymentRequired, url: string): PaymentOffer; + readChallenge(response: Response): Promise; + pay(retry: Request, challenge: PaymentRequired, offer: PaymentOffer): Promise; + account: ClientEvmSigner; + publicClient: PublicClient; + network: BuyerNetwork; +} +type BuyerOptions = Pick; + +const ERC20_ABI = [ + { type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [{ type: 'uint256' }] }, + { type: 'function', name: 'allowance', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }], outputs: [{ type: 'uint256' }] }, + { type: 'function', name: 'approve', stateMutability: 'nonpayable', inputs: [{ name: 'spender', type: 'address' }, { name: 'amount', type: 'uint256' }], outputs: [{ type: 'bool' }] }, + { type: 'function', name: 'transfer', stateMutability: 'nonpayable', inputs: [{ name: 'to', type: 'address' }, { name: 'amount', type: 'uint256' }], outputs: [{ type: 'bool' }] }, +] as const; + +const SPONSORING_KEYS = ['eip2612GasSponsoring', 'erc20ApprovalGasSponsoring']; +/** + * Longest signing window we will authorise, and the default when a challenge omits + * `maxTimeoutSeconds`. An authorisation the facilitator fails to settle stays redeemable until + * its deadline, so the server's value is clamped rather than trusted (matches radius-cli). + */ +const MAX_TIMEOUT_SECONDS = 600; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const ATOMIC_AMOUNT = /^[0-9]+$/; + +function sameOrigin(a: URL, b: URL): boolean { + return a.protocol === b.protocol && a.host === b.host; +} + +function isSigner(v: unknown): v is ClientEvmSigner { + return typeof v === 'object' && v !== null && 'address' in v && typeof (v as ClientEvmSigner).signTypedData === 'function'; +} + +function isWalletClient(v: unknown): v is WalletClient { + const w = v as WalletClient; + return typeof v === 'object' && v !== null && typeof w.request === 'function' && typeof w.writeContract === 'function' && typeof w.signTypedData === 'function'; +} + +function isTxAccount(v: unknown): v is Account { + return typeof v === 'object' && v !== null && typeof (v as Account).signTransaction === 'function'; +} + +/** + * Shared x402 buyer engine for one EVM network and asset. + * The public factories attach either Radius wallet helpers or multi-network routing. + */ +export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNetwork): SingleNetworkBuyer { + if (options.maxPerRequest === undefined || options.maxPerRequest === null) { + throw new RadiusPaymentError('config', 'x402 buyer: maxPerRequest is required (e.g. "$0.05")'); + } + const price = resolvePrice(options.maxPerRequest, network.asset); + if (price.asset.toLowerCase() !== network.asset.address.toLowerCase()) { + throw new RadiusPaymentError('config', 'maxPerRequest asset must match the configured payment asset'); + } + const cap = BigInt(price.amount); + const chain = network.chain; + const publicClient: PublicClient = createPublicClient({ chain, transport: http(network.rpcUrl) }); + let account: ClientEvmSigner; + let walletClient: WalletClient | undefined; + if (typeof options.signer === 'string') { + const local = privateKeyToAccount(options.signer); + account = local as unknown as ClientEvmSigner; + walletClient = createWalletClient({ account: local, chain, transport: http(network.rpcUrl) }); + } else if (isWalletClient(options.signer)) { + const wc = options.signer; + if (wc.chain && wc.chain.id !== network.chain.id) { + throw new RadiusPaymentError('config', `WalletClient chain ${wc.chain.id} does not match payment chain ${network.chain.id}`); + } + const wcAccount = wc.account; + if (!wcAccount) throw new RadiusPaymentError('config', 'x402 buyer: the WalletClient has no account; create it with { account }'); + account = { + address: wcAccount.address, + signTypedData: (msg) => wc.signTypedData({ ...(msg as Omit[0], 'account'>), account: wcAccount } as Parameters[0]), + signMessage: (a: { message: string }) => wc.signMessage({ account: wcAccount, message: a.message }), + } as ClientEvmSigner; + walletClient = wc; + } else { + account = options.signer; + if (!isSigner(account)) throw new RadiusPaymentError('config', 'x402 buyer: signer must be a private key, a WalletClient with an account, or an object with address + signTypedData'); + if (isTxAccount(account)) walletClient = createWalletClient({ account, chain, transport: http(network.rpcUrl) }); + } + // readContract on the signer lets @x402/evm sign the EIP-2612 permit for gas sponsoring. + const signer = toClientEvmSigner(account, publicClient as never); + + const exactScheme = new ExactEvmScheme(signer, { rpcUrl: network.rpcUrl }); + // x402 v1 `exact` is EIP-3009 only. @x402/evm's own ExactEvmSchemeV1 resolves the chain id from a + // table of named v1 networks (base-sepolia, …) and rejects `eip155:`, which is how Radius + // appears in v1 challenges. ExactEvmScheme's EIP-3009 signing is version-agnostic (same EIP-712 + // domain/types, `validAfter: 0`), so delegate to it with the v1 price field normalised and wrap the + // result in the v1 envelope `{ x402Version, scheme, network, payload }`. + const exactV1Scheme: SchemeNetworkClient = { + scheme: 'exact', + async createPaymentPayload(x402Version, requirements) { + const v1 = requirements as unknown as PaymentRequirementsV1; + const { assetTransferMethod: _v2Only, ...extra } = v1.extra ?? {}; + const result = await exactScheme.createPaymentPayload(x402Version, { ...v1, amount: v1.maxAmountRequired, extra } as PaymentRequirements); + return { x402Version, scheme: v1.scheme, network: v1.network, payload: result.payload } as PaymentPayloadResult; + }, + }; + const client = new x402Client() + .register(network.network, exactScheme) + .register(network.network, new UptoEvmScheme(signer, { rpcUrl: network.rpcUrl })) + .registerV1(network.network, exactV1Scheme) + // Backstop; the primary checks live in `chooseOffer` so errors are typed. + .setSpendControls({ + maxAmountPerPayment: false, + allowedAssets: [{ network: network.network, asset: network.asset.address, maxAmountPerPayment: cap.toString() }], + }); + const httpClient = new x402HTTPClient(client); + const baseFetch = options.fetch ?? globalThis.fetch.bind(globalThis); + const explorer = (hash: string) => (network.explorerUrl ? `${network.explorerUrl}/tx/${hash}` : undefined); + + const requireWallet = (what: string): WalletClient => { + if (!walletClient) { + throw new RadiusPaymentError('approval_required', `${what} needs a transaction-capable signer (a private key, viem local account, or WalletClient); this signer can only sign typed data`); + } + return walletClient; + }; + + const sendTx = async (what: string, fn: (wc: WalletClient) => Promise<`0x${string}`>): Promise => { + const wc = requireWallet(what); + const hash = await fn(wc); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + return { hash, status: receipt.status === 'success' ? 'success' : 'reverted', explorerUrl: explorer(hash) }; + }; + + const permit2Allowance = () => + publicClient.readContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'allowance', args: [account.address, PERMIT2_ADDRESS] }); + + const allowance = (spender: Address) => + publicClient.readContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'allowance', args: [account.address, spender] }); + + /** The one policy gate for allowance changes: `onApprovalRequired` may veto, else proceed. */ + const authorizeApproval = async (request: ApprovalRequest): Promise => { + if (options.onApprovalRequired && !(await options.onApprovalRequired(request))) { + throw new RadiusPaymentError('declined', `${request.reason === 'payment' ? 'Permit2' : request.reason} approval declined`, request); + } + }; + + /** ERC-20 `approve` of the payment asset, after `authorizeApproval`. */ + const sendApproval = async (request: ApprovalRequest, what: string): Promise => { + await authorizeApproval(request); + const r = await sendTx(what, (wc) => + wc.writeContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'approve', args: [request.spender, request.amount], chain, account: wc.account! }), + ); + if (r.status !== 'success') throw new RadiusPaymentError('approval_failed', `${what} transaction ${r.hash} reverted`, r); + return r; + }; + + const approvePermit2 = async (): Promise => { + const currentAllowance = await permit2Allowance(); + return sendApproval({ reason: 'approvePermit2', asset: network.asset.address, spender: PERMIT2_ADDRESS, amount: maxUint256, currentAllowance }, 'Permit2 approval'); + }; + + const approve = async (spender: Address, amount: TokenAmount): Promise => { + const [atomic, currentAllowance] = await Promise.all([toTokenAtomic(publicClient, network.asset, amount), allowance(spender)]); + return sendApproval({ reason: 'approve', asset: network.asset.address, spender, amount: atomic, currentAllowance }, 'approve'); + }; + + const amountOf = (version: 1 | 2, a: AnyPaymentRequirements): bigint => { + const field = version === 1 ? 'maxAmountRequired' : 'amount'; + const raw = (a as Record)[field]; + if (typeof raw !== 'string' || !ATOMIC_AMOUNT.test(raw)) { + throw new RadiusPaymentError('invalid_challenge', `Offer ${field} must be a non-negative integer string (got ${JSON.stringify(raw)})`, a); + } + return BigInt(raw); + }; + + const chooseOffer = (pr: PaymentRequired, requestUrl: string): PaymentOffer => { + const version = pr.x402Version; + if (version !== 1 && version !== 2) throw new RadiusPaymentError('invalid_challenge', `Unsupported x402 version ${String(version)}`); + const accepts = pr.accepts as AnyPaymentRequirements[] | undefined; + if (!Array.isArray(accepts) || accepts.length === 0) throw new RadiusPaymentError('invalid_challenge', 'Challenge has no accepts[]'); + const sameNetwork = accepts.filter((a) => a.network === network.network); + if (sameNetwork.length === 0) { + const offered = [...new Set(accepts.map((a) => a.network))].join(', ') || 'none'; + throw new RadiusPaymentError('network_mismatch', `Server accepts ${offered}; this client pays on ${network.network} (${network.name})`, accepts); + } + const sameAsset = sameNetwork.filter((a) => typeof a.asset === 'string' && a.asset.toLowerCase() === network.asset.address.toLowerCase()); + if (sameAsset.length === 0) { + throw new RadiusPaymentError('asset_mismatch', `Server does not accept ${network.asset.symbol} (${network.asset.address}) on ${network.network}`, sameNetwork); + } + // `exact` exists in v1 and v2; `upto` is a v2 scheme only. + const knownScheme = sameAsset.filter((a) => a.scheme === 'exact' || (a.scheme === 'upto' && version === 2)); + if (knownScheme.length === 0) { + const schemes = [...new Set(sameAsset.map((a) => `${a.scheme}@v${version}`))].join(', '); + throw new RadiusPaymentError('no_compatible_offer', `Server offers ${schemes} for ${network.asset.symbol}; this client supports ${describeSupportedSchemes()}`, sameAsset); + } + // v1 `exact` is always EIP-3009 and `upto` always Permit2; v2 `exact` names its transfer method. + const supported = knownScheme.filter((a) => { + if (version === 1 || a.scheme === 'upto') return true; + const m = a.extra?.assetTransferMethod; + return m === undefined || m === 'permit2' || m === 'eip3009'; + }); + if (supported.length === 0) { + const methods = [...new Set(knownScheme.map((a) => String(a.extra?.assetTransferMethod)))].join(', '); + throw new RadiusPaymentError('unsupported_transfer_method', `Server requires assetTransferMethod ${methods}; this client supports permit2 and eip3009`, knownScheme); + } + // Server order is the server's preference (x402 clients honour it; so did radius-cli): take the + // first offer within the cap. Amounts are not compared across schemes — an `upto` amount is a + // ceiling, not a price. + const priced = supported.map((req) => ({ req, amount: amountOf(version, req) })); + const affordable = priced.find((p) => p.amount <= cap); + if (!affordable) { + const { req: first, amount: firstAmount } = priced[0]; + const offered = formatAmount(firstAmount, network.asset.decimals, network.asset.symbol); + const limit = formatAmount(cap, network.asset.decimals, network.asset.symbol); + throw new RadiusPaymentError( + 'price_above_limit', + first.scheme === 'upto' ? `Offer authorises up to ${offered}, exceeding maxPerRequest ${limit}` : `Offer ${offered} exceeds maxPerRequest ${limit}`, + priced.map((p) => p.req), + ); + } + const { req, amount } = affordable; + const scheme = req.scheme as PaymentScheme; + if (typeof req.payTo !== 'string' || !isAddress(req.payTo)) { + throw new RadiusPaymentError('invalid_challenge', `Offer payTo is not an address (got ${JSON.stringify(req.payTo)})`, req); + } + if (scheme === 'upto') { + const facilitator = req.extra?.facilitatorAddress ?? req.extra?.facilitator; + if (typeof facilitator !== 'string' || !isAddress(facilitator)) { + throw new RadiusPaymentError('invalid_challenge', 'upto offer is missing a valid extra.facilitatorAddress; cannot bind the Permit2 witness', req); + } + } + const gasSponsored = SPONSORING_KEYS.some((k) => pr.extensions !== undefined && k in pr.extensions); + // v1 has no top-level resource; its accepts[] carry description/mimeType. + const v1 = req as Partial; + const resource = version === 2 && pr.resource ? pr.resource : { url: requestUrl, description: v1.description, mimeType: v1.mimeType }; + return { + x402Version: version, + scheme, + amount: amount.toString(), + amountFormatted: formatAmount(amount, network.asset.decimals, network.asset.symbol), + asset: req.asset as Address, + payTo: req.payTo as Address, + network: req.network, + resource, + requirements: req, + transferMethod: scheme === 'upto' || (version === 2 && req.extra?.assetTransferMethod === 'permit2') ? 'permit2' : 'eip3009', + gasSponsored, + }; + }; + + /** + * The requirement handed to @x402/evm for signing. Fills in what the schemes need but a server may + * omit: the configured asset's EIP-712 domain (EIP-3009 / EIP-2612), a signing window, and the + * `extra.facilitator` alias radius-cli accepts for `facilitatorAddress`. Only the signer sees this; + * the untouched requirement is what gets echoed back to the server. + */ + const forSigning = (offer: PaymentOffer): PaymentRequirements => { + const req = offer.requirements; + const extra: Record = { name: network.asset.name, version: network.asset.version, ...req.extra }; + if (extra.facilitatorAddress === undefined && typeof extra.facilitator === 'string') extra.facilitatorAddress = extra.facilitator; + const t = req.maxTimeoutSeconds; + const maxTimeoutSeconds = typeof t === 'number' && t > 0 ? Math.min(Math.floor(t), MAX_TIMEOUT_SECONDS) : MAX_TIMEOUT_SECONDS; + return { ...req, maxTimeoutSeconds, extra } as PaymentRequirements; + }; + + /** Permit2 needs an ERC-20 allowance. Sponsored: the scheme signs a permit. Unsponsored: approve on-chain once. */ + const ensureAllowance = async (offer: PaymentOffer): Promise => { + if (offer.transferMethod !== 'permit2' || offer.gasSponsored) return; + const current = await permit2Allowance(); + if (current >= BigInt(offer.amount)) return; + const request: ApprovalRequest = { reason: 'payment', asset: network.asset.address, spender: PERMIT2_ADDRESS, amount: maxUint256, currentAllowance: current, offer }; + if ((options.permit2Approval ?? 'auto') === 'never') { + throw new RadiusPaymentError('approval_required', `Permit2 allowance ${current} is below ${offer.amount} and the facilitator does not sponsor approvals; call approvePermit2() or set permit2Approval: 'auto'`, request); + } + await sendApproval(request, 'Permit2 approval'); + }; + + /** + * v2: `PAYMENT-REQUIRED` header (or, off-spec but seen in the wild, a JSON body); v1: JSON body. + * Throws `invalid_challenge` with `{ response, body, cause }` so callers can show what the server sent. + */ + const readChallenge = async (res: Response): Promise => { + let text: string | undefined; + let body: unknown; + try { + if (!res.headers.get('payment-required')) { + text = await res.text(); + if (text) body = JSON.parse(text); + } + try { + return httpClient.getPaymentRequiredResponse((n) => res.headers.get(n), body); + } catch (e) { + if (body && typeof body === 'object' && !Array.isArray(body) && (body as { x402Version?: unknown }).x402Version === 2) return body as PaymentRequired; + throw e; + } + } catch (e) { + throw new RadiusPaymentError('invalid_challenge', `Could not parse the 402 challenge: ${(e as Error).message}`, { response: res, body: text, cause: e }); + } + }; + + /** + * Decode the settlement receipt. For `upto` the reported `amount` is untrusted input: it must be a + * non-negative integer no greater than the signed maximum, else `invalid_receipt`. `exact` receipts + * are decoded leniently (a malformed one just means no receipt). + */ + const decodeReceipt = (header: string, offer: PaymentOffer): PaymentReceipt | undefined => { + let receipt: PaymentReceipt; + try { + receipt = decodePaymentReceipt(header, network); + } catch (e) { + if (offer.scheme === 'upto') throw new RadiusPaymentError('invalid_receipt', `Invalid upto payment response: ${(e as Error).message}`, e); + return undefined; + } + if (receipt.network && receipt.network !== offer.network) { + throw new RadiusPaymentError('invalid_receipt', `Payment response network ${receipt.network} does not match selected network ${offer.network}`, receipt); + } + if (offer.scheme === 'upto' && receipt.amount !== undefined) { + try { + parseUptoSettlementAmount(receipt.amount, BigInt(offer.amount)); + } catch (e) { + throw new RadiusPaymentError('invalid_receipt', `Invalid upto payment response: ${(e as Error).message}`, receipt); + } + } + // `exact` settles the offered amount; `upto` facilitators report what they charged (else assume the maximum). + if (receipt.success && receipt.amount === undefined) receipt.amount = offer.amount; + return receipt; + }; + + const pay = async (retry: Request, paymentRequired: PaymentRequired, offer: PaymentOffer): Promise => { + if (options.onPaymentRequired && !(await options.onPaymentRequired(offer))) { + throw new RadiusPaymentError('declined', `Payment of ${offer.amountFormatted} to ${offer.payTo} declined`, offer); + } + await ensureAllowance(offer); + + // Narrow the challenge to the chosen offer so the upstream selector cannot pick another. + const narrowed: PaymentRequired = { ...paymentRequired, accepts: [forSigning(offer)] }; + const payload = await client.createPaymentPayload(narrowed); + // v2 servers match `accepted` against the requirement they sent (core fields equal, their `extra` + // a subset of ours), so echo it untouched rather than the filled-in signing copy. + if (payload.x402Version === 2) payload.accepted = offer.requirements as PaymentRequirements; + for (const [k, v] of Object.entries(httpClient.encodePaymentSignatureHeader(payload))) retry.headers.set(k, v); + retry.headers.set('Access-Control-Expose-Headers', 'PAYMENT-RESPONSE,X-PAYMENT-RESPONSE'); + + const second = await baseFetch(retry); + if (REDIRECT_STATUSES.has(second.status)) { + const location = second.headers.get('location'); + let target: URL | undefined; + try { + target = location ? new URL(location, retry.url) : undefined; + } catch { + /* malformed Location: refused below */ + } + if (!target || !sameOrigin(target, new URL(retry.url))) { + throw new RadiusPaymentError( + 'redirect_refused', + `Server redirected the paid request to ${location ?? '(no Location)'}; refusing to replay the payment header across origins`, + { status: second.status, location }, + ); + } + // Same-origin: handed back unfollowed. Re-requesting the target is the caller's call (it may cost another payment). + return second; + } + const header = second.headers.get('payment-response') ?? second.headers.get('x-payment-response'); + if (second.status === 402) { + // The body is left unread: `details.response` is the server's answer for the caller to inspect. + let challenge: PaymentRequired | undefined; + try { + challenge = httpClient.getPaymentRequiredResponse((n) => second.headers.get(n)); + } catch { + /* no decodable PAYMENT-REQUIRED header */ + } + const details: PaymentRejectedDetails = { response: second, error: challenge?.error, challenge }; + throw new RadiusPaymentError('payment_rejected', `Server rejected the payment (${challenge?.error ?? 'no reason given'})`, details); + } + if (header) { + const receipt = decodeReceipt(header, offer); + if (receipt && options.onPaid) { + try { + await options.onPaid(receipt, offer); + } catch (e) { + console.error('radius-sdk onPaid hook failed:', e); + } + } + } + return second; + }; + + const paidFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const request = new Request(input, init); + if (request.headers.has('payment-signature') || request.headers.has('x-payment')) { + return baseFetch(request); + } + // The paid retry never follows redirects: a 3xx must not carry the payment header to another origin. + const retry = new Request(request.clone(), { redirect: 'manual' }); + const first = await baseFetch(request); + if (first.status !== 402) return first; + + const paymentRequired = await readChallenge(first); + const offer = chooseOffer(paymentRequired, request.url); + return pay(retry, paymentRequired, offer); + }; + + const balance = async () => { + const atomic = await publicClient.readContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'balanceOf', args: [account.address] }); + return { atomic, formatted: formatAmount(atomic, network.asset.decimals, network.asset.symbol) }; + }; + + const send = (to: Address, amount: Price): Promise => { + const atomic = BigInt(resolvePrice(amount, network.asset).amount); + return sendTx('send', (wc) => + wc.writeContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'transfer', args: [to, atomic], chain, account: wc.account! }), + ); + }; + + + return { + fetch: paidFetch, + chooseOffer, + readChallenge, + pay, + account, + publicClient, + address: account.address, + network, + maxPerRequest: cap, + balance, + permit2Allowance, + approvePermit2, + send, + allowance, + approve, + getSettlement: (txHash: `0x${string}`) => getSettlement(network, txHash, publicClient), + client, + }; +} + +/** Create a Radius buyer with its existing wallet, balance, and faucet helpers. */ +export function createRadiusFetch(options: RadiusFetchOptions): RadiusFetch { + const network = resolveNetwork(options.network, options); + const buyer = createSingleNetworkBuyer(options, network); + const { account, publicClient } = buyer; + const fund = async (): Promise => { + if (!network.faucetUrl) throw new RadiusPaymentError('faucet', `No faucet configured for network ${network.name}`); + const signMessage = (account as { signMessage?: (a: { message: string }) => Promise<`0x${string}`> }).signMessage; + if (typeof signMessage !== 'function') throw new RadiusPaymentError('faucet', 'fund() needs a signer with signMessage (EIP-191), e.g. a private key or viem local account'); + const base = network.faucetUrl.replace(/\/+$/, ''); + const token = network.asset.symbol; + const challenge = (await (await fetch(`${base}/challenge/${account.address}?token=${token}`)).json()) as { message?: string }; + if (!challenge.message) throw new RadiusPaymentError('faucet', 'Faucet returned no challenge message', challenge); + const signature = await signMessage.call(account, { message: challenge.message }); + const res = await fetch(`${base}/drip`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ address: account.address, token, signature }), + }); + const raw = (await res.json().catch(() => ({}))) as { success?: boolean; amount?: string; tx_hash?: `0x${string}`; error?: { code?: string; message?: string; retry_after_ms?: number } }; + if (!res.ok || raw.success !== true) { + throw new RadiusPaymentError('faucet', `Faucet drip failed: ${raw.error?.code ?? res.status} ${raw.error?.message ?? ''}`.trim(), raw); + } + return { success: true, amount: raw.amount, txHash: raw.tx_hash, raw }; + }; + + return Object.assign(buyer.fetch, { + address: buyer.address, + network, + maxPerRequest: buyer.maxPerRequest, + balance: buyer.balance, + balances: () => getBalances(publicClient, { address: account.address, network }), + permit2Allowance: buyer.permit2Allowance, + approvePermit2: buyer.approvePermit2, + send: buyer.send, + allowance: buyer.allowance, + approve: buyer.approve, + getSettlement: buyer.getSettlement, + fund, + client: buyer.client, + }); +} + +export { getSettlement } from '../settlement.js'; +export type { Settlement, SettlementTransfer } from '../settlement.js'; +export { getBalances, getNativeBalance, getAggregateBalance, getTokenBalance, radiusActions, defaultTokens, nativeBalanceBytecode } from '../balances.js'; +export type { + AccountBalances, + NativeBalance, + TokenBalance, + BalanceToken, + BalanceClient, + RadiusActions, + RadiusActionsConfig, + GetBalancesParameters, + GetNativeBalanceParameters, + GetTokenBalanceParameters, +} from '../balances.js'; +export { + erc20Actions, + getTokenMetadata, + getAllowance, + approve, + transfer, + transferFrom, + getTransfers, + watchTransfers, + transferKey, + MAX_LOG_RANGE, + MAX_LOG_CHUNKS, + toTokenAtomic, + formatTokenAmount, +} from '../erc20.js'; +export type { + Erc20Actions, + Erc20ActionsConfig, + TokenMetadata, + TokenTransfer, + TokenInput, + TokenAmount, + TokenWalletClient, + GetTokenMetadataParameters, + GetAllowanceParameters, + ApproveParameters, + TransferParameters, + TransferFromParameters, + GetTransfersParameters, + WatchTransfersParameters, +} from '../erc20.js'; +export { getPaymentReceipt, decodePaymentReceipt, parseUptoSettlementAmount } from '../receipt.js'; +export type { PaymentReceipt } from '../receipt.js'; +export { RadiusPaymentError } from '../errors.js'; +export { radiusEnv } from '../env.js'; +export type { RadiusEnvConfig } from '../env.js'; diff --git a/packages/sdk/src/client/evm.ts b/packages/sdk/src/client/evm.ts new file mode 100644 index 0000000..e09fd46 --- /dev/null +++ b/packages/sdk/src/client/evm.ts @@ -0,0 +1,139 @@ +import type { PaymentRequired } from '@x402/core/types'; +import { isAddress, type Chain } from 'viem'; +import type { Price } from '../amounts.js'; +import { RadiusPaymentError } from '../errors.js'; +import type { Address, Caip2, RadiusAsset } from '../networks.js'; +import type { Settlement } from '../settlement.js'; +import { createSingleNetworkBuyer, type AnyPaymentRequirements, type RadiusFetchOptions, type RadiusSigner } from './buyer.js'; + +/** An explicitly allowed ERC-20 and its independent per-request spending limit. */ +export interface EvmAssetConfig { + /** Complete metadata: no SBC defaults are applied. name/version are the token's EIP-712 domain. */ + asset: RadiusAsset; + /** Token units ("0.05") or atomic units ({ amount: "50000" }); no exchange-rate conversion. */ + maxPerRequest: Price; +} + +/** A supported EVM chain, with its own RPC, signer and allowed payment assets. */ +export interface EvmNetworkConfig { + /** Any viem EVM Chain, including Base, Arc, Monad, Polygon, Arbitrum and Radius. */ + chain: Chain; + rpcUrl?: string; + /** Overrides the default signer. A WalletClient must be configured for this chain. */ + signer?: RadiusSigner; + assets: readonly EvmAssetConfig[]; + /** Defaults to 'never'. 'auto' permits an unlimited ERC-20 approval and spends this chain's gas token. */ + permit2Approval?: 'auto' | 'never'; +} + +export interface EvmFetchOptions extends Pick { + networks: readonly EvmNetworkConfig[]; + /** Default signer, used by chains that do not supply their own. */ + signer?: RadiusSigner; +} + +/** Resolved payment context. Contains no signing credentials. */ +export interface EvmPaymentRoute { + readonly network: Caip2; + readonly asset: Readonly; + readonly address: Address; + /** Atomic cap for this network/asset pair. Not a cumulative budget. */ + readonly maxPerRequest: bigint; + /** Reconcile transfers of this asset using this chain's RPC. */ + getSettlement(txHash: `0x${string}`): Promise; +} + +export interface EvmFetch { + (input: RequestInfo | URL, init?: RequestInit): Promise; + readonly routes: readonly EvmPaymentRoute[]; +} + +/** + * Pay x402 challenges across an explicit allowlist of EVM networks and assets. + * Selects the first compatible offer within its own asset cap in server order. + * A policy decline is final: it never falls back to another offer after authorization. + * Requires CAIP-2 eip155 network IDs for both v1 and v2 challenges. + */ +export function createEvmFetch(options: EvmFetchOptions): EvmFetch { + if (!options.networks?.length) throw new RadiusPaymentError('config', 'createEvmFetch: networks must not be empty'); + const buyers = new Map>(); + const networks = new Set(); + const routes: EvmPaymentRoute[] = []; + const key = (network: string, asset: string) => `${network}/${asset.toLowerCase()}`; + for (const config of options.networks) { + const { chain } = config; + if (!chain || !Number.isSafeInteger(chain.id) || chain.id <= 0) { + throw new RadiusPaymentError('config', 'createEvmFetch: each network needs a viem EVM Chain with a positive chain id'); + } + const network = `eip155:${chain.id}` as const; + if (networks.has(network)) throw new RadiusPaymentError('config', `Duplicate network ${network}; put its assets in one entry`); + networks.add(network); + const rpcUrl = config.rpcUrl ?? chain.rpcUrls.default.http[0]; + if (!rpcUrl) throw new RadiusPaymentError('config', `No HTTP RPC configured for ${network}`); + const signer = config.signer ?? options.signer; + if (!signer) throw new RadiusPaymentError('config', `No signer configured for ${network}`); + if (!config.assets?.length) throw new RadiusPaymentError('config', `No payment assets configured for ${network}`); + for (const entry of config.assets) { + const asset = entry.asset; + if (!asset || !isAddress(asset.address) || !Number.isInteger(asset.decimals) || asset.decimals < 0 || asset.decimals > 255 || + !asset.symbol || !asset.name || !asset.version) { + throw new RadiusPaymentError('config', `Incomplete ERC-20 metadata for ${network}; address, decimals, symbol, name and version are required`); + } + const routeKey = key(network, asset.address); + if (buyers.has(routeKey)) throw new RadiusPaymentError('config', `Duplicate payment asset ${asset.address} on ${network}`); + const buyer = createSingleNetworkBuyer({ + signer, maxPerRequest: entry.maxPerRequest, + permit2Approval: config.permit2Approval ?? 'never', + onPaymentRequired: options.onPaymentRequired, + onApprovalRequired: options.onApprovalRequired, + onPaid: options.onPaid, + fetch: options.fetch, + }, { + name: chain.name, chain, network, rpcUrl, + explorerUrl: chain.blockExplorers?.default.url, + asset: { ...asset }, + }); + buyers.set(routeKey, buyer); + routes.push(Object.freeze({ network, asset: Object.freeze({ ...asset }), address: buyer.address, maxPerRequest: buyer.maxPerRequest, getSettlement: buyer.getSettlement })); + } + } + const firstBuyer = buyers.values().next().value!; + const baseFetch = options.fetch ?? globalThis.fetch.bind(globalThis); + const select = (challenge: PaymentRequired, url: string) => { + if (challenge.x402Version !== 1 && challenge.x402Version !== 2) { + throw new RadiusPaymentError('invalid_challenge', `Unsupported x402 version ${String(challenge.x402Version)}`); + } + if (!Array.isArray(challenge.accepts) || !challenge.accepts.length) throw new RadiusPaymentError('invalid_challenge', 'Challenge has no accepts[]'); + let failure: RadiusPaymentError | undefined; + let matchesNetwork = false; + for (const req of challenge.accepts as AnyPaymentRequirements[]) { + if (!networks.has(req.network)) continue; + matchesNetwork = true; + if (typeof req.asset !== 'string') continue; + const buyer = buyers.get(key(req.network, req.asset)); + if (!buyer) continue; + try { + const offer = buyer.chooseOffer({ ...challenge, accepts: [req] } as PaymentRequired, url); + return { buyer, offer }; + } catch (e) { + // An unsupported or unaffordable offer can coexist with a usable alternative. + if (!(e instanceof RadiusPaymentError) || !['price_above_limit', 'no_compatible_offer', 'unsupported_transfer_method'].includes(e.code)) throw e; + failure ??= e; + } + } + if (failure) throw failure; + throw new RadiusPaymentError(matchesNetwork ? 'asset_mismatch' : 'network_mismatch', + matchesNetwork ? 'Server does not accept a configured payment asset on a supported network' : `Server offers no configured EVM network (${[...networks].join(', ')})`, challenge.accepts); + }; + const paidFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const request = new Request(input, init); + if (request.headers.has('payment-signature') || request.headers.has('x-payment')) return baseFetch(request); + const retry = new Request(request.clone(), { redirect: 'manual' }); + const response = await baseFetch(request); + if (response.status !== 402) return response; + const challenge = await firstBuyer.readChallenge(response); + const { buyer, offer } = select(challenge, request.url); + return buyer.pay(retry, challenge, offer); + }; + return Object.assign(paidFetch, { routes: Object.freeze(routes) }); +} diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 5e8b32c..a4dbf43 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -1,635 +1,18 @@ -import { x402Client, x402HTTPClient } from '@x402/core/client'; -import type { PaymentPayloadResult, PaymentRequired, PaymentRequirements, PaymentRequirementsV1, SchemeNetworkClient } from '@x402/core/types'; -import { ExactEvmScheme, UptoEvmScheme, toClientEvmSigner, type ClientEvmSigner } from '@x402/evm'; -import { createPublicClient, createWalletClient, http, isAddress, maxUint256, type Account, type PublicClient, type WalletClient } from 'viem'; -import { privateKeyToAccount } from 'viem/accounts'; -import { formatAmount, resolvePrice, type Price } from '../amounts.js'; -import { getBalances, type AccountBalances } from '../balances.js'; -import { toTokenAtomic, type TokenAmount, type TxResult } from '../erc20.js'; -import { RadiusPaymentError } from '../errors.js'; -import { describeSupportedSchemes } from '../schemes.js'; -import { PERMIT2_ADDRESS, resolveNetwork, type Address, type NetworkInput, type NetworkOverrides, type RadiusNetwork } from '../networks.js'; -import { decodePaymentReceipt, parseUptoSettlementAmount, type PaymentReceipt } from '../receipt.js'; -import { getSettlement, type Settlement } from '../settlement.js'; - -/** - * Who pays: a private key, a viem local account (or any `{ address, signTypedData }`), - * or a viem WalletClient with an account (e.g. MetaMask via `custom(window.ethereum)`). - */ -export type RadiusSigner = `0x${string}` | ClientEvmSigner | WalletClient; - -/** x402 payment schemes this client can pay. */ -export type PaymentScheme = 'exact' | 'upto'; - -/** A challenge entry from either protocol version (v1 prices in `maxAmountRequired`, v2 in `amount`). */ -export type AnyPaymentRequirements = PaymentRequirements | PaymentRequirementsV1; - -/** What a server is asking for, presented to `onPaymentRequired` before anything is signed. */ -export interface PaymentOffer { - /** x402 protocol version of the challenge: v1 pays with `X-PAYMENT`, v2 with `PAYMENT-SIGNATURE`. */ - x402Version: 1 | 2; - /** `exact`: pay exactly `amount`. `upto`: authorise up to `amount`; the facilitator charges what was used. */ - scheme: PaymentScheme; - /** Atomic amount, e.g. "10000". For `upto` this is the authorised maximum, not what will be charged. */ - amount: string; - /** Display amount, e.g. "0.01 SBC". */ - amountFormatted: string; - asset: Address; - payTo: Address; - network: string; - resource: { url: string; description?: string; mimeType?: string }; - /** Untouched requirement chosen from the 402 (a v1 entry when `x402Version` is 1). */ - requirements: AnyPaymentRequirements; - /** How the asset moves: `permit2` needs a one-time ERC-20 approval (unless sponsored), `eip3009` does not. */ - transferMethod: 'permit2' | 'eip3009'; - /** True when the server's facilitator will sponsor the one-time Permit2 approval. */ - gasSponsored: boolean; -} - -/** - * An allowance change the client is about to make, handed to `onApprovalRequired` before anything - * is signed. Every path that changes an allowance goes through it: the automatic Permit2 approval - * during a payment (`reason: 'payment'`, with the `offer`), an explicit `approvePermit2()` - * (`'approvePermit2'`), and `approve(spender, amount)` (`'approve'`). The request is also the - * `details` of the `declined` error, so a policy layer has one auditable object either way. - */ -export interface ApprovalRequest { - /** Which call is asking. */ - reason: 'payment' | 'approvePermit2' | 'approve'; - asset: Address; - spender: Address; - /** Amount to approve: unlimited for Permit2 (the x402 "one-time gas approval" model), the caller's amount for `approve`. */ - amount: bigint; - currentAllowance: bigint; - /** The payment that needs the approval; only for `reason: 'payment'`. */ - offer?: PaymentOffer; -} - -/** `details` of a `payment_rejected` error: the server's second 402, unread. */ -export interface PaymentRejectedDetails { - response: Response; - /** `error` from the decoded `PAYMENT-REQUIRED` header, when the server sent one. */ - error?: string; - challenge?: PaymentRequired; -} - -/** `details` of an `invalid_challenge` error raised while parsing a 402. */ -export interface InvalidChallengeDetails { - response: Response; - /** Body text, when it had to be read to look for a challenge. */ - body?: string; - cause: unknown; -} - -export interface RadiusFetchOptions extends NetworkOverrides { - /** 'mainnet' (default), 'testnet', a preset, or a custom instance. */ - network?: NetworkInput; - signer: RadiusSigner; - /** - * Hard ceiling per request, e.g. "$0.05" or { amount: "50000" }. Required. - * This is NOT a cumulative budget: an agent looping over requests can exceed - * any total unless you enforce one outside the SDK. - */ - maxPerRequest: Price; - /** Approve or decline an offer before signing. Return false to decline. */ - onPaymentRequired?: (offer: PaymentOffer) => boolean | Promise; - /** - * Permit2 needs a one-time ERC-20 approval. When the server's facilitator sponsors it - * (`eip2612GasSponsoring`) nothing is sent on-chain. Otherwise: 'auto' (default) sends an - * unlimited approval transaction from the signer (gas via Turnstile from SBC, so the wallet - * needs ~0.01 SBC spare on Radius); 'never' throws `approval_required` instead. - */ - permit2Approval?: 'auto' | 'never'; - /** - * Approve or decline an allowance change before it is signed: the automatic Permit2 approval of - * a payment, `approvePermit2()` and `approve()` all pass through here (`request.reason` says - * which). Return false to decline (`declined` error carrying the request). - */ - onApprovalRequired?: (request: ApprovalRequest) => boolean | Promise; - /** Called with the decoded receipt after a paid response. */ - onPaid?: (receipt: PaymentReceipt, offer: PaymentOffer) => void | Promise; - /** Underlying fetch (defaults to globalThis.fetch). */ - fetch?: typeof globalThis.fetch; -} - -export type { TxResult } from '../erc20.js'; - -export interface FaucetResult { - success: boolean; - /** Display amount dripped, e.g. "0.5". */ - amount?: string; - txHash?: `0x${string}`; - raw: unknown; -} - -export interface RadiusFetch { - (input: RequestInfo | URL, init?: RequestInit): Promise; - readonly address: Address; - readonly network: RadiusNetwork; - /** Atomic cap per request. */ - readonly maxPerRequest: bigint; - /** Payment-asset (SBC) balance of the signer: a raw ERC-20 `balanceOf`, nothing aggregated. */ - balance(): Promise<{ atomic: bigint; formatted: string }>; - /** - * Native RUSD, payment-asset and aggregate balances of the signer, reported separately. - * On Radius `eth_getBalance` is native plus convertible stablecoins; see `getBalances`. - */ - balances(): Promise; - /** Current ERC-20 allowance granted to Permit2 for the payment asset. */ - permit2Allowance(): Promise; - /** Send an unlimited Permit2 approval now (rather than lazily on first unsponsored payment). Subject to `onApprovalRequired`. */ - approvePermit2(): Promise; - /** Transfer the payment asset. Needs a transaction-capable signer (private key or viem local account). */ - send(to: Address, amount: Price): Promise; - /** Payment-asset allowance the signer has granted to `spender` (atomic units). */ - allowance(spender: Address): Promise; - /** - * Approve `spender` for `amount` of the payment asset (bigint atomic, or "1.5" in display units). - * Subject to `onApprovalRequired` (`reason: 'approve'`), like every allowance change this client makes. - */ - approve(spender: Address, amount: TokenAmount): Promise; - /** Reconcile a settlement transaction on-chain (undefined while unknown to the node). */ - getSettlement(txHash: `0x${string}`): Promise; - /** Request a faucet drip for this wallet (testnet ~0.5 SBC; mainnet ~0.01 SBC/day). */ - fund(): Promise; - /** Escape hatch to the underlying x402 client. */ - readonly client: x402Client; -} - -const ERC20_ABI = [ - { type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [{ type: 'uint256' }] }, - { type: 'function', name: 'allowance', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }], outputs: [{ type: 'uint256' }] }, - { type: 'function', name: 'approve', stateMutability: 'nonpayable', inputs: [{ name: 'spender', type: 'address' }, { name: 'amount', type: 'uint256' }], outputs: [{ type: 'bool' }] }, - { type: 'function', name: 'transfer', stateMutability: 'nonpayable', inputs: [{ name: 'to', type: 'address' }, { name: 'amount', type: 'uint256' }], outputs: [{ type: 'bool' }] }, -] as const; - -const SPONSORING_KEYS = ['eip2612GasSponsoring', 'erc20ApprovalGasSponsoring']; -/** - * Longest signing window we will authorise, and the default when a challenge omits - * `maxTimeoutSeconds`. An authorisation the facilitator fails to settle stays redeemable until - * its deadline, so the server's value is clamped rather than trusted (matches radius-cli). - */ -const MAX_TIMEOUT_SECONDS = 600; -const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); -const ATOMIC_AMOUNT = /^[0-9]+$/; - -function sameOrigin(a: URL, b: URL): boolean { - return a.protocol === b.protocol && a.host === b.host; -} - -function isSigner(v: unknown): v is ClientEvmSigner { - return typeof v === 'object' && v !== null && 'address' in v && typeof (v as ClientEvmSigner).signTypedData === 'function'; -} - -function isWalletClient(v: unknown): v is WalletClient { - const w = v as WalletClient; - return typeof v === 'object' && v !== null && typeof w.request === 'function' && typeof w.writeContract === 'function' && typeof w.signTypedData === 'function'; -} - -function isTxAccount(v: unknown): v is Account { - return typeof v === 'object' && v !== null && typeof (v as Account).signTransaction === 'function'; -} - -/** - * Create a `fetch` that pays Radius x402 challenges automatically, within a - * per-request ceiling, on one network, in one asset. - */ -export function createRadiusFetch(options: RadiusFetchOptions): RadiusFetch { - const network = resolveNetwork(options.network, options); - if (options.maxPerRequest === undefined || options.maxPerRequest === null) { - throw new RadiusPaymentError('config', 'createRadiusFetch: maxPerRequest is required (e.g. "$0.05")'); - } - const cap = BigInt(resolvePrice(options.maxPerRequest, network.asset).amount); - const chain = network.chain; - const publicClient: PublicClient = createPublicClient({ chain, transport: http(network.rpcUrl) }); - let account: ClientEvmSigner; - let walletClient: WalletClient | undefined; - if (typeof options.signer === 'string') { - const local = privateKeyToAccount(options.signer); - account = local as unknown as ClientEvmSigner; - walletClient = createWalletClient({ account: local, chain, transport: http(network.rpcUrl) }); - } else if (isWalletClient(options.signer)) { - const wc = options.signer; - const wcAccount = wc.account; - if (!wcAccount) throw new RadiusPaymentError('config', 'createRadiusFetch: the WalletClient has no account; create it with { account }'); - account = { - address: wcAccount.address, - signTypedData: (msg) => wc.signTypedData({ ...(msg as Omit[0], 'account'>), account: wcAccount } as Parameters[0]), - signMessage: (a: { message: string }) => wc.signMessage({ account: wcAccount, message: a.message }), - } as ClientEvmSigner; - walletClient = wc; - } else { - account = options.signer; - if (!isSigner(account)) throw new RadiusPaymentError('config', 'createRadiusFetch: signer must be a private key, a WalletClient with an account, or an object with address + signTypedData'); - if (isTxAccount(account)) walletClient = createWalletClient({ account, chain, transport: http(network.rpcUrl) }); - } - // readContract on the signer lets @x402/evm sign the EIP-2612 permit for gas sponsoring. - const signer = toClientEvmSigner(account, publicClient as never); - - const exactScheme = new ExactEvmScheme(signer, { rpcUrl: network.rpcUrl }); - // x402 v1 `exact` is EIP-3009 only. @x402/evm's own ExactEvmSchemeV1 resolves the chain id from a - // table of named v1 networks (base-sepolia, …) and rejects `eip155:`, which is how Radius - // appears in v1 challenges. ExactEvmScheme's EIP-3009 signing is version-agnostic (same EIP-712 - // domain/types, `validAfter: 0`), so delegate to it with the v1 price field normalised and wrap the - // result in the v1 envelope `{ x402Version, scheme, network, payload }`. - const exactV1Scheme: SchemeNetworkClient = { - scheme: 'exact', - async createPaymentPayload(x402Version, requirements) { - const v1 = requirements as unknown as PaymentRequirementsV1; - const { assetTransferMethod: _v2Only, ...extra } = v1.extra ?? {}; - const result = await exactScheme.createPaymentPayload(x402Version, { ...v1, amount: v1.maxAmountRequired, extra } as PaymentRequirements); - return { x402Version, scheme: v1.scheme, network: v1.network, payload: result.payload } as PaymentPayloadResult; - }, - }; - const client = new x402Client() - .register(network.network, exactScheme) - .register(network.network, new UptoEvmScheme(signer, { rpcUrl: network.rpcUrl })) - .registerV1(network.network, exactV1Scheme) - // Backstop; the primary checks live in `chooseOffer` so errors are typed. - .setSpendControls({ - maxAmountPerPayment: false, - allowedAssets: [{ network: network.network, asset: network.asset.address, maxAmountPerPayment: cap.toString() }], - }); - const httpClient = new x402HTTPClient(client); - const baseFetch = options.fetch ?? globalThis.fetch.bind(globalThis); - const explorer = (hash: string) => (network.explorerUrl ? `${network.explorerUrl}/tx/${hash}` : undefined); - - const requireWallet = (what: string): WalletClient => { - if (!walletClient) { - throw new RadiusPaymentError('approval_required', `${what} needs a transaction-capable signer (a private key, viem local account, or WalletClient); this signer can only sign typed data`); - } - return walletClient; - }; - - const sendTx = async (what: string, fn: (wc: WalletClient) => Promise<`0x${string}`>): Promise => { - const wc = requireWallet(what); - const hash = await fn(wc); - const receipt = await publicClient.waitForTransactionReceipt({ hash }); - return { hash, status: receipt.status === 'success' ? 'success' : 'reverted', explorerUrl: explorer(hash) }; - }; - - const permit2Allowance = () => - publicClient.readContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'allowance', args: [account.address, PERMIT2_ADDRESS] }); - - const allowance = (spender: Address) => - publicClient.readContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'allowance', args: [account.address, spender] }); - - /** The one policy gate for allowance changes: `onApprovalRequired` may veto, else proceed. */ - const authorizeApproval = async (request: ApprovalRequest): Promise => { - if (options.onApprovalRequired && !(await options.onApprovalRequired(request))) { - throw new RadiusPaymentError('declined', `${request.reason === 'payment' ? 'Permit2' : request.reason} approval declined`, request); - } - }; - - /** ERC-20 `approve` of the payment asset, after `authorizeApproval`. */ - const sendApproval = async (request: ApprovalRequest, what: string): Promise => { - await authorizeApproval(request); - const r = await sendTx(what, (wc) => - wc.writeContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'approve', args: [request.spender, request.amount], chain, account: wc.account! }), - ); - if (r.status !== 'success') throw new RadiusPaymentError('approval_failed', `${what} transaction ${r.hash} reverted`, r); - return r; - }; - - const approvePermit2 = async (): Promise => { - const currentAllowance = await permit2Allowance(); - return sendApproval({ reason: 'approvePermit2', asset: network.asset.address, spender: PERMIT2_ADDRESS, amount: maxUint256, currentAllowance }, 'Permit2 approval'); - }; - - const approve = async (spender: Address, amount: TokenAmount): Promise => { - const [atomic, currentAllowance] = await Promise.all([toTokenAtomic(publicClient, network.asset, amount), allowance(spender)]); - return sendApproval({ reason: 'approve', asset: network.asset.address, spender, amount: atomic, currentAllowance }, 'approve'); - }; - - const amountOf = (version: 1 | 2, a: AnyPaymentRequirements): bigint => { - const field = version === 1 ? 'maxAmountRequired' : 'amount'; - const raw = (a as Record)[field]; - if (typeof raw !== 'string' || !ATOMIC_AMOUNT.test(raw)) { - throw new RadiusPaymentError('invalid_challenge', `Offer ${field} must be a non-negative integer string (got ${JSON.stringify(raw)})`, a); - } - return BigInt(raw); - }; - - const chooseOffer = (pr: PaymentRequired, requestUrl: string): PaymentOffer => { - const version = pr.x402Version; - if (version !== 1 && version !== 2) throw new RadiusPaymentError('invalid_challenge', `Unsupported x402 version ${String(version)}`); - const accepts = pr.accepts as AnyPaymentRequirements[] | undefined; - if (!Array.isArray(accepts) || accepts.length === 0) throw new RadiusPaymentError('invalid_challenge', 'Challenge has no accepts[]'); - const sameNetwork = accepts.filter((a) => a.network === network.network); - if (sameNetwork.length === 0) { - const offered = [...new Set(accepts.map((a) => a.network))].join(', ') || 'none'; - throw new RadiusPaymentError('network_mismatch', `Server accepts ${offered}; this client pays on ${network.network} (${network.name})`, accepts); - } - const sameAsset = sameNetwork.filter((a) => typeof a.asset === 'string' && a.asset.toLowerCase() === network.asset.address.toLowerCase()); - if (sameAsset.length === 0) { - throw new RadiusPaymentError('asset_mismatch', `Server does not accept ${network.asset.symbol} (${network.asset.address}) on ${network.network}`, sameNetwork); - } - // `exact` exists in v1 and v2; `upto` is a v2 scheme only. - const knownScheme = sameAsset.filter((a) => a.scheme === 'exact' || (a.scheme === 'upto' && version === 2)); - if (knownScheme.length === 0) { - const schemes = [...new Set(sameAsset.map((a) => `${a.scheme}@v${version}`))].join(', '); - throw new RadiusPaymentError('no_compatible_offer', `Server offers ${schemes} for ${network.asset.symbol}; this client supports ${describeSupportedSchemes()}`, sameAsset); - } - // v1 `exact` is always EIP-3009 and `upto` always Permit2; v2 `exact` names its transfer method. - const supported = knownScheme.filter((a) => { - if (version === 1 || a.scheme === 'upto') return true; - const m = a.extra?.assetTransferMethod; - return m === undefined || m === 'permit2' || m === 'eip3009'; - }); - if (supported.length === 0) { - const methods = [...new Set(knownScheme.map((a) => String(a.extra?.assetTransferMethod)))].join(', '); - throw new RadiusPaymentError('unsupported_transfer_method', `Server requires assetTransferMethod ${methods}; this client supports permit2 and eip3009`, knownScheme); - } - // Server order is the server's preference (x402 clients honour it; so did radius-cli): take the - // first offer within the cap. Amounts are not compared across schemes — an `upto` amount is a - // ceiling, not a price. - const priced = supported.map((req) => ({ req, amount: amountOf(version, req) })); - const affordable = priced.find((p) => p.amount <= cap); - if (!affordable) { - const { req: first, amount: firstAmount } = priced[0]; - const offered = formatAmount(firstAmount, network.asset.decimals, network.asset.symbol); - const limit = formatAmount(cap, network.asset.decimals, network.asset.symbol); - throw new RadiusPaymentError( - 'price_above_limit', - first.scheme === 'upto' ? `Offer authorises up to ${offered}, exceeding maxPerRequest ${limit}` : `Offer ${offered} exceeds maxPerRequest ${limit}`, - priced.map((p) => p.req), - ); - } - const { req, amount } = affordable; - const scheme = req.scheme as PaymentScheme; - if (typeof req.payTo !== 'string' || !isAddress(req.payTo)) { - throw new RadiusPaymentError('invalid_challenge', `Offer payTo is not an address (got ${JSON.stringify(req.payTo)})`, req); - } - if (scheme === 'upto') { - const facilitator = req.extra?.facilitatorAddress ?? req.extra?.facilitator; - if (typeof facilitator !== 'string' || !isAddress(facilitator)) { - throw new RadiusPaymentError('invalid_challenge', 'upto offer is missing a valid extra.facilitatorAddress; cannot bind the Permit2 witness', req); - } - } - const gasSponsored = SPONSORING_KEYS.some((k) => pr.extensions !== undefined && k in pr.extensions); - // v1 has no top-level resource; its accepts[] carry description/mimeType. - const v1 = req as Partial; - const resource = version === 2 && pr.resource ? pr.resource : { url: requestUrl, description: v1.description, mimeType: v1.mimeType }; - return { - x402Version: version, - scheme, - amount: amount.toString(), - amountFormatted: formatAmount(amount, network.asset.decimals, network.asset.symbol), - asset: req.asset as Address, - payTo: req.payTo as Address, - network: req.network, - resource, - requirements: req, - transferMethod: scheme === 'upto' || (version === 2 && req.extra?.assetTransferMethod === 'permit2') ? 'permit2' : 'eip3009', - gasSponsored, - }; - }; - - /** - * The requirement handed to @x402/evm for signing. Fills in what the schemes need but a server may - * omit: the configured asset's EIP-712 domain (EIP-3009 / EIP-2612), a signing window, and the - * `extra.facilitator` alias radius-cli accepts for `facilitatorAddress`. Only the signer sees this; - * the untouched requirement is what gets echoed back to the server. - */ - const forSigning = (offer: PaymentOffer): PaymentRequirements => { - const req = offer.requirements; - const extra: Record = { name: network.asset.name, version: network.asset.version, ...req.extra }; - if (extra.facilitatorAddress === undefined && typeof extra.facilitator === 'string') extra.facilitatorAddress = extra.facilitator; - const t = req.maxTimeoutSeconds; - const maxTimeoutSeconds = typeof t === 'number' && t > 0 ? Math.min(Math.floor(t), MAX_TIMEOUT_SECONDS) : MAX_TIMEOUT_SECONDS; - return { ...req, maxTimeoutSeconds, extra } as PaymentRequirements; - }; - - /** Permit2 needs an ERC-20 allowance. Sponsored: the scheme signs a permit. Unsponsored: approve on-chain once. */ - const ensureAllowance = async (offer: PaymentOffer): Promise => { - if (offer.transferMethod !== 'permit2' || offer.gasSponsored) return; - const current = await permit2Allowance(); - if (current >= BigInt(offer.amount)) return; - const request: ApprovalRequest = { reason: 'payment', asset: network.asset.address, spender: PERMIT2_ADDRESS, amount: maxUint256, currentAllowance: current, offer }; - if ((options.permit2Approval ?? 'auto') === 'never') { - throw new RadiusPaymentError('approval_required', `Permit2 allowance ${current} is below ${offer.amount} and the facilitator does not sponsor approvals; call approvePermit2() or set permit2Approval: 'auto'`, request); - } - await sendApproval(request, 'Permit2 approval'); - }; - - /** - * v2: `PAYMENT-REQUIRED` header (or, off-spec but seen in the wild, a JSON body); v1: JSON body. - * Throws `invalid_challenge` with `{ response, body, cause }` so callers can show what the server sent. - */ - const readChallenge = async (res: Response): Promise => { - let text: string | undefined; - let body: unknown; - try { - if (!res.headers.get('payment-required')) { - text = await res.text(); - if (text) body = JSON.parse(text); - } - try { - return httpClient.getPaymentRequiredResponse((n) => res.headers.get(n), body); - } catch (e) { - if (body && typeof body === 'object' && !Array.isArray(body) && (body as { x402Version?: unknown }).x402Version === 2) return body as PaymentRequired; - throw e; - } - } catch (e) { - throw new RadiusPaymentError('invalid_challenge', `Could not parse the 402 challenge: ${(e as Error).message}`, { response: res, body: text, cause: e }); - } - }; - - /** - * Decode the settlement receipt. For `upto` the reported `amount` is untrusted input: it must be a - * non-negative integer no greater than the signed maximum, else `invalid_receipt`. `exact` receipts - * are decoded leniently (a malformed one just means no receipt). - */ - const decodeReceipt = (header: string, offer: PaymentOffer): PaymentReceipt | undefined => { - let receipt: PaymentReceipt; - try { - receipt = decodePaymentReceipt(header, network); - } catch (e) { - if (offer.scheme === 'upto') throw new RadiusPaymentError('invalid_receipt', `Invalid upto payment response: ${(e as Error).message}`, e); - return undefined; - } - if (offer.scheme === 'upto' && receipt.amount !== undefined) { - try { - parseUptoSettlementAmount(receipt.amount, BigInt(offer.amount)); - } catch (e) { - throw new RadiusPaymentError('invalid_receipt', `Invalid upto payment response: ${(e as Error).message}`, receipt); - } - } - // `exact` settles the offered amount; `upto` facilitators report what they charged (else assume the maximum). - if (receipt.success && receipt.amount === undefined) receipt.amount = offer.amount; - return receipt; - }; - - const paidFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const request = new Request(input, init); - if (request.headers.has('payment-signature') || request.headers.has('x-payment')) { - return baseFetch(request); - } - // The paid retry never follows redirects: a 3xx must not carry the payment header to another origin. - const retry = new Request(request.clone(), { redirect: 'manual' }); - const first = await baseFetch(request); - if (first.status !== 402) return first; - - const paymentRequired = await readChallenge(first); - const offer = chooseOffer(paymentRequired, request.url); - if (options.onPaymentRequired && !(await options.onPaymentRequired(offer))) { - throw new RadiusPaymentError('declined', `Payment of ${offer.amountFormatted} to ${offer.payTo} declined`, offer); - } - await ensureAllowance(offer); - - // Narrow the challenge to the chosen offer so the upstream selector cannot pick another. - const narrowed: PaymentRequired = { ...paymentRequired, accepts: [forSigning(offer)] }; - const payload = await client.createPaymentPayload(narrowed); - // v2 servers match `accepted` against the requirement they sent (core fields equal, their `extra` - // a subset of ours), so echo it untouched rather than the filled-in signing copy. - if (payload.x402Version === 2) payload.accepted = offer.requirements as PaymentRequirements; - for (const [k, v] of Object.entries(httpClient.encodePaymentSignatureHeader(payload))) retry.headers.set(k, v); - retry.headers.set('Access-Control-Expose-Headers', 'PAYMENT-RESPONSE,X-PAYMENT-RESPONSE'); - - const second = await baseFetch(retry); - if (REDIRECT_STATUSES.has(second.status)) { - const location = second.headers.get('location'); - let target: URL | undefined; - try { - target = location ? new URL(location, retry.url) : undefined; - } catch { - /* malformed Location: refused below */ - } - if (!target || !sameOrigin(target, new URL(retry.url))) { - throw new RadiusPaymentError( - 'redirect_refused', - `Server redirected the paid request to ${location ?? '(no Location)'}; refusing to replay the payment header across origins`, - { status: second.status, location }, - ); - } - // Same-origin: handed back unfollowed. Re-requesting the target is the caller's call (it may cost another payment). - return second; - } - const header = second.headers.get('payment-response') ?? second.headers.get('x-payment-response'); - if (second.status === 402) { - // The body is left unread: `details.response` is the server's answer for the caller to inspect. - let challenge: PaymentRequired | undefined; - try { - challenge = httpClient.getPaymentRequiredResponse((n) => second.headers.get(n)); - } catch { - /* no decodable PAYMENT-REQUIRED header */ - } - const details: PaymentRejectedDetails = { response: second, error: challenge?.error, challenge }; - throw new RadiusPaymentError('payment_rejected', `Server rejected the payment (${challenge?.error ?? 'no reason given'})`, details); - } - if (header) { - const receipt = decodeReceipt(header, offer); - if (receipt && options.onPaid) { - try { - await options.onPaid(receipt, offer); - } catch (e) { - console.error('radius-sdk onPaid hook failed:', e); - } - } - } - return second; - }; - - const balance = async () => { - const atomic = await publicClient.readContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'balanceOf', args: [account.address] }); - return { atomic, formatted: formatAmount(atomic, network.asset.decimals, network.asset.symbol) }; - }; - - const balances = () => getBalances(publicClient, { address: account.address, network }); - - const send = (to: Address, amount: Price): Promise => { - const atomic = BigInt(resolvePrice(amount, network.asset).amount); - return sendTx('send', (wc) => - wc.writeContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'transfer', args: [to, atomic], chain, account: wc.account! }), - ); - }; - - const fund = async (): Promise => { - if (!network.faucetUrl) throw new RadiusPaymentError('faucet', `No faucet configured for network ${network.name}`); - const signMessage = (account as { signMessage?: (a: { message: string }) => Promise<`0x${string}`> }).signMessage; - if (typeof signMessage !== 'function') throw new RadiusPaymentError('faucet', 'fund() needs a signer with signMessage (EIP-191), e.g. a private key or viem local account'); - const base = network.faucetUrl.replace(/\/+$/, ''); - const token = network.asset.symbol; - const challenge = (await (await fetch(`${base}/challenge/${account.address}?token=${token}`)).json()) as { message?: string }; - if (!challenge.message) throw new RadiusPaymentError('faucet', 'Faucet returned no challenge message', challenge); - const signature = await signMessage.call(account, { message: challenge.message }); - const res = await fetch(`${base}/drip`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ address: account.address, token, signature }), - }); - const raw = (await res.json().catch(() => ({}))) as { success?: boolean; amount?: string; tx_hash?: `0x${string}`; error?: { code?: string; message?: string; retry_after_ms?: number } }; - if (!res.ok || raw.success !== true) { - throw new RadiusPaymentError('faucet', `Faucet drip failed: ${raw.error?.code ?? res.status} ${raw.error?.message ?? ''}`.trim(), raw); - } - return { success: true, amount: raw.amount, txHash: raw.tx_hash, raw }; - }; - - return Object.assign(paidFetch, { - address: account.address, - network, - maxPerRequest: cap, - balance, - balances, - permit2Allowance, - approvePermit2, - send, - allowance, - approve, - getSettlement: (txHash: `0x${string}`) => getSettlement(network, txHash, publicClient), - fund, - client, - }); -} - -export { getSettlement } from '../settlement.js'; -export type { Settlement, SettlementTransfer } from '../settlement.js'; -export { getBalances, getNativeBalance, getAggregateBalance, getTokenBalance, radiusActions, defaultTokens, nativeBalanceBytecode } from '../balances.js'; -export type { - AccountBalances, - NativeBalance, - TokenBalance, - BalanceToken, - BalanceClient, - RadiusActions, - RadiusActionsConfig, - GetBalancesParameters, - GetNativeBalanceParameters, - GetTokenBalanceParameters, -} from '../balances.js'; export { - erc20Actions, - getTokenMetadata, - getAllowance, - approve, - transfer, - transferFrom, - getTransfers, - watchTransfers, - transferKey, - MAX_LOG_RANGE, - MAX_LOG_CHUNKS, - toTokenAtomic, - formatTokenAmount, -} from '../erc20.js'; + createRadiusFetch, + getSettlement, + getBalances, getNativeBalance, getAggregateBalance, getTokenBalance, radiusActions, defaultTokens, nativeBalanceBytecode, + getPaymentReceipt, decodePaymentReceipt, parseUptoSettlementAmount, + RadiusPaymentError, radiusEnv, +} from './buyer.js'; export type { - Erc20Actions, - Erc20ActionsConfig, - TokenMetadata, - TokenTransfer, - TokenInput, - TokenAmount, - TokenWalletClient, - GetTokenMetadataParameters, - GetAllowanceParameters, - ApproveParameters, - TransferParameters, - TransferFromParameters, - GetTransfersParameters, - WatchTransfersParameters, -} from '../erc20.js'; -export { getPaymentReceipt, decodePaymentReceipt, parseUptoSettlementAmount } from '../receipt.js'; -export type { PaymentReceipt } from '../receipt.js'; -export { RadiusPaymentError } from '../errors.js'; -export { radiusEnv } from '../env.js'; -export type { RadiusEnvConfig } from '../env.js'; + RadiusSigner, PaymentScheme, AnyPaymentRequirements, PaymentOffer, ApprovalRequest, + PaymentRejectedDetails, InvalidChallengeDetails, RadiusFetchOptions, TxResult, FaucetResult, RadiusFetch, + Settlement, SettlementTransfer, AccountBalances, NativeBalance, TokenBalance, BalanceToken, BalanceClient, + RadiusActions, RadiusActionsConfig, GetBalancesParameters, GetNativeBalanceParameters, GetTokenBalanceParameters, + PaymentReceipt, RadiusEnvConfig, +} from './buyer.js'; +export { createEvmFetch } from './evm.js'; +export type { EvmFetch, EvmFetchOptions, EvmNetworkConfig, EvmAssetConfig, EvmPaymentRoute } from './evm.js'; +export { erc20Actions, getTokenMetadata, getAllowance, approve, transfer, transferFrom, getTransfers, watchTransfers, transferKey, MAX_LOG_RANGE, MAX_LOG_CHUNKS, toTokenAtomic, formatTokenAmount } from '../erc20.js'; +export type { Erc20Actions, Erc20ActionsConfig, TokenMetadata, TokenTransfer, TokenInput, TokenAmount, TokenWalletClient, GetTokenMetadataParameters, GetAllowanceParameters, ApproveParameters, TransferParameters, TransferFromParameters, GetTransfersParameters, WatchTransfersParameters } from '../erc20.js'; diff --git a/packages/sdk/src/networks.ts b/packages/sdk/src/networks.ts index e588800..971511a 100644 --- a/packages/sdk/src/networks.ts +++ b/packages/sdk/src/networks.ts @@ -220,7 +220,7 @@ export function chainIdFromCaip2(network: string): number | undefined { } /** Explorer link for a settlement transaction, e.g. https://testnet.radiustech.xyz/tx/0x… */ -export function explorerTxUrl(network: RadiusNetwork, txHash: string): string | undefined { +export function explorerTxUrl(network: Pick, txHash: string): string | undefined { return network.explorerUrl ? `${network.explorerUrl}/tx/${txHash}` : undefined; } diff --git a/packages/sdk/src/receipt.ts b/packages/sdk/src/receipt.ts index 57063df..096faf6 100644 --- a/packages/sdk/src/receipt.ts +++ b/packages/sdk/src/receipt.ts @@ -37,7 +37,7 @@ export function parseUptoSettlementAmount(amount: string, maximum: bigint): bigi return settled; } -export function decodePaymentReceipt(headerValue: string, network?: RadiusNetwork, expected?: { amount: string }): PaymentReceipt { +export function decodePaymentReceipt(headerValue: string, network?: Pick, expected?: { amount: string }): PaymentReceipt { const r = decodePaymentResponseHeader(headerValue) as Record; if (r.amount !== undefined && (typeof r.amount !== 'string' || !ATOMIC_AMOUNT.test(r.amount))) { throw new Error("payment response: 'amount' must be a non-negative integer string"); @@ -56,7 +56,7 @@ export function decodePaymentReceipt(headerValue: string, network?: RadiusNetwor } /** Read the payment receipt from a Response (or Headers), if the server attached one. */ -export function getPaymentReceipt(source: Response | Headers, network?: RadiusNetwork): PaymentReceipt | undefined { +export function getPaymentReceipt(source: Response | Headers, network?: Pick): PaymentReceipt | undefined { const headers = source instanceof Headers ? source : source.headers; const v = headers.get(PAYMENT_RESPONSE_HEADER) ?? headers.get('x-payment-response'); if (!v) return undefined; diff --git a/packages/sdk/src/settlement.ts b/packages/sdk/src/settlement.ts index 4c43aa2..873c4f8 100644 --- a/packages/sdk/src/settlement.ts +++ b/packages/sdk/src/settlement.ts @@ -29,7 +29,7 @@ const TRANSFER = parseAbiItem('event Transfer(address indexed from, address inde * transaction is unknown to the node (not yet mined, or never existed). * Use it to reconcile a timed-out payment before authorising another charge. */ -export async function getSettlement(network: RadiusNetwork, txHash: `0x${string}`, client?: PublicClient): Promise { +export async function getSettlement(network: Pick, txHash: `0x${string}`, client?: PublicClient): Promise { const pc = client ?? createPublicClient({ chain: network.chain, transport: http(network.rpcUrl) }); let receipt; try { diff --git a/packages/sdk/test/client-multinetwork.test.ts b/packages/sdk/test/client-multinetwork.test.ts new file mode 100644 index 0000000..ce52775 --- /dev/null +++ b/packages/sdk/test/client-multinetwork.test.ts @@ -0,0 +1,292 @@ +/** Real x402 payloads and recoverable signatures; RPC is local and no funds leave the process. */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createPublicClient, createWalletClient, encodeDeployData, http, maxUint256, recoverTypedDataAddress, toHex, type Abi, type Address, type Chain, type Hex } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { arbitrum, arcTestnet, base, monad, polygon } from 'viem/chains'; +import { createEvmFetch, type EvmNetworkConfig, type PaymentReceipt } from '../src/client/index.js'; +import { PERMIT2_ADDRESS, radiusMainnet, SBC, type RadiusAsset } from '../src/networks.js'; +import { evmNode } from './evmNode.js'; +import artifact from './fixtures/TestToken.json' with { type: 'json' }; + +const account = privateKeyToAccount(`0x${'01'.repeat(32)}`); +const otherAccount = privateKeyToAccount(`0x${'02'.repeat(32)}`); +const recipient = '0x1111111111111111111111111111111111111111' as Address; +const facilitator = '0x2222222222222222222222222222222222222222' as Address; +const url = 'https://data.example/lookup'; +const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64'); +const decode = (request: Request) => JSON.parse(Buffer.from(request.headers.get('payment-signature') ?? request.headers.get('x-payment')!, 'base64').toString()); +const usdc = (address: Address, name = 'USD Coin'): RadiusAsset => ({ address, name, version: '2', symbol: 'USDC', decimals: 6 }); +// Circle-issued ERC-20 addresses, not the chain's native balance representation. +const baseUsdc = usdc('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'); +const chainAssets: [Chain, RadiusAsset][] = [ + [radiusMainnet.chain, SBC], + [base, baseUsdc], + [arbitrum, usdc('0xaf88d065e77c8cC2239327C5EDb3A432268e5831')], + [polygon, usdc('0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359')], + [monad, usdc('0x754704Bc059F8C67012fEd69BC8A327a5aafb603', 'USDC')], + [arcTestnet, usdc('0x3600000000000000000000000000000000000000', 'USDC')], +]; +const config = (chain: Chain, asset: RadiusAsset, maxPerRequest = '0.05'): EvmNetworkConfig => ({ chain, assets: [{ asset, maxPerRequest }], rpcUrl: `https://rpc-${chain.id}.example` }); +const configs = chainAssets.map(([chain, asset]) => config(chain, asset)); +const offer = (chain = base, asset = baseUsdc, amount = '13000') => ({ + scheme: 'exact', network: `eip155:${chain.id}`, asset: asset.address, payTo: recipient, amount, maxTimeoutSeconds: 120, + extra: { assetTransferMethod: 'eip3009' }, // configured token domain fills in the signing copy +}); +const challenge = (accepts: unknown[], extra = {}) => ({ x402Version: 2, resource: { url }, accepts, ...extra }); +function seller(required: unknown, paid: (req: Request) => Response | Promise = () => Response.json({ data: 'enrichment' })) { + const requests: Request[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + requests.push(request); + return request.headers.has('payment-signature') || request.headers.has('x-payment') ? paid(request) + : new Response(null, { status: 402, headers: { 'PAYMENT-REQUIRED': encode(required) } }); + }; + return { fetch, requests }; +} +const 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; +async function recover(payload: any, chain: Chain, asset: RadiusAsset) { + const a = payload.payload.authorization; + return recoverTypedDataAddress({ domain: { name: asset.name, version: asset.version, chainId: chain.id, verifyingContract: asset.address }, types, primaryType: 'TransferWithAuthorization', + message: { ...a, value: BigInt(a.value), validAfter: BigInt(a.validAfter), validBefore: BigInt(a.validBefore) }, signature: payload.payload.signature }); +} +function rpcAllowance(value = maxUint256) { + const calls: { url: string; method: string; params: any[] }[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = new Request(input, init); + const body = JSON.parse(await request.text()); + const respond = (call: any) => { + calls.push({ url: request.url, ...call }); + if (call.method !== 'eth_call') throw new Error(`Unexpected RPC ${call.method}`); + return { jsonrpc: '2.0', id: call.id, result: toHex(call.params[0].data.startsWith('0xdd62ed3e') ? value : 0n, { size: 32 }) }; + }; + return Response.json(Array.isArray(body) ? body.map(respond) : respond(body)); + }); + return calls; +} +afterEach(() => vi.restoreAllMocks()); + +describe('EVM network routing', () => { + it.each(chainAssets)('signs exact on $0.name with its own token domain and chain id', async (chain, asset) => { + const requirement = offer(chain, asset); + const server = seller(challenge([requirement])); + const rpc = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('EIP-3009 should not need RPC')); + const pay = createEvmFetch({ signer: account, networks: configs, fetch: server.fetch }); + expect(await (await pay(url)).json()).toEqual({ data: 'enrichment' }); + expect(server.requests).toHaveLength(2); + const payload = decode(server.requests[1]); + expect(payload.accepted).toEqual(requirement); + expect(await recover(payload, chain, asset)).toBe(account.address); + expect(rpc).not.toHaveBeenCalled(); + }); + + it('skips SVM, unconfigured assets, unsupported schemes, and above-cap offers in server order', async () => { + const wanted = offer(arbitrum, chainAssets[2][1], '40000'); + const server = seller(challenge([ + { ...offer(), network: 'solana:mainnet' }, + offer(base, SBC), + { ...offer(), scheme: 'subscription' }, + offer(base, baseUsdc, '50001'), + wanted, + offer(radiusMainnet.chain, SBC, '1000'), + ])); + await createEvmFetch({ signer: account, networks: configs, fetch: server.fetch })(url); + expect(decode(server.requests[1]).accepted).toEqual(wanted); + }); + + it('uses independent token-unit caps for multiple assets on one chain', async () => { + const token18 = { ...baseUsdc, address: recipient, symbol: 'CREDITS', decimals: 18, name: 'Credits', version: '1' }; + const server = seller(challenge([offer(base, token18, '50000000000000001'), offer()])); + const pay = createEvmFetch({ signer: account, networks: [{ chain: base, assets: [{ asset: token18, maxPerRequest: '0.05' }, { asset: baseUsdc, maxPerRequest: '0.02' }] }], fetch: server.fetch }); + await pay(url); + expect(pay.routes.map(r => r.maxPerRequest)).toEqual([50000000000000000n, 20000n]); + expect(decode(server.requests[1]).accepted.asset).toBe(baseUsdc.address); + }); + + it.each([ + [challenge([{ ...offer(), network: 'solana:mainnet' }]), 'network_mismatch'], + [challenge([offer(base, SBC)]), 'asset_mismatch'], + [challenge([offer(base, baseUsdc, '50001')]), 'price_above_limit'], + [challenge([{ ...offer(), extra: { assetTransferMethod: 'erc7710' } }]), 'unsupported_transfer_method'], + ])('refuses incompatible challenges before signing or retrying (%s)', async (required, code) => { + const signTypedData = vi.fn(account.signTypedData); + const server = seller(required); + await expect(createEvmFetch({ signer: { address: account.address, signTypedData }, networks: configs, fetch: server.fetch })(url)).rejects.toMatchObject({ code }); + expect(signTypedData).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(1); + }); + + it('makes a policy decline terminal, even when another chain is affordable', async () => { + const server = seller(challenge([offer(), offer(radiusMainnet.chain, SBC)])); + const onPaymentRequired = vi.fn(() => false); + await expect(createEvmFetch({ signer: account, networks: configs, fetch: server.fetch, onPaymentRequired })(url)).rejects.toMatchObject({ code: 'declined' }); + expect(onPaymentRequired).toHaveBeenCalledTimes(1); + expect(server.requests).toHaveLength(1); + }); + + it('pays a legacy v1 CAIP-2 challenge with the selected chain and X-PAYMENT', async () => { + const requests: Request[] = []; + const { amount, ...req } = offer(); + const fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init); requests.push(request); + return request.headers.has('x-payment') ? new Response('legacy data') + : Response.json({ x402Version: 1, accepts: [{ ...req, maxAmountRequired: amount, resource: url }] }, { status: 402 }); + }; + await createEvmFetch({ signer: account, networks: configs, fetch })(url); + const payload = decode(requests[1]); + expect(requests[1].headers.has('payment-signature')).toBe(false); + expect(payload).toMatchObject({ x402Version: 1, network: 'eip155:8453', scheme: 'exact' }); + expect(await recover(payload, base, baseUsdc)).toBe(account.address); + }); + + it('keeps concurrent requests, POST bodies and chain-specific signers separate', async () => { + const requests: Request[] = []; + const fetch: typeof globalThis.fetch = async (input, init) => { + const req = new Request(input, init); requests.push(req); + const index = req.url.endsWith('/base') ? 1 : 2; + const [chain, asset] = chainAssets[index]; + if (!req.headers.has('payment-signature')) return new Response(null, { status: 402, headers: { 'PAYMENT-REQUIRED': encode(challenge([offer(chain, asset)])) } }); + await Promise.resolve(); + expect(await recover(decode(req), chain, asset)).toBe(index === 1 ? account.address : otherAccount.address); + expect(req.method).toBe('POST'); + expect(req.headers.get('authorization')).toBe('Bearer test-fixture'); + expect(req.redirect).toBe('manual'); + return Response.json({ body: await req.text() }); + }; + const pay = createEvmFetch({ signer: account, networks: [configs[1], { ...configs[2], signer: otherAccount }], fetch }); + const result = await Promise.all(['base', 'arbitrum'].map(async name => (await pay(`${url}/${name}`, { method: 'POST', body: name, headers: { authorization: 'Bearer test-fixture' } })).json())); + expect(result).toEqual([{ body: 'base' }, { body: 'arbitrum' }]); + expect(requests).toHaveLength(4); + }); + + it('rejects a WalletClient bound to the wrong chain during configuration', () => { + const wallet = createWalletClient({ account, chain: base, transport: http() }); + expect(() => createEvmFetch({ signer: wallet, networks: [configs[2]] })).toThrow(/WalletClient chain 8453 does not match payment chain 42161/); + }); + + it('requires complete token metadata, unique routes, and a cap for the configured asset', () => { + expect(() => createEvmFetch({ signer: account, networks: [{ chain: base, assets: [{ asset: { address: baseUsdc.address } as RadiusAsset, maxPerRequest: '1' }] }] })).toThrow(/Incomplete ERC-20/); + expect(() => createEvmFetch({ signer: account, networks: [configs[1], configs[1]] })).toThrow(/Duplicate network/); + expect(() => createEvmFetch({ signer: account, networks: [{ chain: base, assets: [{ asset: baseUsdc, maxPerRequest: { amount: '5', asset: SBC.address } }] }] })).toThrow(/asset must match/); + }); +}); + +describe('Permit2 and receipts across networks', () => { + it.each(['exact', 'upto'])('signs %s Permit2 on Arbitrum with that chain allowance and witness', async scheme => { + const calls = rpcAllowance(); + const [chain, asset] = chainAssets[2]; + const required = { ...offer(chain, asset, '50000'), scheme, extra: { assetTransferMethod: 'permit2', ...(scheme === 'upto' ? { facilitatorAddress: facilitator } : {}) } }; + const receipts: PaymentReceipt[] = []; + const server = seller(challenge([required]), () => new Response('data', { headers: { 'PAYMENT-RESPONSE': encode({ success: true, network: required.network, transaction: `0x${'ab'.repeat(32)}`, ...(scheme === 'upto' ? { amount: '12000' } : {}) }) } })); + await createEvmFetch({ signer: account, networks: configs, fetch: server.fetch, onPaid: r => { receipts.push(r); } })(url); + const payload = decode(server.requests[1]); + const a = payload.payload.permit2Authorization; + const recovered = await recoverTypedDataAddress({ + domain: { name: 'Permit2', chainId: chain.id, verifyingContract: PERMIT2_ADDRESS }, + primaryType: 'PermitWitnessTransferFrom', + types: { + PermitWitnessTransferFrom: [{ name: 'permitted', type: 'TokenPermissions' }, { name: 'spender', type: 'address' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, { name: 'witness', type: 'Witness' }], + TokenPermissions: [{ name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }], + Witness: [{ name: 'to', type: 'address' }, ...(scheme === 'upto' ? [{ name: 'facilitator', type: 'address' }] : []), { name: 'validAfter', type: 'uint256' }], + }, + message: { ...a, permitted: { ...a.permitted, amount: BigInt(a.permitted.amount) }, nonce: BigInt(a.nonce), deadline: BigInt(a.deadline), witness: { ...a.witness, validAfter: BigInt(a.witness.validAfter) } }, + signature: payload.payload.signature, + }); + expect(recovered).toBe(account.address); + expect(calls.every(c => c.url === configs[2].rpcUrl + '/')).toBe(true); + expect(calls[0].params[0].to.toLowerCase()).toBe(asset.address.toLowerCase()); + expect(receipts[0]).toMatchObject({ amount: scheme === 'upto' ? '12000' : '50000', network: required.network, explorerUrl: `${chain.blockExplorers!.default.url}/tx/0x${'ab'.repeat(32)}` }); + }); + + it('does not send an approval by default when allowance is missing', async () => { + const calls = rpcAllowance(0n); + const server = seller(challenge([{ ...offer(), extra: { assetTransferMethod: 'permit2' } }])); + await expect(createEvmFetch({ signer: account, networks: configs, fetch: server.fetch })(url)).rejects.toMatchObject({ code: 'approval_required', details: { reason: 'payment', offer: { network: 'eip155:8453' } } }); + expect(calls.map(c => c.method)).toEqual(['eth_call']); + expect(server.requests).toHaveLength(1); + }); + + it('rejects an upto maximum above the cap before allowance reads', async () => { + const rpc = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('No RPC expected')); + const server = seller(challenge([{ ...offer(base, baseUsdc, '50001'), scheme: 'upto', extra: { facilitatorAddress: facilitator } }])); + await expect(createEvmFetch({ signer: account, networks: configs, fetch: server.fetch })(url)).rejects.toMatchObject({ code: 'price_above_limit' }); + expect(rpc).not.toHaveBeenCalled(); + }); + + it('supports sponsored Permit2 without making an approval transaction', async () => { + const calls = rpcAllowance(0n); + const server = seller(challenge([{ ...offer(), extra: { assetTransferMethod: 'permit2' } }], { extensions: { eip2612GasSponsoring: { version: '1' } } })); + await createEvmFetch({ signer: account, networks: configs, fetch: server.fetch })(url); + expect(decode(server.requests[1]).extensions.eip2612GasSponsoring.info).toMatchObject({ asset: baseUsdc.address, spender: PERMIT2_ADDRESS, amount: '13000' }); + expect(calls.every(c => c.method === 'eth_call' && c.url === configs[1].rpcUrl + '/')).toBe(true); + }); + + it('lets approval policy veto an explicitly enabled auto approval', async () => { + const calls = rpcAllowance(0n); + const server = seller(challenge([{ ...offer(), extra: { assetTransferMethod: 'permit2' } }])); + const onApprovalRequired = vi.fn(() => false); + await expect(createEvmFetch({ signer: account, networks: [{ ...configs[1], permit2Approval: 'auto' }], fetch: server.fetch, onApprovalRequired })(url)).rejects.toMatchObject({ code: 'declined' }); + expect(onApprovalRequired).toHaveBeenCalledOnce(); + expect(calls.map(c => c.method)).toEqual(['eth_call']); + expect(server.requests).toHaveLength(1); + }); + + it('refuses a receipt from a different chain instead of attaching the selected explorer', async () => { + const server = seller(challenge([offer()]), () => new Response('data', { headers: { 'PAYMENT-RESPONSE': encode({ success: true, network: 'eip155:42161', transaction: `0x${'ab'.repeat(32)}` }) } })); + const onPaid = vi.fn(); + await expect(createEvmFetch({ signer: account, networks: configs, fetch: server.fetch, onPaid })(url)).rejects.toMatchObject({ code: 'invalid_receipt' }); + expect(onPaid).not.toHaveBeenCalled(); + }); + + it('refuses a cross-origin paid redirect without forwarding its signature', async () => { + const server = seller(challenge([offer()]), () => new Response(null, { status: 307, headers: { location: 'https://other.example/lookup' } })); + await expect(createEvmFetch({ signer: account, networks: configs, fetch: server.fetch })(url)).rejects.toMatchObject({ code: 'redirect_refused' }); + expect(server.requests).toHaveLength(2); + expect(server.requests[1].redirect).toBe('manual'); + }); + + it('does not synthesize a receipt for HTTP success without settlement evidence', async () => { + const server = seller(challenge([offer()])); + const onPaid = vi.fn(); + await createEvmFetch({ signer: account, networks: configs, fetch: server.fetch, onPaid })(url); + expect(onPaid).not.toHaveBeenCalled(); + }); + + it('surfaces a paid rejection without retrying on another network', async () => { + const server = seller(challenge([offer(), offer(radiusMainnet.chain, SBC)]), () => new Response('rejected', { status: 402 })); + await expect(createEvmFetch({ signer: account, networks: configs, fetch: server.fetch })(url)).rejects.toMatchObject({ code: 'payment_rejected' }); + expect(server.requests).toHaveLength(2); + }); + + it('executes explicitly allowed approval on the selected EVM and reconciles a real token transfer', async () => { + const node = await evmNode({ chainId: base.id, accounts: [account.address], blockNumber: 100n }); + const tokenAddress = await node.deploy(encodeDeployData({ abi: artifact.abi as Abi, bytecode: artifact.bytecode as Hex, args: [6, 1000000n] }), account.address); + const token = { address: tokenAddress, decimals: 6, symbol: 'TST', name: 'Test Token', version: '1' }; + const transport = node.transport({ chain: base }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = new Request(input, init); + expect(request.url).toBe('https://rpc-8453.example/'); + const body = JSON.parse(await request.text()); + const respond = async (call: any) => ({ jsonrpc: '2.0', id: call.id, result: await transport.request({ method: call.method, params: call.params }) }); + return Response.json(Array.isArray(body) ? await Promise.all(body.map(respond)) : await respond(body)); + }); + const server = seller(challenge([{ ...offer(base, token), extra: { assetTransferMethod: 'permit2' } }])); + const onApprovalRequired = vi.fn(() => true); + const pay = createEvmFetch({ signer: account, networks: [configs[0], { ...config(base, token), permit2Approval: 'auto' }], fetch: server.fetch, onApprovalRequired }); + await pay(url); + expect(onApprovalRequired).toHaveBeenCalledOnce(); + expect(onApprovalRequired.mock.calls[0][0]).toMatchObject({ reason: 'payment', amount: maxUint256, offer: { network: 'eip155:8453' } }); + const pc = createPublicClient({ chain: base, transport: node.transport }); + expect(await pc.readContract({ address: tokenAddress, abi: artifact.abi, functionName: 'allowance', args: [account.address, PERMIT2_ADDRESS] })).toBe(maxUint256); + await pay(url); + expect(node.sent).toHaveLength(1); // second purchase reuses the real allowance + const wallet = createWalletClient({ account, chain: base, transport: node.transport }); + const hash = await wallet.writeContract({ address: tokenAddress, abi: artifact.abi, functionName: 'transfer', args: [recipient, 13000n] }); + const settled = await pay.routes[1].getSettlement(hash); + expect(settled?.status).toBe('success'); + expect(settled?.paid(recipient)).toBe(13000n); + expect(settled?.paidFormatted(recipient)).toBe('0.013 TST'); + }); +}); From 2e45e70e4f718e98f931f61e938d036a076b5f66 Mon Sep 17 00:00:00 2001 From: Eriks Reks Date: Wed, 30 Sep 2026 12:39:50 -0400 Subject: [PATCH 2/2] refactor(sdk): delegate multi-network selection to x402 core hooks --- .changeset/evm-multinetwork-buyer.md | 2 +- packages/sdk/README.md | 30 +++++- packages/sdk/src/client/buyer.ts | 84 +++++++++------ packages/sdk/src/client/evm.ts | 102 +++++++++++++----- packages/sdk/test/client-multinetwork.test.ts | 46 +++++++- 5 files changed, 199 insertions(+), 65 deletions(-) diff --git a/.changeset/evm-multinetwork-buyer.md b/.changeset/evm-multinetwork-buyer.md index 81e5550..47c7bd0 100644 --- a/.changeset/evm-multinetwork-buyer.md +++ b/.changeset/evm-multinetwork-buyer.md @@ -2,4 +2,4 @@ "radius-sdk": minor --- -Add createEvmFetch for x402 purchases across explicitly configured EVM networks and ERC-20 assets, with independent spending caps, chain-specific signers and settlement reconciliation. Unsponsored Permit2 approval is opt-in. Preserve createRadiusFetch and its Radius wallet helpers; reject receipts that name a different payment network. +Add createEvmFetch for x402 purchases across explicitly configured EVM networks and ERC-20 assets, using the upstream x402 multi-network registry and lifecycle hooks, with independent spending caps, chain-specific signers and settlement reconciliation. Unsponsored Permit2 approval is opt-in. Preserve createRadiusFetch and its Radius wallet helpers; reject receipts that name a different payment network. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 3a5f1e8..f6613cf 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -167,8 +167,9 @@ const payFetch = createEvmFetch({ const response = await payFetch('https://provider.example/lookup'); ``` -The buyer scans the server's `accepts` in order and selects the first supported -network/asset/scheme within that asset's cap. It skips unconfigured networks (including +The upstream x402 client selects a supported network/asset/scheme within that asset's +cap. It prefers authorization offers over upfront/escrow offers, then preserves server +order among the remaining offers. It skips unconfigured networks (including non-EVM offers), unconfigured tokens, unsupported schemes and offers above their cap. `exact` v2 supports EIP-3009 and Permit2; `upto` v2 supports Permit2. Legacy `exact` v1 is supported with CAIP-2 `eip155:` identifiers; named v1 aliases such as @@ -209,6 +210,31 @@ and the chosen chain's docs for current metadata. The test suite covers wire pay for all named chains, with local EVM execution for approval and reconciliation; it does not establish live facilitator support on those networks. +### Upstream x402 packages + +The buyer uses the pinned `@x402/core` and `@x402/evm` 2.25.0 packages: + +| Responsibility | Implementation | +| --- | --- | +| Multi-network registration, scheme/flow selection and atomic caps | One `x402Client`, `setSpendControls` and `registerPolicy` | +| Policy and approval before signing | Public `onBeforePaymentCreation` lifecycle hook | +| EIP-3009, Permit2, upto and sponsored permit signatures | `ExactEvmScheme`, `UptoEvmScheme`, `toClientEvmSigner` | +| Permit2 allowance reads and approval calldata | `getPermit2AllowanceReadParams`, `createPermit2ApprovalTx` | +| Challenge and payment-header encoding/decoding | `x402HTTPClient` and core HTTP helpers | + +SDK code supplies explicit configuration, strict token allowlisting, approval policy, +metadata/deadline normalization, typed errors, receipt checks and onchain reconciliation. +The strict allowlist policy is necessary because upstream `allowedAssets` also admits +recognized default tokens. Each request has its own hook context, so concurrent requests +retain their selected chain, signer and original challenge. + +We retain one guarded HTTP retry wrapper: `@x402/fetch` 2.25.0 inherits the request's +redirect-following mode on a paid retry, converts signing/policy failures to plain +`Error`, and supports additional signing on recovery. The SDK contract requires a manual +paid redirect, typed refusal errors and one payment attempt. The small v1 CAIP-2 adapter +also remains because upstream's v1 EVM scheme expects named network aliases; signatures +still come from the upstream EVM implementation. + Runnable Radius + Base example: [multi-network.mjs](./examples/agent-buyer/multi-network.mjs). ## ERC-20 interactions diff --git a/packages/sdk/src/client/buyer.ts b/packages/sdk/src/client/buyer.ts index 44caec8..e37b63b 100644 --- a/packages/sdk/src/client/buyer.ts +++ b/packages/sdk/src/client/buyer.ts @@ -1,6 +1,7 @@ import { x402Client, x402HTTPClient } from '@x402/core/client'; -import type { PaymentPayloadResult, PaymentRequired, PaymentRequirements, PaymentRequirementsV1, SchemeNetworkClient } from '@x402/core/types'; +import type { PaymentPayload, PaymentPayloadResult, PaymentRequired, PaymentRequirements, PaymentRequirementsV1, SchemeNetworkClient } from '@x402/core/types'; import { ExactEvmScheme, UptoEvmScheme, toClientEvmSigner, type ClientEvmSigner } from '@x402/evm'; +import { createPermit2ApprovalTx, getPermit2AllowanceReadParams } from '@x402/evm/exact/client'; import { createPublicClient, createWalletClient, http, isAddress, maxUint256, type Account, type PublicClient, type WalletClient } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { formatAmount, resolvePrice, type Price } from '../amounts.js'; @@ -162,7 +163,9 @@ interface SingleNetworkBuyer extends Pick; chooseOffer(challenge: PaymentRequired, url: string): PaymentOffer; readChallenge(response: Response): Promise; - pay(retry: Request, challenge: PaymentRequired, offer: PaymentOffer): Promise; + authorize(offer: PaymentOffer): Promise; + forSigning(requirements: AnyPaymentRequirements): PaymentRequirements; + sendPaid(retry: Request, offer: PaymentOffer, payload: PaymentPayload): Promise; account: ClientEvmSigner; publicClient: PublicClient; network: BuyerNetwork; @@ -207,7 +210,7 @@ function isTxAccount(v: unknown): v is Account { * Shared x402 buyer engine for one EVM network and asset. * The public factories attach either Radius wallet helpers or multi-network routing. */ -export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNetwork): SingleNetworkBuyer { +export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNetwork, sharedClient?: x402Client): SingleNetworkBuyer { if (options.maxPerRequest === undefined || options.maxPerRequest === null) { throw new RadiusPaymentError('config', 'x402 buyer: maxPerRequest is required (e.g. "$0.05")'); } @@ -260,15 +263,15 @@ export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNe return { x402Version, scheme: v1.scheme, network: v1.network, payload: result.payload } as PaymentPayloadResult; }, }; - const client = new x402Client() + const client = (sharedClient ?? new x402Client()) .register(network.network, exactScheme) .register(network.network, new UptoEvmScheme(signer, { rpcUrl: network.rpcUrl })) - .registerV1(network.network, exactV1Scheme) - // Backstop; the primary checks live in `chooseOffer` so errors are typed. - .setSpendControls({ - maxAmountPerPayment: false, - allowedAssets: [{ network: network.network, asset: network.asset.address, maxAmountPerPayment: cap.toString() }], - }); + .registerV1(network.network, exactV1Scheme); + // Multi-network callers configure one set of upstream spend controls after registering all routes. + if (!sharedClient) client.setSpendControls({ + maxAmountPerPayment: false, + allowedAssets: [{ network: network.network, asset: network.asset.address, maxAmountPerPayment: cap.toString() }], + }); const httpClient = new x402HTTPClient(client); const baseFetch = options.fetch ?? globalThis.fetch.bind(globalThis); const explorer = (hash: string) => (network.explorerUrl ? `${network.explorerUrl}/tx/${hash}` : undefined); @@ -288,7 +291,7 @@ export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNe }; const permit2Allowance = () => - publicClient.readContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'allowance', args: [account.address, PERMIT2_ADDRESS] }); + publicClient.readContract(getPermit2AllowanceReadParams({ tokenAddress: network.asset.address, ownerAddress: account.address })); const allowance = (spender: Address) => publicClient.readContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'allowance', args: [account.address, spender] }); @@ -303,8 +306,9 @@ export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNe /** ERC-20 `approve` of the payment asset, after `authorizeApproval`. */ const sendApproval = async (request: ApprovalRequest, what: string): Promise => { await authorizeApproval(request); - const r = await sendTx(what, (wc) => - wc.writeContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'approve', args: [request.spender, request.amount], chain, account: wc.account! }), + const r = await sendTx(what, (wc) => request.reason === 'approve' + ? wc.writeContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'approve', args: [request.spender, request.amount], chain, account: wc.account! }) + : wc.sendTransaction({ ...createPermit2ApprovalTx(network.asset.address), chain, account: wc.account! }), ); if (r.status !== 'success') throw new RadiusPaymentError('approval_failed', `${what} transaction ${r.hash} reverted`, r); return r; @@ -410,8 +414,7 @@ export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNe * `extra.facilitator` alias radius-cli accepts for `facilitatorAddress`. Only the signer sees this; * the untouched requirement is what gets echoed back to the server. */ - const forSigning = (offer: PaymentOffer): PaymentRequirements => { - const req = offer.requirements; + const forSigning = (req: AnyPaymentRequirements): PaymentRequirements => { const extra: Record = { name: network.asset.name, version: network.asset.version, ...req.extra }; if (extra.facilitatorAddress === undefined && typeof extra.facilitator === 'string') extra.facilitatorAddress = extra.facilitator; const t = req.maxTimeoutSeconds; @@ -482,15 +485,22 @@ export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNe return receipt; }; - const pay = async (retry: Request, paymentRequired: PaymentRequired, offer: PaymentOffer): Promise => { + const authorize = async (offer: PaymentOffer): Promise => { if (options.onPaymentRequired && !(await options.onPaymentRequired(offer))) { throw new RadiusPaymentError('declined', `Payment of ${offer.amountFormatted} to ${offer.payTo} declined`, offer); } await ensureAllowance(offer); + }; + const pay = async (retry: Request, paymentRequired: PaymentRequired, offer: PaymentOffer): Promise => { + await authorize(offer); // Narrow the challenge to the chosen offer so the upstream selector cannot pick another. - const narrowed: PaymentRequired = { ...paymentRequired, accepts: [forSigning(offer)] }; + const narrowed: PaymentRequired = { ...paymentRequired, accepts: [forSigning(offer.requirements)] }; const payload = await client.createPaymentPayload(narrowed); + return sendPaid(retry, offer, payload); + }; + + const sendPaid = async (retry: Request, offer: PaymentOffer, payload: PaymentPayload): Promise => { // v2 servers match `accepted` against the requirement they sent (core fields equal, their `extra` // a subset of ours), so echo it untouched rather than the filled-in signing copy. if (payload.x402Version === 2) payload.accepted = offer.requirements as PaymentRequirements; @@ -541,20 +551,8 @@ export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNe return second; }; - const paidFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const request = new Request(input, init); - if (request.headers.has('payment-signature') || request.headers.has('x-payment')) { - return baseFetch(request); - } - // The paid retry never follows redirects: a 3xx must not carry the payment header to another origin. - const retry = new Request(request.clone(), { redirect: 'manual' }); - const first = await baseFetch(request); - if (first.status !== 402) return first; - - const paymentRequired = await readChallenge(first); - const offer = chooseOffer(paymentRequired, request.url); - return pay(retry, paymentRequired, offer); - }; + const paidFetch = createPaymentFetch(baseFetch, readChallenge, (retry, paymentRequired, url) => + pay(retry, paymentRequired, chooseOffer(paymentRequired, url))); const balance = async () => { const atomic = await publicClient.readContract({ address: network.asset.address, abi: ERC20_ABI, functionName: 'balanceOf', args: [account.address] }); @@ -573,7 +571,9 @@ export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNe fetch: paidFetch, chooseOffer, readChallenge, - pay, + authorize, + forSigning, + sendPaid, account, publicClient, address: account.address, @@ -590,6 +590,26 @@ export function createSingleNetworkBuyer(options: BuyerOptions, network: BuyerNe }; } +/** + * Guarded transport around upstream x402HTTPClient parsing and header encoding. + * @x402/fetch 2.25.0 inherits redirect-following on paid retries and wraps policy + * errors in plain Error; this preserves the SDK's redirect and typed-error contract. + */ +export function createPaymentFetch( + baseFetch: typeof globalThis.fetch, + readChallenge: (response: Response) => Promise, + pay: (retry: Request, challenge: PaymentRequired, url: string) => Promise, +): typeof globalThis.fetch { + return async (input, init) => { + const request = new Request(input, init); + if (request.headers.has('payment-signature') || request.headers.has('x-payment')) return baseFetch(request); + const retry = new Request(request.clone(), { redirect: 'manual' }); + const response = await baseFetch(request); + if (response.status !== 402) return response; + return pay(retry, await readChallenge(response), request.url); + }; +} + /** Create a Radius buyer with its existing wallet, balance, and faucet helpers. */ export function createRadiusFetch(options: RadiusFetchOptions): RadiusFetch { const network = resolveNetwork(options.network, options); diff --git a/packages/sdk/src/client/evm.ts b/packages/sdk/src/client/evm.ts index e09fd46..1c8c9f3 100644 --- a/packages/sdk/src/client/evm.ts +++ b/packages/sdk/src/client/evm.ts @@ -1,10 +1,11 @@ -import type { PaymentRequired } from '@x402/core/types'; +import { x402Client } from '@x402/core/client'; +import type { PaymentRequired, PaymentRequirements } from '@x402/core/types'; import { isAddress, type Chain } from 'viem'; import type { Price } from '../amounts.js'; import { RadiusPaymentError } from '../errors.js'; import type { Address, Caip2, RadiusAsset } from '../networks.js'; import type { Settlement } from '../settlement.js'; -import { createSingleNetworkBuyer, type AnyPaymentRequirements, type RadiusFetchOptions, type RadiusSigner } from './buyer.js'; +import { createPaymentFetch, createSingleNetworkBuyer, type AnyPaymentRequirements, type PaymentOffer, type RadiusFetchOptions, type RadiusSigner } from './buyer.js'; /** An explicitly allowed ERC-20 and its independent per-request spending limit. */ export interface EvmAssetConfig { @@ -50,12 +51,13 @@ export interface EvmFetch { /** * Pay x402 challenges across an explicit allowlist of EVM networks and assets. - * Selects the first compatible offer within its own asset cap in server order. + * Uses upstream network/scheme selection, payment-flow preference and per-asset caps. * A policy decline is final: it never falls back to another offer after authorization. * Requires CAIP-2 eip155 network IDs for both v1 and v2 challenges. */ export function createEvmFetch(options: EvmFetchOptions): EvmFetch { if (!options.networks?.length) throw new RadiusPaymentError('config', 'createEvmFetch: networks must not be empty'); + const client = new x402Client(); const buyers = new Map>(); const networks = new Set(); const routes: EvmPaymentRoute[] = []; @@ -92,48 +94,94 @@ export function createEvmFetch(options: EvmFetchOptions): EvmFetch { name: chain.name, chain, network, rpcUrl, explorerUrl: chain.blockExplorers?.default.url, asset: { ...asset }, - }); + }, client); buyers.set(routeKey, buyer); routes.push(Object.freeze({ network, asset: Object.freeze({ ...asset }), address: buyer.address, maxPerRequest: buyer.maxPerRequest, getSettlement: buyer.getSettlement })); } } - const firstBuyer = buyers.values().next().value!; - const baseFetch = options.fetch ?? globalThis.fetch.bind(globalThis); - const select = (challenge: PaymentRequired, url: string) => { - if (challenge.x402Version !== 1 && challenge.x402Version !== 2) { - throw new RadiusPaymentError('invalid_challenge', `Unsupported x402 version ${String(challenge.x402Version)}`); - } - if (!Array.isArray(challenge.accepts) || !challenge.accepts.length) throw new RadiusPaymentError('invalid_challenge', 'Challenge has no accepts[]'); + // Upstream spend controls know token caps, but also allow recognized default tokens. + // The policy makes our configured assets a strict allowlist and excludes transfer + // methods this adapter cannot execute. Network/scheme/flow selection stays upstream. + client.setSpendControls({ + maxAmountPerPayment: false, + allowedAssets: routes.map(route => ({ network: route.network, asset: route.asset.address, maxAmountPerPayment: route.maxPerRequest.toString() })), + }); + client.registerPolicy((version, requirements) => requirements.filter(req => { + if (typeof req.asset !== 'string' || !buyers.has(key(req.network, req.asset))) return false; + const method = req.extra?.assetTransferMethod; + return version === 1 || req.scheme === 'upto' || method === undefined || method === 'permit2' || method === 'eip3009'; + })); + + // Preserve the SDK's actionable error codes if upstream finds no payable offer. + // This only diagnoses a refusal; it never chooses or signs an alternative. + const explainRefusal = (challenge: PaymentRequired, url: string, cause: unknown): never => { let failure: RadiusPaymentError | undefined; let matchesNetwork = false; + let matchesAsset = false; for (const req of challenge.accepts as AnyPaymentRequirements[]) { if (!networks.has(req.network)) continue; matchesNetwork = true; if (typeof req.asset !== 'string') continue; const buyer = buyers.get(key(req.network, req.asset)); if (!buyer) continue; + matchesAsset = true; try { - const offer = buyer.chooseOffer({ ...challenge, accepts: [req] } as PaymentRequired, url); - return { buyer, offer }; - } catch (e) { - // An unsupported or unaffordable offer can coexist with a usable alternative. - if (!(e instanceof RadiusPaymentError) || !['price_above_limit', 'no_compatible_offer', 'unsupported_transfer_method'].includes(e.code)) throw e; - failure ??= e; + buyer.chooseOffer({ ...challenge, accepts: [req] } as PaymentRequired, url); + } catch (error) { + if (!(error instanceof RadiusPaymentError)) throw error; + failure ??= error; } } if (failure) throw failure; + if (matchesAsset) throw new RadiusPaymentError('no_compatible_offer', 'No payment offer passed the x402 client policies', cause); throw new RadiusPaymentError(matchesNetwork ? 'asset_mismatch' : 'network_mismatch', matchesNetwork ? 'Server does not accept a configured payment asset on a supported network' : `Server offers no configured EVM network (${[...networks].join(', ')})`, challenge.accepts); }; - const paidFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const request = new Request(input, init); - if (request.headers.has('payment-signature') || request.headers.has('x-payment')) return baseFetch(request); - const retry = new Request(request.clone(), { redirect: 'manual' }); - const response = await baseFetch(request); - if (response.status !== 402) return response; - const challenge = await firstBuyer.readChallenge(response); - const { buyer, offer } = select(challenge, request.url); - return buyer.pay(retry, challenge, offer); - }; + type Buyer = ReturnType; + interface RequestContext { + url: string; + originals: Map; + selected?: { buyer: Buyer; offer: PaymentOffer }; + } + const requests = new WeakMap(); + client.onBeforePaymentCreation(async ({ paymentRequired, selectedRequirements }) => { + const context = requests.get(paymentRequired)!; + const buyer = buyers.get(key(selectedRequirements.network, selectedRequirements.asset))!; + const original = context.originals.get(selectedRequirements)!; + const offer = buyer.chooseOffer({ ...paymentRequired, accepts: [original] } as PaymentRequired, context.url); + context.selected = { buyer, offer }; + // Core calls this hook after selection and before the EVM scheme signs anything. + await buyer.authorize(offer); + }); + + const firstBuyer = buyers.values().next().value!; + const paidFetch = createPaymentFetch(options.fetch ?? globalThis.fetch.bind(globalThis), firstBuyer.readChallenge, async (retry, challenge, url) => { + if (challenge.x402Version !== 1 && challenge.x402Version !== 2) { + throw new RadiusPaymentError('invalid_challenge', `Unsupported x402 version ${String(challenge.x402Version)}`); + } + if (!Array.isArray(challenge.accepts) || !challenge.accepts.length) throw new RadiusPaymentError('invalid_challenge', 'Challenge has no accepts[]'); + // Give upstream signing token metadata and bounded deadlines without changing the + // server's original requirements, which must be echoed in the payment payload. + const context: RequestContext = { url, originals: new Map() }; + const signingChallenge: PaymentRequired = { ...challenge, accepts: challenge.accepts.map(req => { + const buyer = typeof req.asset === 'string' ? buyers.get(key(req.network, req.asset)) : undefined; + const copy = buyer ? buyer.forSigning(req) : { ...req }; + context.originals.set(copy, req); + return copy; + }) }; + requests.set(signingChallenge, context); + try { + // Public core API owns network/scheme/flow selection, spend limits and signing. + const payload = await client.createPaymentPayload(signingChallenge); + const { buyer, offer } = context.selected!; + return buyer.sendPaid(retry, offer, payload); + } catch (cause) { + // Once selected, policy/approval/signing failures must remain terminal. + if (context.selected) throw cause; + return explainRefusal(challenge, url, cause); + } finally { + requests.delete(signingChallenge); + } + }); return Object.assign(paidFetch, { routes: Object.freeze(routes) }); } diff --git a/packages/sdk/test/client-multinetwork.test.ts b/packages/sdk/test/client-multinetwork.test.ts index ce52775..1dbc5ac 100644 --- a/packages/sdk/test/client-multinetwork.test.ts +++ b/packages/sdk/test/client-multinetwork.test.ts @@ -96,6 +96,43 @@ describe('EVM network routing', () => { expect(decode(server.requests[1]).accepted).toEqual(wanted); }); + it('does not implicitly allow upstream default USDC when only a custom token is configured', async () => { + const signTypedData = vi.fn(account.signTypedData); + const server = seller(challenge([offer()])); + await expect(createEvmFetch({ signer: { address: account.address, signTypedData }, networks: [config(base, SBC)], fetch: server.fetch })(url)).rejects.toMatchObject({ code: 'asset_mismatch' }); + expect(signTypedData).not.toHaveBeenCalled(); + expect(server.requests).toHaveLength(1); + }); + + it('skips an unrecognized payment flow and pays a supported alternative through upstream policy', async () => { + const wanted = offer(arbitrum, chainAssets[2][1]); + const server = seller(challenge([{ ...offer(), extra: { paymentFlow: 'provider-specific-unknown' } }, wanted])); + await createEvmFetch({ signer: account, networks: configs, fetch: server.fetch })(url); + expect(decode(server.requests[1]).accepted).toEqual(wanted); + }); + + it('applies upstream preference for authorization when a seller also offers upfront payment', async () => { + const wanted = offer(arbitrum, chainAssets[2][1]); + const server = seller(challenge([{ ...offer(), extra: { paymentFlow: 'upfront' } }, wanted])); + await createEvmFetch({ signer: account, networks: configs, fetch: server.fetch })(url); + expect(decode(server.requests[1]).accepted).toEqual(wanted); + }); + + it('awaits the selected route policy before the upstream EVM signer is called', async () => { + const events: string[] = []; + const signer = { address: account.address, signTypedData: async (data: Parameters[0]) => { + events.push(`sign:${data.domain?.chainId}`); + return account.signTypedData(data); + } }; + const server = seller(challenge([offer()])); + await createEvmFetch({ signer, networks: configs, fetch: server.fetch, onPaymentRequired: async selected => { + await Promise.resolve(); + events.push(`approve:${selected.network}`); + return true; + } })(url); + expect(events).toEqual(['approve:eip155:8453', 'sign:8453']); + }); + it('uses independent token-unit caps for multiple assets on one chain', async () => { const token18 = { ...baseUsdc, address: recipient, symbol: 'CREDITS', decimals: 18, name: 'Credits', version: '1' }; const server = seller(challenge([offer(base, token18, '50000000000000001'), offer()])); @@ -118,10 +155,13 @@ describe('EVM network routing', () => { expect(server.requests).toHaveLength(1); }); - it('makes a policy decline terminal, even when another chain is affordable', async () => { - const server = seller(challenge([offer(), offer(radiusMainnet.chain, SBC)])); + it.each([false, true])('makes policy decline terminal before signing (sponsored Permit2: %s)', async sponsored => { + const first = sponsored ? { ...offer(), extra: { assetTransferMethod: 'permit2' } } : offer(); + const server = seller(challenge([first, offer(radiusMainnet.chain, SBC)], sponsored ? { extensions: { eip2612GasSponsoring: { version: '1' } } } : {})); const onPaymentRequired = vi.fn(() => false); - await expect(createEvmFetch({ signer: account, networks: configs, fetch: server.fetch, onPaymentRequired })(url)).rejects.toMatchObject({ code: 'declined' }); + const signTypedData = vi.fn(account.signTypedData); + await expect(createEvmFetch({ signer: { address: account.address, signTypedData }, networks: configs, fetch: server.fetch, onPaymentRequired })(url)).rejects.toMatchObject({ code: 'declined' }); + expect(signTypedData).not.toHaveBeenCalled(); expect(onPaymentRequired).toHaveBeenCalledTimes(1); expect(server.requests).toHaveLength(1); });