Skip to content
Merged
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/receipt-events-wallet-password.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"radius-cli": minor
---

Add `receipt --event <sig>` to decode a receipt's logs against cast-style event signatures (repeatable; `--json` emits an array of decoded or raw logs), and `wallet password` to add or change the keystore password without changing the key or address (`RADIUS_NEW_PASSWORD`, at least 8 characters, skips the prompt; `--remove` removes the password). `receipt --event` exits 1 when the transaction reverted. The auto-created keystore notice now points to `wallet password` instead of `wallet new --force`, which replaced the key. Keystores are now written to a unique, fsynced temporary file and renamed into place, through a symlinked keystore path to its target. **Breaking for scripts that parse human output:** `wallet balance` without `--json` now prints `SBC:`, `RUSD:` and `Total:` on separate lines (e.g. `SBC: 0.030002 SBC`) instead of one `Balance:` line; the `--json` output is unchanged.
24 changes: 22 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@ Override the URL with `--rpc-url` or `RADIUS_RPC_URL` if you want to point at a

## Wallet

On first use, any account-needing command (`wallet address`, `balance`, `sign`, `send`, …) auto-creates a keystore at `~/.radius/keystore.json` with no password set (file mode `0o600`). To opt into a password, run `radius-cli wallet new --force` (or set `RADIUS_PASSWORD` before the first command). The keystore is Web3 Secret Storage v3 — compatible with geth/foundry. The address is cached in `~/.radius/config.json` so `radius-cli wallet address` is a cheap read.
On first use, any account-needing command (`wallet address`, `balance`, `sign`, `send`, …) auto-creates a keystore at `~/.radius/keystore.json` with no password set (file mode `0o600`). No command prompts while creating it, so agents and scripts never block. To add a password later and keep the same key and address, run `radius-cli wallet password` (set `RADIUS_NEW_PASSWORD`, at least 8 characters, to skip the prompt; `--remove` removes the password); to set one at creation, set `RADIUS_PASSWORD` before the first command. (`wallet new --force` replaces the key with a new one.) The keystore is Web3 Secret Storage v3 — compatible with geth/foundry. The address is cached in `~/.radius/config.json` so `radius-cli wallet address` is a cheap read.

```bash
radius-cli wallet new
radius-cli wallet import 0xPRIVATE_KEY
radius-cli wallet address
radius-cli wallet balance [0xAddr]
radius-cli wallet export # decrypts and prints the private key
radius-cli wallet password # set or change the keystore password, same address
radius-cli --network testnet wallet faucet # request a faucet drip (testnet ~0.5 SBC; mainnet ~0.01 SBC/day)
radius-cli wallet sign "hello" # EIP-191 personal_sign — prints 0x signature
radius-cli wallet sign --raw 0xdeadbeef # sign raw hex bytes
Expand All @@ -58,6 +59,15 @@ radius-cli wallet send 0xToken "transfer(address,uint256)" 0xTo 100 # arbitrar

`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.

`wallet balance` prints one line per asset and the total, so each one greps on its own (`radius-cli wallet balance | grep '^SBC:'`):

```text
Address: 0x4F2D8a3b1c0E5d9b8e7a6c5d4e3f2a1b0c9d8e7f
SBC: 10.000000 SBC
RUSD: 2.345678 RUSD
Total: 12.345678 USD
```

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

## HTTP payments: x402 and MPP (`wallet pay`)
Expand Down Expand Up @@ -118,6 +128,7 @@ Each check prints as `ok`, `warn`, `FAIL`, `skip` or `info`. Exit code `0` means
radius-cli call 0xToken "balanceOf(address)(uint256)" 0xUser # decoded result
radius-cli tx 0xTransactionHash
radius-cli receipt 0xTransactionHash
radius-cli receipt 0xTransactionHash --event "Transfer(address indexed from, address indexed to, uint256 value)"
radius-cli storage 0xContract 0
radius-cli code 0xContract
radius-cli nonce 0xAddress
Expand All @@ -126,6 +137,13 @@ radius-cli --network testnet network # chain id, RPC, explorer, fau

Function signatures use `cast` syntax: `name(args)` for state-changing calls, `name(args)(returns)` for read calls (the result is decoded against the return types).

