Skip to content

Commit 87e75e6

Browse files
claudekylecrawshaw
authored andcommitted
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D1P1hkDQJ1iTaqumjZWtg8
1 parent 95a49ff commit 87e75e6

7 files changed

Lines changed: 954 additions & 8 deletions

File tree

‎packages/sdk/README.md‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,46 @@ await transfer(wallet, { token: '0x…', to, amount: '3' }); // a bare address
156156
`createRadiusFetch(...)` gains `allowance(spender)` and `approve(spender, amount)` for the payment
157157
asset next to `send`.
158158

159+
## Permit2
160+
161+
Actions for the canonical [Permit2](https://github.com/Uniswap/permit2) contract (`PERMIT2_ADDRESS`,
162+
the same on every Radius network), covering both of its flows. `permit2Actions()` is a client
163+
extension; each action is also exported on its own.
164+
165+
```ts
166+
import { permit2Actions } from 'radius-sdk';
167+
const owner = createWalletClient({ account, chain: radiusTestnet.chain, transport: http() }).extend(permit2Actions());
168+
const spender = createWalletClient({ account: spenderAccount, chain: radiusTestnet.chain, transport: http() }).extend(permit2Actions());
169+
170+
// Once per token: let Permit2 move the owner's SBC (unlimited by default, the x402 one-time approval).
171+
await owner.getPermit2Approval({ owner: owner.account.address }); // ERC-20 allowance granted to Permit2
172+
await owner.approvePermit2(); // approvePermit2({ amount: '5' }) to cap it
173+
174+
// SignatureTransfer (what x402 uses): one-off permit signed off-chain, submitted by the spender.
175+
const signed = await owner.signPermit2Transfer({ amount: '0.01', spender: spender.account.address });
176+
// { permit: { permitted: { token, amount }, nonce, deadline }, spender, owner, signature, chainId }
177+
await spender.permit2TransferFrom({ signed, to: spender.account.address }); // amount: pull less than permitted
178+
await spender.isPermit2NonceUsed({ owner: signed.owner, nonce: signed.permit.nonce }); // true afterwards
179+
180+
// With a witness (extra data the signature is bound to, e.g. x402's `Witness(address to,uint256 validAfter)`):
181+
const witness = { typeName: 'Witness', types: { Witness: [{ name: 'to', type: 'address' }, { name: 'validAfter', type: 'uint256' }] }, value: { to, validAfter: 0n } };
182+
const w = await owner.signPermit2Transfer({ amount: '0.01', spender: proxy, witness });
183+
await spender.permit2TransferFrom({ signed: w, to }); // calls permitWitnessTransferFrom with the hash + type string
184+
185+
// AllowanceTransfer (Uniswap-style): a signed allowance the spender can draw on until it expires.
186+
const allowance = await owner.signPermit2Allowance({ amount: '5', spender: spender.account.address, expiration: now + 86_400 });
187+
await spender.permit2Permit({ signed: allowance }); // records it in Permit2
188+
await spender.permit2AllowanceTransferFrom({ from: owner.account.address, to, amount: '1' }); // repeatable
189+
await spender.getPermit2Allowance({ owner: owner.account.address, spender: spender.account.address }); // { amount, expiration, nonce }
190+
```
191+
192+
Nonces: SignatureTransfer nonces are random 256-bit values (`randomPermit2Nonce()`, the default);
193+
AllowanceTransfer nonces are sequential per (owner, token, spender) and read from Permit2 when
194+
omitted. Deadlines default to 600 s, the same cap the x402 client applies. The EIP-712 domain,
195+
type sets (`PERMIT_TRANSFER_FROM_TYPES`, `PERMIT_SINGLE_TYPES`), `permit2WitnessTypeString` and
196+
`permit2WitnessHash` are exported for anyone assembling calls by hand; the witness type string is
197+
derived with EIP-712's ordering rule and checked against the x402 layout in the tests.
198+
159199
## Balances: native RUSD vs stablecoins
160200

161201
Radius differs from other EVM chains here. `eth_getBalance` (viem's `getBalance`, MetaMask's
@@ -240,9 +280,9 @@ self-hosted facilitator with your own auth or routing.
240280

241281
| Path | What |
242282
| --- | --- |
243-
| `src/` | `networks`, `balances`, `erc20`, `amounts`, `receipt`, `errors`; `hono/` (server); `client/` (buyer) |
283+
| `src/` | `networks`, `balances`, `erc20`, `permit2`, `amounts`, `receipt`, `errors`; `hono/` (server); `client/` (buyer) |
244284
| `examples/worker-seller` | Hono worker: free `/`, paid `/api/lookup` and `/api/query` (`pnpm dev`) |
245-
| `examples/agent-buyer` | `buy.mjs` (pay a URL), `fresh-wallet.mjs` (gasless proof from a new wallet) |
285+
| `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) |
246286
| `examples/demo-dapp` | Test-dapp style page exercising both sides in the browser (burner wallet or MetaMask) |
247287
| `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); `test/e2e` real settlement and a live balance reconciliation on testnet or mainnet (`RADIUS_E2E=1 RADIUS_PRIVATE_KEY=… [RADIUS_NETWORK=mainnet] pnpm test:e2e`) |
248288

