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
5 changes: 5 additions & 0 deletions .changeset/cli-wallet-faucet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"radius-cli": minor
---

Add `wallet faucet` (alias `wallet faucet fund`/`drip`) and `wallet faucet status`: request test funds from the Radius faucet for the configured network and inspect its rate-limit state, through `radius-sdk/faucet`. Drips are unsigned first and the local wallet signs the faucet's EIP-191 challenge only when the faucet asks, so the keystore is unlocked only when needed; `--signature always|never`, `--token`, `--faucet-url` (also `RADIUS_FAUCET_URL` / `faucetUrl` in config.json) and `--json` are supported. Exit code 2 when the faucet declines for now (rate limited, empty, signature required for another address).
23 changes: 21 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,27 @@ radius-cli wallet verify "hello" 0xSig --address 0xOther
radius-cli wallet send 0xTo 0.10 RUSD # native value transfer
radius-cli wallet send 0xTo 0.10 SBC # ERC-20 transfer of SBC
radius-cli wallet send 0xToken "transfer(address,uint256)" 0xTo 100 # arbitrary call
radius-cli --network testnet wallet faucet # drip test funds into the local wallet
radius-cli --network testnet wallet faucet status # faucet rate-limit state for the local wallet
```

`--private-key 0xHEX` overrides the keystore on any command.

### Faucet (test funds)

`wallet faucet` requests a drip from the Radius faucet for the configured network (testnet: ~0.5 SBC per request; mainnet: a small daily SBC amount) through [`radius-sdk/faucet`](../sdk#faucet-test-funds). Where the faucet also drips a little RUSD for gas it is reported alongside.

```bash
radius-cli --network testnet wallet faucet # drip into the local wallet (alias: wallet faucet fund)
radius-cli --network testnet wallet faucet 0xOther # drip into another address
radius-cli --network testnet wallet faucet status [0xAddr] # rate-limit state, requests left, drip amounts
radius-cli --network testnet wallet faucet --json # {faucetUrl, address, token, amount, txHash, explorerUrl, native, nextDripAt}
```

Whether a faucet demands a signed request is a server-side switch (testnet currently does not, mainnet is expected to). The CLI drips unsigned first and, only if the faucet answers `signature_required`, signs its EIP-191 challenge with the local wallet and drips again, so the keystore is unlocked only when actually needed. `--signature always` skips the unsigned attempt; `--signature never` disables the fallback. Dripping into another address can therefore only work while that faucet accepts unsigned requests (it exits 2 with `signer_required` otherwise; pass that address's key with `--private-key` to sign for it). `--token <symbol>` requests a token other than SBC.

`--faucet-url <url>`, `RADIUS_FAUCET_URL` or `faucetUrl` in `~/.radius/config.json` point at another faucet (a same-origin proxy, a local instance). Faucet errors are printed to stderr as `faucet: <code> …` with the faucet's `request_id`; exit code 2 means the faucet declined for now (`rate_limited` with the wait, `faucet_empty`, a signature we cannot provide), 1 anything else.

## x402 HTTP payments

Make an HTTP request and, if the server responds with `402 Payment Required` and an [x402](https://x402.org) challenge, pay it from the local wallet and retry. The protocol side is handled by [`radius-sdk`](../sdk) (`createRadiusFetch`), the same code applications and agents use; the CLI adds the wallet, prompts and output.
Expand Down Expand Up @@ -142,6 +159,8 @@ Per-command JSON shapes:
| `wallet balance` | `{address, totalUsd, sbc, rusd, sbcWei, rusdWei, sbcError}` |
| `wallet send` | `{hash, receipt?}` (no `receipt` with `--no-wait`) |
| `wallet x402` | `{status, headers, body, bodyEncoding, payment}` |
| `wallet faucet` / `wallet faucet drip` | `{faucetUrl, address, token, amount, txHash, explorerUrl, native, nextDripAt}` |
| `wallet faucet status` | `{faucetUrl, address, token, rateLimited, retryAfterMs, remainingRequests, dripAmount, nativeDripAmount, unlimited}` |
| `call` | decoded return value (single value or array) |
| `tx` | the full transaction object |
| `receipt` | the full receipt object |
Expand All @@ -156,8 +175,8 @@ Errors continue to go to stderr as `error: <message>` with a non-zero exit code;
In priority order (highest first):

1. **CLI flag** — `--network`, `--rpc-url`, `--private-key`, `--sbc`, `--rusd`, `--json`
2. **Environment** — `RADIUS_NETWORK`, `RADIUS_RPC_URL`, `RADIUS_SBC_ADDRESS`, `RADIUS_RUSD_ADDRESS`, `RADIUS_PASSWORD`, `RADIUS_KEYSTORE_PATH`, `RADIUS_HOME`
3. **`~/.radius/config.json`** — fields: `network`, `rpcUrl`, `sbcAddress`, `rusdAddress`
2. **Environment** — `RADIUS_NETWORK`, `RADIUS_RPC_URL`, `RADIUS_SBC_ADDRESS`, `RADIUS_RUSD_ADDRESS`, `RADIUS_FAUCET_URL`, `RADIUS_PASSWORD`, `RADIUS_KEYSTORE_PATH`, `RADIUS_HOME`
3. **`~/.radius/config.json`** — fields: `network`, `rpcUrl`, `sbcAddress`, `rusdAddress`, `faucetUrl`
4. **Built-in defaults** — mainnet

The SBC contract address must be configured for `wallet balance` and `wallet send … SBC` to work — there is no public default.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { makePublicClient, makeWalletClient } from '../lib/client.js';
import { coerceArg, parseCastSignature } from '../lib/signature.js';
import { formatUsd, formatUsdShort, jsonStringify } from '../lib/format.js';
import { registerWalletX402 } from './walletX402.js';
import { registerWalletFaucet } from './walletFaucet.js';
import type { GlobalOptions } from '../types.js';

const SBC_DECIMALS = 6;
Expand Down Expand Up @@ -345,6 +346,7 @@ export function registerWallet(program: Command): void {
});

registerWalletX402(wallet);
registerWalletFaucet(wallet);
}

function parseGasLimit(input: string | undefined): bigint | undefined {
Expand Down
258 changes: 258 additions & 0 deletions packages/cli/src/commands/walletFaucet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import type { Command } from 'commander';
import { isAddress, type Address } from 'viem';
import {
createFaucetClient,
FaucetError,
type FaucetClient,
type FaucetDrip,
type FaucetSigner,
type FaucetStatus,
} from 'radius-sdk/faucet';
import { resolveConfig } from '../lib/config.js';
import { deferredAccount, getOwnAddress } from '../lib/account.js';
import { jsonStringify } from '../lib/format.js';
import type { GlobalOptions, ResolvedConfig } from '../types.js';

/** Options shared by `wallet faucet drip` and `wallet faucet status`. */
interface FaucetOptions {
faucetUrl?: string;
token?: string;
}

interface DripOptions extends FaucetOptions {
signature?: string;
}

const SIGNATURE_MODES = ['auto', 'always', 'never'] as const;
type SignatureMode = (typeof SIGNATURE_MODES)[number];

/** What `wallet faucet drip` prints (`--json` prints exactly this object). */
export interface DripOutput {
faucetUrl: string;
address: string;
token: string;
amount: string | null;
txHash: string | null;
explorerUrl: string | null;
/** The RUSD gas drip that accompanied the token transfer, where the faucet does one. */
native: { token: string; amount: string; txHash: string | null } | null;
/** ISO-8601 time at which this address may drip again, when the faucet says. */
nextDripAt: string | null;
}

/** What `wallet faucet status` prints (`--json` prints exactly this object). */
export interface StatusOutput {
faucetUrl: string;
address: string;
token: string;
rateLimited: boolean;
/** Milliseconds until the next drip is allowed; null unless rate limited. */
retryAfterMs: number | null;
/** Requests left in the current window; null when the faucet reports no limit. */
remainingRequests: number | null;
dripAmount: string | null;
nativeDripAmount: string | null;
unlimited: boolean;
}

export function registerWalletFaucet(wallet: Command): void {
const faucet = wallet
.command('faucet')
.description(
[
'Request test funds from the Radius faucet for the configured network.',
'',
' radius-cli --network testnet wallet faucet # drip into the local wallet',
' radius-cli --network testnet wallet faucet 0xOther # drip into another address',
' radius-cli --network testnet wallet faucet status # rate-limit state and drip amounts',
].join('\n'),
);

faucet
.command('drip', { isDefault: true })
.alias('fund')
.description('Drip from the faucet into an address (defaults to the local wallet)')
.argument('[address]', 'recipient (defaults to the local wallet)')
.option('--faucet-url <url>', 'faucet base URL (default: the network\'s, or RADIUS_FAUCET_URL)')
.option('--token <symbol>', 'token to request (default: SBC)')
.option(
'--signature <mode>',
`${SIGNATURE_MODES.join('|')}: sign the faucet's EIP-191 challenge only if asked (auto), always, or never (default: auto)`,
)
.action(async (addressArg: string | undefined, subOpts: DripOptions, cmd) => {
const opts = cmd.optsWithGlobals() as GlobalOptions;
const cfg = resolveConfig(opts);
const mode = parseSignatureMode(subOpts.signature);
const client = makeFaucetClient(cfg, subOpts);

// Own address: the keystore is unlocked only if the faucet actually asks for a signature.
// Someone else's address: nothing to sign with, so a signature demand is an error.
const own = await getOwnAddress(cfg, opts.privateKey);
const address = parseRecipient(addressArg) ?? own;
const signer = address.toLowerCase() === own.toLowerCase()
? await deferredAccount(cfg, opts.privateKey)
: undefined;

const out = await withFaucetErrors(() => runDrip(client, address, signer, mode));
if (opts.json) {
console.log(jsonStringify(out));
return;
}
for (const line of formatDrip(out)) console.log(line);
});