`receipt --event <sig>` decodes the receipt's logs against a `cast`-style event signature. Repeat `--event` to try several signatures against every log. A log is decoded when its `topics[0]` is the event's selector and its topic count matches the number of `indexed` params, so mark them with `indexed`: that is how the decoder tells topics from data, and how it tells an ERC-20 `Transfer` (two indexed params) from an ERC-721 `Transfer` (three). A log whose selector matches but whose topic count does not is left undecoded, with a warning on stderr. Logs that match no signature are printed raw, never dropped. Unnamed params are keyed by position (`"0"`, `"1"`, …). A log does not record which params are indexed, so mark exactly the ones the contract indexes: a signature that marks the same number of params but different ones decodes without a warning, to wrong values. With `--event`, the human output starts with `Status:`, and a reverted transaction prints an error on stderr and exits 1, so a revert never reads as "no matching events".

```bash
radius-cli --json receipt 0xTx --event "Transfer(address indexed,address indexed,uint256 indexed tokenId)" \
| jq -r 'map(select(.eventName == "Transfer"))[0].args.tokenId'
```

## JSON output

Pass `--json` to any command to emit machine-readable JSON on stdout (one object per command, pretty-printed). Useful for piping into `jq` or driving the CLI from scripts and agents. Bigints are serialized as decimal strings.
Expand Down Expand Up @@ -167,6 +185,7 @@ Per-command JSON shapes:
| Command | JSON shape |
|---|---|
| `wallet new` / `wallet import` | `{path, address}` |
| `wallet password` | `{path, address, passwordless}` |
| `wallet address` | `{address}` |
| `wallet export` | `{address, privateKey}` |
| `wallet sign-jwt` | `{address, token}` |
Expand All @@ -180,6 +199,7 @@ Per-command JSON shapes:
| `call` | decoded return value (single value or array) |
| `tx` | the full transaction object |
| `receipt` | the full receipt object |
| `receipt --event` | array of logs: `{address, logIndex, eventName, args}` when decoded, `{address, logIndex, topics, data}` when not |
| `code` | `{address, code}` |
| `nonce` | `{address, nonce}` |
| `network` | `{name, chainId, caip2, testnet, rpcUrl, explorerUrl, faucetUrl, facilitatorUrl, nativeCurrency, asset}` |
Expand All @@ -192,7 +212,7 @@ 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_PAY_NETWORKS`, `RADIUS_BASE_RPC_URL`, `RADIUS_BASE_SEPOLIA_RPC_URL`, `RADIUS_FACILITATOR_API_KEY` (`sell check`), `RADIUS_PASSWORD`, `RADIUS_KEYSTORE_PATH`, `RADIUS_HOME`
2. **Environment** — `RADIUS_NETWORK`, `RADIUS_RPC_URL`, `RADIUS_SBC_ADDRESS`, `RADIUS_RUSD_ADDRESS`, `RADIUS_PAY_NETWORKS`, `RADIUS_BASE_RPC_URL`, `RADIUS_BASE_SEPOLIA_RPC_URL`, `RADIUS_FACILITATOR_API_KEY` (`sell check`), `RADIUS_PASSWORD`, `RADIUS_NEW_PASSWORD` (`wallet password`), `RADIUS_KEYSTORE_PATH`, `RADIUS_HOME`
3. **`~/.radius/config.json`** — fields: `network`, `rpcUrl`, `sbcAddress`, `rusdAddress`, `payNetworks` (e.g. `["radius", "base"]`), `rpcUrls` (`{ "base": …, "base-sepolia": … }`)
4. **Built-in defaults** — mainnet

Expand Down
50 changes: 49 additions & 1 deletion packages/cli/src/commands/receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Command } from 'commander';
import type { Hash } from 'viem';
import { resolveConfig } from '../lib/config.js';
import { makePublicClient } from '../lib/client.js';
import { decodeLogs, isDecoded, parseEventSignature } from '../lib/events.js';
import { jsonStringify } from '../lib/format.js';
import type { GlobalOptions } from '../types.js';

Expand All @@ -11,17 +12,64 @@ function assertHash(s: string): asserts s is Hash {
}
}

function collect(value: string, previous: string[] = []): string[] {
return [...previous, value];
}

function formatValue(value: unknown): string {
if (typeof value === 'bigint') return value.toString();
if (typeof value === 'string') return value;
return jsonStringify(value, 0);
}

export function registerReceipt(program: Command): void {
program
.command('receipt')
.description('Get a transaction receipt by hash')
.argument('<hash>', 'transaction hash')
.action(async (hash: string, _opts, cmd) => {
.option(
'--event <sig>',
'decode logs matching a cast-style event signature, e.g. "Transfer(address indexed from, address indexed to, uint256 value)"; mark exactly the params the contract indexes (repeatable; exits 1 if the transaction reverted)',
collect,
)
.action(async (hash: string, subOpts: { event?: string[] }, cmd) => {
const opts = cmd.optsWithGlobals() as GlobalOptions;
const cfg = resolveConfig(opts);
assertHash(hash);
// Parse before the RPC call so a malformed signature fails fast.
const events = (subOpts.event ?? []).map(parseEventSignature);
const client = makePublicClient(cfg);
const receipt = await client.getTransactionReceipt({ hash });

Comment thread
erikzrekz marked this conversation as resolved.
if (events.length > 0) {
const { logs, warnings } = decodeLogs(receipt.logs, events);
for (const w of warnings) process.stderr.write(`warning: ${w}\n`);
// A reverted transaction emits no logs; exit 1 so "reverted" never reads as "no matching events".
if (receipt.status !== 'success') {
process.stderr.write(`error: transaction ${receipt.transactionHash} reverted (status: ${receipt.status})\n`);
process.exitCode = 1;
}
if (opts.json) {
console.log(jsonStringify(logs));
return;
}
console.log(`Status: ${receipt.status}`);
for (const entry of logs) {
if (isDecoded(entry)) {
console.log(`Log ${entry.logIndex ?? '?'} ${entry.address} ${entry.eventName}`);
const width = Math.max(0, ...Object.keys(entry.args).map((k) => k.length)) + 1;
for (const [name, value] of Object.entries(entry.args)) {
console.log(` ${`${name}:`.padEnd(width)} ${formatValue(value)}`);
}
} else {
console.log(`Log ${entry.logIndex ?? '?'} ${entry.address} (no matching event)`);
entry.topics.forEach((topic, i) => console.log(` topic${i}: ${topic}`));
console.log(` data: ${entry.data}`);
}
}
return;
}

if (opts.json) {
console.log(jsonStringify(receipt));
return;
Expand Down
55 changes: 44 additions & 11 deletions packages/cli/src/commands/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import { readFileSync } from 'node:fs';
import { encodeFunctionData, isAddress, parseEther, verifyMessage, type Address, type Hex } from 'viem';
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
import { radiusNetwork, resolveConfig, readPasswordless, writeCachedAddress, writePasswordless } from '../lib/config.js';
import { keystoreExists, loadKeystorePrivateKey, saveKeystore } from '../lib/keystore.js';
import { changeKeystorePassword, 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 { formatBalanceLines, parseAmountArg, parseTokenArg, readBalances } from '../lib/erc20.js';
import { signJwt } from '../lib/jwt.js';
import { parseTypedData } from '../lib/typedData.js';
import { coerceArg, parseCastSignature } from '../lib/signature.js';
import { formatUsd, formatUsdShort, jsonStringify } from '../lib/format.js';
import { errDetail, jsonStringify } from '../lib/format.js';
import { registerWalletPay } from './walletPay.js';
import type { GlobalOptions } from '../types.js';
import { explorerTxUrl } from 'radius-sdk';
Expand Down Expand Up @@ -97,6 +97,46 @@ export function registerWallet(program: Command): void {
console.log(`Address: ${address}`);
});

wallet
.command('password')
.description('Set or change the keystore password, keeping the same key and address')
.option('--remove', 'remove the password instead (the keystore becomes effectively unencrypted)')
.action(async (subOpts: { remove?: boolean }, cmd) => {
const opts = cmd.optsWithGlobals() as GlobalOptions;
const cfg = resolveConfig(opts);
if (!keystoreExists(cfg.keystorePath)) {
throw new Error(`No keystore at ${cfg.keystorePath}. Run \`radius-cli wallet new\` or \`radius-cli wallet import\` first.`);
}
// A passwordless keystore decrypts with '', even when RADIUS_PASSWORD is already exported for later commands.
const current = readPasswordless()
? ''
: cfg.password ?? await promptPassword({ message: 'Current keystore password:', mask: '*' });
// The env value gets the same rule as the prompt, so an empty or unset CI secret never strips the password.
const envNext = process.env.RADIUS_NEW_PASSWORD;
if (subOpts.remove && envNext !== undefined) throw new Error('Pass either --remove or RADIUS_NEW_PASSWORD, not both.');
if (envNext !== undefined && envNext.length < 8) {
throw new Error('RADIUS_NEW_PASSWORD must be at least 8 characters. To remove the password, pass --remove.');
}
const next = subOpts.remove ? '' : await readNewPassword(envNext);
let address: Address;
try {
address = await changeKeystorePassword(cfg.keystorePath, current, next);
} catch (err) {
if (current !== '' && cfg.password !== undefined) {
throw new Error(`Could not decrypt ${cfg.keystorePath} with RADIUS_PASSWORD: ${errDetail(err)}`);
}
throw new Error(`Could not decrypt ${cfg.keystorePath}: ${errDetail(err)}`);
}
writeCachedAddress(address);
writePasswordless(next === '');
if (opts.json) {
console.log(jsonStringify({ path: cfg.keystorePath, address, passwordless: next === '' }));
return;
}
console.log(next === '' ? `Removed the password from ${cfg.keystorePath}` : `Updated the password of ${cfg.keystorePath}`);
console.log(`Address: ${address}`);
});

