diff --git a/src/commands/wallet.ts b/src/commands/wallet.ts index d3f044d..06c9fb0 100644 --- a/src/commands/wallet.ts +++ b/src/commands/wallet.ts @@ -13,13 +13,15 @@ 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'; 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'; const SBC_DECIMALS = 6; @@ -123,6 +125,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 +183,12 @@ export function registerWallet(program: Command): void { .action(async (_subOpts, cmd) => { const opts = cmd.optsWithGlobals() as GlobalOptions; const cfg = resolveConfig(opts); + const provider = getProvider(cfg.walletProvider); + if (!provider.exportPrivateKey) { + throw new Error( + `wallet export is not supported for the ${cfg.walletProvider} provider (remote key material is not exportable).`, + ); + } if (!opts.privateKey && !keystoreExists(cfg.keystorePath)) { throw new Error( `No keystore at ${cfg.keystorePath}. Run \`radius-cli wallet new\` or pass --private-key.`, @@ -158,9 +204,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) { @@ -243,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; @@ -261,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, }), ); @@ -279,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/commands/walletX402.ts b/src/commands/walletX402.ts index 85ba4b4..f16abd6 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,13 @@ import { readBalance, signTransferAuthorization, } from '../lib/x402/eip3009.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 { @@ -106,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); @@ -170,38 +185,136 @@ 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, + // Official Radius v2 settlement: Permit2 dual-signature flow. + const usePermit2 = accept.extra?.assetTransferMethod === 'permit2'; + + let paymentHeader: string; + 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( + 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); + // 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, @@ -220,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/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/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/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/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..7a05de8 --- /dev/null +++ b/src/lib/providers/index.ts @@ -0,0 +1,34 @@ +import { keystoreProvider } from './keystore.js'; +import type { WalletProviderName } from '../../types.js'; +import type { WalletProvider } from './types.js'; + +const providers: Record = { + keystore: keystoreProvider, + cdp: stubProvider('cdp'), + para: stubProvider('para'), + privy: stubProvider('privy'), +}; + +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.`); + }, + 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): WalletProvider { + return providers[name]; +} diff --git a/src/lib/providers/keystore.ts b/src/lib/providers/keystore.ts new file mode 100644 index 0000000..6236781 --- /dev/null +++ b/src/lib/providers/keystore.ts @@ -0,0 +1,84 @@ +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 } 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; + }, + + 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.'); + } + }, + + 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/eip2612.ts b/src/lib/x402/eip2612.ts new file mode 100644 index 0000000..2b2d32f --- /dev/null +++ b/src/lib/x402/eip2612.ts @@ -0,0 +1,113 @@ +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 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: Eip2612SignArgs, +): Promise { + return 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, + }, + }); +} + +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', + 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/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/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 b3f6dbb..6d160d5 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 }; } @@ -89,7 +96,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, `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, @@ -101,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}`; } @@ -120,22 +157,45 @@ 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, + scheme: payload.scheme, + network: payload.network, + ...(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/src/types.ts b/src/types.ts index 0e31da7..7498dfe 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,8 +23,11 @@ export interface GlobalOptions { rusd?: string; json?: boolean; wait?: boolean; + wallet?: string; } +export type { WalletProvider } from './lib/providers/types.js'; + export interface Clients { publicClient: PublicClient; walletClient?: WalletClient; 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')); + }); +}); 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-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-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 8b1ba13..70f18cf 100644 --- a/tests/x402-protocol.test.ts +++ b/tests/x402-protocol.test.ts @@ -36,6 +36,34 @@ 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('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; + expect(() => parseChallenge(neither)).toThrow(/maxAmountRequired/); + }); + it('rejects non-objects', () => { expect(() => parseChallenge(null)).toThrow(); expect(() => parseChallenge([])).toThrow(); @@ -120,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: 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'); + 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,