From 260a0b6a573d3344fbe7eb49dc3c8c49c49a0cde Mon Sep 17 00:00:00 2001 From: AkasshP Date: Fri, 29 May 2026 12:53:16 -0400 Subject: [PATCH 1/8] Add wallet provider selection and lifecycle commands (ENG-1975) --- src/commands/wallet.ts | 44 +++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/lib/config.ts | 26 +++++++++++++++++++-- src/lib/providers/index.ts | 27 +++++++++++++++++++++ src/lib/providers/keystore.ts | 31 ++++++++++++++++++++++++ src/types.ts | 10 ++++++++ 6 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 src/lib/providers/index.ts create mode 100644 src/lib/providers/keystore.ts diff --git a/src/commands/wallet.ts b/src/commands/wallet.ts index d3f044d..e56446e 100644 --- a/src/commands/wallet.ts +++ b/src/commands/wallet.ts @@ -20,6 +20,7 @@ import { makePublicClient, makeWalletClient } from '../lib/client.js'; import { coerceArg, parseCastSignature } from '../lib/signature.js'; import { formatUsd, formatUsdShort, jsonStringify } from '../lib/format.js'; import { registerWalletX402 } from './walletX402.js'; +import { getProvider } from '../lib/providers/index.js'; import type { GlobalOptions } from '../types.js'; const SBC_DECIMALS = 6; @@ -123,6 +124,44 @@ export function registerWallet(program: Command): void { console.log(`Address: ${address}`); }); + wallet + .command('login') + .description('Log in to the active wallet provider') + .action(async (_subOpts, cmd) => { + const opts = cmd.optsWithGlobals() as GlobalOptions; + const cfg = resolveConfig(opts); + const provider = getProvider(cfg.walletProvider); + if (provider.login) { + await provider.login(cfg); + } else { + console.log(`${cfg.walletProvider} provider does not require login.`); + } + }); + + wallet + .command('logout') + .description('Log out of the active wallet provider') + .action(async (_subOpts, cmd) => { + const opts = cmd.optsWithGlobals() as GlobalOptions; + const cfg = resolveConfig(opts); + const provider = getProvider(cfg.walletProvider); + if (provider.logout) { + await provider.logout(cfg); + } else { + console.log(`${cfg.walletProvider} provider does not support logout.`); + } + }); + + wallet + .command('status') + .description('Show the current wallet provider and account status') + .action(async (_subOpts, cmd) => { + const opts = cmd.optsWithGlobals() as GlobalOptions; + const cfg = resolveConfig(opts); + const provider = getProvider(cfg.walletProvider); + await provider.status(cfg, opts); + }); + wallet .command('address') .description('Print the address associated with the local account') @@ -143,6 +182,11 @@ export function registerWallet(program: Command): void { .action(async (_subOpts, cmd) => { const opts = cmd.optsWithGlobals() as GlobalOptions; const cfg = resolveConfig(opts); + if (cfg.walletProvider !== 'keystore') { + throw new Error( + `wallet export is only supported for the keystore provider (current: ${cfg.walletProvider}).`, + ); + } if (!opts.privateKey && !keystoreExists(cfg.keystorePath)) { throw new Error( `No keystore at ${cfg.keystorePath}. Run \`radius-cli wallet new\` or pass --private-key.`, diff --git a/src/index.ts b/src/index.ts index 36235b9..52eba18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ program .option('--private-key ', 'sign with this key instead of the local keystore') .option('--sbc
', 'override the SBC token contract address') .option('--rusd
', 'override the RUSD ERC-20 contract address') + .option('--wallet ', 'wallet provider: keystore, cdp, para, or privy (default: keystore)') .option('--json', 'machine-readable JSON output'); registerWallet(program); diff --git a/src/lib/config.ts b/src/lib/config.ts index a979479..71485e7 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,7 +3,9 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { isAddress, type Address } from 'viem'; import { chainFor, DEFAULT_SBC_ADDRESS } from './chains.js'; -import type { GlobalOptions, NetworkName, ResolvedConfig } from '../types.js'; +import type { GlobalOptions, NetworkName, ResolvedConfig, WalletProviderName } from '../types.js'; + +const VALID_WALLET_PROVIDERS: WalletProviderName[] = ['keystore', 'cdp', 'para', 'privy']; const RADIUS_DIR = process.env.RADIUS_HOME ?? join(homedir(), '.radius'); const CONFIG_PATH = join(RADIUS_DIR, 'config.json'); @@ -16,6 +18,7 @@ interface FileConfig { rusdAddress?: string; cachedAddress?: string; passwordless?: boolean; + wallet?: WalletProviderName; } function readFileConfig(): FileConfig { @@ -56,7 +59,15 @@ export function resolveConfig(opts: GlobalOptions): ResolvedConfig { const keystorePath = process.env.RADIUS_KEYSTORE_PATH ?? DEFAULT_KEYSTORE_PATH; const password = process.env.RADIUS_PASSWORD; - return { network, chain, rpcUrl, sbcAddress, rusdAddress, keystorePath, password }; + const walletRaw = opts.wallet ?? process.env.RADIUS_WALLET ?? file.wallet ?? 'keystore'; + if (!VALID_WALLET_PROVIDERS.includes(walletRaw as WalletProviderName)) { + throw new Error( + `--wallet must be one of ${VALID_WALLET_PROVIDERS.join(', ')} (got '${walletRaw}')`, + ); + } + const walletProvider = walletRaw as WalletProviderName; + + return { network, chain, rpcUrl, sbcAddress, rusdAddress, keystorePath, password, walletProvider }; } export function configPath(): string { @@ -93,3 +104,14 @@ export function writePasswordless(passwordless: boolean): void { else delete file.passwordless; writeFileConfig(file); } + +export function readWalletProvider(): WalletProviderName { + return readFileConfig().wallet ?? 'keystore'; +} + +export function writeWalletProvider(provider: WalletProviderName): void { + const file = readFileConfig(); + if (provider === 'keystore') delete file.wallet; + else file.wallet = provider; + writeFileConfig(file); +} diff --git a/src/lib/providers/index.ts b/src/lib/providers/index.ts new file mode 100644 index 0000000..236c8a3 --- /dev/null +++ b/src/lib/providers/index.ts @@ -0,0 +1,27 @@ +import { keystoreProvider } from './keystore.js'; +import type { WalletProviderName, WalletProviderInterface } from '../../types.js'; + +const providers: Record = { + keystore: keystoreProvider, + cdp: stubProvider('cdp'), + para: stubProvider('para'), + privy: stubProvider('privy'), +}; + +function stubProvider(name: string): WalletProviderInterface { + return { + async login(): Promise { + throw new Error(`${name} provider is not yet implemented.`); + }, + async logout(): Promise { + throw new Error(`${name} provider is not yet implemented.`); + }, + async status(): Promise { + throw new Error(`${name} provider is not yet implemented.`); + }, + }; +} + +export function getProvider(name: WalletProviderName): WalletProviderInterface { + return providers[name]; +} diff --git a/src/lib/providers/keystore.ts b/src/lib/providers/keystore.ts new file mode 100644 index 0000000..442190e --- /dev/null +++ b/src/lib/providers/keystore.ts @@ -0,0 +1,31 @@ +import { readKeystoreAddress } from '../keystore.js'; +import { jsonStringify } from '../format.js'; +import type { ResolvedConfig, GlobalOptions, WalletProviderInterface } from '../../types.js'; + +export const keystoreProvider: WalletProviderInterface = { + async login(_cfg: ResolvedConfig): Promise { + console.log( + 'Keystore wallets do not require login.\n' + + 'To create a new wallet: radius-cli wallet new\n' + + 'To import an existing key: radius-cli wallet import ', + ); + }, + + async logout(_cfg: ResolvedConfig): Promise { + // Keystore logout is a no-op. + }, + + async status(cfg: ResolvedConfig, opts: GlobalOptions): Promise { + const address = readKeystoreAddress(cfg.keystorePath); + if (opts.json) { + console.log(jsonStringify({ provider: 'keystore', address: address ?? null })); + return; + } + console.log(`Provider: keystore`); + if (address) { + console.log(`Address: ${address}`); + } else { + console.log('No keystore found. Run `radius-cli wallet new` to create one.'); + } + }, +}; diff --git a/src/types.ts b/src/types.ts index 0e31da7..271aaf9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,8 @@ import type { Address, Chain, PublicClient, WalletClient } from 'viem'; export type NetworkName = 'mainnet' | 'testnet'; +export type WalletProviderName = 'keystore' | 'cdp' | 'para' | 'privy'; + export interface ResolvedConfig { network: NetworkName; chain: Chain; @@ -10,6 +12,7 @@ export interface ResolvedConfig { rusdAddress?: Address; keystorePath: string; password?: string; + walletProvider: WalletProviderName; } export interface GlobalOptions { @@ -20,6 +23,13 @@ export interface GlobalOptions { rusd?: string; json?: boolean; wait?: boolean; + wallet?: string; +} + +export interface WalletProviderInterface { + login?(cfg: ResolvedConfig): Promise; + logout?(cfg: ResolvedConfig): Promise; + status(cfg: ResolvedConfig, opts: GlobalOptions): Promise; } export interface Clients { From a31528a9dc0e8750b814b3e1f9b6cded3301b2a4 Mon Sep 17 00:00:00 2001 From: AkasshP Date: Wed, 3 Jun 2026 13:16:31 -0400 Subject: [PATCH 2/8] Refactor keystore behind WalletProvider abstraction (ENG-1974) Also fix x402 challenge parsing to accept `amount` field (v2 compat). --- src/commands/wallet.ts | 13 ++++---- src/lib/account.ts | 61 +++++++---------------------------- src/lib/providers/index.ts | 15 ++++++--- src/lib/providers/keystore.ts | 59 +++++++++++++++++++++++++++++++-- src/lib/providers/types.ts | 11 +++++++ src/lib/x402/eip3009.ts | 4 +-- src/lib/x402/protocol.ts | 2 +- src/types.ts | 6 +--- 8 files changed, 99 insertions(+), 72 deletions(-) create mode 100644 src/lib/providers/types.ts diff --git a/src/commands/wallet.ts b/src/commands/wallet.ts index e56446e..59d6002 100644 --- a/src/commands/wallet.ts +++ b/src/commands/wallet.ts @@ -13,8 +13,8 @@ import { type Hex, } from 'viem'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import { resolveConfig, readPasswordless, writeCachedAddress, writePasswordless } from '../lib/config.js'; -import { keystoreExists, loadKeystorePrivateKey, saveKeystore } from '../lib/keystore.js'; +import { resolveConfig, writeCachedAddress, writePasswordless } from '../lib/config.js'; +import { keystoreExists, saveKeystore } from '../lib/keystore.js'; import { getOwnAddress, requireAccount } from '../lib/account.js'; import { makePublicClient, makeWalletClient } from '../lib/client.js'; import { coerceArg, parseCastSignature } from '../lib/signature.js'; @@ -182,9 +182,10 @@ export function registerWallet(program: Command): void { .action(async (_subOpts, cmd) => { const opts = cmd.optsWithGlobals() as GlobalOptions; const cfg = resolveConfig(opts); - if (cfg.walletProvider !== 'keystore') { + const provider = getProvider(cfg.walletProvider); + if (!provider.exportPrivateKey) { throw new Error( - `wallet export is only supported for the keystore provider (current: ${cfg.walletProvider}).`, + `wallet export is not supported for the ${cfg.walletProvider} provider (remote key material is not exportable).`, ); } if (!opts.privateKey && !keystoreExists(cfg.keystorePath)) { @@ -202,9 +203,7 @@ export function registerWallet(program: Command): void { if (opts.privateKey) { pk = normalizePrivateKey(opts.privateKey); } else { - const password = cfg.password - ?? (readPasswordless() ? '' : await promptPassword({ message: 'Keystore password:', mask: '*' })); - pk = await loadKeystorePrivateKey(cfg.keystorePath, password); + pk = await provider.exportPrivateKey(cfg); } const address = privateKeyToAccount(pk).address; if (opts.json) { diff --git a/src/lib/account.ts b/src/lib/account.ts index d038e39..9a5b12b 100644 --- a/src/lib/account.ts +++ b/src/lib/account.ts @@ -1,8 +1,6 @@ -import { password as promptPassword } from '@inquirer/prompts'; -import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import type { Hex, PrivateKeyAccount } from 'viem'; -import { keystoreExists, loadAccount, readKeystoreAddress, saveKeystore } from './keystore.js'; -import { readPasswordless, writeCachedAddress, writePasswordless } from './config.js'; +import { privateKeyToAccount } from 'viem/accounts'; +import type { Address, Hex, LocalAccount } from 'viem'; +import { getProvider } from './providers/index.js'; import type { ResolvedConfig } from '../types.js'; function normalizePrivateKey(input: string): Hex { @@ -14,63 +12,26 @@ function normalizePrivateKey(input: string): Hex { return withPrefix as Hex; } -async function resolvePassword(cfg: ResolvedConfig): Promise { - if (cfg.password !== undefined) return cfg.password; - if (readPasswordless()) return ''; - return await promptPassword({ message: 'Keystore password:', mask: '*' }); -} - -/** - * Generate a fresh keystore on first use. Uses RADIUS_PASSWORD if set, empty otherwise. - * An empty password means the keystore JSON is effectively unencrypted — the file mode - * (0o600) is the only protection. The notice on stderr makes that clear. - */ -async function autoCreateKeystore(cfg: ResolvedConfig): Promise { - const pk = generatePrivateKey(); - const password = cfg.password ?? ''; - const address = await saveKeystore(cfg.keystorePath, pk, password); - writeCachedAddress(address); - writePasswordless(password === ''); - - const lines = [ - `Created new keystore at ${cfg.keystorePath}`, - `Address: ${address}`, - ]; - if (password === '') { - lines.push( - 'No password set — keystore is effectively unencrypted (file mode 0o600).', - 'To rotate to a password-protected keystore: `radius-cli wallet new --force`', - ); - } - process.stderr.write(lines.join('\n') + '\n'); - - return privateKeyToAccount(pk); -} - -/** Returns a viem account. Auto-creates a keystore on first use; prompts for password if one exists. */ +/** Returns a viem account via the active provider. --private-key overrides any provider. */ export async function requireAccount( cfg: ResolvedConfig, privateKeyOpt: string | undefined, -): Promise { +): Promise { if (privateKeyOpt) { return privateKeyToAccount(normalizePrivateKey(privateKeyOpt)); } - if (!keystoreExists(cfg.keystorePath)) { - return await autoCreateKeystore(cfg); - } - const password = await resolvePassword(cfg); - return await loadAccount(cfg.keystorePath, password); + const provider = getProvider(cfg.walletProvider); + return await provider.getAccount(cfg); } -/** Returns just the local address — no password prompt. Auto-creates a keystore on first use. */ +/** Returns just the address via the active provider — no password prompt for keystore. */ export async function getOwnAddress( cfg: ResolvedConfig, privateKeyOpt: string | undefined, -): Promise<`0x${string}`> { +): Promise
{ if (privateKeyOpt) { return privateKeyToAccount(normalizePrivateKey(privateKeyOpt)).address; } - const addr = readKeystoreAddress(cfg.keystorePath); - if (addr) return addr; - return (await autoCreateKeystore(cfg)).address; + const provider = getProvider(cfg.walletProvider); + return await provider.getAddress(cfg); } diff --git a/src/lib/providers/index.ts b/src/lib/providers/index.ts index 236c8a3..7a05de8 100644 --- a/src/lib/providers/index.ts +++ b/src/lib/providers/index.ts @@ -1,15 +1,22 @@ import { keystoreProvider } from './keystore.js'; -import type { WalletProviderName, WalletProviderInterface } from '../../types.js'; +import type { WalletProviderName } from '../../types.js'; +import type { WalletProvider } from './types.js'; -const providers: Record = { +const providers: Record = { keystore: keystoreProvider, cdp: stubProvider('cdp'), para: stubProvider('para'), privy: stubProvider('privy'), }; -function stubProvider(name: string): WalletProviderInterface { +function stubProvider(name: string): WalletProvider { return { + async getAccount(): Promise { + throw new Error(`${name} provider is not yet implemented.`); + }, + async getAddress(): Promise { + throw new Error(`${name} provider is not yet implemented.`); + }, async login(): Promise { throw new Error(`${name} provider is not yet implemented.`); }, @@ -22,6 +29,6 @@ function stubProvider(name: string): WalletProviderInterface { }; } -export function getProvider(name: WalletProviderName): WalletProviderInterface { +export function getProvider(name: WalletProviderName): WalletProvider { return providers[name]; } diff --git a/src/lib/providers/keystore.ts b/src/lib/providers/keystore.ts index 442190e..6236781 100644 --- a/src/lib/providers/keystore.ts +++ b/src/lib/providers/keystore.ts @@ -1,8 +1,55 @@ -import { readKeystoreAddress } from '../keystore.js'; +import { password as promptPassword } from '@inquirer/prompts'; +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; +import type { Address, Hex, LocalAccount } from 'viem'; +import { keystoreExists, loadAccount, loadKeystorePrivateKey, readKeystoreAddress, saveKeystore } from '../keystore.js'; +import { readPasswordless, writeCachedAddress, writePasswordless } from '../config.js'; import { jsonStringify } from '../format.js'; -import type { ResolvedConfig, GlobalOptions, WalletProviderInterface } from '../../types.js'; +import type { ResolvedConfig, GlobalOptions } from '../../types.js'; +import type { WalletProvider } from './types.js'; + +async function resolvePassword(cfg: ResolvedConfig): Promise { + if (cfg.password !== undefined) return cfg.password; + if (readPasswordless()) return ''; + return await promptPassword({ message: 'Keystore password:', mask: '*' }); +} + +async function autoCreateKeystore(cfg: ResolvedConfig): Promise { + const pk = generatePrivateKey(); + const password = cfg.password ?? ''; + const address = await saveKeystore(cfg.keystorePath, pk, password); + writeCachedAddress(address); + writePasswordless(password === ''); + + const lines = [ + `Created new keystore at ${cfg.keystorePath}`, + `Address: ${address}`, + ]; + if (password === '') { + lines.push( + 'No password set — keystore is effectively unencrypted (file mode 0o600).', + 'To rotate to a password-protected keystore: `radius-cli wallet new --force`', + ); + } + process.stderr.write(lines.join('\n') + '\n'); + + return privateKeyToAccount(pk); +} + +export const keystoreProvider: WalletProvider = { + async getAccount(cfg: ResolvedConfig): Promise { + if (!keystoreExists(cfg.keystorePath)) { + return await autoCreateKeystore(cfg); + } + const password = await resolvePassword(cfg); + return await loadAccount(cfg.keystorePath, password); + }, + + async getAddress(cfg: ResolvedConfig): Promise
{ + const addr = readKeystoreAddress(cfg.keystorePath); + if (addr) return addr; + return (await autoCreateKeystore(cfg)).address; + }, -export const keystoreProvider: WalletProviderInterface = { async login(_cfg: ResolvedConfig): Promise { console.log( 'Keystore wallets do not require login.\n' + @@ -28,4 +75,10 @@ export const keystoreProvider: WalletProviderInterface = { console.log('No keystore found. Run `radius-cli wallet new` to create one.'); } }, + + async exportPrivateKey(cfg: ResolvedConfig): Promise { + const password = cfg.password + ?? (readPasswordless() ? '' : await promptPassword({ message: 'Keystore password:', mask: '*' })); + return await loadKeystorePrivateKey(cfg.keystorePath, password); + }, }; diff --git a/src/lib/providers/types.ts b/src/lib/providers/types.ts new file mode 100644 index 0000000..d29c539 --- /dev/null +++ b/src/lib/providers/types.ts @@ -0,0 +1,11 @@ +import type { Address, Hex, LocalAccount } from 'viem'; +import type { ResolvedConfig, GlobalOptions } from '../../types.js'; + +export interface WalletProvider { + getAccount(cfg: ResolvedConfig): Promise; + getAddress(cfg: ResolvedConfig): Promise
; + login?(cfg: ResolvedConfig): Promise; + logout?(cfg: ResolvedConfig): Promise; + status(cfg: ResolvedConfig, opts: GlobalOptions): Promise; + exportPrivateKey?(cfg: ResolvedConfig): Promise; +} diff --git a/src/lib/x402/eip3009.ts b/src/lib/x402/eip3009.ts index e9934d0..70d8b2e 100644 --- a/src/lib/x402/eip3009.ts +++ b/src/lib/x402/eip3009.ts @@ -1,5 +1,5 @@ import { randomBytes } from 'node:crypto'; -import type { Address, Hex, PrivateKeyAccount, PublicClient } from 'viem'; +import type { Address, Hex, LocalAccount, PublicClient } from 'viem'; import type { Authorization } from './protocol.js'; export const ERC20_X402_ABI = [ @@ -117,7 +117,7 @@ export function makeAuthorization(args: { } export async function signTransferAuthorization( - account: PrivateKeyAccount, + account: LocalAccount, args: { asset: Address; chainId: number; diff --git a/src/lib/x402/protocol.ts b/src/lib/x402/protocol.ts index b3f6dbb..efbbc76 100644 --- a/src/lib/x402/protocol.ts +++ b/src/lib/x402/protocol.ts @@ -89,7 +89,7 @@ export function parseChallenge(raw: unknown): Challenge { network: asString(e.network, `accepts[${i}].network`), asset: asAddress(e.asset, `accepts[${i}].asset`), payTo: asAddress(e.payTo, `accepts[${i}].payTo`), - maxAmountRequired: asBigInt(e.maxAmountRequired, `accepts[${i}].maxAmountRequired`), + maxAmountRequired: asBigInt(e.maxAmountRequired ?? e.amount, `accepts[${i}].maxAmountRequired`), resource: typeof e.resource === 'string' ? e.resource : undefined, description: typeof e.description === 'string' ? e.description : undefined, mimeType: typeof e.mimeType === 'string' ? e.mimeType : undefined, diff --git a/src/types.ts b/src/types.ts index 271aaf9..7498dfe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,11 +26,7 @@ export interface GlobalOptions { wallet?: string; } -export interface WalletProviderInterface { - login?(cfg: ResolvedConfig): Promise; - logout?(cfg: ResolvedConfig): Promise; - status(cfg: ResolvedConfig, opts: GlobalOptions): Promise; -} +export type { WalletProvider } from './lib/providers/types.js'; export interface Clients { publicClient: PublicClient; From 5eb741cec00531a5d5dc0359badc40984f4e322e Mon Sep 17 00:00:00 2001 From: Eriks Reks Date: Wed, 3 Jun 2026 14:03:17 -0400 Subject: [PATCH 3/8] Add tests for WalletProvider abstraction and x402 v2 amount fallback - tests/providers.test.ts: stub providers (cdp/para/privy) reject with "not yet implemented" and expose no exportPrivateKey; keystore provider auto-creates on first use, round-trips export, honors explicit password, rejects wrong password; requireAccount/getOwnAddress dispatch to the selected provider and --private-key overrides it - tests/x402-protocol.test.ts: lock in the `amount` fallback (x402 v2), precedence of maxAmountRequired, and rejection when both are absent Covers the test gap flagged in the PR #10 review. 50/50 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/providers.test.ts | 109 ++++++++++++++++++++++++++++++++++++ tests/x402-protocol.test.ts | 21 +++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/providers.test.ts diff --git a/tests/providers.test.ts b/tests/providers.test.ts new file mode 100644 index 0000000..3a16ebb --- /dev/null +++ b/tests/providers.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest'; +import { existsSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { privateKeyToAccount } from 'viem/accounts'; +import { chainFor } from '../src/lib/chains.js'; +import type { ResolvedConfig, WalletProviderName } from '../src/types.js'; + +// config.ts captures RADIUS_HOME at module load, so isolate ~/.radius before +// dynamically importing anything that transitively loads it. +const radiusHome = mkdtempSync(join(tmpdir(), 'radius-cli-providers-')); +process.env.RADIUS_HOME = radiusHome; + +const { getProvider } = await import('../src/lib/providers/index.js'); +const { requireAccount, getOwnAddress } = await import('../src/lib/account.js'); + +function makeCfg(overrides: Partial = {}): ResolvedConfig { + return { + network: 'testnet', + chain: chainFor('testnet'), + rpcUrl: 'http://localhost:0', + keystorePath: join(radiusHome, 'keystore.json'), + walletProvider: 'keystore', + ...overrides, + }; +} + +describe('stub providers (cdp, para, privy)', () => { + const stubs: WalletProviderName[] = ['cdp', 'para', 'privy']; + + for (const name of stubs) { + it(`${name}: getAccount rejects with "not yet implemented"`, async () => { + await expect(getProvider(name).getAccount(makeCfg({ walletProvider: name }))).rejects.toThrow( + /not yet implemented/, + ); + }); + + it(`${name}: getAddress rejects with "not yet implemented"`, async () => { + await expect(getProvider(name).getAddress(makeCfg({ walletProvider: name }))).rejects.toThrow( + /not yet implemented/, + ); + }); + + it(`${name}: does not expose exportPrivateKey (remote key material)`, () => { + expect(getProvider(name).exportPrivateKey).toBeUndefined(); + }); + } +}); + +describe('keystore provider', () => { + const provider = getProvider('keystore'); + + it('auto-creates a keystore on first getAddress and reuses it for getAccount', async () => { + const cfg = makeCfg(); + expect(existsSync(cfg.keystorePath)).toBe(false); + + const address = await provider.getAddress(cfg); + expect(address).toMatch(/^0x[0-9a-fA-F]{40}$/); + expect(existsSync(cfg.keystorePath)).toBe(true); + + // Passwordless flag was persisted, so this must not prompt. + const account = await provider.getAccount(cfg); + expect(account.address.toLowerCase()).toBe(address.toLowerCase()); + }, 20_000); + + it('exportPrivateKey returns the key backing the keystore address', async () => { + const cfg = makeCfg(); + expect(provider.exportPrivateKey).toBeDefined(); + const pk = await provider.exportPrivateKey!(cfg); + const address = await provider.getAddress(cfg); + expect(privateKeyToAccount(pk).address.toLowerCase()).toBe(address.toLowerCase()); + }, 20_000); + + it('auto-creates with an explicit password and rejects a wrong one', async () => { + const cfg = makeCfg({ + keystorePath: join(radiusHome, 'keystore-pw.json'), + password: 'correct-horse-battery-staple', + }); + + const account = await provider.getAccount(cfg); + const reloaded = await provider.getAccount(cfg); + expect(reloaded.address).toBe(account.address); + + await expect(provider.getAccount({ ...cfg, password: 'wrong' })).rejects.toThrow(); + }, 60_000); +}); + +describe('account shims (requireAccount / getOwnAddress)', () => { + const PK = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; + const PK_ADDRESS = privateKeyToAccount(PK).address; + + it('dispatch to the selected provider', async () => { + const cfg = makeCfg({ walletProvider: 'cdp' }); + await expect(requireAccount(cfg, undefined)).rejects.toThrow(/not yet implemented/); + await expect(getOwnAddress(cfg, undefined)).rejects.toThrow(/not yet implemented/); + }); + + it('--private-key overrides the provider entirely', async () => { + const cfg = makeCfg({ walletProvider: 'cdp' }); + const account = await requireAccount(cfg, PK); + expect(account.address).toBe(PK_ADDRESS); + expect(await getOwnAddress(cfg, PK)).toBe(PK_ADDRESS); + }); + + it('normalizes a private key missing its 0x prefix', async () => { + const account = await requireAccount(makeCfg(), PK.slice(2)); + expect(account.address).toBe(PK_ADDRESS); + }); +}); diff --git a/tests/x402-protocol.test.ts b/tests/x402-protocol.test.ts index 8b1ba13..85801fb 100644 --- a/tests/x402-protocol.test.ts +++ b/tests/x402-protocol.test.ts @@ -36,6 +36,27 @@ describe('parseChallenge', () => { expect(c.error).toBe('X-PAYMENT required'); }); + it('falls back to `amount` when `maxAmountRequired` is absent (x402 v2)', () => { + const v2 = JSON.parse(JSON.stringify(VALID_CHALLENGE)); + delete v2.accepts[0].maxAmountRequired; + v2.accepts[0].amount = '13000'; + const c = parseChallenge(v2); + expect(c.accepts[0].maxAmountRequired).toBe(13000n); + }); + + it('prefers `maxAmountRequired` over `amount` when both are present', () => { + const both = JSON.parse(JSON.stringify(VALID_CHALLENGE)); + both.accepts[0].amount = '99'; + const c = parseChallenge(both); + expect(c.accepts[0].maxAmountRequired).toBe(13000n); + }); + + it('rejects when neither maxAmountRequired nor amount is present', () => { + const neither = JSON.parse(JSON.stringify(VALID_CHALLENGE)); + delete neither.accepts[0].maxAmountRequired; + expect(() => parseChallenge(neither)).toThrow(/maxAmountRequired/); + }); + it('rejects non-objects', () => { expect(() => parseChallenge(null)).toThrow(); expect(() => parseChallenge([])).toThrow(); From fc1173d7b9d85060b7fa07cfaf081af6b1d38533 Mon Sep 17 00:00:00 2001 From: AkasshP Date: Wed, 3 Jun 2026 14:11:06 -0400 Subject: [PATCH 4/8] Fix x402 error message to reflect actual field name --- src/lib/x402/protocol.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/x402/protocol.ts b/src/lib/x402/protocol.ts index efbbc76..fd43753 100644 --- a/src/lib/x402/protocol.ts +++ b/src/lib/x402/protocol.ts @@ -89,7 +89,14 @@ export function parseChallenge(raw: unknown): Challenge { network: asString(e.network, `accepts[${i}].network`), asset: asAddress(e.asset, `accepts[${i}].asset`), payTo: asAddress(e.payTo, `accepts[${i}].payTo`), - maxAmountRequired: asBigInt(e.maxAmountRequired ?? e.amount, `accepts[${i}].maxAmountRequired`), + maxAmountRequired: asBigInt( + e.maxAmountRequired ?? e.amount, + e.maxAmountRequired !== undefined + ? `accepts[${i}].maxAmountRequired` + : e.amount !== undefined + ? `accepts[${i}].amount` + : `accepts[${i}].maxAmountRequired`, + ), resource: typeof e.resource === 'string' ? e.resource : undefined, description: typeof e.description === 'string' ? e.description : undefined, mimeType: typeof e.mimeType === 'string' ? e.mimeType : undefined, From 698c967cd88cfe1ee76c952aac195ac8a54931b2 Mon Sep 17 00:00:00 2001 From: Eriks Reks Date: Wed, 3 Jun 2026 14:24:46 -0400 Subject: [PATCH 5/8] Cover the amount-sourced error label in parseChallenge Exercises the branch added in fc1173d: an invalid `amount` with no `maxAmountRequired` reports accepts[i].amount in the error. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/x402-protocol.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/x402-protocol.test.ts b/tests/x402-protocol.test.ts index 85801fb..503d60f 100644 --- a/tests/x402-protocol.test.ts +++ b/tests/x402-protocol.test.ts @@ -51,6 +51,13 @@ describe('parseChallenge', () => { expect(c.accepts[0].maxAmountRequired).toBe(13000n); }); + it('reports `amount` in the error when it is the source field', () => { + const bad = JSON.parse(JSON.stringify(VALID_CHALLENGE)); + delete bad.accepts[0].maxAmountRequired; + bad.accepts[0].amount = '-1'; + expect(() => parseChallenge(bad)).toThrow(/accepts\[0\]\.amount/); + }); + it('rejects when neither maxAmountRequired nor amount is present', () => { const neither = JSON.parse(JSON.stringify(VALID_CHALLENGE)); delete neither.accepts[0].maxAmountRequired; From c56c71de95c125defad6c21822462c40a0092999 Mon Sep 17 00:00:00 2001 From: Eriks Reks Date: Wed, 3 Jun 2026 14:35:42 -0400 Subject: [PATCH 6/8] Fix wallet balance double-counting SBC after eth_getBalance semantics change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Radius RPC change (2026-06) made eth_getBalance return token_balance × per_unit_exchange_rate + raw_native, so the SBC token balance is already included in the native balance. The balance command was summing eth_getBalance + SBC balanceOf on top, double-counting SBC (e.g. reporting $0.20 for a wallet holding only 0.1 SBC). eth_getBalance is now treated as the total; the raw-native (RUSD) line is derived as the remainder via splitAggregateBalance (clamped at zero), assuming the 1 SBC = 1 native unit peg. JSON output gains totalWei and rusdWei now reports the derived native remainder. Note: per the same RPC change, the returned balance may exceed what the network will provision for a single transaction — the CLI does not gate sends on balance (viem's eth_estimateGas path validates instead). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/wallet.ts | 22 +++++++++++----- src/lib/balance.ts | 26 +++++++++++++++++++ tests/balance.test.ts | 57 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 src/lib/balance.ts create mode 100644 tests/balance.test.ts diff --git a/src/commands/wallet.ts b/src/commands/wallet.ts index 59d6002..06c9fb0 100644 --- a/src/commands/wallet.ts +++ b/src/commands/wallet.ts @@ -19,6 +19,7 @@ import { getOwnAddress, requireAccount } from '../lib/account.js'; import { makePublicClient, makeWalletClient } from '../lib/client.js'; import { coerceArg, parseCastSignature } from '../lib/signature.js'; import { formatUsd, formatUsdShort, jsonStringify } from '../lib/format.js'; +import { splitAggregateBalance } from '../lib/balance.js'; import { registerWalletX402 } from './walletX402.js'; import { getProvider } from '../lib/providers/index.js'; import type { GlobalOptions } from '../types.js'; @@ -286,8 +287,10 @@ export function registerWallet(program: Command): void { } const client = makePublicClient(cfg); - const rusdWei = await client.getBalance({ address }); - const rusd = formatEther(rusdWei); + // eth_getBalance is the aggregate: token_balance × rate + raw_native. + // SBC is already included, so derive the raw-native (RUSD) remainder + // instead of summing — see splitAggregateBalance. + const aggregateWei = await client.getBalance({ address }); let sbc = '0'; let sbcRawWei = 0n; @@ -304,17 +307,24 @@ export function registerWallet(program: Command): void { sbcError = e instanceof Error ? e.message : String(e); } - const total = Number(rusd) + Number(sbc); + const { nativeWei } = splitAggregateBalance({ + aggregateWei, + sbcRaw: sbcRawWei, + sbcDecimals: SBC_DECIMALS, + }); + const rusd = formatEther(nativeWei); + const total = formatEther(aggregateWei); if (opts.json) { console.log( jsonStringify({ address, - totalUsd: total, + totalUsd: Number(total), sbc, rusd, sbcWei: sbcRawWei.toString(), - rusdWei: rusdWei.toString(), + rusdWei: nativeWei.toString(), + totalWei: aggregateWei.toString(), sbcError, }), ); @@ -322,7 +332,7 @@ export function registerWallet(program: Command): void { } console.log(`Address: ${address}`); if (sbcError) { - console.log(`Balance: $${rusd} ($${formatUsd(rusd)} RUSD; SBC unavailable)`); + console.log(`Balance: $${formatUsdShort(total)} (SBC/RUSD breakdown unavailable)`); } else { console.log( `Balance: $${formatUsdShort(total)} ($${formatUsd(sbc)} SBC + $${formatUsd(rusd)} RUSD)`, diff --git a/src/lib/balance.ts b/src/lib/balance.ts new file mode 100644 index 0000000..2cdeccd --- /dev/null +++ b/src/lib/balance.ts @@ -0,0 +1,26 @@ +/** + * Split an aggregate `eth_getBalance` result into its SBC and raw-native parts. + * + * Radius RPC semantics change (2026-06): `eth_getBalance` now returns + * `token_balance × per_unit_exchange_rate + raw_native` — no floor rounding, + * no per-transaction cap. The SBC token balance is therefore already included + * in the native balance, so summing `eth_getBalance + balanceOf(SBC)` double + * counts. + * + * Assuming the stablecoin peg (1 SBC = 1 native unit = $1), the raw native + * (RUSD) portion is the aggregate minus the SBC balance scaled to wei, + * clamped at zero in case the exchange rate ever drifts below peg. + */ +export function splitAggregateBalance(args: { + /** result of eth_getBalance (wei, 18 decimals) */ + aggregateWei: bigint; + /** result of SBC balanceOf (token units) */ + sbcRaw: bigint; + /** SBC token decimals */ + sbcDecimals: number; +}): { totalWei: bigint; sbcAsWei: bigint; nativeWei: bigint } { + const scale = 10n ** BigInt(18 - args.sbcDecimals); + const sbcAsWei = args.sbcRaw * scale; + const nativeWei = args.aggregateWei > sbcAsWei ? args.aggregateWei - sbcAsWei : 0n; + return { totalWei: args.aggregateWei, sbcAsWei, nativeWei }; +} diff --git a/tests/balance.test.ts b/tests/balance.test.ts new file mode 100644 index 0000000..8bbcbaa --- /dev/null +++ b/tests/balance.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { parseEther, parseUnits } from 'viem'; +import { splitAggregateBalance } from '../src/lib/balance.js'; + +const SBC_DECIMALS = 6; + +describe('splitAggregateBalance', () => { + it('does not double-count SBC included in the aggregate', () => { + // 0.1 SBC, no raw native: eth_getBalance returns 0.1 (token × rate). + const { totalWei, sbcAsWei, nativeWei } = splitAggregateBalance({ + aggregateWei: parseEther('0.1'), + sbcRaw: parseUnits('0.1', SBC_DECIMALS), + sbcDecimals: SBC_DECIMALS, + }); + expect(totalWei).toBe(parseEther('0.1')); + expect(sbcAsWei).toBe(parseEther('0.1')); + expect(nativeWei).toBe(0n); + }); + + it('derives the raw-native remainder when both are held', () => { + const { nativeWei } = splitAggregateBalance({ + aggregateWei: parseEther('0.25'), + sbcRaw: parseUnits('0.1', SBC_DECIMALS), + sbcDecimals: SBC_DECIMALS, + }); + expect(nativeWei).toBe(parseEther('0.15')); + }); + + it('clamps the native remainder at zero if the rate drifts below peg', () => { + const { nativeWei } = splitAggregateBalance({ + aggregateWei: parseEther('0.09'), + sbcRaw: parseUnits('0.1', SBC_DECIMALS), + sbcDecimals: SBC_DECIMALS, + }); + expect(nativeWei).toBe(0n); + }); + + it('handles zero balances', () => { + const { totalWei, sbcAsWei, nativeWei } = splitAggregateBalance({ + aggregateWei: 0n, + sbcRaw: 0n, + sbcDecimals: SBC_DECIMALS, + }); + expect(totalWei).toBe(0n); + expect(sbcAsWei).toBe(0n); + expect(nativeWei).toBe(0n); + }); + + it('scales correctly for 18-decimal tokens', () => { + const { sbcAsWei } = splitAggregateBalance({ + aggregateWei: parseEther('1'), + sbcRaw: parseEther('1'), + sbcDecimals: 18, + }); + expect(sbcAsWei).toBe(parseEther('1')); + }); +}); From 2ae67dc929d5c27d1292642bbbd3abaa66e3378e Mon Sep 17 00:00:00 2001 From: Eriks Reks Date: Wed, 3 Jun 2026 15:09:25 -0400 Subject: [PATCH 7/8] Add x402 v2 payment envelope and EIP-2612 permit settlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two interop fixes verified end-to-end against a live Radius x402 server (rad-read.replit.app — paid 0.001 SBC on mainnet, content delivered): 1. v2 payment header envelope. The CLI emitted the v1 shape (flat scheme/network, numeric validity bounds) regardless of challenge version. Per coinbase/x402 specs/schemes/exact/scheme_exact_evm.md, v2 echoes the chosen accepts entry verbatim as `accepted`, carries the challenge's `resource`, and stringifies validAfter/validBefore. parseChallenge now retains the raw accepts entry and resource for the echo. v1 output is unchanged. 2. EIP-2612 permit settlement (Radius flavor). SBC does not implement EIP-3009 transferWithAuthorization, so signTransferAuthorization payloads can never settle against it. Radius servers advertise `extra.settlementMethod: "permit-transferFrom"` plus `extra.settlementSpender` in the challenge; the CLI now detects that, reads the owner's permit nonce on-chain, signs an EIP-2612 Permit for the settlement spender (deadline = now + maxTimeoutSeconds), and sends the documented flat envelope with a `kind: "permit-eip2612"` payload (v/r/s split signature). Servers without the extra keep the EIP-3009 path. Tests: v2 envelope round-trip + v1 stability, permit signature recovery to owner, permit header shape. 60/60 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/walletX402.ts | 99 ++++++++++++++++++++++++---------- src/lib/x402/eip2612.ts | 103 ++++++++++++++++++++++++++++++++++++ src/lib/x402/protocol.ts | 83 +++++++++++++++++++++++------ tests/x402-eip2612.test.ts | 80 ++++++++++++++++++++++++++++ tests/x402-protocol.test.ts | 73 +++++++++++++++++++++++++ 5 files changed, 395 insertions(+), 43 deletions(-) create mode 100644 src/lib/x402/eip2612.ts create mode 100644 tests/x402-eip2612.test.ts diff --git a/src/commands/walletX402.ts b/src/commands/walletX402.ts index 85ba4b4..07b0845 100644 --- a/src/commands/walletX402.ts +++ b/src/commands/walletX402.ts @@ -1,6 +1,6 @@ import type { Command } from 'commander'; import { confirm } from '@inquirer/prompts'; -import { formatUnits, parseUnits, type Address } from 'viem'; +import { formatUnits, isAddress, parseUnits, type Address } from 'viem'; import { resolveConfig } from '../lib/config.js'; import { requireAccount } from '../lib/account.js'; import { makePublicClient } from '../lib/client.js'; @@ -20,6 +20,7 @@ import { import { decodePaymentResponse, encodePaymentHeader, + encodePermitPaymentHeader, networkIdForChain, parseChallenge, pickAccept, @@ -32,6 +33,7 @@ import { readBalance, signTransferAuthorization, } from '../lib/x402/eip3009.js'; +import { readPermitNonce, signPermit } from '../lib/x402/eip2612.js'; import type { GlobalOptions } from '../types.js'; interface SubOptions { @@ -170,35 +172,78 @@ async function runX402( } } - const authorization = makeAuthorization({ - from: account.address, - to: accept.payTo, - value: accept.maxAmountRequired, - maxTimeoutSeconds: accept.maxTimeoutSeconds, - }); + // Radius servers settle SBC via EIP-2612 permit + transferFrom (SBC has no + // EIP-3009) and advertise it in the challenge's extra. Standard x402 servers + // get the EIP-3009 transferWithAuthorization path. + const settlementSpender = + accept.extra?.settlementMethod === 'permit-transferFrom' && + typeof accept.extra.settlementSpender === 'string' && + isAddress(accept.extra.settlementSpender) + ? (accept.extra.settlementSpender as Address) + : undefined; - let signature; - try { - signature = await signTransferAuthorization(account, { - asset: accept.asset, - chainId: cfg.chain.id, - name: asset.name, - version: asset.version, - authorization, + let paymentHeader: string; + if (settlementSpender) { + try { + const nonce = await readPermitNonce(client, accept.asset, account.address); + const deadline = BigInt( + Math.floor(Date.now() / 1000) + (accept.maxTimeoutSeconds ?? 300), + ); + const permit = await signPermit(account, { + asset: accept.asset, + chainId: cfg.chain.id, + name: asset.name, + version: asset.version, + spender: settlementSpender, + value: accept.maxAmountRequired, + nonce, + deadline, + }); + paymentHeader = encodePermitPaymentHeader({ + x402Version: challenge.x402Version, + scheme: accept.scheme, + network: accept.network, + payload: permit, + }); + } catch (e) { + process.stderr.write( + `x402: failed to sign EIP-2612 permit: ${(e as Error).message}\n`, + ); + process.exit(1); + } + } else { + const authorization = makeAuthorization({ + from: account.address, + to: accept.payTo, + value: accept.maxAmountRequired, + maxTimeoutSeconds: accept.maxTimeoutSeconds, }); - } catch (e) { - process.stderr.write( - `x402: failed to sign EIP-3009 authorization (asset may not support transferWithAuthorization): ${(e as Error).message}\n`, - ); - process.exit(1); - } - const paymentHeader = encodePaymentHeader({ - x402Version: challenge.x402Version, - scheme: accept.scheme, - network: accept.network, - payload: { signature, authorization }, - }); + let signature; + try { + signature = await signTransferAuthorization(account, { + asset: accept.asset, + chainId: cfg.chain.id, + name: asset.name, + version: asset.version, + authorization, + }); + } catch (e) { + process.stderr.write( + `x402: failed to sign EIP-3009 authorization (asset may not support transferWithAuthorization): ${(e as Error).message}\n`, + ); + process.exit(1); + } + + paymentHeader = encodePaymentHeader({ + x402Version: challenge.x402Version, + scheme: accept.scheme, + network: accept.network, + accepted: accept.raw, + resource: challenge.resource, + payload: { signature, authorization }, + }); + } const retryHeaders = new Headers(reqHeaders); retryHeaders.set('x-payment', paymentHeader); diff --git a/src/lib/x402/eip2612.ts b/src/lib/x402/eip2612.ts new file mode 100644 index 0000000..ce70fe8 --- /dev/null +++ b/src/lib/x402/eip2612.ts @@ -0,0 +1,103 @@ +import { parseSignature, type Address, type Hex, type LocalAccount, type PublicClient } from 'viem'; + +/** + * EIP-2612 permit support for x402 settlement on Radius. + * + * SBC (the Radius-native stablecoin) does not implement EIP-3009 + * transferWithAuthorization, so Radius x402 servers settle via EIP-2612: + * the client signs a `Permit(owner, spender, value, nonce, deadline)` for + * the server's settlement spender, and the facilitator calls `permit()` + * followed by `transferFrom()`. Servers advertise this with + * `extra.settlementMethod: "permit-transferFrom"` and + * `extra.settlementSpender` in the 402 challenge. + */ + +export const EIP2612_NONCES_ABI = [ + { + type: 'function', + name: 'nonces', + stateMutability: 'view', + inputs: [{ name: 'owner', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, +] as const; + +export const EIP2612_TYPES = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], +} as const; + +// type alias (not interface) so it satisfies the encoder's index signature +export type PermitPayload = { + kind: 'permit-eip2612'; + owner: Address; + spender: Address; + value: string; + nonce: string; + deadline: string; + v: number; + r: Hex; + s: Hex; +}; + +export async function readPermitNonce( + client: PublicClient, + asset: Address, + owner: Address, +): Promise { + return await client.readContract({ + address: asset, + abi: EIP2612_NONCES_ABI, + functionName: 'nonces', + args: [owner], + }); +} + +export async function signPermit( + account: LocalAccount, + args: { + asset: Address; + chainId: number; + name: string; + version: string; + spender: Address; + value: bigint; + nonce: bigint; + deadline: bigint; + }, +): Promise { + const signature = await account.signTypedData({ + domain: { + name: args.name, + version: args.version, + chainId: args.chainId, + verifyingContract: args.asset, + }, + types: EIP2612_TYPES, + primaryType: 'Permit', + message: { + owner: account.address, + spender: args.spender, + value: args.value, + nonce: args.nonce, + deadline: args.deadline, + }, + }); + const { v, r, s } = parseSignature(signature); + return { + kind: 'permit-eip2612', + owner: account.address, + spender: args.spender, + value: args.value.toString(), + nonce: args.nonce.toString(), + deadline: args.deadline.toString(), + v: Number(v), + r, + s, + }; +} diff --git a/src/lib/x402/protocol.ts b/src/lib/x402/protocol.ts index fd43753..0173715 100644 --- a/src/lib/x402/protocol.ts +++ b/src/lib/x402/protocol.ts @@ -14,11 +14,15 @@ export interface AcceptEntry { outputSchema?: unknown; maxTimeoutSeconds?: number; extra?: { name?: string; version?: string; [k: string]: unknown }; + /** the accepts entry exactly as the server sent it — echoed back as `accepted` in v2 payment headers */ + raw: Record; } export interface Challenge { x402Version: number; accepts: AcceptEntry[]; + /** v2 challenges carry a top-level resource object — echoed back in v2 payment headers */ + resource?: unknown; error?: string; } @@ -35,6 +39,9 @@ export interface PaymentPayload { x402Version: number; scheme: string; network: string; + /** v2 only: the chosen accepts entry verbatim and the challenge's resource object */ + accepted?: Record; + resource?: unknown; payload: { signature: Hex; authorization: Authorization }; } @@ -108,15 +115,38 @@ export function parseChallenge(raw: unknown): Challenge { extra: e.extra && typeof e.extra === 'object' && !Array.isArray(e.extra) ? (e.extra as AcceptEntry['extra']) : undefined, + raw: e, } satisfies AcceptEntry; }); return { x402Version: version, accepts: parsedAccepts, + resource: obj.resource, error: typeof obj.error === 'string' ? obj.error : undefined, }; } +/** + * Radius x402 flavor: SBC has no EIP-3009, so servers advertising + * `extra.settlementMethod: "permit-transferFrom"` settle via EIP-2612. + * The header keeps the flat v1-style envelope with a permit payload + * (`kind: "permit-eip2612"`) per the radius-dev integration guide. + */ +export function encodePermitPaymentHeader(args: { + x402Version: number; + scheme: string; + network: string; + payload: { kind: 'permit-eip2612'; [k: string]: unknown }; +}): string { + const json = JSON.stringify({ + x402Version: args.x402Version, + scheme: args.scheme, + network: args.network, + payload: args.payload, + }); + return Buffer.from(json, 'utf8').toString('base64'); +} + export function networkIdForChain(chainId: number): string { return `eip155:${chainId}`; } @@ -127,22 +157,43 @@ export function pickAccept(accepts: AcceptEntry[], chainId: number): AcceptEntry } export function encodePaymentHeader(payload: PaymentPayload): string { - const json = JSON.stringify({ - x402Version: payload.x402Version, - scheme: payload.scheme, - network: payload.network, - payload: { - signature: payload.payload.signature, - authorization: { - from: payload.payload.authorization.from, - to: payload.payload.authorization.to, - value: payload.payload.authorization.value.toString(), - validAfter: payload.payload.authorization.validAfter, - validBefore: payload.payload.authorization.validBefore, - nonce: payload.payload.authorization.nonce, - }, - }, - }); + const a = payload.payload.authorization; + // v2 (specs/schemes/exact/scheme_exact_evm.md): the header echoes the chosen + // accepts entry as `accepted` plus the challenge's `resource`, and the + // authorization validity bounds are strings. v1 keeps the flat envelope. + const json = payload.x402Version >= 2 + ? JSON.stringify({ + x402Version: payload.x402Version, + ...(payload.resource !== undefined ? { resource: payload.resource } : {}), + accepted: payload.accepted ?? { scheme: payload.scheme, network: payload.network }, + payload: { + signature: payload.payload.signature, + authorization: { + from: a.from, + to: a.to, + value: a.value.toString(), + validAfter: a.validAfter.toString(), + validBefore: a.validBefore.toString(), + nonce: a.nonce, + }, + }, + }) + : JSON.stringify({ + x402Version: payload.x402Version, + scheme: payload.scheme, + network: payload.network, + payload: { + signature: payload.payload.signature, + authorization: { + from: a.from, + to: a.to, + value: a.value.toString(), + validAfter: a.validAfter, + validBefore: a.validBefore, + nonce: a.nonce, + }, + }, + }); return Buffer.from(json, 'utf8').toString('base64'); } diff --git a/tests/x402-eip2612.test.ts b/tests/x402-eip2612.test.ts new file mode 100644 index 0000000..2eb6a63 --- /dev/null +++ b/tests/x402-eip2612.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { recoverTypedDataAddress, type Address, type Hex } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { EIP2612_TYPES, signPermit } from '../src/lib/x402/eip2612.js'; +import { encodePermitPaymentHeader } from '../src/lib/x402/protocol.js'; + +const PK = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as Hex; +const ASSET = '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb' as Address; +const SPENDER = '0x2c88F72487690c5F4C933c7b519027D787F00DA7' as Address; + +describe('signPermit', () => { + it('produces a v/r/s signature recoverable to the owner', async () => { + const account = privateKeyToAccount(PK); + const permit = await signPermit(account, { + asset: ASSET, + chainId: 723487, + name: 'Stable Coin', + version: '1', + spender: SPENDER, + value: 1000n, + nonce: 0n, + deadline: 9999999999n, + }); + + expect(permit.kind).toBe('permit-eip2612'); + expect(permit.owner).toBe(account.address); + expect(permit.spender).toBe(SPENDER); + expect(permit.value).toBe('1000'); + expect(permit.nonce).toBe('0'); + expect(permit.deadline).toBe('9999999999'); + expect([27, 28]).toContain(permit.v); + + const recovered = await recoverTypedDataAddress({ + domain: { name: 'Stable Coin', version: '1', chainId: 723487, verifyingContract: ASSET }, + types: EIP2612_TYPES, + primaryType: 'Permit', + message: { + owner: account.address, + spender: SPENDER, + value: 1000n, + nonce: 0n, + deadline: 9999999999n, + }, + signature: { r: permit.r, s: permit.s, v: BigInt(permit.v) }, + }); + expect(recovered.toLowerCase()).toBe(account.address.toLowerCase()); + }); +}); + +describe('encodePermitPaymentHeader', () => { + it('emits the flat radius-flavor envelope with a permit payload', async () => { + const account = privateKeyToAccount(PK); + const permit = await signPermit(account, { + asset: ASSET, + chainId: 723487, + name: 'Stable Coin', + version: '1', + spender: SPENDER, + value: 1000n, + nonce: 0n, + deadline: 9999999999n, + }); + const header = encodePermitPaymentHeader({ + x402Version: 2, + scheme: 'exact', + network: 'eip155:723487', + payload: permit, + }); + const decoded = JSON.parse(Buffer.from(header, 'base64').toString('utf8')); + expect(decoded.x402Version).toBe(2); + expect(decoded.scheme).toBe('exact'); + expect(decoded.network).toBe('eip155:723487'); + expect(decoded.payload.kind).toBe('permit-eip2612'); + expect(decoded.payload.owner).toBe(account.address); + expect(decoded.payload.spender).toBe(SPENDER); + expect(typeof decoded.payload.v).toBe('number'); + expect(decoded.payload.r).toMatch(/^0x[0-9a-f]{64}$/); + expect(decoded.payload.s).toMatch(/^0x[0-9a-f]{64}$/); + }); +}); diff --git a/tests/x402-protocol.test.ts b/tests/x402-protocol.test.ts index 503d60f..f8368bd 100644 --- a/tests/x402-protocol.test.ts +++ b/tests/x402-protocol.test.ts @@ -148,6 +148,79 @@ describe('encodePaymentHeader / decodePaymentResponse', () => { expect(decoded.payload.authorization.validBefore).toBe(1234567890); }); + it('emits the v2 envelope: accepted echo, resource, string validity bounds', () => { + const v2Challenge = { + x402Version: 2, + accepts: [ + { + scheme: 'exact', + network: 'eip155:723487', + amount: '1000', + asset: '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb', + payTo: '0x000000000000000000000000000000000000dEaD', + maxTimeoutSeconds: 300, + extra: { name: 'Stable Coin', version: '1' }, + }, + ], + resource: { url: 'https://example.com/r', method: 'GET' }, + error: 'Payment required', + }; + const c = parseChallenge(v2Challenge); + const header = encodePaymentHeader({ + x402Version: c.x402Version, + scheme: c.accepts[0].scheme, + network: c.accepts[0].network, + accepted: c.accepts[0].raw, + resource: c.resource, + payload: { + signature: '0xdeadbeef', + authorization: { + from: '0x0000000000000000000000000000000000000001', + to: '0x000000000000000000000000000000000000dEaD', + value: 1000n, + validAfter: 0, + validBefore: 1234567890, + nonce: '0xaa', + }, + }, + }); + const decoded = JSON.parse(Buffer.from(header, 'base64').toString('utf8')); + expect(decoded.x402Version).toBe(2); + // v2: accepted echoes the server's entry verbatim (amount, not maxAmountRequired) + expect(decoded.accepted).toEqual(v2Challenge.accepts[0]); + expect(decoded.resource).toEqual(v2Challenge.resource); + // v2: no flat scheme/network at the top level + expect(decoded.scheme).toBeUndefined(); + expect(decoded.network).toBeUndefined(); + // v2: validity bounds are strings + expect(decoded.payload.authorization.validAfter).toBe('0'); + expect(decoded.payload.authorization.validBefore).toBe('1234567890'); + expect(decoded.payload.authorization.value).toBe('1000'); + }); + + it('keeps the v1 envelope unchanged for x402Version 1', () => { + const header = encodePaymentHeader({ + x402Version: 1, + scheme: 'exact', + network: 'eip155:723487', + payload: { + signature: '0xdeadbeef', + authorization: { + from: '0x0000000000000000000000000000000000000001', + to: '0x000000000000000000000000000000000000dEaD', + value: 1000n, + validAfter: 0, + validBefore: 1234567890, + nonce: '0xaa', + }, + }, + }); + const decoded = JSON.parse(Buffer.from(header, 'base64').toString('utf8')); + expect(decoded.scheme).toBe('exact'); + expect(decoded.accepted).toBeUndefined(); + expect(decoded.payload.authorization.validBefore).toBe(1234567890); + }); + it('decodes a payment-response header', () => { const body = { success: true, From 44540c3ecdb36c41e82821d70bb2b5cade13e972 Mon Sep 17 00:00:00 2001 From: Eriks Reks Date: Wed, 3 Jun 2026 15:23:14 -0400 Subject: [PATCH 8/8] Align x402 client with the official Radius x402 skill (permit2 flow) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs.radiustech.xyz/skills/x402.md and the reference client in radiustechsystems/skills (plugins/radius/skills/x402): - Permit2 dual-signature settlement when the challenge advertises extra.assetTransferMethod: "permit2": an EIP-2612 Permit for the canonical Permit2 contract (sequential nonce from token.nonces, goes in extensions.eip2612GasSponsoring for gas-free allowance setup) plus a Permit2 PermitWitnessTransferFrom for the x402 proxy (random 128-bit nonce, witness binds payTo), sharing one deadline. Typed data taken verbatim from references/permit2-typed-data.template.json. - Challenge detection: official v2 servers carry the challenge in a base64 PAYMENT-REQUIRED response header; body JSON remains the fallback for older servers. - Payment is sent in both PAYMENT-SIGNATURE (official v2) and X-Payment (legacy) headers; receipts read from PAYMENT-RESPONSE before x-payment-response. - v2 envelope keeps flat scheme/network alongside the accepted echo, matching the skill's payload example. Settlement dispatch is now: permit2 (assetTransferMethod) → permit-eip2612 (settlementMethod, verified live against rad-read) → EIP-3009 (standard x402 default). Tests: permit2 signature recovery under the skill typed data, payload structure field-for-field vs the skill example, nonce randomness, canonical addresses. 64/64 passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/walletX402.ts | 80 ++++++++++++++++++-- src/lib/x402/eip2612.ts | 36 +++++---- src/lib/x402/permit2.ts | 128 +++++++++++++++++++++++++++++++ src/lib/x402/protocol.ts | 2 + tests/x402-permit2.test.ts | 145 ++++++++++++++++++++++++++++++++++++ tests/x402-protocol.test.ts | 6 +- 6 files changed, 375 insertions(+), 22 deletions(-) create mode 100644 src/lib/x402/permit2.ts create mode 100644 tests/x402-permit2.test.ts diff --git a/src/commands/walletX402.ts b/src/commands/walletX402.ts index 07b0845..f16abd6 100644 --- a/src/commands/walletX402.ts +++ b/src/commands/walletX402.ts @@ -33,7 +33,13 @@ import { readBalance, signTransferAuthorization, } from '../lib/x402/eip3009.js'; -import { readPermitNonce, signPermit } from '../lib/x402/eip2612.js'; +import { readPermitNonce, signEip2612Permit, signPermit } from '../lib/x402/eip2612.js'; +import { + buildPermit2PaymentPayload, + PERMIT2_ADDRESS, + randomPermit2Nonce, + signPermit2WitnessTransfer, +} from '../lib/x402/permit2.js'; import type { GlobalOptions } from '../types.js'; interface SubOptions { @@ -108,13 +114,20 @@ async function runX402( } const cfg = resolveConfig(opts); + // Official Radius x402 v2 servers carry the challenge in a base64 + // PAYMENT-REQUIRED response header; older servers put JSON in the body. let challenge; + const paymentRequired = initial.headers.get('payment-required'); try { - const text = decodeBodyAsUtf8(initial.body) ?? ''; - challenge = parseChallenge(JSON.parse(text)); + if (paymentRequired) { + challenge = parseChallenge(JSON.parse(Buffer.from(paymentRequired, 'base64').toString('utf8'))); + } else { + const text = decodeBodyAsUtf8(initial.body) ?? ''; + challenge = parseChallenge(JSON.parse(text)); + } } catch (e) { process.stderr.write( - `x402: server returned 402 but the body is not a valid challenge: ${(e as Error).message}\n`, + `x402: server returned 402 but no valid challenge in ${paymentRequired ? 'PAYMENT-REQUIRED header' : 'body'}: ${(e as Error).message}\n`, ); process.stderr.write(safeBodyPreview(initial.body)); process.exit(2); @@ -182,8 +195,60 @@ async function runX402( ? (accept.extra.settlementSpender as Address) : undefined; + // Official Radius v2 settlement: Permit2 dual-signature flow. + const usePermit2 = accept.extra?.assetTransferMethod === 'permit2'; + let paymentHeader: string; - if (settlementSpender) { + if (usePermit2) { + try { + const eip2612Nonce = await readPermitNonce(client, accept.asset, account.address); + const deadline = BigInt( + Math.floor(Date.now() / 1000) + (accept.maxTimeoutSeconds ?? 300), + ); + const eip2612Signature = await signEip2612Permit(account, { + asset: accept.asset, + chainId: cfg.chain.id, + name: asset.name, + version: asset.version, + spender: PERMIT2_ADDRESS, + value: accept.maxAmountRequired, + nonce: eip2612Nonce, + deadline, + }); + const permit2Nonce = randomPermit2Nonce(); + const permit2Signature = await signPermit2WitnessTransfer(account, { + token: accept.asset, + chainId: cfg.chain.id, + amount: accept.maxAmountRequired, + payTo: accept.payTo, + nonce: permit2Nonce, + deadline, + }); + const challengeResource = + challenge.resource && typeof challenge.resource === 'object' + ? (challenge.resource as { url?: string; description?: string; mimeType?: string }) + : {}; + paymentHeader = buildPermit2PaymentPayload({ + chainId: cfg.chain.id, + resource: { ...challengeResource, url: challengeResource.url ?? url }, + accepted: accept.raw, + token: accept.asset, + amount: accept.maxAmountRequired, + owner: account.address, + payTo: accept.payTo, + permit2Signature, + permit2Nonce, + eip2612Signature, + eip2612Nonce, + deadline, + }); + } catch (e) { + process.stderr.write( + `x402: failed to sign permit2 payment: ${(e as Error).message}\n`, + ); + process.exit(1); + } + } else if (settlementSpender) { try { const nonce = await readPermitNonce(client, accept.asset, account.address); const deadline = BigInt( @@ -246,7 +311,10 @@ async function runX402( } const retryHeaders = new Headers(reqHeaders); + // Send both header spellings: official Radius v2 servers read + // PAYMENT-SIGNATURE, older x402 servers read X-Payment. retryHeaders.set('x-payment', paymentHeader); + retryHeaders.set('payment-signature', paymentHeader); const retry = await runRequest(verb as HttpVerb, url, { headers: retryHeaders, @@ -265,7 +333,7 @@ async function runX402( } let paymentResponse: PaymentResponseBody | null = null; - const xpr = retry.headers.get('x-payment-response'); + const xpr = retry.headers.get('payment-response') ?? retry.headers.get('x-payment-response'); if (xpr) { try { paymentResponse = decodePaymentResponse(xpr); } catch { /* ignore malformed */ } } diff --git a/src/lib/x402/eip2612.ts b/src/lib/x402/eip2612.ts index ce70fe8..2b2d32f 100644 --- a/src/lib/x402/eip2612.ts +++ b/src/lib/x402/eip2612.ts @@ -58,20 +58,23 @@ export async function readPermitNonce( }); } -export async function signPermit( +export interface Eip2612SignArgs { + asset: Address; + chainId: number; + name: string; + version: string; + spender: Address; + value: bigint; + nonce: bigint; + deadline: bigint; +} + +/** Raw EIP-2612 Permit signature — used directly by the permit2 gas-sponsoring extension. */ +export async function signEip2612Permit( account: LocalAccount, - args: { - asset: Address; - chainId: number; - name: string; - version: string; - spender: Address; - value: bigint; - nonce: bigint; - deadline: bigint; - }, -): Promise { - const signature = await account.signTypedData({ + args: Eip2612SignArgs, +): Promise { + return await account.signTypedData({ domain: { name: args.name, version: args.version, @@ -88,6 +91,13 @@ export async function signPermit( deadline: args.deadline, }, }); +} + +export async function signPermit( + account: LocalAccount, + args: Eip2612SignArgs, +): Promise { + const signature = await signEip2612Permit(account, args); const { v, r, s } = parseSignature(signature); return { kind: 'permit-eip2612', diff --git a/src/lib/x402/permit2.ts b/src/lib/x402/permit2.ts new file mode 100644 index 0000000..af953c6 --- /dev/null +++ b/src/lib/x402/permit2.ts @@ -0,0 +1,128 @@ +import { randomBytes } from 'node:crypto'; +import type { Address, Hex, LocalAccount } from 'viem'; + +/** + * Official Radius x402 v2 settlement: Permit2 PermitWitnessTransferFrom. + * + * Servers advertise it with `extra.assetTransferMethod: "permit2"`. The client + * signs two EIP-712 messages sharing one deadline: + * + * 1. EIP-2612 Permit — spender is the canonical Permit2 contract, nonce is + * sequential from `token.nonces(owner)`. Goes in + * `extensions.eip2612GasSponsoring.info.signature` so the facilitator can + * establish the Permit2 allowance gas-free on the payer's behalf. + * 2. Permit2 PermitWitnessTransferFrom — spender is the x402 proxy, nonce is + * random, the witness binds the transfer to the merchant's payTo. Goes in + * `payload.signature`. + * + * Source of truth: radiustechsystems/skills plugins/radius/skills/x402 + * (scripts/x402-pay.mjs, references/permit2-typed-data.template.json). + */ + +export const PERMIT2_ADDRESS: Address = '0x000000000022D473030F116dDEE9F6B43aC78BA3'; +export const X402_PERMIT2_PROXY: Address = '0x402085c248EeA27D92E8b30b2C58ed07f9E20001'; + +export const PERMIT2_WITNESS_TYPES = { + PermitWitnessTransferFrom: [ + { name: 'permitted', type: 'TokenPermissions' }, + { name: 'spender', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + { name: 'witness', type: 'Witness' }, + ], + TokenPermissions: [ + { name: 'token', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + Witness: [ + { name: 'to', type: 'address' }, + { name: 'validAfter', type: 'uint256' }, + ], +} as const; + +export function randomPermit2Nonce(): bigint { + return BigInt('0x' + randomBytes(16).toString('hex')); +} + +export async function signPermit2WitnessTransfer( + account: LocalAccount, + args: { + token: Address; + chainId: number; + amount: bigint; + payTo: Address; + nonce: bigint; + deadline: bigint; + }, +): Promise { + return await account.signTypedData({ + domain: { + name: 'Permit2', + chainId: args.chainId, + verifyingContract: PERMIT2_ADDRESS, + }, + types: PERMIT2_WITNESS_TYPES, + primaryType: 'PermitWitnessTransferFrom', + message: { + permitted: { token: args.token, amount: args.amount }, + spender: X402_PERMIT2_PROXY, + nonce: args.nonce, + deadline: args.deadline, + witness: { to: args.payTo, validAfter: 0n }, + }, + }); +} + +/** Assemble the full base64 PAYMENT-SIGNATURE payload per the radius x402 skill. */ +export function buildPermit2PaymentPayload(args: { + chainId: number; + resource: { url: string; description?: string; mimeType?: string }; + accepted: Record; + token: Address; + amount: bigint; + owner: Address; + payTo: Address; + permit2Signature: Hex; + permit2Nonce: bigint; + eip2612Signature: Hex; + eip2612Nonce: bigint; + deadline: bigint; +}): string { + const payload = { + x402Version: 2, + scheme: 'exact', + network: `eip155:${args.chainId}`, + resource: { + url: args.resource.url, + description: args.resource.description ?? '', + mimeType: args.resource.mimeType ?? 'application/json', + }, + accepted: args.accepted, + payload: { + signature: args.permit2Signature, + permit2Authorization: { + permitted: { token: args.token, amount: args.amount.toString() }, + from: args.owner, + spender: X402_PERMIT2_PROXY, + nonce: args.permit2Nonce.toString(), + deadline: args.deadline.toString(), + witness: { to: args.payTo, validAfter: '0' }, + }, + }, + extensions: { + eip2612GasSponsoring: { + info: { + from: args.owner, + asset: args.token, + spender: PERMIT2_ADDRESS, + amount: args.amount.toString(), + nonce: args.eip2612Nonce.toString(), + deadline: args.deadline.toString(), + signature: args.eip2612Signature, + version: '1', + }, + }, + }, + }; + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); +} diff --git a/src/lib/x402/protocol.ts b/src/lib/x402/protocol.ts index 0173715..6d160d5 100644 --- a/src/lib/x402/protocol.ts +++ b/src/lib/x402/protocol.ts @@ -164,6 +164,8 @@ export function encodePaymentHeader(payload: PaymentPayload): string { const json = payload.x402Version >= 2 ? JSON.stringify({ x402Version: payload.x402Version, + scheme: payload.scheme, + network: payload.network, ...(payload.resource !== undefined ? { resource: payload.resource } : {}), accepted: payload.accepted ?? { scheme: payload.scheme, network: payload.network }, payload: { diff --git a/tests/x402-permit2.test.ts b/tests/x402-permit2.test.ts new file mode 100644 index 0000000..81c8d11 --- /dev/null +++ b/tests/x402-permit2.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { recoverTypedDataAddress, type Address, type Hex } from 'viem'; +import { privateKeyToAccount } from 'viem/accounts'; +import { + buildPermit2PaymentPayload, + PERMIT2_ADDRESS, + PERMIT2_WITNESS_TYPES, + randomPermit2Nonce, + signPermit2WitnessTransfer, + X402_PERMIT2_PROXY, +} from '../src/lib/x402/permit2.js'; +import { signEip2612Permit } from '../src/lib/x402/eip2612.js'; + +const PK = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' as Hex; +const SBC = '0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb' as Address; +const PAY_TO = '0x000000000000000000000000000000000000dEaD' as Address; + +describe('permit2 constants', () => { + it('uses the canonical Permit2 and x402 proxy addresses from the radius skill', () => { + expect(PERMIT2_ADDRESS).toBe('0x000000000022D473030F116dDEE9F6B43aC78BA3'); + expect(X402_PERMIT2_PROXY).toBe('0x402085c248EeA27D92E8b30b2C58ed07f9E20001'); + }); +}); + +describe('randomPermit2Nonce', () => { + it('produces distinct 128-bit nonces', () => { + const a = randomPermit2Nonce(); + const b = randomPermit2Nonce(); + expect(a).not.toBe(b); + expect(a).toBeLessThan(2n ** 128n); + expect(a).toBeGreaterThanOrEqual(0n); + }); +}); + +describe('signPermit2WitnessTransfer', () => { + it('signature recovers to the payer under the skill typed data (no domain version)', async () => { + const account = privateKeyToAccount(PK); + const nonce = 12345n; + const deadline = 9999999999n; + const signature = await signPermit2WitnessTransfer(account, { + token: SBC, + chainId: 723487, + amount: 1000n, + payTo: PAY_TO, + nonce, + deadline, + }); + const recovered = await recoverTypedDataAddress({ + domain: { name: 'Permit2', chainId: 723487, verifyingContract: PERMIT2_ADDRESS }, + types: PERMIT2_WITNESS_TYPES, + primaryType: 'PermitWitnessTransferFrom', + message: { + permitted: { token: SBC, amount: 1000n }, + spender: X402_PERMIT2_PROXY, + nonce, + deadline, + witness: { to: PAY_TO, validAfter: 0n }, + }, + signature, + }); + expect(recovered.toLowerCase()).toBe(account.address.toLowerCase()); + }); +}); + +describe('buildPermit2PaymentPayload', () => { + it('matches the radius x402 skill payload structure field-for-field', async () => { + const account = privateKeyToAccount(PK); + const deadline = 9999999999n; + const permit2Nonce = 777n; + const eip2612Nonce = 0n; + const eip2612Signature = await signEip2612Permit(account, { + asset: SBC, + chainId: 723487, + name: 'Stable Coin', + version: '1', + spender: PERMIT2_ADDRESS, + value: 1000n, + nonce: eip2612Nonce, + deadline, + }); + const permit2Signature = await signPermit2WitnessTransfer(account, { + token: SBC, + chainId: 723487, + amount: 1000n, + payTo: PAY_TO, + nonce: permit2Nonce, + deadline, + }); + const acceptedRaw = { + scheme: 'exact', + network: 'eip155:723487', + amount: '1000', + asset: SBC, + payTo: PAY_TO, + maxTimeoutSeconds: 300, + extra: { name: 'Stable Coin', version: '1', assetTransferMethod: 'permit2' }, + }; + const header = buildPermit2PaymentPayload({ + chainId: 723487, + resource: { url: 'https://example.com/api/data' }, + accepted: acceptedRaw, + token: SBC, + amount: 1000n, + owner: account.address, + payTo: PAY_TO, + permit2Signature, + permit2Nonce, + eip2612Signature, + eip2612Nonce, + deadline, + }); + const decoded = JSON.parse(Buffer.from(header, 'base64').toString('utf8')); + + expect(decoded.x402Version).toBe(2); + expect(decoded.scheme).toBe('exact'); + expect(decoded.network).toBe('eip155:723487'); + expect(decoded.resource).toEqual({ + url: 'https://example.com/api/data', + description: '', + mimeType: 'application/json', + }); + expect(decoded.accepted).toEqual(acceptedRaw); + + expect(decoded.payload.signature).toBe(permit2Signature); + expect(decoded.payload.permit2Authorization).toEqual({ + permitted: { token: SBC, amount: '1000' }, + from: account.address, + spender: X402_PERMIT2_PROXY, + nonce: '777', + deadline: '9999999999', + witness: { to: PAY_TO, validAfter: '0' }, + }); + + expect(decoded.extensions.eip2612GasSponsoring.info).toEqual({ + from: account.address, + asset: SBC, + spender: PERMIT2_ADDRESS, + amount: '1000', + nonce: '0', + deadline: '9999999999', + signature: eip2612Signature, + version: '1', + }); + }); +}); diff --git a/tests/x402-protocol.test.ts b/tests/x402-protocol.test.ts index f8368bd..70f18cf 100644 --- a/tests/x402-protocol.test.ts +++ b/tests/x402-protocol.test.ts @@ -189,9 +189,9 @@ describe('encodePaymentHeader / decodePaymentResponse', () => { // v2: accepted echoes the server's entry verbatim (amount, not maxAmountRequired) expect(decoded.accepted).toEqual(v2Challenge.accepts[0]); expect(decoded.resource).toEqual(v2Challenge.resource); - // v2: no flat scheme/network at the top level - expect(decoded.scheme).toBeUndefined(); - expect(decoded.network).toBeUndefined(); + // v2: flat scheme/network kept alongside the accepted echo (radius x402 skill) + expect(decoded.scheme).toBe('exact'); + expect(decoded.network).toBe('eip155:723487'); // v2: validity bounds are strings expect(decoded.payload.authorization.validAfter).toBe('0'); expect(decoded.payload.authorization.validBefore).toBe('1234567890');