‎packages/sdk/examples/agent-buyer/fresh-wallet.mjs‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
// Usage: RADIUS_PRIVATE_KEY=<funded key> node fresh-wallet.mjs [url]
44
import { createPublicClient, createWalletClient, http } from 'viem';
55
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
6-
import { createRadiusFetch, getPaymentReceipt, erc20Actions, radiusActions } from 'radius-sdk/client';
7-
import { PERMIT2_ADDRESS, SBC, formatAmount, radiusTestnet } from 'radius-sdk';
6+
import { createRadiusFetch, getPaymentReceipt, erc20Actions, permit2Actions, radiusActions } from 'radius-sdk/client';
7+
import { SBC, formatAmount, radiusTestnet } from 'radius-sdk';
88

99
const url = process.argv[2] ?? 'http://localhost:8787/api/lookup?ip=9.9.9.9';
1010
const funder = privateKeyToAccount(process.env.RADIUS_PRIVATE_KEY);
@@ -13,16 +13,17 @@ const fresh = privateKeyToAccount(freshKey);
1313
const chain = radiusTestnet.chain; // the SDK network's viem Chain (id, RPC, explorer)
1414
// radiusActions() adds getBalances(): on Radius eth_getBalance is native RUSD *plus* SBC at 1:1,
1515
// so a wallet holding only SBC still shows a non-zero eth_getBalance. getBalances() splits them.
16-
// erc20Actions() adds transfer/approve/getAllowance/… for SBC (the default token on Radius networks).
17-
const pub = createPublicClient({ chain, transport: http() }).extend(radiusActions()).extend(erc20Actions());
16+
// erc20Actions() adds transfer/approve/getAllowance/… for SBC (the default token on Radius networks);
17+
// permit2Actions() adds the Permit2 reads (getPermit2Approval = the ERC-20 allowance granted to Permit2).
18+
const pub = createPublicClient({ chain, transport: http() }).extend(radiusActions()).extend(erc20Actions()).extend(permit2Actions());
1819
const wallet = createWalletClient({ chain, transport: http(), account: funder }).extend(erc20Actions());
1920

2021
console.error(`fresh wallet ${fresh.address}; funding 0.005 SBC from ${funder.address}`);
2122
const funded = await wallet.transfer({ to: fresh.address, amount: '0.005' }); // waits for the receipt
2223
console.error(`funded in ${funded.hash} (${funded.status})`);
2324
const { native, tokens: [sbcBefore] } = await pub.getBalances({ address: fresh.address });
2425
const before = sbcBefore.atomic;
25-
const allowance = await pub.getAllowance({ owner: fresh.address, spender: PERMIT2_ADDRESS });
26+
const allowance = await pub.getPermit2Approval({ owner: fresh.address });
2627
console.error(`before: SBC ${sbcBefore.formatted}, native RUSD ${native.rawFormatted} (eth_getBalance reports ${native.aggregateFormatted}: SBC counted 1:1), Permit2 allowance ${allowance}`);
2728

2829
const payFetch = createRadiusFetch({ network: 'testnet', signer: freshKey, maxPerRequest: '$0.01' });
@@ -32,5 +33,5 @@ console.log(await res.text());
3233
console.error('receipt:', getPaymentReceipt(res, payFetch.network));
3334

