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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ Verbs: `get`, `post`, `put`, `patch`, `delete`, `head`, `options`.

`-d, --data` accepts a literal string, `-d @path` to read from a file, or `-d -` to read from stdin. JSON-shaped bodies default to `Content-Type: application/json` unless one is set with `-H`.

`--x402-threshold <decimal>` is in the asset's display units (e.g. `0.05` means 0.05 SBC, which is $0.05 since SBC is USD-pegged). When the offered fee is at or below the threshold, the request pays without prompting — designed for AI agents and other non-interactive use. With no threshold and no TTY, the command refuses (exit 2) rather than hang. The `exact` x402 scheme on EVM is supported (uses EIP-3009 `transferWithAuthorization`); the asset must implement it on the configured network.
`--x402-threshold <decimal>` is in the asset's display units (e.g. `0.05` means 0.05 SBC, which is $0.05 since SBC is USD-pegged). When the offered fee is at or below the threshold, the request pays without prompting — designed for AI agents and other non-interactive use. For the `upto` scheme the threshold is compared against the authorized maximum. With no threshold and no TTY, the command refuses (exit 2) rather than hang.

Both x402 v1 and v2 are supported, selected automatically from the server's advertised `x402Version`:

- **`exact`** (v1, EIP-3009 `transferWithAuthorization`) — a fixed price; the asset must implement EIP-3009 on the configured network.
- **`upto`** (v2, Uniswap Permit2 `permitWitnessTransferFrom` via the `x402UptoPermit2Proxy`) — the client signs a Permit2 authorization up to a maximum and the facilitator settles the actual usage (which may be less, or zero). `upto` needs a one-time ERC-20 `approve` for the canonical Permit2 contract; pass `--x402-approve-permit2` (or `-y`) to send it automatically, otherwise the CLI prompts.

Body goes to stdout; payment confirmation and (optionally, with `--include`) headers go to stderr — pipeable.

Expand Down
226 changes: 176 additions & 50 deletions src/commands/walletX402.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { Command } from 'commander';
import { confirm } from '@inquirer/prompts';
import { formatUnits, parseUnits, type Address } from 'viem';
import { encodeFunctionData, formatUnits, maxUint256, parseUnits, type Address } from 'viem';
import { resolveConfig } from '../lib/config.js';
import { requireAccount } from '../lib/account.js';
import { makePublicClient } from '../lib/client.js';
import { makePublicClient, makeWalletClient } from '../lib/client.js';
import { jsonStringify } from '../lib/format.js';
import {
decodeBodyAsUtf8,
Expand All @@ -19,19 +19,22 @@ import {
} from '../lib/x402/http.js';
import {
decodePaymentResponse,
encodePaymentHeader,
networkIdForChain,
parseChallenge,
pickAccept,
V1_PAYMENT_RESPONSE_HEADER,
V2_PAYMENT_REQUIRED_HEADER,
V2_PAYMENT_RESPONSE_HEADER,
type AcceptEntry,
type PaymentResponseBody,
} from '../lib/x402/protocol.js';
import { readAssetInfo, readBalance } from '../lib/x402/eip3009.js';
import {
makeAuthorization,
readAssetInfo,
readBalance,
signTransferAuthorization,
} from '../lib/x402/eip3009.js';
selectHandler,
readPermit2Allowance,
CANONICAL_PERMIT2_ADDRESS,
PERMIT2_ALLOWANCE_ABI,
type SchemeHandler,
} from '../lib/x402/handlers.js';
import type { GlobalOptions } from '../types.js';

