Skip to content
Open
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-erc20-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"radius-cli": minor
---

New ERC-20 wallet commands on `radius-sdk/client`'s `erc20Actions`, each taking `SBC` (default) or any token address: `wallet approve <spender> <amount> [token]` (`max` for unlimited), `wallet allowance <spender> [token] [--owner]`, `wallet token [token]` (name, symbol, decimals, supply), `wallet transfers [token]` (decoded `Transfer` events over a block range, last 10 000 blocks by default) and `wallet watch [token]` (the same, streamed live until Ctrl-C, one JSON object per line with `--json`). `transfers` and `watch` default to transfers sent or received by the local account; `--from` / `--to` pick one side, `--address` another account, `--all` every transfer of the token.
15 changes: 13 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,16 @@ 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 0xTo 0.10 0xToken # ERC-20 transfer of any token (decimals read on-chain)
radius-cli wallet send 0xToken "transfer(address,uint256)" 0xTo 100 # arbitrary call
radius-cli wallet approve 0xSpender 25 [SBC|0xToken] # ERC-20 approve; amount "max" for unlimited
radius-cli wallet allowance 0xSpender [SBC|0xToken] [--owner 0xAddr]
radius-cli wallet token [SBC|0xToken] # name, symbol, decimals, total supply
radius-cli wallet transfers [SBC|0xToken] [--blocks 10000] [--from 0xA] [--to 0xB] [--address 0xC] [--all]
radius-cli wallet watch [SBC|0xToken] [--from 0xA] [--to 0xB] [--address 0xC] [--all] # live, Ctrl-C to stop
```

`wallet send` takes the token as its last argument: `RUSD` for a native transfer, `SBC`, or any ERC-20 contract address. Amounts are in display units (`1.5` means 1.5 tokens) and are parsed with the token's decimals — known for SBC, read on-chain for other tokens. ERC-20 sends and `wallet balance` run on the actions of [`radius-sdk/client`](../sdk) (`erc20Actions`, `radiusActions`), so a script and the CLI encode the same calls.
ERC-20 commands take the token as their last argument: `SBC` (the default) or any contract address (`wallet send` also takes `RUSD` for a native transfer). Amounts are in display units (`1.5` means 1.5 tokens) and are parsed with the token's decimals — known for SBC, read on-chain for other tokens. `wallet send`, `balance`, `approve`, `allowance`, `token`, `transfers` and `watch` run on the actions of [`radius-sdk/client`](../sdk) (`erc20Actions`, `radiusActions`), so a script and the CLI encode the same calls.

`wallet transfers` lists decoded `Transfer` events; `wallet watch` streams them as they land. Both default to transfers sent *or* received by the local account (`--address` for another account, `--from` / `--to` for one side, `--all` for every transfer of the token). `transfers` searches the last 10 000 blocks unless `--blocks`, `--from-block` or `--to-block` say otherwise; with `--json`, `watch` prints one JSON object per line.

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

Expand Down Expand Up @@ -145,7 +152,11 @@ Per-command JSON shapes:
| `wallet sign` | `{address, signature}` |
| `wallet verify` | `{address, valid}` (exit 1 when invalid) |
| `wallet balance` | `{address, totalUsd, sbc, rusd, sbcWei, rusdWei, aggregateWei, rusdSource, sbcError}` |
| `wallet send` | `{hash, receipt?}` (no `receipt` with `--no-wait`) |
| `wallet send` / `wallet approve` | `{hash, receipt?}` (no `receipt` with `--no-wait`) |
| `wallet allowance` | `{token, symbol, decimals, owner, spender, allowance, allowanceWei}` |
| `wallet token` | `{address, name, symbol, decimals, totalSupply, totalSupplyFormatted}` |
| `wallet transfers` | `{token, symbol, fromBlock, toBlock, transfers: [{token, symbol, from, to, amount, amountWei, transactionHash, blockNumber, logIndex}]}` |
| `wallet watch` | one `{token, symbol, from, to, amount, amountWei, transactionHash, blockNumber, logIndex}` per line |
| `wallet x402` | `{status, headers, body, bodyEncoding, payment}` |
| `call` | decoded return value (single value or array) |
| `tx` | the full transaction object |
Expand Down
201 changes: 199 additions & 2 deletions packages/cli/src/commands/wallet.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { Command } from 'commander';
import { confirm, password as promptPassword } from '@inquirer/prompts';
import { readFileSync } from 'node:fs';
import { encodeFunctionData, isAddress, parseEther, verifyMessage, type Address, type Hex } from 'viem';
import { encodeFunctionData, formatUnits, isAddress, parseEther, verifyMessage, type Address, type Hex } from 'viem';
import type { TokenTransfer } from 'radius-sdk/client';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
import { resolveConfig, readPasswordless, writeCachedAddress, writePasswordless } from '../lib/config.js';
import { keystoreExists, loadKeystorePrivateKey, saveKeystore } from '../lib/keystore.js';
import { getOwnAddress, requireAccount } from '../lib/account.js';
import { makePublicClient, makeWalletClient } from '../lib/client.js';
import { parseAmountArg, parseTokenArg, readBalances } from '../lib/erc20.js';
import { describeToken, listTransfers, parseAmountArg, parseTokenArg, readBalances, toTransferRow, transferFilters, type TransferSides } from '../lib/erc20.js';
import { coerceArg, parseCastSignature } from '../lib/signature.js';
import { formatUsd, formatUsdShort, jsonStringify } from '../lib/format.js';
import { registerWalletX402 } from './walletX402.js';
import type { GlobalOptions } from '../types.js';
import type { TokenInput } from 'radius-sdk/client';

const TOKEN_ARG_HELP = 'SBC (default) or any ERC-20 contract address';

function readMessageArg(arg: string, raw: boolean): string | { raw: Hex } {
const text = arg === '-' ? readFileSync(0, 'utf8') : arg;
if (raw) {
Expand Down Expand Up @@ -283,9 +286,203 @@ export function registerWallet(program: Command): void {
);
});

wallet
.command('approve')
.description('Approve a spender to move tokens from the local account (ERC-20 approve)')
.argument('<spender>', 'address allowed to spend')
.argument('<amount>', 'amount in display units (e.g. 1.5), or "max" for an unlimited approval')
.argument('[token]', TOKEN_ARG_HELP, 'SBC')
.option('--no-wait', 'do not wait for the receipt before returning')
.option('--gas-limit <units>', 'gas limit for the transaction (skips the eth_estimateGas roundtrip)')
.action(async (spender: string, amountArg: string, tokenArg: string, subOpts: { wait?: boolean; gasLimit?: string }, cmd) => {
const opts = cmd.optsWithGlobals() as GlobalOptions;
const cfg = resolveConfig(opts);
if (!isAddress(spender)) throw new Error(`Not a valid address: ${spender}`);
const token = parseTokenArg(cfg, tokenArg);
const amount = parseAmountArg(amountArg);
const gas = parseGasLimit(subOpts.gasLimit);

const account = await requireAccount(cfg, opts.privateKey);
const publicClient = makePublicClient(cfg);
const walletClient = makeWalletClient(cfg, account);
const { hash } = await walletClient.approve({ token, spender: spender as Address, amount, gas, wait: false });
await reportTx(publicClient, hash, opts, subOpts.wait !== false);
});

wallet
.command('allowance')
.description('Show how much of a token a spender may move from an owner (ERC-20 allowance)')
.argument('<spender>', 'address allowed to spend')
.argument('[token]', TOKEN_ARG_HELP, 'SBC')
.option('--owner <address>', 'token owner (defaults to the local account)')
.action(async (spender: string, tokenArg: string, subOpts: { owner?: string }, cmd) => {
const opts = cmd.optsWithGlobals() as GlobalOptions;
const cfg = resolveConfig(opts);
if (!isAddress(spender)) throw new Error(`Not a valid address: ${spender}`);
let owner: Address;
if (subOpts.owner) {
if (!isAddress(subOpts.owner)) throw new Error(`Not a valid address: ${subOpts.owner}`);
owner = subOpts.owner as Address;
} else {
owner = await getOwnAddress(cfg, opts.privateKey);
}
const token = parseTokenArg(cfg, tokenArg);

const client = makePublicClient(cfg);
const [info, allowanceWei] = await Promise.all([
describeToken(client, token),
client.getAllowance({ token, owner, spender: spender as Address }),
]);
const allowance = formatUnits(allowanceWei, info.decimals);
if (opts.json) {
console.log(jsonStringify({ token: info.address, symbol: info.symbol, decimals: info.decimals, owner, spender, allowance, allowanceWei }));
return;
}
console.log(`Token: ${info.symbol} (${info.address})`);
console.log(`Owner: ${owner}`);
console.log(`Spender: ${spender}`);
console.log(`Allowance: ${allowance} ${info.symbol}`);
});

wallet
.command('token')
.description('Show an ERC-20 token\'s name, symbol, decimals and total supply')
.argument('[token]', TOKEN_ARG_HELP, 'SBC')
.action(async (tokenArg: string, _subOpts, cmd) => {
const opts = cmd.optsWithGlobals() as GlobalOptions;
const cfg = resolveConfig(opts);
const client = makePublicClient(cfg);
const meta = await client.getTokenMetadata({ token: parseTokenArg(cfg, tokenArg) });
if (opts.json) {
console.log(jsonStringify({ ...meta, totalSupplyFormatted: formatUnits(meta.totalSupply, meta.decimals) }));
return;
}
console.log(`Address: ${meta.address}`);
console.log(`Name: ${meta.name}`);
console.log(`Symbol: ${meta.symbol}`);
console.log(`Decimals: ${meta.decimals}`);
console.log(`Total supply: ${formatUnits(meta.totalSupply, meta.decimals)} ${meta.symbol}`);
});

wallet
.command('transfers')
.description('List ERC-20 Transfer events of a token (defaults to those sent or received by the local account)')
.argument('[token]', TOKEN_ARG_HELP, 'SBC')
.option('--from <address>', 'only transfers sent by this address')
.option('--to <address>', 'only transfers received by this address')
.option('--address <address>', 'transfers sent or received by this address (default: local account)')
.option('--all', 'every transfer of the token, no address filter')
.option('--blocks <n>', 'look back this many blocks from the latest (default: 10000)')
.option('--from-block <n>', 'first block to search (overrides --blocks)')
.option('--to-block <n>', 'last block to search (default: latest)')
.action(async (tokenArg: string, subOpts: TransferSideOptions & { blocks?: string; fromBlock?: string; toBlock?: string }, cmd) => {
const opts = cmd.optsWithGlobals() as GlobalOptions;
const cfg = resolveConfig(opts);
const token = parseTokenArg(cfg, tokenArg);
const client = makePublicClient(cfg);
const sides = await resolveTransferSides(cfg, opts, subOpts);

const toBlock = subOpts.toBlock !== undefined ? parseBlock(subOpts.toBlock, '--to-block') : await client.getBlockNumber();
let fromBlock: bigint;
if (subOpts.fromBlock !== undefined) {
fromBlock = parseBlock(subOpts.fromBlock, '--from-block');
} else {
const lookback = subOpts.blocks !== undefined ? parseBlock(subOpts.blocks, '--blocks') : 10_000n;
fromBlock = toBlock > lookback ? toBlock - lookback : 0n;
}
if (fromBlock > toBlock) throw new Error(`--from-block ${fromBlock} is after --to-block ${toBlock}`);

const [info, transfers] = await Promise.all([describeToken(client, token), listTransfers(client, { token, ...sides, fromBlock, toBlock })]);
const rows = transfers.map((t) => toTransferRow(t, info));
if (opts.json) {
console.log(jsonStringify({ token: info.address, symbol: info.symbol, fromBlock, toBlock, transfers: rows }));
return;
}
console.log(`${info.symbol} transfers, blocks ${fromBlock}–${toBlock}: ${rows.length}`);
for (const t of transfers) console.log(formatTransferLine(t, info));
});

wallet
.command('watch')
.description('Stream ERC-20 Transfer events of a token as they happen (defaults to the local account, Ctrl-C to stop)')
.argument('[token]', TOKEN_ARG_HELP, 'SBC')
.option('--from <address>', 'only transfers sent by this address')
.option('--to <address>', 'only transfers received by this address')
.option('--address <address>', 'transfers sent or received by this address (default: local account)')
.option('--all', 'every transfer of the token, no address filter')
.option('--poll <ms>', 'polling interval in milliseconds (default: the client\'s)')
.action(async (tokenArg: string, subOpts: TransferSideOptions & { poll?: string }, cmd) => {
const opts = cmd.optsWithGlobals() as GlobalOptions;
const cfg = resolveConfig(opts);
const token = parseTokenArg(cfg, tokenArg);
const client = makePublicClient(cfg);
const sides = await resolveTransferSides(cfg, opts, subOpts);
const pollingInterval = subOpts.poll !== undefined ? Number(parseBlock(subOpts.poll, '--poll')) : undefined;
const info = await describeToken(client, token);

const seen = new Set<string>();
const onTransfer = (t: TokenTransfer) => {
const key = `${t.transactionHash}:${t.logIndex}`;
if (seen.has(key)) return; // a self-transfer matches both directions
seen.add(key);
if (opts.json) console.log(jsonStringify(toTransferRow(t, info), 0));
else console.log(formatTransferLine(t, info));
};
const onError = (e: Error) => process.stderr.write(`watch: ${e.message}\n`);
const stops = transferFilters(sides).map((f) => client.watchTransfers({ token, ...f, onTransfer, onError, pollingInterval }));
if (!opts.json) {
const who = sides.from || sides.to ? [sides.from && `from ${sides.from}`, sides.to && `to ${sides.to}`].filter(Boolean).join(' ') : sides.address ? `involving ${sides.address}` : 'all';
process.stderr.write(`Watching ${info.symbol} transfers (${who}) on ${cfg.network}… Ctrl-C to stop\n`);
}
await new Promise<void>((resolve) => {
const stop = () => {
for (const unwatch of stops) unwatch();
resolve();
};
process.once('SIGINT', stop);
process.once('SIGTERM', stop);
});
});

registerWalletX402(wallet);
}

interface TransferSideOptions {
from?: string;
to?: string;
address?: string;
all?: boolean;
}

/** `--from` / `--to` win; `--all` drops the address filter; otherwise `--address` or the local account, both directions. */
async function resolveTransferSides(cfg: ReturnType<typeof resolveConfig>, opts: GlobalOptions, subOpts: TransferSideOptions): Promise<TransferSides> {
const addr = (value: string | undefined, flag: string): Address | undefined => {
if (value === undefined) return undefined;
if (!isAddress(value)) throw new Error(`${flag}: not a valid address: ${value}`);
return value as Address;
};
const from = addr(subOpts.from, '--from');
const to = addr(subOpts.to, '--to');
if (from || to) return { from, to };
if (subOpts.all) return {};
return { address: addr(subOpts.address, '--address') ?? (await getOwnAddress(cfg, opts.privateKey)) };
}

function parseBlock(input: string, flag: string): bigint {
let value: bigint;
try {
value = BigInt(input);
} catch {
throw new Error(`${flag} must be a non-negative integer, got: ${input}`);
}
if (value < 0n) throw new Error(`${flag} must be a non-negative integer, got: ${input}`);
return value;
}

function formatTransferLine(t: TokenTransfer, info: { symbol: string; decimals: number }): string {
return `${t.blockNumber.toString().padStart(12)} ${t.from} → ${t.to} ${formatUnits(t.amount, info.decimals)} ${info.symbol} ${t.transactionHash}`;
}

function parseGasLimit(input: string | undefined): bigint | undefined {
if (input === undefined) return undefined;
let value: bigint;
Expand Down
Loading
Loading