3435
const after = (await pub.getTokenBalance({ address: fresh.address, token: SBC })).atomic;
35-
const allowanceAfter = await pub.getAllowance({ owner: fresh.address, spender: PERMIT2_ADDRESS });
36+
const allowanceAfter = await pub.getPermit2Approval({ owner: fresh.address });
3637
console.error(`after: SBC ${formatAmount(after, 6)} (spent ${formatAmount(before - after, 6)}), Permit2 allowance ${allowanceAfter === (2n ** 256n - 1n) ? 'MaxUint256' : allowanceAfter}`);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Permit2 SignatureTransfer end to end, outside of x402: a payer signs a one-off permit
2+
// off-chain and a collector pulls the SBC with it. This is the primitive x402 `exact`
3+
// (Permit2) payments are built on; here the collector is a plain account, not the x402 proxy.
4+
//
5+
// Usage: RADIUS_PRIVATE_KEY=<payer key> COLLECTOR_PRIVATE_KEY=<collector key> node permit2-pull.mjs [amount]
6+
// The payer needs SBC plus a one-time Permit2 approval (sent here if missing; gas comes from
7+
// SBC via Turnstile). The collector pays gas for the pull, so it needs a little SBC too.
8+
import { createWalletClient, http } from 'viem';
9+
import { privateKeyToAccount } from 'viem/accounts';
10+
import { SBC, erc20Actions, formatTokenAmount, permit2Actions, radiusActions, radiusTestnet } from 'radius-sdk';
11+
12+
const amount = process.argv[2] ?? '0.001';
13+
const payer = createWalletClient({ account: privateKeyToAccount(process.env.RADIUS_PRIVATE_KEY), chain: radiusTestnet.chain, transport: http() })
14+
.extend(radiusActions()).extend(erc20Actions()).extend(permit2Actions());
15+
const collector = createWalletClient({ account: privateKeyToAccount(process.env.COLLECTOR_PRIVATE_KEY), chain: radiusTestnet.chain, transport: http() })
16+
.extend(radiusActions()).extend(permit2Actions());
17+
18+
// 1. One-time: the payer lets Permit2 move its SBC (unlimited, the x402 "one-time gas approval" model).
19+
if ((await payer.getPermit2Approval({ owner: payer.account.address })) < 10n ** 12n) {
20+
const tx = await payer.approvePermit2();
21+
console.error(`payer approved Permit2 in ${tx.hash} (${tx.status})`);
22+
}
23+
24+
// 2. Off-chain: the payer signs a permit for the collector. Nothing is sent; `signed` is plain JSON-able data.
25+
const signed = await payer.signPermit2Transfer({ amount, spender: collector.account.address });
26+
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}`);
27+
console.error(`nonce used before pull: ${await collector.isPermit2NonceUsed({ owner: signed.owner, nonce: signed.permit.nonce })}`);
28+
29+
// 3. On-chain: the collector pulls the SBC to itself. It could also pull less than the permitted amount.
30+
const before = (await collector.getTokenBalance({ address: collector.account.address, token: SBC })).atomic;
31+
const pull = await collector.permit2TransferFrom({ signed, to: collector.account.address });
32+
const after = (await collector.getTokenBalance({ address: collector.account.address, token: SBC })).atomic;
33+
console.error(`collector pulled ${formatTokenAmount(after - before, SBC)} in ${pull.hash} (${pull.status}) ${pull.explorerUrl ?? ''}`);
34+
console.error(`nonce used after pull: ${await collector.isPermit2NonceUsed({ owner: signed.owner, nonce: signed.permit.nonce })}`);

‎packages/sdk/src/client/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,8 @@ export type {
599599
GetTransfersParameters,
600600
WatchTransfersParameters,
601601
} from '../erc20.js';
602+
export { permit2Actions, getPermit2Approval, getPermit2Allowance, isPermit2NonceUsed, approvePermit2, signPermit2Transfer, signPermit2Allowance, permit2TransferFrom, permit2Permit, permit2AllowanceTransferFrom } from '../permit2.js';
603+
export type { Permit2Actions, Permit2Witness, SignedPermit2Transfer, SignedPermit2Allowance, Permit2Allowance } from '../permit2.js';
602604
export { getPaymentReceipt, decodePaymentReceipt, parseUptoSettlementAmount } from '../receipt.js';
603605
export type { PaymentReceipt } from '../receipt.js';
604606
export { RadiusPaymentError } from '../errors.js';

‎packages/sdk/src/index.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,48 @@ export type {
5454
GetTransfersParameters,
5555
WatchTransfersParameters,
5656
} from './erc20.js';
57+
export {
58+
permit2Actions,
59+
getPermit2Approval,
60+
getPermit2Allowance,
61+
isPermit2NonceUsed,
62+
approvePermit2,
63+
signPermit2Transfer,
64+
signPermit2Allowance,
65+
permit2TransferFrom,
66+
permit2Permit,
67+
permit2AllowanceTransferFrom,
68+
permit2Domain,
69+
permit2WitnessTypeString,
70+
permit2WitnessHash,
71+
permitWitnessTransferFromTypes,
72+
encodeTypedDataType,
73+
randomPermit2Nonce,
74+
PERMIT2_ABI,
75+
PERMIT_TRANSFER_FROM_TYPES,
76+
PERMIT_SINGLE_TYPES,
77+
TOKEN_PERMISSIONS_TYPE,
78+
PERMIT2_DEFAULT_DEADLINE_SECONDS,
79+
} from './permit2.js';
80+
export type {
81+
Permit2Actions,
82+
Permit2ActionsConfig,
83+
Permit2Witness,
84+
PermitTransferFrom,
85+
PermitSingle,
86+
SignedPermit2Transfer,
87+
SignedPermit2Allowance,
88+
Permit2Allowance,
89+
GetPermit2ApprovalParameters,
90+
GetPermit2AllowanceParameters,
91+
IsPermit2NonceUsedParameters,
92+
ApprovePermit2Parameters,
93+
SignPermit2TransferParameters,
94+
SignPermit2AllowanceParameters,
95+
Permit2TransferFromParameters,
96+
Permit2PermitParameters,
97+
Permit2AllowanceTransferFromParameters,
98+
} from './permit2.js';
5799
export { toAtomic, formatAmount, resolvePrice } from './amounts.js';
58100
export type { Price } from './amounts.js';
59101
export { RadiusPaymentError } from './errors.js';

0 commit comments

Comments
 (0)