interface SubOptions {
Expand All @@ -40,10 +43,12 @@ interface SubOptions {
x402Threshold?: string;
yes?: boolean;
include?: boolean;
x402ApprovePermit2?: boolean;
}

interface PaymentSummary {
paid: boolean;
scheme: string;
asset: Address;
assetSymbol: string | null;
amount: string;
Expand All @@ -59,6 +64,7 @@ export function registerWalletX402(wallet: Command): void {
.description(
[
'Make an HTTP request and pay an x402 challenge if the server responds with 402.',
'Supports x402 v1 (exact / EIP-3009) and v2 (upto / Permit2).',
'',
' radius-cli wallet x402 get https://example.com/resource',
' radius-cli wallet x402 post https://api.example.com/x -d \'{"a":1}\'',
Expand All @@ -74,6 +80,10 @@ export function registerWalletX402(wallet: Command): void {
"auto-pay if the offered fee ≤ this amount in the asset's display units",
)
.option('-y, --yes', 'auto-confirm payment regardless of amount')
.option(
'--x402-approve-permit2',
'auto-approve the one-time Permit2 ERC-20 allowance needed by the upto scheme',
)
.option('--include', 'write response status and headers to stderr')
.action(async (verbArg: string, url: string, subOpts: SubOptions, cmd) => {
const opts = cmd.optsWithGlobals() as GlobalOptions;
Expand Down Expand Up @@ -108,8 +118,7 @@ async function runX402(
const cfg = resolveConfig(opts);
let challenge;
try {
const text = decodeBodyAsUtf8(initial.body) ?? '';
challenge = parseChallenge(JSON.parse(text));
challenge = parseChallenge(readChallenge(initial));
} catch (e) {
process.stderr.write(
`x402: server returned 402 but the body is not a valid challenge: ${(e as Error).message}\n`,
Expand All @@ -118,13 +127,23 @@ async function runX402(
process.exit(2);
}

const accept = pickAccept(challenge.accepts, cfg.chain.id);
if (!accept) {
let accept: AcceptEntry | undefined;
let handler: SchemeHandler | undefined;
for (const candidate of challenge.accepts) {
const h = selectHandler(candidate, cfg.chain.id);
if (h) {
accept = candidate;
handler = h;
break;
}
}
if (!accept || !handler) {
const offered = challenge.accepts
.map((a) => `${a.scheme}@${a.network} (${a.asset})`)
.join(', ');
process.stderr.write(
`x402: no compatible payment option. Wanted scheme=exact network=${networkIdForChain(cfg.chain.id)}; server offered: ${offered}\n`,
`x402: no compatible payment option for network=${networkIdForChain(cfg.chain.id)}. ` +
`Supported: exact@v1, upto@v2. Server offered: ${offered}\n`,
);
process.exit(1);
}
Expand All @@ -146,22 +165,25 @@ async function runX402(
const amountStr = formatUnits(accept.maxAmountRequired, asset.decimals);
const balanceStr = formatUnits(balance, asset.decimals);
const symbol = asset.symbol ?? accept.asset;
const isUpto = handler.scheme === 'upto';

if (balance < accept.maxAmountRequired) {
process.stderr.write(
`x402: insufficient balance. Need ${amountStr} ${symbol}, have ${balanceStr} ${symbol}.\n`,
`x402: insufficient balance. Need ${isUpto ? 'up to ' : ''}${amountStr} ${symbol}, ` +
`have ${balanceStr} ${symbol}.\n`,
);
process.exit(1);
}

const decided = await decideAutoPay(subOpts, accept, asset.decimals);
if (decided === 'refuse-no-tty') {
writeChallengeSummary(accept, asset.decimals, asset.symbol, balanceStr);
writeChallengeSummary(accept, asset.decimals, asset.symbol, balanceStr, isUpto);
process.exit(2);
}
if (decided === 'prompt') {
const verbText = isUpto ? `Authorize up to ${amountStr}` : `Pay ${amountStr}`;
const proceed = await confirm({
message: `Pay ${amountStr} ${symbol} to ${accept.payTo}? (balance: ${balanceStr} ${symbol})`,
message: `${verbText} ${symbol} to ${accept.payTo}? (balance: ${balanceStr} ${symbol})`,
default: false,
});
if (!proceed) {
Expand All @@ -170,38 +192,38 @@ async function runX402(
}
}

const authorization = makeAuthorization({
from: account.address,
to: accept.payTo,
value: accept.maxAmountRequired,
maxTimeoutSeconds: accept.maxTimeoutSeconds,
});
// upto rides on Permit2: the payer must have a one-time ERC-20 approval for Permit2.
if (isUpto) {
const ok = await ensurePermit2Allowance(
client,
cfg,
account,
accept,
asset.decimals,
symbol,
subOpts,
);
if (!ok) process.exit(1);
}

let signature;
let built;
try {
signature = await signTransferAuthorization(account, {
asset: accept.asset,
built = await handler.buildPayload(accept, {
account,
chainId: cfg.chain.id,
name: asset.name,
version: asset.version,
authorization,
x402Version: challenge.x402Version,
assetName: asset.name,
assetVersion: asset.version,
amount: accept.maxAmountRequired,
url,
});
} catch (e) {
process.stderr.write(
`x402: failed to sign EIP-3009 authorization (asset may not support transferWithAuthorization): ${(e as Error).message}\n`,
);
process.stderr.write(`x402: failed to build ${handler.scheme} payment: ${(e as Error).message}\n`);
process.exit(1);
}

const paymentHeader = encodePaymentHeader({
x402Version: challenge.x402Version,
scheme: accept.scheme,
network: accept.network,
payload: { signature, authorization },
});

const retryHeaders = new Headers(reqHeaders);
retryHeaders.set('x-payment', paymentHeader);
retryHeaders.set(built.headerName, built.headerValueBase64);

const retry = await runRequest(verb as HttpVerb, url, {
headers: retryHeaders,
Expand All @@ -213,26 +235,41 @@ async function runX402(
const loc = retry.headers.get('location');
if (!loc || !sameOrigin(url, new URL(loc, url).toString())) {
process.stderr.write(
'x402: server redirected the paid request cross-origin; refusing to replay X-PAYMENT.\n',
'x402: server redirected the paid request cross-origin; refusing to replay the payment header.\n',
);
process.exit(1);
}
}

if (retry.status === 412) {
process.stderr.write(
'x402: facilitator rejected the payment — Permit2 allowance required (412). ' +
'Re-run with --x402-approve-permit2 to grant it.\n',
);
process.stderr.write(safeBodyPreview(retry.body));
process.exit(1);
}

let paymentResponse: PaymentResponseBody | null = null;
const xpr = retry.headers.get('x-payment-response');
const xpr =
retry.headers.get(V2_PAYMENT_RESPONSE_HEADER) ?? retry.headers.get(V1_PAYMENT_RESPONSE_HEADER);
if (xpr) {
try { paymentResponse = decodePaymentResponse(xpr); } catch { /* ignore malformed */ }
}

// For upto the facilitator reports the actual charged amount; fall back to the max.
const settledWei = paymentResponse?.amount ?? accept.maxAmountRequired.toString();
const settledStr = formatUnits(BigInt(settledWei), asset.decimals);

const summary: PaymentSummary = {
paid: retry.status >= 200 && retry.status < 300,
scheme: handler.scheme,
asset: accept.asset,
assetSymbol: asset.symbol,
amount: amountStr,
amountWei: accept.maxAmountRequired.toString(),
amount: settledStr,
amountWei: settledWei,
payTo: accept.payTo,
txHash: paymentResponse?.transaction,
txHash: paymentResponse?.transaction || undefined,
payer: paymentResponse?.payer ?? account.address,
};

Expand All @@ -252,6 +289,90 @@ async function runX402(
process.exit(retry.status >= 400 ? 1 : 0);
}

/** Read the v2 PAYMENT-REQUIRED header (base64 JSON) if present, else the JSON body. */
function readChallenge(res: HttpResponse): unknown {
const v2 = res.headers.get(V2_PAYMENT_REQUIRED_HEADER);
if (v2) {
return JSON.parse(Buffer.from(v2, 'base64').toString('utf8'));
}
const text = decodeBodyAsUtf8(res.body) ?? '';
return JSON.parse(text);
}

/**
* Ensure the payer has approved the canonical Permit2 contract for at least the
* authorized max. Sends an approve tx if missing (prompting unless -y / --x402-approve-permit2).
* Returns false when the user declines or there's no way to proceed.
*/
async function ensurePermit2Allowance(
client: ReturnType<typeof makePublicClient>,
cfg: ReturnType<typeof resolveConfig>,
account: Awaited<ReturnType<typeof requireAccount>>,
accept: AcceptEntry,
decimals: number,
symbol: string,
subOpts: SubOptions,
): Promise<boolean> {
let allowance: bigint;
try {
allowance = await readPermit2Allowance(
client,
accept.asset,
account.address,
CANONICAL_PERMIT2_ADDRESS,
);
} catch (e) {
process.stderr.write(`x402: failed to read Permit2 allowance: ${(e as Error).message}\n`);
return false;
}
if (allowance >= accept.maxAmountRequired) return true;

const needStr = formatUnits(accept.maxAmountRequired, decimals);
const auto = subOpts.yes || subOpts.x402ApprovePermit2;
if (!auto) {
if (!process.stdin.isTTY) {
process.stderr.write(
`x402: upto requires a one-time Permit2 approval for ${symbol} (have ` +
`${formatUnits(allowance, decimals)}, need ${needStr}). ` +
'Re-run with --x402-approve-permit2 (or -y) to grant it.\n',
);
return false;
}
const proceed = await confirm({
message: `Approve Permit2 (${CANONICAL_PERMIT2_ADDRESS}) to spend ${symbol}? (one-time, required by upto)`,
default: false,
});
if (!proceed) {
process.stderr.write('x402: Permit2 approval declined.\n');
return false;
}
}

try {
const walletClient = makeWalletClient(cfg, account);
const data = encodeFunctionData({
abi: PERMIT2_ALLOWANCE_ABI,
functionName: 'approve',
args: [CANONICAL_PERMIT2_ADDRESS, maxUint256],
});
const gasPrice = await client.getGasPrice();
const hash = await walletClient.sendTransaction({
account,
to: accept.asset,
data,
gasPrice,
type: 'legacy',
chain: cfg.chain,
});
process.stderr.write(`x402: sent Permit2 approval (tx ${hash}); waiting for confirmation…\n`);
await client.waitForTransactionReceipt({ hash });
return true;
} catch (e) {
process.stderr.write(`x402: Permit2 approval failed: ${(e as Error).message}\n`);
return false;
}
}

type Decision = 'auto-pay' | 'prompt' | 'refuse-no-tty';

async function decideAutoPay(
Expand All @@ -267,6 +388,7 @@ async function decideAutoPay(
} catch {
throw new Error(`--x402-threshold must be a decimal number, got: ${subOpts.x402Threshold}`);
}
// For upto, maxAmountRequired is the authorized ceiling; compare against it.
if (limit >= accept.maxAmountRequired) return 'auto-pay';
}
if (process.stdin.isTTY) return 'prompt';
Expand All @@ -278,12 +400,16 @@ function writeChallengeSummary(
decimals: number,
symbol: string | null,
balanceStr: string,
isUpto: boolean,
): void {
const amount = formatUnits(accept.maxAmountRequired, decimals);
const tag = symbol ?? accept.asset;
const lead = isUpto
? `payment required (authorize up to ${amount} ${tag}, charged on use, to ${accept.payTo}).`
: `payment required (${amount} ${tag} to ${accept.payTo}).`;
process.stderr.write(
[
`x402: payment required (${amount} ${tag} to ${accept.payTo}).`,
`x402: ${lead}`,
` balance: ${balanceStr} ${tag}`,
` pass --x402-threshold ${amount} (or higher) to auto-pay, or --yes to confirm.`,
'',
Expand All @@ -301,10 +427,10 @@ function emit(res: HttpResponse, payment: PaymentSummary | null, json: boolean,
res.headers.forEach((v, k) => { process.stderr.write(`${k}: ${v}\n`); });
process.stderr.write('\n');
}
if (payment?.paid && payment.txHash) {
process.stderr.write(`x402: paid ${payment.amount} ${payment.assetSymbol ?? payment.asset} (tx ${payment.txHash})\n`);
} else if (payment?.paid) {
process.stderr.write(`x402: paid ${payment.amount} ${payment.assetSymbol ?? payment.asset}\n`);
if (payment?.paid) {
const tag = payment.assetSymbol ?? payment.asset;
const tx = payment.txHash ? ` (tx ${payment.txHash})` : '';
process.stderr.write(`x402: paid ${payment.amount} ${tag}${tx}\n`);
}
process.stdout.write(res.body);
}
Expand Down
Loading