From 042a9ac6d1a942c0798510e3d06bd852ab10b97d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 02:19:43 +0000 Subject: [PATCH 1/3] Add Permit2 interactions to the SDK New src/permit2.ts with viem actions and a permit2Actions() client extension for the canonical Permit2 contract, covering both flows: - SignatureTransfer: signPermit2Transfer (PermitTransferFrom, or PermitWitnessTransferFrom with a witness), permit2TransferFrom for the spender, isPermit2NonceUsed against the nonce bitmap, random nonces. - AllowanceTransfer: signPermit2Allowance (PermitSingle, nonce read from Permit2), permit2Permit, permit2AllowanceTransferFrom, getPermit2Allowance. - approvePermit2 / getPermit2Approval for the one-time ERC-20 approval. The EIP-712 domain, type sets, witness hash and witness type string are exported; the type string is derived with EIP-712's ordering rule and tested against the x402 layout and viem's type hash. Tests use the in-memory node from the ERC-20 change and recover every signature to the owner. Examples: fresh-wallet reads the Permit2 approval through the new action; permit2-pull.mjs signs a transfer off-chain and pulls it from a second account. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01D1P1hkDQJ1iTaqumjZWtg8 --- packages/sdk/README.md | 44 +- .../sdk/examples/agent-buyer/fresh-wallet.mjs | 13 +- .../sdk/examples/agent-buyer/permit2-pull.mjs | 34 ++ packages/sdk/src/client/index.ts | 2 + packages/sdk/src/index.ts | 42 ++ packages/sdk/src/permit2.ts | 548 ++++++++++++++++++ packages/sdk/test/permit2.test.ts | 279 +++++++++ 7 files changed, 954 insertions(+), 8 deletions(-) create mode 100644 packages/sdk/examples/agent-buyer/permit2-pull.mjs create mode 100644 packages/sdk/src/permit2.ts create mode 100644 packages/sdk/test/permit2.test.ts diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 38c35b2..a91ebee 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -199,6 +199,46 @@ await transfer(wallet, { token: '0x…', to, amount: '3' }); // a bare address veto, every allowance the payment client grants. The plain `erc20Actions().approve` on your own wallet client has no hook: it is you signing, not the SDK. +## Permit2 + +Actions for the canonical [Permit2](https://github.com/Uniswap/permit2) contract (`PERMIT2_ADDRESS`, +the same on every Radius network), covering both of its flows. `permit2Actions()` is a client +extension; each action is also exported on its own. + +```ts +import { permit2Actions } from 'radius-sdk'; +const owner = createWalletClient({ account, chain: radiusTestnet.chain, transport: http() }).extend(permit2Actions()); +const spender = createWalletClient({ account: spenderAccount, chain: radiusTestnet.chain, transport: http() }).extend(permit2Actions()); + +// Once per token: let Permit2 move the owner's SBC (unlimited by default, the x402 one-time approval). +await owner.getPermit2Approval({ owner: owner.account.address }); // ERC-20 allowance granted to Permit2 +await owner.approvePermit2(); // approvePermit2({ amount: '5' }) to cap it + +// SignatureTransfer (what x402 uses): one-off permit signed off-chain, submitted by the spender. +const signed = await owner.signPermit2Transfer({ amount: '0.01', spender: spender.account.address }); +// { permit: { permitted: { token, amount }, nonce, deadline }, spender, owner, signature, chainId } +await spender.permit2TransferFrom({ signed, to: spender.account.address }); // amount: pull less than permitted +await spender.isPermit2NonceUsed({ owner: signed.owner, nonce: signed.permit.nonce }); // true afterwards + +// With a witness (extra data the signature is bound to, e.g. x402's `Witness(address to,uint256 validAfter)`): +const witness = { typeName: 'Witness', types: { Witness: [{ name: 'to', type: 'address' }, { name: 'validAfter', type: 'uint256' }] }, value: { to, validAfter: 0n } }; +const w = await owner.signPermit2Transfer({ amount: '0.01', spender: proxy, witness }); +await spender.permit2TransferFrom({ signed: w, to }); // calls permitWitnessTransferFrom with the hash + type string + +// AllowanceTransfer (Uniswap-style): a signed allowance the spender can draw on until it expires. +const allowance = await owner.signPermit2Allowance({ amount: '5', spender: spender.account.address, expiration: now + 86_400 }); +await spender.permit2Permit({ signed: allowance }); // records it in Permit2 +await spender.permit2AllowanceTransferFrom({ from: owner.account.address, to, amount: '1' }); // repeatable +await spender.getPermit2Allowance({ owner: owner.account.address, spender: spender.account.address }); // { amount, expiration, nonce } +``` + +Nonces: SignatureTransfer nonces are random 256-bit values (`randomPermit2Nonce()`, the default); +AllowanceTransfer nonces are sequential per (owner, token, spender) and read from Permit2 when +omitted. Deadlines default to 600 s, the same cap the x402 client applies. The EIP-712 domain, +type sets (`PERMIT_TRANSFER_FROM_TYPES`, `PERMIT_SINGLE_TYPES`), `permit2WitnessTypeString` and +`permit2WitnessHash` are exported for anyone assembling calls by hand; the witness type string is +derived with EIP-712's ordering rule and checked against the x402 layout in the tests. + ## Balances: native RUSD vs stablecoins Radius differs from other EVM chains here. `eth_getBalance` (viem's `getBalance`, MetaMask's @@ -283,9 +323,9 @@ self-hosted facilitator with your own auth or routing. | Path | What | | --- | --- | -| `src/` | `networks`, `balances`, `erc20`, `amounts`, `receipt`, `settlement`, `schemes`, `env`, `errors`; `hono/` (server); `client/` (buyer) | +| `src/` | `networks`, `balances`, `erc20`, `permit2`, `amounts`, `receipt`, `settlement`, `schemes`, `env`, `errors`; `hono/` (server); `client/` (buyer) | | `examples/worker-seller` | Hono worker: free `/`, paid `/api/lookup` and `/api/query` (`pnpm --filter radius-worker-seller dev`) | -| `examples/agent-buyer` | `buy.mjs` (pay a URL), `fresh-wallet.mjs` (gasless proof from a new wallet) | +| `examples/agent-buyer` | `buy.mjs` (pay a URL), `fresh-wallet.mjs` (gasless proof from a new wallet), `permit2-pull.mjs` (sign a Permit2 transfer off-chain, pull it from another account) | | `examples/demo-dapp` | Test-dapp style page exercising both sides in the browser (burner wallet or MetaMask) | | `test/` | unit tests (facilitator and RPC mocked; `client-parity.test.ts` pins the wire format against radius-cli's; `balances.test.ts` runs the native-balance init code in a real EVM; `erc20.semantics.test.ts` runs the ERC-20 actions against `evmNode.ts`, a JSON-RPC node backed by @ethereumjs/evm executing the forge-compiled `fixtures/TestToken` (rebuild with `fixtures/build.sh` after editing the .sol; the artifact is committed because CI has no forge)); `test/e2e` real settlement, balance reconciliation and ERC-20 round trips on testnet or mainnet (`RADIUS_E2E=1 RADIUS_PRIVATE_KEY=… [RADIUS_NETWORK=mainnet] pnpm test:e2e`) | diff --git a/packages/sdk/examples/agent-buyer/fresh-wallet.mjs b/packages/sdk/examples/agent-buyer/fresh-wallet.mjs index 57e6e60..c5fb4d3 100644 --- a/packages/sdk/examples/agent-buyer/fresh-wallet.mjs +++ b/packages/sdk/examples/agent-buyer/fresh-wallet.mjs @@ -3,8 +3,8 @@ // Usage: RADIUS_PRIVATE_KEY= node fresh-wallet.mjs [url] import { createPublicClient, createWalletClient, http } from 'viem'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import { createRadiusFetch, getPaymentReceipt, erc20Actions, radiusActions } from 'radius-sdk/client'; -import { PERMIT2_ADDRESS, SBC, formatAmount, radiusTestnet } from 'radius-sdk'; +import { createRadiusFetch, getPaymentReceipt, erc20Actions, permit2Actions, radiusActions } from 'radius-sdk/client'; +import { SBC, formatAmount, radiusTestnet } from 'radius-sdk'; const url = process.argv[2] ?? 'http://localhost:8787/api/lookup?ip=9.9.9.9'; const funder = privateKeyToAccount(process.env.RADIUS_PRIVATE_KEY); @@ -13,8 +13,9 @@ const fresh = privateKeyToAccount(freshKey); const chain = radiusTestnet.chain; // the SDK network's viem Chain (id, RPC, explorer) // radiusActions() adds getBalances(): on Radius eth_getBalance is native RUSD *plus* SBC at 1:1, // so a wallet holding only SBC still shows a non-zero eth_getBalance. getBalances() splits them. -// erc20Actions() adds transfer/approve/getAllowance/… for SBC (the default token on Radius networks). -const pub = createPublicClient({ chain, transport: http() }).extend(radiusActions()).extend(erc20Actions()); +// erc20Actions() adds transfer/approve/getAllowance/… for SBC (the default token on Radius networks); +// permit2Actions() adds the Permit2 reads (getPermit2Approval = the ERC-20 allowance granted to Permit2). +const pub = createPublicClient({ chain, transport: http() }).extend(radiusActions()).extend(erc20Actions()).extend(permit2Actions()); const wallet = createWalletClient({ chain, transport: http(), account: funder }).extend(erc20Actions()); console.error(`fresh wallet ${fresh.address}; funding 0.005 SBC from ${funder.address}`); @@ -22,7 +23,7 @@ const funded = await wallet.transfer({ to: fresh.address, amount: '0.005' }); console.error(`funded in ${funded.hash} (${funded.status})`); const { native, tokens: [sbcBefore] } = await pub.getBalances({ address: fresh.address }); const before = sbcBefore.atomic; -const allowance = await pub.getAllowance({ owner: fresh.address, spender: PERMIT2_ADDRESS }); +const allowance = await pub.getPermit2Approval({ owner: fresh.address }); console.error(`before: SBC ${sbcBefore.formatted}, native RUSD ${native.rawFormatted} (eth_getBalance reports ${native.aggregateFormatted}: SBC counted 1:1), Permit2 allowance ${allowance}`); const payFetch = createRadiusFetch({ network: 'testnet', signer: freshKey, maxPerRequest: '$0.01' }); @@ -32,5 +33,5 @@ console.log(await res.text()); console.error('receipt:', getPaymentReceipt(res, payFetch.network)); const after = (await pub.getTokenBalance({ address: fresh.address, token: SBC })).atomic; -const allowanceAfter = await pub.getAllowance({ owner: fresh.address, spender: PERMIT2_ADDRESS }); +const allowanceAfter = await pub.getPermit2Approval({ owner: fresh.address }); console.error(`after: SBC ${formatAmount(after, 6)} (spent ${formatAmount(before - after, 6)}), Permit2 allowance ${allowanceAfter === (2n ** 256n - 1n) ? 'MaxUint256' : allowanceAfter}`); diff --git a/packages/sdk/examples/agent-buyer/permit2-pull.mjs b/packages/sdk/examples/agent-buyer/permit2-pull.mjs new file mode 100644 index 0000000..51d1d9f --- /dev/null +++ b/packages/sdk/examples/agent-buyer/permit2-pull.mjs @@ -0,0 +1,34 @@ +// Permit2 SignatureTransfer end to end, outside of x402: a payer signs a one-off permit +// off-chain and a collector pulls the SBC with it. This is the primitive x402 `exact` +// (Permit2) payments are built on; here the collector is a plain account, not the x402 proxy. +// +// Usage: RADIUS_PRIVATE_KEY= COLLECTOR_PRIVATE_KEY= node permit2-pull.mjs [amount] +// The payer needs SBC plus a one-time Permit2 approval (sent here if missing; gas comes from +// SBC via Turnstile). The collector pays gas for the pull, so it needs a little SBC too. +import { createWalletClient, http } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { SBC, erc20Actions, formatTokenAmount, permit2Actions, radiusActions, radiusTestnet } from 'radius-sdk'; + +const amount = process.argv[2] ?? '0.001'; +const payer = createWalletClient({ account: privateKeyToAccount(process.env.RADIUS_PRIVATE_KEY), chain: radiusTestnet.chain, transport: http() }) + .extend(radiusActions()).extend(erc20Actions()).extend(permit2Actions()); +const collector = createWalletClient({ account: privateKeyToAccount(process.env.COLLECTOR_PRIVATE_KEY), chain: radiusTestnet.chain, transport: http() }) + .extend(radiusActions()).extend(permit2Actions()); + +// 1. One-time: the payer lets Permit2 move its SBC (unlimited, the x402 "one-time gas approval" model). +if ((await payer.getPermit2Approval({ owner: payer.account.address })) < 10n ** 12n) { + const tx = await payer.approvePermit2(); + console.error(`payer approved Permit2 in ${tx.hash} (${tx.status})`); +} + +// 2. Off-chain: the payer signs a permit for the collector. Nothing is sent; `signed` is plain JSON-able data. +const signed = await payer.signPermit2Transfer({ amount, spender: collector.account.address }); +console.error(`payer ${payer.account.address} signed a permit for ${formatTokenAmount(signed.permit.permitted.amount, SBC)} to spender ${signed.spender}, nonce ${signed.permit.nonce}, deadline ${signed.permit.deadline}`); +console.error(`nonce used before pull: ${await collector.isPermit2NonceUsed({ owner: signed.owner, nonce: signed.permit.nonce })}`); + +// 3. On-chain: the collector pulls the SBC to itself. It could also pull less than the permitted amount. +const before = (await collector.getTokenBalance({ address: collector.account.address, token: SBC })).atomic; +const pull = await collector.permit2TransferFrom({ signed, to: collector.account.address }); +const after = (await collector.getTokenBalance({ address: collector.account.address, token: SBC })).atomic; +console.error(`collector pulled ${formatTokenAmount(after - before, SBC)} in ${pull.hash} (${pull.status}) ${pull.explorerUrl ?? ''}`); +console.error(`nonce used after pull: ${await collector.isPermit2NonceUsed({ owner: signed.owner, nonce: signed.permit.nonce })}`); diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 5e8b32c..8502269 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -628,6 +628,8 @@ export type { GetTransfersParameters, WatchTransfersParameters, } from '../erc20.js'; +export { permit2Actions, getPermit2Approval, getPermit2Allowance, isPermit2NonceUsed, approvePermit2, signPermit2Transfer, signPermit2Allowance, permit2TransferFrom, permit2Permit, permit2AllowanceTransferFrom } from '../permit2.js'; +export type { Permit2Actions, Permit2Witness, SignedPermit2Transfer, SignedPermit2Allowance, Permit2Allowance } from '../permit2.js'; export { getPaymentReceipt, decodePaymentReceipt, parseUptoSettlementAmount } from '../receipt.js'; export type { PaymentReceipt } from '../receipt.js'; export { RadiusPaymentError } from '../errors.js'; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 3b248ae..ee61f54 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -54,6 +54,48 @@ export type { GetTransfersParameters, WatchTransfersParameters, } from './erc20.js'; +export { + permit2Actions, + getPermit2Approval, + getPermit2Allowance, + isPermit2NonceUsed, + approvePermit2, + signPermit2Transfer, + signPermit2Allowance, + permit2TransferFrom, + permit2Permit, + permit2AllowanceTransferFrom, + permit2Domain, + permit2WitnessTypeString, + permit2WitnessHash, + permitWitnessTransferFromTypes, + encodeTypedDataType, + randomPermit2Nonce, + PERMIT2_ABI, + PERMIT_TRANSFER_FROM_TYPES, + PERMIT_SINGLE_TYPES, + TOKEN_PERMISSIONS_TYPE, + PERMIT2_DEFAULT_DEADLINE_SECONDS, +} from './permit2.js'; +export type { + Permit2Actions, + Permit2ActionsConfig, + Permit2Witness, + PermitTransferFrom, + PermitSingle, + SignedPermit2Transfer, + SignedPermit2Allowance, + Permit2Allowance, + GetPermit2ApprovalParameters, + GetPermit2AllowanceParameters, + IsPermit2NonceUsedParameters, + ApprovePermit2Parameters, + SignPermit2TransferParameters, + SignPermit2AllowanceParameters, + Permit2TransferFromParameters, + Permit2PermitParameters, + Permit2AllowanceTransferFromParameters, +} from './permit2.js'; export { toAtomic, formatAmount, resolvePrice } from './amounts.js'; export type { Price } from './amounts.js'; export { RadiusPaymentError } from './errors.js'; diff --git a/packages/sdk/src/permit2.ts b/packages/sdk/src/permit2.ts new file mode 100644 index 0000000..fe21857 --- /dev/null +++ b/packages/sdk/src/permit2.ts @@ -0,0 +1,548 @@ +/** + * Uniswap Permit2 interactions as viem actions, for the canonical Permit2 contract every + * Radius network shares (`PERMIT2_ADDRESS`). Two Permit2 flows are covered: + * + * SignatureTransfer (what x402 uses): the owner signs a one-off `PermitTransferFrom` + * (optionally with a witness binding extra data), and the spender submits it with + * `permitTransferFrom` / `permitWitnessTransferFrom` to pull the tokens. Nonces are unordered + * 256-bit values tracked in a bitmap; `isPermit2NonceUsed` checks one. + * + * AllowanceTransfer (what Uniswap-style apps use): the owner signs a `PermitSingle` granting a + * spender an amount until an expiration; the spender submits it with `permit` and then moves + * tokens with `transferFrom` any number of times within the allowance. + * + * Both need the owner to have granted Permit2 an ERC-20 allowance once (`approvePermit2`, + * unlimited by default, the x402 "one-time gas approval" model) unless a facilitator sponsors it. + * + * const owner = createWalletClient({ account, chain, transport: http() }).extend(permit2Actions()); + * await owner.approvePermit2(); // once per token + * const signed = await owner.signPermit2Transfer({ amount: '0.01', spender }); // off-chain + * // ...hand `signed` to the spender, who pulls the tokens: + * await spender.permit2TransferFrom({ signed, to: spender.account.address }); + */ + +import { erc20Abi, hashStruct, maxUint160, maxUint256, maxUint48, type Address, type Hex, type TypedDataDomain, type TypedDataParameter } from 'viem'; +import { readContract, signTypedData, writeContract } from 'viem/actions'; +import { defaultTokens, type BalanceClient } from './balances.js'; +import { RadiusPaymentError } from './errors.js'; +import { getAllowance, requireAccount, sendAndWait, toTokenAtomic, type TokenAmount, type TokenInput, type TokenWalletClient, type TxResult } from './erc20.js'; +import { PERMIT2_ADDRESS } from './networks.js'; + +/** Default signing window for permits without an explicit deadline (matches the x402 client cap). */ +export const PERMIT2_DEFAULT_DEADLINE_SECONDS = 600; + +export const PERMIT2_ABI = [ + // SignatureTransfer + { + type: 'function', + name: 'permitTransferFrom', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'permit', + type: 'tuple', + components: [ + { name: 'permitted', type: 'tuple', components: [{ name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }] }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + { name: 'transferDetails', type: 'tuple', components: [{ name: 'to', type: 'address' }, { name: 'requestedAmount', type: 'uint256' }] }, + { name: 'owner', type: 'address' }, + { name: 'signature', type: 'bytes' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'permitWitnessTransferFrom', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'permit', + type: 'tuple', + components: [ + { name: 'permitted', type: 'tuple', components: [{ name: 'token', type: 'address' }, { name: 'amount', type: 'uint256' }] }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }, + { name: 'transferDetails', type: 'tuple', components: [{ name: 'to', type: 'address' }, { name: 'requestedAmount', type: 'uint256' }] }, + { name: 'owner', type: 'address' }, + { name: 'witness', type: 'bytes32' }, + { name: 'witnessTypeString', type: 'string' }, + { name: 'signature', type: 'bytes' }, + ], + outputs: [], + }, + { type: 'function', name: 'nonceBitmap', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'wordPos', type: 'uint256' }], outputs: [{ type: 'uint256' }] }, + { type: 'function', name: 'invalidateUnorderedNonces', stateMutability: 'nonpayable', inputs: [{ name: 'wordPos', type: 'uint256' }, { name: 'mask', type: 'uint256' }], outputs: [] }, + // AllowanceTransfer + { + type: 'function', + name: 'allowance', + stateMutability: 'view', + inputs: [{ name: 'user', type: 'address' }, { name: 'token', type: 'address' }, { name: 'spender', type: 'address' }], + outputs: [{ name: 'amount', type: 'uint160' }, { name: 'expiration', type: 'uint48' }, { name: 'nonce', type: 'uint48' }], + }, + { + type: 'function', + name: 'permit', + stateMutability: 'nonpayable', + inputs: [ + { name: 'owner', type: 'address' }, + { + name: 'permitSingle', + type: 'tuple', + components: [ + { + name: 'details', + type: 'tuple', + components: [{ name: 'token', type: 'address' }, { name: 'amount', type: 'uint160' }, { name: 'expiration', type: 'uint48' }, { name: 'nonce', type: 'uint48' }], + }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + }, + { name: 'signature', type: 'bytes' }, + ], + outputs: [], + }, + { + type: 'function', + name: 'transferFrom', + stateMutability: 'nonpayable', + inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'amount', type: 'uint160' }, { name: 'token', type: 'address' }], + outputs: [], + }, + { type: 'function', name: 'DOMAIN_SEPARATOR', stateMutability: 'view', inputs: [], outputs: [{ type: 'bytes32' }] }, +] as const; + +/** EIP-712 domain of the canonical Permit2 deployment on `chainId`. */ +export function permit2Domain(chainId: number): TypedDataDomain { + return { name: 'Permit2', chainId, verifyingContract: PERMIT2_ADDRESS }; +} + +export const TOKEN_PERMISSIONS_TYPE = [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, +] as const satisfies readonly TypedDataParameter[]; + +export const PERMIT_TRANSFER_FROM_TYPES = { + PermitTransferFrom: [ + { name: 'permitted', type: 'TokenPermissions' }, + { name: 'spender', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + TokenPermissions: TOKEN_PERMISSIONS_TYPE, +} as const; + +export const PERMIT_SINGLE_TYPES = { + PermitSingle: [ + { name: 'details', type: 'PermitDetails' }, + { name: 'spender', type: 'address' }, + { name: 'sigDeadline', type: 'uint256' }, + ], + PermitDetails: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint160' }, + { name: 'expiration', type: 'uint48' }, + { name: 'nonce', type: 'uint48' }, + ], +} as const; + +/** Extra data a SignatureTransfer permit is bound to (e.g. x402's `Witness(address to,uint256 validAfter)`). */ +export interface Permit2Witness { + /** Name of the witness struct, e.g. `Witness`. */ + typeName: string; + /** EIP-712 definitions of the witness struct and anything it references. */ + types: Record; + /** The witness value. */ + value: Record; +} + +export interface PermitTransferFrom { + permitted: { token: Address; amount: bigint }; + nonce: bigint; + deadline: bigint; +} + +/** A signed SignatureTransfer permit: everything the spender needs to call Permit2. */ +export interface SignedPermit2Transfer { + permit: PermitTransferFrom; + spender: Address; + owner: Address; + signature: Hex; + chainId: number; + witness?: Permit2Witness; +} + +export interface PermitSingle { + details: { token: Address; amount: bigint; expiration: number; nonce: number }; + spender: Address; + sigDeadline: bigint; +} + +/** A signed AllowanceTransfer permit, submitted with `permit2Permit`. */ +export interface SignedPermit2Allowance { + permitSingle: PermitSingle; + owner: Address; + signature: Hex; + chainId: number; +} + +export interface Permit2Allowance { + /** Remaining allowance (uint160). */ + amount: bigint; + /** Unix seconds after which the allowance is void (uint48). */ + expiration: number; + /** Next AllowanceTransfer nonce for (owner, token, spender). */ + nonce: number; +} + +export interface GetPermit2ApprovalParameters { + token?: TokenInput; + owner: Address; +} +export interface GetPermit2AllowanceParameters { + token?: TokenInput; + owner: Address; + spender: Address; +} +export interface IsPermit2NonceUsedParameters { + owner: Address; + nonce: bigint; +} +export interface ApprovePermit2Parameters { + token?: TokenInput; + /** ERC-20 allowance to grant Permit2. Default: unlimited. */ + amount?: TokenAmount; + wait?: boolean; +} +export interface SignPermit2TransferParameters { + token?: TokenInput; + amount: TokenAmount; + /** Who may submit the permit (the contract or account that will call Permit2). */ + spender: Address; + /** Unordered nonce; random by default. */ + nonce?: bigint; + /** Unix seconds; default now + 600. */ + deadline?: bigint | number; + witness?: Permit2Witness; +} +export interface SignPermit2AllowanceParameters { + token?: TokenInput; + amount: TokenAmount; + spender: Address; + /** Unix seconds the allowance lasts until (uint48). */ + expiration: number; + /** Unix seconds the signature is valid until; default now + 600. */ + sigDeadline?: bigint | number; + /** AllowanceTransfer nonce; read from Permit2 when omitted. */ + nonce?: number; +} +export interface Permit2TransferFromParameters { + signed: SignedPermit2Transfer; + /** Recipient of the tokens. */ + to: Address; + /** Amount to pull, at most `signed.permit.permitted.amount` (the default). */ + amount?: bigint; + wait?: boolean; +} +export interface Permit2PermitParameters { + signed: SignedPermit2Allowance; + wait?: boolean; +} +export interface Permit2AllowanceTransferFromParameters { + token?: TokenInput; + from: Address; + to: Address; + amount: TokenAmount; + wait?: boolean; +} + +function tokenAddress(client: BalanceClient, token: TokenInput | undefined): Address { + const t = token ?? defaultTokens(client)[0]; + return typeof t === 'string' ? t : t.address; +} + +function tokenFor(client: BalanceClient, token: TokenInput | undefined): TokenInput { + return token ?? defaultTokens(client)[0]; +} + +/** Chain id for EIP-712: the client's chain, else looked up from the node. */ +async function chainIdOf(client: BalanceClient): Promise { + if (client.chain) return client.chain.id; + const hex = (await client.request({ method: 'eth_chainId' })) as Hex; + return Number(hex); +} + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +function toDeadline(v: bigint | number | undefined): bigint { + if (v === undefined) return BigInt(nowSeconds() + PERMIT2_DEFAULT_DEADLINE_SECONDS); + const d = BigInt(v); + if (d <= BigInt(nowSeconds())) throw new RadiusPaymentError('config', `Permit2 deadline ${d} is in the past`); + return d; +} + +/** A random unordered nonce for SignatureTransfer (256 bits). */ +export function randomPermit2Nonce(): bigint { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return bytes.reduce((acc, b) => (acc << 8n) | BigInt(b), 0n); +} + +/** + * EIP-712 `encodeType` for `primaryType`: its fields, then every referenced struct sorted by + * name (the rule Permit2 relies on for its witness type string). + */ +export function encodeTypedDataType(primaryType: string, types: Record): string { + const deps = new Set(); + const visit = (name: string) => { + if (deps.has(name) || !types[name]) return; + deps.add(name); + for (const f of types[name]) visit(f.type.replace(/\[.*\]$/, '')); + }; + visit(primaryType); + deps.delete(primaryType); + const encode = (name: string) => `${name}(${types[name].map((f) => `${f.type} ${f.name}`).join(',')})`; + return encode(primaryType) + [...deps].sort().map(encode).join(''); +} + +const PERMIT_WITNESS_STUB = 'PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,'; + +/** The full EIP-712 types for a witnessed permit: `PermitWitnessTransferFrom` + `TokenPermissions` + the witness structs. */ +export function permitWitnessTransferFromTypes(witness: Permit2Witness): Record { + return { + PermitWitnessTransferFrom: [ + { name: 'permitted', type: 'TokenPermissions' }, + { name: 'spender', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'witness', type: witness.typeName }, + ], + TokenPermissions: TOKEN_PERMISSIONS_TYPE, + ...witness.types, + }; +} + +/** + * The `witnessTypeString` Permit2 expects in `permitWitnessTransferFrom`: everything after its + * own `PermitWitnessTransferFrom(...,` stub, e.g. + * `Witness witness)TokenPermissions(address token,uint256 amount)Witness(address to,uint256 validAfter)`. + */ +export function permit2WitnessTypeString(witness: Permit2Witness): string { + const full = encodeTypedDataType('PermitWitnessTransferFrom', permitWitnessTransferFromTypes(witness)); + if (!full.startsWith(PERMIT_WITNESS_STUB)) throw new RadiusPaymentError('config', 'permit2WitnessTypeString: unexpected type encoding'); + return full.slice(PERMIT_WITNESS_STUB.length); +} + +/** `hashStruct` of the witness value, the `witness` argument of `permitWitnessTransferFrom`. */ +export function permit2WitnessHash(witness: Permit2Witness): Hex { + return hashStruct({ primaryType: witness.typeName, types: witness.types, data: witness.value } as Parameters[0]); +} + +// -- reads ---------------------------------------------------------------------------------------- + +/** ERC-20 allowance the owner has granted to Permit2 (the one-time approval), in atomic units. */ +export function getPermit2Approval(client: BalanceClient, args: GetPermit2ApprovalParameters): Promise { + return getAllowance(client, { token: tokenFor(client, args.token), owner: args.owner, spender: PERMIT2_ADDRESS }); +} + +/** AllowanceTransfer state Permit2 holds for (owner, token, spender). */ +export async function getPermit2Allowance(client: BalanceClient, args: GetPermit2AllowanceParameters): Promise { + const [amount, expiration, nonce] = await readContract(client, { + address: PERMIT2_ADDRESS, + abi: PERMIT2_ABI, + functionName: 'allowance', + args: [args.owner, tokenAddress(client, args.token), args.spender], + }); + return { amount, expiration, nonce }; +} + +/** Whether a SignatureTransfer nonce has been consumed (or invalidated) for `owner`. */ +export async function isPermit2NonceUsed(client: BalanceClient, args: IsPermit2NonceUsedParameters): Promise { + const wordPos = args.nonce >> 8n; + const bit = 1n << (args.nonce & 0xffn); + const bitmap = await readContract(client, { address: PERMIT2_ADDRESS, abi: PERMIT2_ABI, functionName: 'nonceBitmap', args: [args.owner, wordPos] }); + return (bitmap & bit) !== 0n; +} + +// -- owner side ----------------------------------------------------------------------------------- + +/** ERC-20 `approve(Permit2, amount)` from the client's account; unlimited by default. */ +export async function approvePermit2(client: TokenWalletClient, args: ApprovePermit2Parameters = {}): Promise { + const account = requireAccount(client, 'approvePermit2'); + const token = tokenFor(client, args.token); + const amount = args.amount === undefined ? maxUint256 : await toTokenAtomic(client, token, args.amount); + return sendAndWait(client, args.wait, () => + writeContract(client, { address: typeof token === 'string' ? token : token.address, abi: erc20Abi, functionName: 'approve', args: [PERMIT2_ADDRESS, amount], account, chain: client.chain }), + ); +} + +/** + * Sign a SignatureTransfer permit (`PermitTransferFrom`, or `PermitWitnessTransferFrom` when a + * witness is given). Nothing is sent on-chain; hand the result to the spender. + */ +export async function signPermit2Transfer(client: TokenWalletClient, args: SignPermit2TransferParameters): Promise { + const account = requireAccount(client, 'signPermit2Transfer'); + const token = tokenFor(client, args.token); + const [amount, chainId] = await Promise.all([toTokenAtomic(client, token, args.amount), chainIdOf(client)]); + const permit: PermitTransferFrom = { + permitted: { token: typeof token === 'string' ? token : token.address, amount }, + nonce: args.nonce ?? randomPermit2Nonce(), + deadline: toDeadline(args.deadline), + }; + const domain = permit2Domain(chainId); + const base = { permitted: permit.permitted, spender: args.spender, nonce: permit.nonce, deadline: permit.deadline }; + const signature = args.witness + ? await signTypedData(client, { + account, + domain, + types: permitWitnessTransferFromTypes(args.witness), + primaryType: 'PermitWitnessTransferFrom', + message: { ...base, witness: args.witness.value }, + } as Parameters[1]) + : await signTypedData(client, { + account, + domain, + types: PERMIT_TRANSFER_FROM_TYPES, + primaryType: 'PermitTransferFrom', + message: base, + }); + return { permit, spender: args.spender, owner: account.address, signature, chainId, ...(args.witness ? { witness: args.witness } : {}) }; +} + +/** Sign an AllowanceTransfer `PermitSingle`. The nonce is read from Permit2 unless given. */ +export async function signPermit2Allowance(client: TokenWalletClient, args: SignPermit2AllowanceParameters): Promise { + const account = requireAccount(client, 'signPermit2Allowance'); + const token = tokenFor(client, args.token); + const address = typeof token === 'string' ? token : token.address; + const [amount, chainId, nonce] = await Promise.all([ + toTokenAtomic(client, token, args.amount), + chainIdOf(client), + args.nonce ?? getPermit2Allowance(client, { token, owner: account.address, spender: args.spender }).then((a) => a.nonce), + ]); + if (amount > maxUint160) throw new RadiusPaymentError('config', `Permit2 allowance amount ${amount} exceeds uint160`); + if (!Number.isInteger(args.expiration) || args.expiration < 0 || BigInt(args.expiration) > maxUint48) { + throw new RadiusPaymentError('config', `Permit2 expiration must be a uint48 of unix seconds (got ${args.expiration})`); + } + const permitSingle: PermitSingle = { + details: { token: address, amount, expiration: args.expiration, nonce }, + spender: args.spender, + sigDeadline: toDeadline(args.sigDeadline), + }; + const signature = await signTypedData(client, { + account, + domain: permit2Domain(chainId), + types: PERMIT_SINGLE_TYPES, + primaryType: 'PermitSingle', + message: permitSingle, + }); + return { permitSingle, owner: account.address, signature, chainId }; +} + +// -- spender side --------------------------------------------------------------------------------- + +/** + * Submit a signed SignatureTransfer permit, pulling `amount` (default: all of it) of the owner's + * tokens to `to`. The client's account must be `signed.spender`. + */ +export async function permit2TransferFrom(client: TokenWalletClient, args: Permit2TransferFromParameters): Promise { + const account = requireAccount(client, 'permit2TransferFrom'); + const { signed } = args; + if (account.address.toLowerCase() !== signed.spender.toLowerCase()) { + throw new RadiusPaymentError('config', `permit2TransferFrom must be sent by the permit's spender ${signed.spender}, not ${account.address}`); + } + const requestedAmount = args.amount ?? signed.permit.permitted.amount; + if (requestedAmount > signed.permit.permitted.amount) { + throw new RadiusPaymentError('config', `Requested ${requestedAmount} exceeds the permitted ${signed.permit.permitted.amount}`); + } + const transferDetails = { to: args.to, requestedAmount }; + return sendAndWait(client, args.wait, () => + signed.witness + ? writeContract(client, { + address: PERMIT2_ADDRESS, + abi: PERMIT2_ABI, + functionName: 'permitWitnessTransferFrom', + args: [signed.permit, transferDetails, signed.owner, permit2WitnessHash(signed.witness), permit2WitnessTypeString(signed.witness), signed.signature], + account, + chain: client.chain, + }) + : writeContract(client, { + address: PERMIT2_ADDRESS, + abi: PERMIT2_ABI, + functionName: 'permitTransferFrom', + args: [signed.permit, transferDetails, signed.owner, signed.signature], + account, + chain: client.chain, + }), + ); +} + +/** Submit a signed `PermitSingle` so Permit2 records the allowance. Anyone may send it. */ +export async function permit2Permit(client: TokenWalletClient, args: Permit2PermitParameters): Promise { + const account = requireAccount(client, 'permit2Permit'); + const { signed } = args; + return sendAndWait(client, args.wait, () => + writeContract(client, { address: PERMIT2_ADDRESS, abi: PERMIT2_ABI, functionName: 'permit', args: [signed.owner, signed.permitSingle, signed.signature], account, chain: client.chain }), + ); +} + +/** AllowanceTransfer `transferFrom`: move tokens within an allowance granted to the client's account. */ +export async function permit2AllowanceTransferFrom(client: TokenWalletClient, args: Permit2AllowanceTransferFromParameters): Promise { + const account = requireAccount(client, 'permit2AllowanceTransferFrom'); + const token = tokenFor(client, args.token); + const amount = await toTokenAtomic(client, token, args.amount); + if (amount > maxUint160) throw new RadiusPaymentError('config', `Permit2 transfer amount ${amount} exceeds uint160`); + return sendAndWait(client, args.wait, () => + writeContract(client, { + address: PERMIT2_ADDRESS, + abi: PERMIT2_ABI, + functionName: 'transferFrom', + args: [args.from, args.to, amount, typeof token === 'string' ? token : token.address], + account, + chain: client.chain, + }), + ); +} + +// A type alias, not an interface: viem's `client.extend()` needs the implicit index signature. +export type Permit2Actions = { + getPermit2Approval: (args: GetPermit2ApprovalParameters) => Promise; + getPermit2Allowance: (args: GetPermit2AllowanceParameters) => Promise; + isPermit2NonceUsed: (args: IsPermit2NonceUsedParameters) => Promise; + approvePermit2: (args?: ApprovePermit2Parameters) => Promise; + signPermit2Transfer: (args: SignPermit2TransferParameters) => Promise; + signPermit2Allowance: (args: SignPermit2AllowanceParameters) => Promise; + permit2TransferFrom: (args: Permit2TransferFromParameters) => Promise; + permit2Permit: (args: Permit2PermitParameters) => Promise; + permit2AllowanceTransferFrom: (args: Permit2AllowanceTransferFromParameters) => Promise; +}; + +export interface Permit2ActionsConfig { + /** Default token (else the network's payment asset, SBC). */ + token?: TokenInput; +} + +/** viem client extension for Permit2. Reads work on any client; the rest need an account. */ +export function permit2Actions(config: Permit2ActionsConfig = {}) { + return (client: TokenWalletClient): Permit2Actions => { + const withToken = (args: T): T => ({ ...args, token: args.token ?? config.token }); + return { + getPermit2Approval: (args) => getPermit2Approval(client, withToken(args)), + getPermit2Allowance: (args) => getPermit2Allowance(client, withToken(args)), + isPermit2NonceUsed: (args) => isPermit2NonceUsed(client, args), + approvePermit2: (args = {}) => approvePermit2(client, withToken(args)), + signPermit2Transfer: (args) => signPermit2Transfer(client, withToken(args)), + signPermit2Allowance: (args) => signPermit2Allowance(client, withToken(args)), + permit2TransferFrom: (args) => permit2TransferFrom(client, args), + permit2Permit: (args) => permit2Permit(client, args), + permit2AllowanceTransferFrom: (args) => permit2AllowanceTransferFrom(client, withToken(args)), + }; + }; +} diff --git a/packages/sdk/test/permit2.test.ts b/packages/sdk/test/permit2.test.ts new file mode 100644 index 0000000..fc15075 --- /dev/null +++ b/packages/sdk/test/permit2.test.ts @@ -0,0 +1,279 @@ +import { createPublicClient, createWalletClient, decodeFunctionData, encodeAbiParameters, erc20Abi, hashStruct, hashTypedData, keccak256, maxUint160, maxUint256, numberToHex, recoverTypedDataAddress, toHex, type Address, type Hex } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { describe, expect, it } from 'vitest'; +import { + approvePermit2, + encodeTypedDataType, + getPermit2Allowance, + getPermit2Approval, + isPermit2NonceUsed, + PERMIT2_ABI, + PERMIT_SINGLE_TYPES, + PERMIT_TRANSFER_FROM_TYPES, + permit2Actions, + permit2AllowanceTransferFrom, + permit2Domain, + permit2Permit, + permit2TransferFrom, + permit2WitnessHash, + permit2WitnessTypeString, + permitWitnessTransferFromTypes, + randomPermit2Nonce, + signPermit2Allowance, + signPermit2Transfer, + type Permit2Witness, +} from '../src/permit2.js'; +import { PERMIT2_ADDRESS, radiusTestnet, SBC, X402_EXACT_PERMIT2_PROXY } from '../src/networks.js'; +import { fakeNode } from './fakeNode.js'; + +const OWNER_PK = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as Hex; +const SPENDER_PK = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba' as Hex; +const OWNER = privateKeyToAccount(OWNER_PK); +const SPENDER = privateKeyToAccount(SPENDER_PK); +const PAY_TO = '0x000000000000000000000000000000000000dEaD' as Address; +const FACILITATOR = '0x00000000000000000000000000000000fac11107' as Address; +const CHAIN_ID = radiusTestnet.chainId; +const NOW = Math.floor(Date.now() / 1000); + +const word = (v: bigint | number) => numberToHex(BigInt(v), { size: 32 }); + +/** Permit2 + SBC view answers. */ +function node(state: { erc20Allowance?: bigint; allowance?: [bigint, number, number]; bitmap?: bigint } = {}) { + return fakeNode({ + chainId: CHAIN_ID, + onCall: ({ to, data }) => { + if (to!.toLowerCase() === PERMIT2_ADDRESS.toLowerCase()) { + const { functionName, args } = decodeFunctionData({ abi: PERMIT2_ABI, data: data! }); + if (functionName === 'allowance') { + const [amount, expiration, nonce] = state.allowance ?? [0n, 0, 0]; + expect((args as Address[])[1].toLowerCase()).toBe(SBC.address.toLowerCase()); + return encodeAbiParameters([{ type: 'uint160' }, { type: 'uint48' }, { type: 'uint48' }], [amount, expiration, nonce]); + } + if (functionName === 'nonceBitmap') return word(state.bitmap ?? 0n); + throw new Error(`unexpected Permit2 view ${functionName}`); + } + const { functionName, args } = decodeFunctionData({ abi: erc20Abi, data: data! }); + if (functionName === 'allowance') { + expect((args as Address[])[1].toLowerCase()).toBe(PERMIT2_ADDRESS.toLowerCase()); + return word(state.erc20Allowance ?? 0n); + } + if (functionName === 'decimals') return word(6); + throw new Error(`unexpected ERC-20 view ${functionName}`); + }, + }); +} + +const ownerClient = (n = node()) => ({ n, client: createWalletClient({ account: OWNER, chain: radiusTestnet.chain, transport: n.transport }) }); +const spenderClient = (n = node()) => ({ n, client: createWalletClient({ account: SPENDER, chain: radiusTestnet.chain, transport: n.transport }) }); + +const X402_WITNESS: Permit2Witness = { + typeName: 'Witness', + types: { Witness: [{ name: 'to', type: 'address' }, { name: 'facilitator', type: 'address' }, { name: 'validAfter', type: 'uint256' }] }, + value: { to: PAY_TO, facilitator: FACILITATOR, validAfter: 0n }, +}; + +describe('type encoding', () => { + it('encodeTypedDataType lists the primary type then referenced structs alphabetically', () => { + expect(encodeTypedDataType('PermitTransferFrom', PERMIT_TRANSFER_FROM_TYPES)).toBe( + 'PermitTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline)TokenPermissions(address token,uint256 amount)', + ); + expect(encodeTypedDataType('PermitSingle', PERMIT_SINGLE_TYPES)).toBe( + 'PermitSingle(PermitDetails details,address spender,uint256 sigDeadline)PermitDetails(address token,uint160 amount,uint48 expiration,uint48 nonce)', + ); + }); + + it('permit2WitnessTypeString matches the x402 / Permit2 WITNESS_TYPE_STRING layout', () => { + // As radius-cli's upto scheme signs it and the deployed x402UptoPermit2Proxy verifies it. + expect(permit2WitnessTypeString(X402_WITNESS)).toBe('Witness witness)TokenPermissions(address token,uint256 amount)Witness(address to,address facilitator,uint256 validAfter)'); + // A witness struct that sorts before TokenPermissions moves ahead of it. + const order: Permit2Witness = { typeName: 'Order', types: { Order: [{ name: 'id', type: 'bytes32' }] }, value: { id: '0x'.padEnd(66, '1') } }; + expect(permit2WitnessTypeString(order)).toBe('Order witness)Order(bytes32 id)TokenPermissions(address token,uint256 amount)'); + // Nested witness structs are included once, sorted. + const nested: Permit2Witness = { + typeName: 'Witness', + types: { Witness: [{ name: 'order', type: 'Order' }, { name: 'items', type: 'Item[]' }], Order: [{ name: 'id', type: 'uint256' }], Item: [{ name: 'sku', type: 'string' }] }, + value: {}, + }; + expect(permit2WitnessTypeString(nested)).toBe('Witness witness)Item(string sku)Order(uint256 id)TokenPermissions(address token,uint256 amount)Witness(Order order,Item[] items)'); + }); + + it('agrees with viem on the type hash, so the on-chain string verifies the signed digest', () => { + const types = permitWitnessTransferFromTypes(X402_WITNESS); + const stub = 'PermitWitnessTransferFrom(TokenPermissions permitted,address spender,uint256 nonce,uint256 deadline,'; + const typeHash = keccak256(toHex(stub + permit2WitnessTypeString(X402_WITNESS))); + // hashStruct(PermitWitnessTransferFrom) == keccak(typeHash ‖ encodeData); recompute with viem's own primitives. + const data = { permitted: { token: SBC.address, amount: 1n }, spender: SPENDER.address, nonce: 7n, deadline: 9n, witness: X402_WITNESS.value }; + const viemHash = hashStruct({ primaryType: 'PermitWitnessTransferFrom', types, data } as never); + const manual = keccak256( + encodeAbiParameters( + [{ type: 'bytes32' }, { type: 'bytes32' }, { type: 'address' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'bytes32' }], + [typeHash, hashStruct({ primaryType: 'TokenPermissions', types, data: data.permitted } as never), SPENDER.address, 7n, 9n, permit2WitnessHash(X402_WITNESS)], + ), + ); + expect(viemHash).toBe(manual); + }); + + it('permit2Domain and randomPermit2Nonce', () => { + expect(permit2Domain(CHAIN_ID)).toEqual({ name: 'Permit2', chainId: CHAIN_ID, verifyingContract: PERMIT2_ADDRESS }); + const a = randomPermit2Nonce(); + const b = randomPermit2Nonce(); + expect(a).not.toBe(b); + expect(a).toBeLessThanOrEqual(maxUint256); + }); +}); + +describe('reads', () => { + it('getPermit2Approval reads the ERC-20 allowance granted to Permit2', async () => { + const { client } = ownerClient(node({ erc20Allowance: 5n })); + expect(await getPermit2Approval(client, { owner: OWNER.address })).toBe(5n); + }); + it('getPermit2Allowance decodes (amount, expiration, nonce)', async () => { + const { client } = ownerClient(node({ allowance: [123n, NOW + 60, 3] })); + expect(await getPermit2Allowance(client, { owner: OWNER.address, spender: SPENDER.address })).toEqual({ amount: 123n, expiration: NOW + 60, nonce: 3 }); + }); + it('isPermit2NonceUsed reads the right word and bit of the bitmap', async () => { + const nonce = (5n << 8n) | 130n; // word 5, bit 130 + const n = node({ bitmap: 1n << 130n }); + const { client } = ownerClient(n); + expect(await isPermit2NonceUsed(client, { owner: OWNER.address, nonce })).toBe(true); + expect(await isPermit2NonceUsed(client, { owner: OWNER.address, nonce: nonce + 1n })).toBe(false); + const call = n.calls.find((c) => c.method === 'eth_call')!.params[0] as { data: Hex }; + expect(decodeFunctionData({ abi: PERMIT2_ABI, data: call.data })).toEqual({ functionName: 'nonceBitmap', args: [OWNER.address, 5n] }); + }); +}); + +describe('owner side', () => { + it('approvePermit2 approves Permit2 for the token, unlimited by default', async () => { + const { client, n } = ownerClient(); + const r = await approvePermit2(client); + expect(r.status).toBe('success'); + expect(n.sent[0].to).toBe(SBC.address.toLowerCase()); + expect(decodeFunctionData({ abi: erc20Abi, data: n.sent[0].data! })).toEqual({ functionName: 'approve', args: [PERMIT2_ADDRESS, maxUint256] }); + await approvePermit2(client, { amount: '1.5' }); + expect(decodeFunctionData({ abi: erc20Abi, data: n.sent[1].data! }).args).toEqual([PERMIT2_ADDRESS, 1_500_000n]); + }); + + it('signPermit2Transfer signs a PermitTransferFrom that recovers to the owner', async () => { + const { client, n } = ownerClient(); + const signed = await signPermit2Transfer(client, { amount: '0.01', spender: SPENDER.address, nonce: 42n, deadline: NOW + 300 }); + expect(signed).toMatchObject({ permit: { permitted: { token: SBC.address, amount: 10_000n }, nonce: 42n, deadline: BigInt(NOW + 300) }, spender: SPENDER.address, owner: OWNER.address, chainId: CHAIN_ID }); + expect(signed.witness).toBeUndefined(); + expect(n.methods()).not.toContain('eth_sendRawTransaction'); + const recovered = await recoverTypedDataAddress({ + domain: permit2Domain(CHAIN_ID), + types: PERMIT_TRANSFER_FROM_TYPES, + primaryType: 'PermitTransferFrom', + message: { permitted: signed.permit.permitted, spender: signed.spender, nonce: signed.permit.nonce, deadline: signed.permit.deadline }, + signature: signed.signature, + }); + expect(recovered).toBe(OWNER.address); + }); + + it('signPermit2Transfer with a witness produces the x402-shaped digest', async () => { + const { client } = ownerClient(); + const signed = await signPermit2Transfer(client, { amount: 13_000n, spender: X402_EXACT_PERMIT2_PROXY, nonce: 1n, deadline: NOW + 120, witness: X402_WITNESS }); + expect(signed.witness).toBe(X402_WITNESS); + const digest = hashTypedData({ + domain: permit2Domain(CHAIN_ID), + types: permitWitnessTransferFromTypes(X402_WITNESS), + primaryType: 'PermitWitnessTransferFrom', + message: { permitted: signed.permit.permitted, spender: X402_EXACT_PERMIT2_PROXY, nonce: 1n, deadline: BigInt(NOW + 120), witness: X402_WITNESS.value }, + } as never); + expect( + await recoverTypedDataAddress({ + domain: permit2Domain(CHAIN_ID), + types: permitWitnessTransferFromTypes(X402_WITNESS), + primaryType: 'PermitWitnessTransferFrom', + message: { permitted: signed.permit.permitted, spender: X402_EXACT_PERMIT2_PROXY, nonce: 1n, deadline: BigInt(NOW + 120), witness: X402_WITNESS.value }, + signature: signed.signature, + } as never), + ).toBe(OWNER.address); + expect(digest).toMatch(/^0x[0-9a-f]{64}$/); + }); + + it('defaults the nonce to a random value and the deadline to ~10 minutes; rejects past deadlines', async () => { + const { client } = ownerClient(); + const a = await signPermit2Transfer(client, { amount: 1n, spender: SPENDER.address }); + const b = await signPermit2Transfer(client, { amount: 1n, spender: SPENDER.address }); + expect(a.permit.nonce).not.toBe(b.permit.nonce); + expect(Number(a.permit.deadline) - NOW).toBeGreaterThanOrEqual(598); + expect(Number(a.permit.deadline) - NOW).toBeLessThanOrEqual(602); + await expect(signPermit2Transfer(client, { amount: 1n, spender: SPENDER.address, deadline: NOW - 1 })).rejects.toThrow(/in the past/); + }); + + it('signPermit2Allowance reads the nonce from Permit2 and signs a PermitSingle', async () => { + const { client } = ownerClient(node({ allowance: [0n, 0, 4] })); + const signed = await signPermit2Allowance(client, { amount: '2', spender: SPENDER.address, expiration: NOW + 3600, sigDeadline: NOW + 60 }); + expect(signed.permitSingle).toEqual({ details: { token: SBC.address, amount: 2_000_000n, expiration: NOW + 3600, nonce: 4 }, spender: SPENDER.address, sigDeadline: BigInt(NOW + 60) }); + expect( + await recoverTypedDataAddress({ domain: permit2Domain(CHAIN_ID), types: PERMIT_SINGLE_TYPES, primaryType: 'PermitSingle', message: signed.permitSingle, signature: signed.signature }), + ).toBe(OWNER.address); + await expect(signPermit2Allowance(client, { amount: maxUint160 + 1n, spender: SPENDER.address, expiration: NOW + 1 })).rejects.toThrow(/uint160/); + await expect(signPermit2Allowance(client, { amount: 1n, spender: SPENDER.address, expiration: 1.5 })).rejects.toThrow(/uint48/); + }); + + it('needs an account', async () => { + const pub = createPublicClient({ chain: radiusTestnet.chain, transport: node().transport }); + await expect(signPermit2Transfer(pub as never, { amount: 1n, spender: SPENDER.address })).rejects.toMatchObject({ code: 'config' }); + }); +}); + +describe('spender side', () => { + it('permit2TransferFrom submits permitTransferFrom with the signed permit', async () => { + const signed = await signPermit2Transfer(ownerClient().client, { amount: 10_000n, spender: SPENDER.address, nonce: 9n, deadline: NOW + 100 }); + const { client, n } = spenderClient(); + const r = await permit2TransferFrom(client, { signed, to: PAY_TO, amount: 6_000n }); + expect(r.status).toBe('success'); + expect(n.sent[0].to).toBe(PERMIT2_ADDRESS.toLowerCase()); + expect(decodeFunctionData({ abi: PERMIT2_ABI, data: n.sent[0].data! })).toEqual({ + functionName: 'permitTransferFrom', + args: [{ permitted: { token: SBC.address, amount: 10_000n }, nonce: 9n, deadline: BigInt(NOW + 100) }, { to: PAY_TO, requestedAmount: 6_000n }, OWNER.address, signed.signature], + }); + // Default amount is the full permitted amount; more than permitted is refused before sending. + await permit2TransferFrom(client, { signed, to: PAY_TO }); + expect((decodeFunctionData({ abi: PERMIT2_ABI, data: n.sent[1].data! }).args as [unknown, { requestedAmount: bigint }])[1].requestedAmount).toBe(10_000n); + await expect(permit2TransferFrom(client, { signed, to: PAY_TO, amount: 10_001n })).rejects.toThrow(/exceeds the permitted/); + }); + + it('permit2TransferFrom with a witness submits permitWitnessTransferFrom with hash and type string', async () => { + const signed = await signPermit2Transfer(ownerClient().client, { amount: 1n, spender: SPENDER.address, nonce: 2n, deadline: NOW + 100, witness: X402_WITNESS }); + const { client, n } = spenderClient(); + await permit2TransferFrom(client, { signed, to: PAY_TO }); + const decoded = decodeFunctionData({ abi: PERMIT2_ABI, data: n.sent[0].data! }); + expect(decoded.functionName).toBe('permitWitnessTransferFrom'); + const [, , owner, witness, typeString, signature] = decoded.args as [unknown, unknown, Address, Hex, string, Hex]; + expect(owner).toBe(OWNER.address); + expect(witness).toBe(permit2WitnessHash(X402_WITNESS)); + expect(witness).toBe(hashStruct({ primaryType: 'Witness', types: X402_WITNESS.types, data: X402_WITNESS.value } as never)); + expect(typeString).toBe('Witness witness)TokenPermissions(address token,uint256 amount)Witness(address to,address facilitator,uint256 validAfter)'); + expect(signature).toBe(signed.signature); + }); + + it('refuses to submit a permit signed for another spender', async () => { + const signed = await signPermit2Transfer(ownerClient().client, { amount: 1n, spender: PAY_TO }); + await expect(permit2TransferFrom(spenderClient().client, { signed, to: PAY_TO })).rejects.toThrow(/must be sent by the permit's spender/); + }); + + it('permit2Permit and permit2AllowanceTransferFrom encode the AllowanceTransfer calls', async () => { + const signed = await signPermit2Allowance(ownerClient(node({ allowance: [0n, 0, 0] })).client, { amount: 5_000_000n, spender: SPENDER.address, expiration: NOW + 3600, sigDeadline: NOW + 60 }); + const { client, n } = spenderClient(); + await permit2Permit(client, { signed }); + expect(decodeFunctionData({ abi: PERMIT2_ABI, data: n.sent[0].data! })).toEqual({ functionName: 'permit', args: [OWNER.address, signed.permitSingle, signed.signature] }); + await permit2AllowanceTransferFrom(client, { from: OWNER.address, to: PAY_TO, amount: '1.25' }); + expect(decodeFunctionData({ abi: PERMIT2_ABI, data: n.sent[1].data! })).toEqual({ functionName: 'transferFrom', args: [OWNER.address, PAY_TO, 1_250_000n, SBC.address] }); + expect(n.sent.every((t) => t.to === PERMIT2_ADDRESS.toLowerCase())).toBe(true); + }); +}); + +describe('permit2Actions', () => { + it('extends wallet clients on both sides of a transfer', async () => { + const shared = node({ erc20Allowance: maxUint256 }); + const owner = createWalletClient({ account: OWNER, chain: radiusTestnet.chain, transport: shared.transport }).extend(permit2Actions()); + const spender = createWalletClient({ account: SPENDER, chain: radiusTestnet.chain, transport: shared.transport }).extend(permit2Actions()); + expect(await owner.getPermit2Approval({ owner: OWNER.address })).toBe(maxUint256); + const signed = await owner.signPermit2Transfer({ amount: '0.001', spender: SPENDER.address }); + const r = await spender.permit2TransferFrom({ signed, to: SPENDER.address }); + expect(r.status).toBe('success'); + expect(decodeFunctionData({ abi: PERMIT2_ABI, data: shared.sent[0].data! }).functionName).toBe('permitTransferFrom'); + }); +}); From c4e30607104d83be3c52d2985cf1ae777aa84943 Mon Sep 17 00:00:00 2001 From: Kyle Crawshaw Date: Wed, 23 Sep 2026 10:45:35 -0400 Subject: [PATCH 2/3] Keep Permit2 actions off the SDK root entry The Permit2 actions and EIP-712 helpers import viem, which the root entry point must not load at runtime. Their runtime exports move to radius-sdk/client alongside the balance and ERC-20 actions; the root keeps only the types. Add the changeset. Co-Authored-By: Claude Fable 5.1 --- .changeset/sdk-permit2.md | 5 +++ packages/sdk/README.md | 6 +-- .../sdk/examples/agent-buyer/permit2-pull.mjs | 3 +- packages/sdk/src/client/index.ts | 44 ++++++++++++++++++- packages/sdk/src/index.ts | 24 +--------- 5 files changed, 53 insertions(+), 29 deletions(-) create mode 100644 .changeset/sdk-permit2.md diff --git a/.changeset/sdk-permit2.md b/.changeset/sdk-permit2.md new file mode 100644 index 0000000..5f0535a --- /dev/null +++ b/.changeset/sdk-permit2.md @@ -0,0 +1,5 @@ +--- +"radius-sdk": minor +--- + +Add Permit2 interactions covering both SignatureTransfer (with optional witness) and AllowanceTransfer: `approvePermit2`, `signPermit2Transfer`, `permit2TransferFrom`, `signPermit2Allowance`, `permit2Permit`, `permit2AllowanceTransferFrom`, the `permit2Actions()` client extension and the EIP-712 helpers, exported from `radius-sdk/client`. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a91ebee..ca9d5f5 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -203,10 +203,10 @@ await transfer(wallet, { token: '0x…', to, amount: '3' }); // a bare address Actions for the canonical [Permit2](https://github.com/Uniswap/permit2) contract (`PERMIT2_ADDRESS`, the same on every Radius network), covering both of its flows. `permit2Actions()` is a client -extension; each action is also exported on its own. +extension; each action is also exported on its own. All of them come from `radius-sdk/client`. ```ts -import { permit2Actions } from 'radius-sdk'; +import { permit2Actions } from 'radius-sdk/client'; const owner = createWalletClient({ account, chain: radiusTestnet.chain, transport: http() }).extend(permit2Actions()); const spender = createWalletClient({ account: spenderAccount, chain: radiusTestnet.chain, transport: http() }).extend(permit2Actions()); @@ -236,7 +236,7 @@ Nonces: SignatureTransfer nonces are random 256-bit values (`randomPermit2Nonce( AllowanceTransfer nonces are sequential per (owner, token, spender) and read from Permit2 when omitted. Deadlines default to 600 s, the same cap the x402 client applies. The EIP-712 domain, type sets (`PERMIT_TRANSFER_FROM_TYPES`, `PERMIT_SINGLE_TYPES`), `permit2WitnessTypeString` and -`permit2WitnessHash` are exported for anyone assembling calls by hand; the witness type string is +`permit2WitnessHash` are exported from `radius-sdk/client` for anyone assembling calls by hand; the witness type string is derived with EIP-712's ordering rule and checked against the x402 layout in the tests. ## Balances: native RUSD vs stablecoins diff --git a/packages/sdk/examples/agent-buyer/permit2-pull.mjs b/packages/sdk/examples/agent-buyer/permit2-pull.mjs index 51d1d9f..a60713c 100644 --- a/packages/sdk/examples/agent-buyer/permit2-pull.mjs +++ b/packages/sdk/examples/agent-buyer/permit2-pull.mjs @@ -7,7 +7,8 @@ // SBC via Turnstile). The collector pays gas for the pull, so it needs a little SBC too. import { createWalletClient, http } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; -import { SBC, erc20Actions, formatTokenAmount, permit2Actions, radiusActions, radiusTestnet } from 'radius-sdk'; +import { erc20Actions, formatTokenAmount, permit2Actions, radiusActions } from 'radius-sdk/client'; +import { SBC, radiusTestnet } from 'radius-sdk'; const amount = process.argv[2] ?? '0.001'; const payer = createWalletClient({ account: privateKeyToAccount(process.env.RADIUS_PRIVATE_KEY), chain: radiusTestnet.chain, transport: http() }) diff --git a/packages/sdk/src/client/index.ts b/packages/sdk/src/client/index.ts index 8502269..58ccc2f 100644 --- a/packages/sdk/src/client/index.ts +++ b/packages/sdk/src/client/index.ts @@ -628,8 +628,48 @@ export type { GetTransfersParameters, WatchTransfersParameters, } from '../erc20.js'; -export { permit2Actions, getPermit2Approval, getPermit2Allowance, isPermit2NonceUsed, approvePermit2, signPermit2Transfer, signPermit2Allowance, permit2TransferFrom, permit2Permit, permit2AllowanceTransferFrom } from '../permit2.js'; -export type { Permit2Actions, Permit2Witness, SignedPermit2Transfer, SignedPermit2Allowance, Permit2Allowance } from '../permit2.js'; +export { + permit2Actions, + getPermit2Approval, + getPermit2Allowance, + isPermit2NonceUsed, + approvePermit2, + signPermit2Transfer, + signPermit2Allowance, + permit2TransferFrom, + permit2Permit, + permit2AllowanceTransferFrom, + permit2Domain, + permit2WitnessTypeString, + permit2WitnessHash, + permitWitnessTransferFromTypes, + encodeTypedDataType, + randomPermit2Nonce, + PERMIT2_ABI, + PERMIT_TRANSFER_FROM_TYPES, + PERMIT_SINGLE_TYPES, + TOKEN_PERMISSIONS_TYPE, + PERMIT2_DEFAULT_DEADLINE_SECONDS, +} from '../permit2.js'; +export type { + Permit2Actions, + Permit2ActionsConfig, + Permit2Witness, + PermitTransferFrom, + PermitSingle, + SignedPermit2Transfer, + SignedPermit2Allowance, + Permit2Allowance, + GetPermit2ApprovalParameters, + GetPermit2AllowanceParameters, + IsPermit2NonceUsedParameters, + ApprovePermit2Parameters, + SignPermit2TransferParameters, + SignPermit2AllowanceParameters, + Permit2TransferFromParameters, + Permit2PermitParameters, + Permit2AllowanceTransferFromParameters, +} from '../permit2.js'; export { getPaymentReceipt, decodePaymentReceipt, parseUptoSettlementAmount } from '../receipt.js'; export type { PaymentReceipt } from '../receipt.js'; export { RadiusPaymentError } from '../errors.js'; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ee61f54..0ff9bb0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -54,29 +54,7 @@ export type { GetTransfersParameters, WatchTransfersParameters, } from './erc20.js'; -export { - permit2Actions, - getPermit2Approval, - getPermit2Allowance, - isPermit2NonceUsed, - approvePermit2, - signPermit2Transfer, - signPermit2Allowance, - permit2TransferFrom, - permit2Permit, - permit2AllowanceTransferFrom, - permit2Domain, - permit2WitnessTypeString, - permit2WitnessHash, - permitWitnessTransferFromTypes, - encodeTypedDataType, - randomPermit2Nonce, - PERMIT2_ABI, - PERMIT_TRANSFER_FROM_TYPES, - PERMIT_SINGLE_TYPES, - TOKEN_PERMISSIONS_TYPE, - PERMIT2_DEFAULT_DEADLINE_SECONDS, -} from './permit2.js'; +// Permit2 actions and EIP-712 helpers load viem too; their runtime exports live on `radius-sdk/client`. export type { Permit2Actions, Permit2ActionsConfig, From 8f7d178ee1db1e5200b0de3ea5857bb0ccf48582 Mon Sep 17 00:00:00 2001 From: Kyle Crawshaw Date: Thu, 24 Sep 2026 21:53:51 -0400 Subject: [PATCH 3/3] Resolve the Permit2 default token like the ERC-20 actions; accept a network Mirror the ERC-20 change: only the Radius presets have a default token, a custom chain throws a `config` error naming the action until `token` is given, and `permit2Actions({ network })` resolves the default from the network's payment asset after checking it is the client's chain. Document `wait: false` as `pending`. Co-Authored-By: Claude Fable 5.1 --- packages/sdk/src/permit2.ts | 42 ++++++++++++++++++------------- packages/sdk/test/permit2.test.ts | 18 ++++++++++++- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/packages/sdk/src/permit2.ts b/packages/sdk/src/permit2.ts index fe21857..8e52904 100644 --- a/packages/sdk/src/permit2.ts +++ b/packages/sdk/src/permit2.ts @@ -23,10 +23,10 @@ import { erc20Abi, hashStruct, maxUint160, maxUint256, maxUint48, type Address, type Hex, type TypedDataDomain, type TypedDataParameter } from 'viem'; import { readContract, signTypedData, writeContract } from 'viem/actions'; -import { defaultTokens, type BalanceClient } from './balances.js'; +import type { BalanceClient } from './balances.js'; import { RadiusPaymentError } from './errors.js'; -import { getAllowance, requireAccount, sendAndWait, toTokenAtomic, type TokenAmount, type TokenInput, type TokenWalletClient, type TxResult } from './erc20.js'; -import { PERMIT2_ADDRESS } from './networks.js'; +import { configuredToken, getAllowance, requireAccount, resolveToken, sendAndWait, toTokenAtomic, type TokenAmount, type TokenInput, type TokenWalletClient, type TxResult } from './erc20.js'; +import { PERMIT2_ADDRESS, type NetworkInput } from './networks.js'; /** Default signing window for permits without an explicit deadline (matches the x402 client cap). */ export const PERMIT2_DEFAULT_DEADLINE_SECONDS = 600; @@ -218,6 +218,7 @@ export interface ApprovePermit2Parameters { token?: TokenInput; /** ERC-20 allowance to grant Permit2. Default: unlimited. */ amount?: TokenAmount; + /** `false`: return `{ status: 'pending' }` right after sending instead of waiting for the receipt. */ wait?: boolean; } export interface SignPermit2TransferParameters { @@ -248,10 +249,12 @@ export interface Permit2TransferFromParameters { to: Address; /** Amount to pull, at most `signed.permit.permitted.amount` (the default). */ amount?: bigint; + /** `false`: return `{ status: 'pending' }` right after sending instead of waiting for the receipt. */ wait?: boolean; } export interface Permit2PermitParameters { signed: SignedPermit2Allowance; + /** `false`: return `{ status: 'pending' }` right after sending instead of waiting for the receipt. */ wait?: boolean; } export interface Permit2AllowanceTransferFromParameters { @@ -259,16 +262,18 @@ export interface Permit2AllowanceTransferFromParameters { from: Address; to: Address; amount: TokenAmount; + /** `false`: return `{ status: 'pending' }` right after sending instead of waiting for the receipt. */ wait?: boolean; } -function tokenAddress(client: BalanceClient, token: TokenInput | undefined): Address { - const t = token ?? defaultTokens(client)[0]; - return typeof t === 'string' ? t : t.address; +/** The token an action works on; like the ERC-20 actions, only the Radius presets have a default (see `resolveToken`). */ +function tokenFor(client: BalanceClient, token: TokenInput | undefined, what: string): TokenInput { + return resolveToken(client, token, what); } -function tokenFor(client: BalanceClient, token: TokenInput | undefined): TokenInput { - return token ?? defaultTokens(client)[0]; +function tokenAddress(client: BalanceClient, token: TokenInput | undefined, what: string): Address { + const t = tokenFor(client, token, what); + return typeof t === 'string' ? t : t.address; } /** Chain id for EIP-712: the client's chain, else looked up from the node. */ @@ -349,8 +354,8 @@ export function permit2WitnessHash(witness: Permit2Witness): Hex { // -- reads ---------------------------------------------------------------------------------------- /** ERC-20 allowance the owner has granted to Permit2 (the one-time approval), in atomic units. */ -export function getPermit2Approval(client: BalanceClient, args: GetPermit2ApprovalParameters): Promise { - return getAllowance(client, { token: tokenFor(client, args.token), owner: args.owner, spender: PERMIT2_ADDRESS }); +export async function getPermit2Approval(client: BalanceClient, args: GetPermit2ApprovalParameters): Promise { + return await getAllowance(client, { token: tokenFor(client, args.token, 'getPermit2Approval'), owner: args.owner, spender: PERMIT2_ADDRESS }); } /** AllowanceTransfer state Permit2 holds for (owner, token, spender). */ @@ -359,7 +364,7 @@ export async function getPermit2Allowance(client: BalanceClient, args: GetPermit address: PERMIT2_ADDRESS, abi: PERMIT2_ABI, functionName: 'allowance', - args: [args.owner, tokenAddress(client, args.token), args.spender], + args: [args.owner, tokenAddress(client, args.token, 'getPermit2Allowance'), args.spender], }); return { amount, expiration, nonce }; } @@ -377,7 +382,7 @@ export async function isPermit2NonceUsed(client: BalanceClient, args: IsPermit2N /** ERC-20 `approve(Permit2, amount)` from the client's account; unlimited by default. */ export async function approvePermit2(client: TokenWalletClient, args: ApprovePermit2Parameters = {}): Promise { const account = requireAccount(client, 'approvePermit2'); - const token = tokenFor(client, args.token); + const token = tokenFor(client, args.token, 'approvePermit2'); const amount = args.amount === undefined ? maxUint256 : await toTokenAtomic(client, token, args.amount); return sendAndWait(client, args.wait, () => writeContract(client, { address: typeof token === 'string' ? token : token.address, abi: erc20Abi, functionName: 'approve', args: [PERMIT2_ADDRESS, amount], account, chain: client.chain }), @@ -390,7 +395,7 @@ export async function approvePermit2(client: TokenWalletClient, args: ApprovePer */ export async function signPermit2Transfer(client: TokenWalletClient, args: SignPermit2TransferParameters): Promise { const account = requireAccount(client, 'signPermit2Transfer'); - const token = tokenFor(client, args.token); + const token = tokenFor(client, args.token, 'signPermit2Transfer'); const [amount, chainId] = await Promise.all([toTokenAtomic(client, token, args.amount), chainIdOf(client)]); const permit: PermitTransferFrom = { permitted: { token: typeof token === 'string' ? token : token.address, amount }, @@ -420,7 +425,7 @@ export async function signPermit2Transfer(client: TokenWalletClient, args: SignP /** Sign an AllowanceTransfer `PermitSingle`. The nonce is read from Permit2 unless given. */ export async function signPermit2Allowance(client: TokenWalletClient, args: SignPermit2AllowanceParameters): Promise { const account = requireAccount(client, 'signPermit2Allowance'); - const token = tokenFor(client, args.token); + const token = tokenFor(client, args.token, 'signPermit2Allowance'); const address = typeof token === 'string' ? token : token.address; const [amount, chainId, nonce] = await Promise.all([ toTokenAtomic(client, token, args.amount), @@ -496,7 +501,7 @@ export async function permit2Permit(client: TokenWalletClient, args: Permit2Perm /** AllowanceTransfer `transferFrom`: move tokens within an allowance granted to the client's account. */ export async function permit2AllowanceTransferFrom(client: TokenWalletClient, args: Permit2AllowanceTransferFromParameters): Promise { const account = requireAccount(client, 'permit2AllowanceTransferFrom'); - const token = tokenFor(client, args.token); + const token = tokenFor(client, args.token, 'permit2AllowanceTransferFrom'); const amount = await toTokenAtomic(client, token, args.amount); if (amount > maxUint160) throw new RadiusPaymentError('config', `Permit2 transfer amount ${amount} exceeds uint160`); return sendAndWait(client, args.wait, () => @@ -525,14 +530,17 @@ export type Permit2Actions = { }; export interface Permit2ActionsConfig { - /** Default token (else the network's payment asset, SBC). */ + /** Default token for every action. */ token?: TokenInput; + /** Default token = this network's payment asset (must be the chain the client is on); see `erc20Actions`. */ + network?: NetworkInput; } /** viem client extension for Permit2. Reads work on any client; the rest need an account. */ export function permit2Actions(config: Permit2ActionsConfig = {}) { return (client: TokenWalletClient): Permit2Actions => { - const withToken = (args: T): T => ({ ...args, token: args.token ?? config.token }); + const fallback = configuredToken(client, config, 'permit2Actions'); + const withToken = (args: T): T => ({ ...args, token: args.token ?? fallback }); return { getPermit2Approval: (args) => getPermit2Approval(client, withToken(args)), getPermit2Allowance: (args) => getPermit2Allowance(client, withToken(args)), diff --git a/packages/sdk/test/permit2.test.ts b/packages/sdk/test/permit2.test.ts index fc15075..dee952c 100644 --- a/packages/sdk/test/permit2.test.ts +++ b/packages/sdk/test/permit2.test.ts @@ -23,7 +23,7 @@ import { signPermit2Transfer, type Permit2Witness, } from '../src/permit2.js'; -import { PERMIT2_ADDRESS, radiusTestnet, SBC, X402_EXACT_PERMIT2_PROXY } from '../src/networks.js'; +import { defineRadiusNetwork, PERMIT2_ADDRESS, radiusTestnet, SBC, X402_EXACT_PERMIT2_PROXY } from '../src/networks.js'; import { fakeNode } from './fakeNode.js'; const OWNER_PK = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as Hex; @@ -265,6 +265,22 @@ describe('spender side', () => { }); }); +describe('default token', () => { + it('has none on a custom chain until token or network is given', async () => { + const n = node(); + const custom = defineRadiusNetwork({ chainId: 4242, rpcUrl: 'http://rpc', facilitatorUrl: 'http://f', asset: { address: '0x2222222222222222222222222222222222222222', decimals: 6, symbol: 'USDX' } }); + const client = createWalletClient({ account: OWNER, chain: custom.chain, transport: n.transport }); + await expect(getPermit2Approval(client, { owner: OWNER.address })).rejects.toMatchObject({ code: 'config' }); + await expect(approvePermit2(client)).rejects.toThrow(/approvePermit2: no token given and chain 4242 is not a Radius preset/); + await expect(signPermit2Transfer(client, { spender: SPENDER.address, amount: 1n })).rejects.toThrow(/signPermit2Transfer:/); + expect(n.sent).toHaveLength(0); + expect(() => client.extend(permit2Actions({ network: 'testnet' }))).toThrow(/network testnet is chain 72344 but the client is on chain 4242/); + // With the network (or an explicit token) the custom asset is used. + const signed = await client.extend(permit2Actions({ network: custom })).signPermit2Transfer({ spender: SPENDER.address, amount: 1n, nonce: 1n, deadline: BigInt(NOW + 60) }); + expect(signed.permit.permitted.token).toBe(custom.asset.address); + }); +}); + describe('permit2Actions', () => { it('extends wallet clients on both sides of a transfer', async () => { const shared = node({ erc20Allowance: maxUint256 });