faucet
.command('status')
.description('Show rate-limit state and drip amounts for an address (defaults to the local wallet)')
.argument('[address]', 'address to query (defaults to the local wallet)')
.option('--faucet-url <url>', 'faucet base URL (default: the network\'s, or RADIUS_FAUCET_URL)')
.option('--token <symbol>', 'token to query (default: SBC)')
.action(async (addressArg: string | undefined, subOpts: FaucetOptions, cmd) => {
const opts = cmd.optsWithGlobals() as GlobalOptions;
const cfg = resolveConfig(opts);
const client = makeFaucetClient(cfg, subOpts);
const address = parseRecipient(addressArg) ?? (await getOwnAddress(cfg, opts.privateKey));

const out = await withFaucetErrors(() => runStatus(client, address));
if (opts.json) {
console.log(jsonStringify(out));
return;
}
for (const line of formatStatus(out)) console.log(line);
});
}

/** The faucet client for the configured network; `--faucet-url` > RADIUS_FAUCET_URL > config.json > network default. */
export function makeFaucetClient(cfg: Pick<ResolvedConfig, 'network' | 'faucetUrl'>, subOpts: FaucetOptions): FaucetClient {
const url = subOpts.faucetUrl ?? cfg.faucetUrl;
return createFaucetClient({ network: cfg.network, url, token: subOpts.token });
}

