Skip to content
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);

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');
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