wallet
.command('address')
.description('Print the address associated with the local account')
Expand Down Expand Up @@ -283,14 +323,7 @@ export function registerWallet(program: Command): void {
console.log(jsonStringify(report));
return;
}
console.log(`Address: ${address}`);
if (report.sbcError) {
console.log(`Balance: $${formatUsdShort(report.totalUsd)} ($${formatUsd(report.rusd)} RUSD; SBC unavailable: ${report.sbcError})`);
} else {
console.log(
`Balance: $${formatUsdShort(report.totalUsd)} ($${formatUsd(report.sbc)} SBC + $${formatUsd(report.rusd)} RUSD)`,
);
}
for (const line of formatBalanceLines(report)) console.log(line);
});

wallet
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ async function autoCreateKeystore(cfg: ResolvedConfig): Promise<PrivateKeyAccoun
if (password === '') {
lines.push(
'No password set — keystore is effectively unencrypted (file mode 0o600).',
'To rotate to a password-protected keystore: `radius-cli wallet new --force`',
'To add one and keep this address: `radius-cli wallet password` (set RADIUS_NEW_PASSWORD to skip the prompt).',
'To set one at creation instead, set RADIUS_PASSWORD before the first command.',
);
}
process.stderr.write(lines.join('\n') + '\n');
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/lib/erc20.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { formatUnits, isAddress, type Address } from 'viem';
import { SBC } from 'radius-sdk';
import type { BalanceClient, BalanceToken, TokenAmount, TokenInput } from 'radius-sdk/client';
import { getBalances, getAggregateBalance } from 'radius-sdk/client';
import { formatUsd } from './format.js';
import type { ResolvedConfig } from '../types.js';

/** The configured SBC contract as a balance/ERC-20 token: `--sbc` / `RADIUS_SBC_ADDRESS`, else the canonical address. */
Expand Down Expand Up @@ -83,3 +84,15 @@ export async function readBalances(client: BalanceClient, cfg: ResolvedConfig, a
};
}
}

/** Human `wallet balance` output: one `label: value` line per asset and the total, so each is greppable. */
export function formatBalanceLines(report: BalanceReport): string[] {
// Only the first line of the error: viem's are multi-line (URL, request body) and would break the one-line-per-asset layout.
const sbc = report.sbcError ? `unavailable (${report.sbcError.split('\n')[0]})` : `${formatUsd(report.sbc)} SBC`;
return [
`Address: ${report.address}`,
Comment thread
erikzrekz marked this conversation as resolved.
`SBC: ${sbc}`,
`RUSD: ${formatUsd(report.rusd)} RUSD`,
`Total: ${formatUsd(report.totalUsd)} USD`,
];
}
Loading
Loading