Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sdk-permit2.md
Original file line number Diff line number Diff line change
@@ -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`.
44 changes: 42 additions & 2 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EIP-3009 will come next, yes?


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. All of them come from `radius-sdk/client`.

```ts
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());

// 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 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

Radius differs from other EVM chains here. `eth_getBalance` (viem's `getBalance`, MetaMask's
Expand Down Expand Up @@ -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`) |

Expand Down
13 changes: 7 additions & 6 deletions packages/sdk/examples/agent-buyer/fresh-wallet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
// Usage: RADIUS_PRIVATE_KEY=<funded 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);
Expand All @@ -13,16 +13,17 @@ 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}`);
const funded = await wallet.transfer({ to: fresh.address, amount: '0.005' }); // waits for the receipt
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' });
Expand All @@ -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}`);
35 changes: 35 additions & 0 deletions packages/sdk/examples/agent-buyer/permit2-pull.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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=<payer key> COLLECTOR_PRIVATE_KEY=<collector 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 { 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() })
.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 })}`);
42 changes: 42 additions & 0 deletions packages/sdk/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,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 { getPaymentReceipt, decodePaymentReceipt, parseUptoSettlementAmount } from '../receipt.js';
export type { PaymentReceipt } from '../receipt.js';
export { RadiusPaymentError } from '../errors.js';
Expand Down
20 changes: 20 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,26 @@ export type {
GetTransfersParameters,
WatchTransfersParameters,
} from './erc20.js';
// Permit2 actions and EIP-712 helpers load viem too; their runtime exports live on `radius-sdk/client`.
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';
Expand Down
Loading
Loading