export function parseSignatureMode(input: string | undefined): SignatureMode {
if (input === undefined) return 'auto';
if ((SIGNATURE_MODES as readonly string[]).includes(input)) return input as SignatureMode;
throw new Error(`--signature must be one of ${SIGNATURE_MODES.join(', ')} (got '${input}')`);
}

function parseRecipient(input: string | undefined): Address | undefined {
if (input === undefined) return undefined;
if (!isAddress(input)) throw new Error(`Not a valid address: ${input}`);
return input;
}

export async function runDrip(
client: FaucetClient,
address: Address,
signer: FaucetSigner | undefined,
signature: SignatureMode,
): Promise<DripOutput> {
const drip = await client.fund(address, { signer, signature });
return dripOutput(client, drip);
}

export async function runStatus(client: FaucetClient, address: Address): Promise<StatusOutput> {
const status = await client.status(address);
return statusOutput(client, status);
}

export function dripOutput(client: Pick<FaucetClient, 'url'>, drip: FaucetDrip): DripOutput {
return {
faucetUrl: client.url,
address: drip.address,
token: drip.token,
amount: drip.amount ?? null,
txHash: drip.txHash ?? null,
explorerUrl: drip.explorerUrl ?? null,
native: drip.native ? { token: drip.native.token, amount: drip.native.amount, txHash: drip.native.txHash ?? null } : null,
nextDripAt: drip.nextDripAt !== undefined ? new Date(drip.nextDripAt * 1000).toISOString() : null,
};
}

export function statusOutput(client: Pick<FaucetClient, 'url'>, status: FaucetStatus): StatusOutput {
return {
faucetUrl: client.url,
address: status.address,
token: status.token,
rateLimited: status.rateLimited,
retryAfterMs: status.retryAfterMs ?? null,
remainingRequests: status.remainingRequests ?? null,
dripAmount: status.dripAmount ?? null,
nativeDripAmount: status.nativeDripAmount ?? null,
unlimited: status.unlimited === true,
};
}

