Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,742 changes: 4,138 additions & 604 deletions package-lock.json

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,26 @@
"ethers": "^6.13.0",
"viem": "^2.21.0"
},
"peerDependencies": {
"@coinbase/cdp-sdk": "^1.51.0",
"@getpara/server-sdk": "3.0.0",
"@getpara/viem-v2-integration": "3.0.0"
},
"peerDependenciesMeta": {
"@coinbase/cdp-sdk": {
"optional": true
},
"@getpara/server-sdk": {
"optional": true
},
"@getpara/viem-v2-integration": {
"optional": true
}
},
"devDependencies": {
"@coinbase/cdp-sdk": "^1.51.0",
"@getpara/server-sdk": "3.0.0",
"@getpara/viem-v2-integration": "3.0.0",
"@types/node": "^22.0.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
Expand Down
75 changes: 64 additions & 11 deletions src/commands/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')
Expand All @@ -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.`,
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -261,25 +307,32 @@ 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,
}),
);
return;
}
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)`,
Expand Down
175 changes: 144 additions & 31 deletions src/commands/walletX402.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -20,6 +20,7 @@ import {
import {
decodePaymentResponse,
encodePaymentHeader,
encodePermitPaymentHeader,
networkIdForChain,
parseChallenge,
pickAccept,
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since this PR now sends payment-signature, we should also filter user-supplied payment-signature headers on the initial request. parseHeaderArgs currently blocks x-payment but not this spelling, so a stale or mismatched payment could be sent before the challenge/retry flow.


const retry = await runRequest(verb as HttpVerb, url, {
headers: retryHeaders,
Expand All @@ -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');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

While adding official payment-response, can we normalize common tx hash fields too? decodePaymentResponse currently only reads transaction, but facilitators may return txHash, transactionHash, or hash, so the CLI can report a paid request without the settlement tx even though the server provided it.

if (xpr) {
try { paymentResponse = decodePaymentResponse(xpr); } catch { /* ignore malformed */ }
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ program
.option('--private-key <hex>', 'sign with this key instead of the local keystore')
.option('--sbc <address>', 'override the SBC token contract address')
.option('--rusd <address>', 'override the RUSD ERC-20 contract address')
.option('--wallet <provider>', 'wallet provider: keystore, cdp, para, or privy (default: keystore)')
.option('--json', 'machine-readable JSON output');

registerWallet(program);
Expand Down
Loading