export function formatDrip(out: DripOutput): string[] {
const lines = [
`Faucet: ${out.faucetUrl}`,
`Address: ${out.address}`,
`Dripped: ${out.amount ?? '?'} ${out.token}`,
];
if (out.txHash) lines.push(`Tx: ${out.explorerUrl ?? out.txHash}`);
if (out.native) {
const tx = out.native.txHash ? ` (tx ${out.native.txHash})` : '';
lines.push(`Gas: ${out.native.amount} ${out.native.token}${tx}`);
}
if (out.nextDripAt) lines.push(`Next drip: ${out.nextDripAt}`);
return lines;
}

export function formatStatus(out: StatusOutput): string[] {
const gas = out.nativeDripAmount ? ` + ${out.nativeDripAmount} RUSD for gas` : '';
const lines = [
`Faucet: ${out.faucetUrl}`,
`Address: ${out.address}`,
`Drip: ${out.dripAmount ?? '?'} ${out.token}${gas}`,
];
if (out.rateLimited) {
lines.push(`Status: rate limited, retry in ${formatSeconds(out.retryAfterMs)}`);
} else if (out.unlimited) {
lines.push('Status: ready (no rate limit)');
} else {
const left = out.remainingRequests !== null ? `${out.remainingRequests} request${out.remainingRequests === 1 ? '' : 's'} left` : 'requests left unknown';
lines.push(`Status: ready, ${left}`);
}
return lines;
}

function formatSeconds(ms: number | null): string {
if (ms === null) return 'a while';
const s = Math.ceil(ms / 1000);
if (s < 90) return `${s} s`;
if (s <= 5400) return `${Math.ceil(s / 60)} min`;
return `${Math.ceil(s / 3600)} h`;
}

/**
* Exit code for a faucet failure: 2 when the request was well-formed but the faucet declined for now
* (rate limit, empty, needs a signature we do not hold), 1 for everything else.
*/
export function faucetExitCode(e: FaucetError): 1 | 2 {
switch (e.faucetCode) {
case 'rate_limited':
case 'faucet_empty':
case 'signer_required':
case 'signature_required':
return 2;
default:
return 1;
}
}

/** Faucet API errors go to stderr as `faucet: …` with their code; anything else propagates to the top-level handler. */
async function withFaucetErrors<T>(run: () => Promise<T>): Promise<T> {
try {
return await run();
} catch (e) {
if (!(e instanceof FaucetError)) throw e;
process.stderr.write(`faucet: ${e.message}\n`);
if (e.faucetCode === 'signer_required') {
process.stderr.write('faucet: this faucet needs the recipient to sign; drip into the local wallet (no address argument) or pass its key with --private-key.\n');
}
if (e.faucetCode === 'receipt_timeout' || e.faucetCode === 'transaction_reverted' || e.faucetCode === 'native_drip_failed') {
const tx = e.errorDetails?.tx_hash;
if (typeof tx === 'string') process.stderr.write(`faucet: transaction ${tx}\n`);
}
process.exit(faucetExitCode(e));
}
}
5 changes: 4 additions & 1 deletion packages/cli/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ interface FileConfig {
rpcUrl?: string;
sbcAddress?: string;
rusdAddress?: string;
faucetUrl?: string;
cachedAddress?: string;
passwordless?: boolean;
}
Expand Down Expand Up @@ -53,10 +54,12 @@ export function resolveConfig(opts: GlobalOptions): ResolvedConfig {
'RUSD address',
);

const faucetUrl = process.env.RADIUS_FAUCET_URL ?? file.faucetUrl;

const keystorePath = process.env.RADIUS_KEYSTORE_PATH ?? DEFAULT_KEYSTORE_PATH;
const password = process.env.RADIUS_PASSWORD;

return { network, chain, rpcUrl, sbcAddress, rusdAddress, keystorePath, password };
return { network, chain, rpcUrl, sbcAddress, rusdAddress, faucetUrl, keystorePath, password };
}

export function configPath(): string {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface ResolvedConfig {
rpcUrl: string;
sbcAddress?: Address;
rusdAddress?: Address;
/** Faucet base URL override (RADIUS_FAUCET_URL or config.json `faucetUrl`); the network's when unset. */
faucetUrl?: string;
keystorePath: string;
password?: string;
}
Expand Down
Loading
Loading