diff --git a/.changeset/receipt-events-wallet-password.md b/.changeset/receipt-events-wallet-password.md new file mode 100644 index 0000000..785e780 --- /dev/null +++ b/.changeset/receipt-events-wallet-password.md @@ -0,0 +1,5 @@ +--- +"radius-cli": minor +--- + +Add `receipt --event ` 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. diff --git a/packages/cli/README.md b/packages/cli/README.md index a1e69b7..089e596 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -34,7 +34,7 @@ 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 @@ -42,6 +42,7 @@ 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 @@ -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`) @@ -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 @@ -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 ` 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. @@ -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}` | @@ -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}` | @@ -192,7 +212,7 @@ Errors continue to go to stderr as `error: ` 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 diff --git a/packages/cli/src/commands/receipt.ts b/packages/cli/src/commands/receipt.ts index d604ba5..abb785d 100644 --- a/packages/cli/src/commands/receipt.ts +++ b/packages/cli/src/commands/receipt.ts @@ -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'; @@ -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('', 'transaction hash') - .action(async (hash: string, _opts, cmd) => { + .option( + '--event ', + '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 }); + + 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; diff --git a/packages/cli/src/commands/wallet.ts b/packages/cli/src/commands/wallet.ts index b11113c..c103c71 100644 --- a/packages/cli/src/commands/wallet.ts +++ b/packages/cli/src/commands/wallet.ts @@ -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'; @@ -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') @@ -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 diff --git a/packages/cli/src/lib/account.ts b/packages/cli/src/lib/account.ts index 3cd2d87..2baa38b 100644 --- a/packages/cli/src/lib/account.ts +++ b/packages/cli/src/lib/account.ts @@ -39,7 +39,8 @@ async function autoCreateKeystore(cfg: ResolvedConfig): Promise; +} + +/** An undecoded log, trimmed to the fields of `RawLog` (the receipt's log carries more). */ +export type RawLogEntry = RawLog; + +export type LogEntry = DecodedLogEntry | RawLogEntry; + +export interface DecodeResult { + logs: LogEntry[]; + /** Logs whose topics[0] matched a signature but whose topic count did not (usually a missing `indexed`). */ + warnings: string[]; +} + +/** + * Parse a cast-style event signature: "Transfer(address indexed from, address indexed to, uint256 value)". + * A leading `event ` is optional. `indexed` decides which params are read from topics and which from data. + */ +export function parseEventSignature(sig: string): ParsedEvent { + const trimmed = sig.trim().replace(/^event\s+/, ''); + const invalid = (detail: string) => + new Error(`Invalid event signature: '${sig}': ${detail} (expected e.g. "Transfer(address indexed from, address indexed to, uint256 value)")`); + const firstParen = trimmed.indexOf('('); + if (firstParen === -1) throw invalid("no '(' found"); + if (!/^[a-zA-Z_$][\w$]*$/.test(trimmed.slice(0, firstParen).trim())) throw invalid('invalid event name'); + let argsClose: number; + try { + argsClose = findMatchingClose(trimmed, firstParen); + } catch (err) { + throw invalid(errDetail(err)); + } + const tail = trimmed.slice(argsClose + 1).trim(); + if (tail) throw invalid(`unexpected trailing content '${tail}'`); + let abiItem: AbiEvent; + try { + abiItem = parseAbiItem(`event ${trimmed}`) as AbiEvent; + } catch (err) { + throw invalid(errDetail(err)); + } + return { + abiItem, + selector: toEventSelector(abiItem), + indexedCount: abiItem.inputs.filter((input) => input.indexed).length, + }; +} + +/** + * Decode each log against the first event whose selector equals topics[0] and whose indexed-param + * count matches the log's topics. Logs that match no event are returned raw, never dropped. + * Which params are indexed is taken from the signature: a log does not record it, so a signature that + * marks the same number of params `indexed` but different ones decodes without error, to wrong values. + */ +export function decodeLogs(logs: readonly RawLog[], events: readonly ParsedEvent[]): DecodeResult { + const warnings: string[] = []; + const out = logs.map((log): LogEntry => { + const raw: RawLogEntry = { address: log.address, logIndex: log.logIndex, topics: log.topics, data: log.data }; + const candidates = events.filter((e) => e.selector.toLowerCase() === log.topics[0]?.toLowerCase()); + if (candidates.length === 0) return raw; + const event = candidates.find((e) => e.indexedCount === log.topics.length - 1); + if (!event) { + const declared = [...new Set(candidates.map((e) => e.indexedCount))].join(' or '); + warnings.push( + `log ${log.logIndex ?? '?'} matches the ${candidates[0].abiItem.name} selector but has ${log.topics.length - 1} indexed topics; ` + + `the signature declares ${declared}. Mark the indexed params with \`indexed\`. Left undecoded.`, + ); + return raw; + } + let decoded; + try { + decoded = decodeEventLog({ abi: [event.abiItem], topics: log.topics as [Hex, ...Hex[]], data: log.data, strict: true }); + } catch (err) { + warnings.push(`log ${log.logIndex ?? '?'} matches the ${event.abiItem.name} selector but its data does not decode: ${errDetail(err)}. Left undecoded.`); + return raw; + } + return { address: log.address, logIndex: log.logIndex, eventName: event.abiItem.name, args: namedArgs(event.abiItem, decoded.args) }; + }); + return { logs: out, warnings }; +} + +/** + * Args in signature order, keyed by name, or by position when unnamed. viem returns an array when any + * param is unnamed, and otherwise an object with the indexed params first. + */ +function namedArgs(event: AbiEvent, args: unknown): Record { + const byName = (args ?? {}) as Record; + return Object.fromEntries( + event.inputs.map((input, i) => [input.name || String(i), Array.isArray(args) ? args[i] : byName[input.name!]]), + ); +} + +export function isDecoded(entry: LogEntry): entry is DecodedLogEntry { + return 'eventName' in entry; +} diff --git a/packages/cli/src/lib/format.ts b/packages/cli/src/lib/format.ts index e4b0f3f..78d587f 100644 --- a/packages/cli/src/lib/format.ts +++ b/packages/cli/src/lib/format.ts @@ -3,9 +3,10 @@ export function formatUsd(value: string | number): string { return Number(value).toFixed(6); } -/** Same, but at the typical 2-decimal "headline" precision. */ -export function formatUsdShort(value: string | number): string { - return Number(value).toFixed(2); +/** A one-line message for an error: viem's `shortMessage` when present, else the first line of `message`. */ +export function errDetail(err: unknown): string { + if (!(err instanceof Error)) return String(err).split('\n')[0]; + return ((err as { shortMessage?: string }).shortMessage ?? err.message).split('\n')[0]; } /** Stringify with bigints as decimal strings — viem returns lots of bigints. */ diff --git a/packages/cli/src/lib/keystore.ts b/packages/cli/src/lib/keystore.ts index 60c4ddd..e415165 100644 --- a/packages/cli/src/lib/keystore.ts +++ b/packages/cli/src/lib/keystore.ts @@ -1,5 +1,6 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; +import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmSync, writeSync } from 'node:fs'; +import { randomBytes } from 'node:crypto'; +import { basename, dirname, join } from 'node:path'; import { Wallet } from 'ethers'; import { privateKeyToAccount } from 'viem/accounts'; import { getAddress, type Address, type Hex } from 'viem'; @@ -12,13 +13,37 @@ export async function saveKeystore(path: string, privateKey: Hex, password: stri const wallet = new Wallet(privateKey); const json = await wallet.encrypt(password); - const dir = dirname(path); + // Write through a symlink to its target, as a plain write would, rather than replacing the link. + const target = existsSync(path) ? realpathSync(path) : path; + const dir = dirname(target); if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); - writeFileSync(path, json, { mode: 0o600 }); + + // Write a fresh, uniquely named temp file (0o600 from creation, fsynced), then rename it into place, + // so an interrupted write or a concurrent CLI process never leaves a partial or foreign keystore. + const tmp = join(dir, `.${basename(target)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`); + try { + const fd = openSync(tmp, 'wx', 0o600); + try { + writeSync(fd, json); + fsyncSync(fd); + } finally { + closeSync(fd); + } + renameSync(tmp, target); + } catch (err) { + rmSync(tmp, { force: true }); + throw err; + } return wallet.address as Address; } +/** Re-encrypt the keystore under a new password. The key, and so the address, stay the same. */ +export async function changeKeystorePassword(path: string, currentPassword: string, newPassword: string): Promise
{ + const pk = await loadKeystorePrivateKey(path, currentPassword); + return await saveKeystore(path, pk, newPassword); +} + export async function loadKeystorePrivateKey(path: string, password: string): Promise { if (!existsSync(path)) { throw new Error(`No keystore at ${path}. Run \`radius-cli wallet new\` or \`radius-cli wallet import\` first.`); diff --git a/packages/cli/src/lib/signature.ts b/packages/cli/src/lib/signature.ts index c5a5c5e..244bea1 100644 --- a/packages/cli/src/lib/signature.ts +++ b/packages/cli/src/lib/signature.ts @@ -8,7 +8,7 @@ export interface ParsedSignature { isView: boolean; } -function findMatchingClose(s: string, openIdx: number): number { +export function findMatchingClose(s: string, openIdx: number): number { if (s[openIdx] !== '(') throw new Error('Expected opening paren'); let depth = 0; for (let i = openIdx; i < s.length; i++) { diff --git a/packages/cli/tests/erc20.test.ts b/packages/cli/tests/erc20.test.ts index 7174fc8..a73570b 100644 --- a/packages/cli/tests/erc20.test.ts +++ b/packages/cli/tests/erc20.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { createPublicClient, custom, decodeFunctionData, erc20Abi, encodeAbiParameters, numberToHex, type Address, type Hex } from 'viem'; import { radiusTestnetChain, SBC } from 'radius-sdk'; -import { parseAmountArg, parseTokenArg, readBalances, sbcToken } from '../src/lib/erc20.js'; +import { formatBalanceLines, parseAmountArg, parseTokenArg, readBalances, sbcToken, type BalanceReport } from '../src/lib/erc20.js'; import type { ResolvedConfig } from '../src/types.js'; const OWNER: Address = '0x4f2d8a3b1c0e5d9b8e7a6c5d4e3f2a1b0c9d8e7f'; @@ -95,3 +95,33 @@ describe('readBalances', () => { expect(r.totalUsd).toBe(6); }); }); + +describe('formatBalanceLines', () => { + const report: BalanceReport = { + address: OWNER, + totalUsd: 0.159963, + sbc: '0.030002', + rusd: '0.129961', + sbcWei: '30002', + rusdWei: '129961000000000000', + aggregateWei: '159963000000000000', + rusdSource: 'evm', + sbcError: null, + }; + + it('prints one greppable line per asset and the total', () => { + expect(formatBalanceLines(report)).toEqual([ + `Address: ${OWNER}`, + 'SBC: 0.030002 SBC', + 'RUSD: 0.129961 RUSD', + 'Total: 0.159963 USD', + ]); + }); + + it('marks SBC unavailable when its read failed, keeping a multi-line error to its first line', () => { + const sbcError = 'HTTP request failed.\n\nURL: https://rpc.example\nRequest body: {}'; + const lines = formatBalanceLines({ ...report, sbcError }); + expect(lines).toHaveLength(4); + expect(lines[1]).toBe('SBC: unavailable (HTTP request failed.)'); + }); +}); diff --git a/packages/cli/tests/events.test.ts b/packages/cli/tests/events.test.ts new file mode 100644 index 0000000..1a7fd87 --- /dev/null +++ b/packages/cli/tests/events.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { encodeAbiParameters, encodeEventTopics, parseAbiItem, type AbiEvent, type Address, type Hex } from 'viem'; +import { decodeLogs, parseEventSignature, type RawLog } from '../src/lib/events.js'; + +const TOKEN: Address = '0x1111111111111111111111111111111111111111'; +const ALICE: Address = '0x2222222222222222222222222222222222222222'; +const BOB: Address = '0x3333333333333333333333333333333333333333'; + +function makeLog(sig: string, args: Record, logIndex: number): RawLog { + const event = parseAbiItem(`event ${sig}`) as AbiEvent; + const topics = encodeEventTopics({ abi: [event], eventName: event.name, args } as never) as Hex[]; + const dataInputs = event.inputs.filter((i) => !i.indexed); + const data = encodeAbiParameters(dataInputs, dataInputs.map((i) => args[i.name!])); + return { address: TOKEN, topics, data, logIndex }; +} + +const NFT_TRANSFER = 'Transfer(address indexed from, address indexed to, uint256 indexed tokenId)'; +const ERC20_TRANSFER = 'Transfer(address indexed from, address indexed to, uint256 value)'; +const REGISTERED = 'Registered(uint256 indexed agentId, string agentURI, address indexed owner)'; + +describe('parseEventSignature', () => { + it('accepts an optional `event` prefix and counts indexed params', () => { + const a = parseEventSignature(NFT_TRANSFER); + const b = parseEventSignature(`event ${NFT_TRANSFER}`); + expect(a.selector).toBe(b.selector); + expect(a.indexedCount).toBe(3); + expect(parseEventSignature(ERC20_TRANSFER).indexedCount).toBe(2); + }); + + it('rejects malformed signatures with a clear message', () => { + expect(() => parseEventSignature('Transfer')).toThrow(/Invalid event signature/); + expect(() => parseEventSignature('Transfer(address indexed from')).toThrow(/Invalid event signature/); + expect(() => parseEventSignature('Transfer(adress from)')).toThrow(/Invalid event signature/); + expect(() => parseEventSignature('1bad(uint256)')).toThrow(/Invalid event signature/); + expect(() => parseEventSignature('Foo(uint256)garbage)')).toThrow(/trailing content 'garbage\)'/); + }); +}); + +describe('decodeLogs', () => { + it('decodes an all-indexed event, keying unnamed params by position', () => { + const log = makeLog(NFT_TRANSFER, { from: ALICE, to: BOB, tokenId: 42n }, 0); + const { logs, warnings } = decodeLogs([log], [parseEventSignature('Transfer(address indexed,address indexed,uint256 indexed tokenId)')]); + expect(warnings).toEqual([]); + expect(logs).toEqual([{ address: TOKEN, logIndex: 0, eventName: 'Transfer', args: { 0: ALICE, 1: BOB, tokenId: 42n } }]); + }); + + it('decodes a mixed indexed / non-indexed event', () => { + const log = makeLog(REGISTERED, { agentId: 7n, agentURI: 'ipfs://agent', owner: ALICE }, 3); + const { logs } = decodeLogs([log], [parseEventSignature(REGISTERED)]); + expect(logs).toEqual([{ address: TOKEN, logIndex: 3, eventName: 'Registered', args: { agentId: 7n, agentURI: 'ipfs://agent', owner: ALICE } }]); + expect(Object.keys((logs[0] as { args: object }).args)).toEqual(['agentId', 'agentURI', 'owner']); + }); + + it('tries every --event against every log and passes non-matching logs through raw', () => { + const transfer = makeLog(NFT_TRANSFER, { from: ALICE, to: BOB, tokenId: 1n }, 0); + const registered = makeLog(REGISTERED, { agentId: 1n, agentURI: 'x', owner: BOB }, 1); + const other = makeLog('Approval(address indexed owner, address indexed spender, uint256 value)', { owner: ALICE, spender: BOB, value: 5n }, 2); + const { logs } = decodeLogs([transfer, registered, other], [parseEventSignature(NFT_TRANSFER), parseEventSignature(REGISTERED)]); + expect(logs.map((l) => ('eventName' in l ? l.eventName : 'raw'))).toEqual(['Transfer', 'Registered', 'raw']); + expect(logs[2]).toEqual({ address: TOKEN, logIndex: 2, topics: other.topics, data: other.data }); + }); + + it('tells ERC-20 and ERC-721 Transfer apart by topic count', () => { + const erc20 = makeLog(ERC20_TRANSFER, { from: ALICE, to: BOB, value: 100n }, 0); + const erc721 = makeLog(NFT_TRANSFER, { from: ALICE, to: BOB, tokenId: 9n }, 1); + const { logs, warnings } = decodeLogs([erc20, erc721], [parseEventSignature(ERC20_TRANSFER), parseEventSignature(NFT_TRANSFER)]); + expect(warnings).toEqual([]); + expect(logs.map((l) => ('args' in l ? l.args : null))).toEqual([ + { from: ALICE, to: BOB, value: 100n }, + { from: ALICE, to: BOB, tokenId: 9n }, + ]); + }); + + it('warns and passes the log through when `indexed` is missing from the signature', () => { + const log = makeLog(NFT_TRANSFER, { from: ALICE, to: BOB, tokenId: 1n }, 4); + const { logs, warnings } = decodeLogs([log], [parseEventSignature('Transfer(address,address,uint256)')]); + expect(logs[0]).toEqual({ address: TOKEN, logIndex: 4, topics: log.topics, data: log.data }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toMatch(/log 4 .*Transfer.*3 indexed topics.*declares 0.*indexed/); + }); +}); diff --git a/packages/cli/tests/keystore.test.ts b/packages/cli/tests/keystore.test.ts index a1d961f..c232e45 100644 --- a/packages/cli/tests/keystore.test.ts +++ b/packages/cli/tests/keystore.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeAll } from 'vitest'; -import { mkdtempSync } from 'node:fs'; +import { lstatSync, mkdtempSync, readdirSync, statSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; -import { keystoreExists, loadKeystorePrivateKey, saveKeystore, readKeystoreAddress } from '../src/lib/keystore.js'; +import { changeKeystorePassword, keystoreExists, loadKeystorePrivateKey, saveKeystore, readKeystoreAddress } from '../src/lib/keystore.js'; describe('keystore', () => { let dir: string; @@ -38,4 +38,30 @@ describe('keystore', () => { it('throws on a bad password', async () => { await expect(loadKeystorePrivateKey(path, 'wrong')).rejects.toThrow(); }, 20_000); + + it('adds a password to a passwordless keystore without changing the key', async () => { + const p = join(dir, 'passwordless.json'); + await saveKeystore(p, privateKey, ''); + const addr = await changeKeystorePassword(p, '', password); + expect(addr.toLowerCase()).toBe(expectedAddress.toLowerCase()); + expect(readdirSync(dir).filter((f) => f.endsWith('.tmp'))).toEqual([]); + expect(statSync(p).mode & 0o777).toBe(0o600); + await expect(loadKeystorePrivateKey(p, '')).rejects.toThrow(); + expect((await loadKeystorePrivateKey(p, password)).toLowerCase()).toBe(privateKey.toLowerCase()); + }, 60_000); + + it('refuses to change the password without the current one', async () => { + await expect(changeKeystorePassword(path, 'wrong', 'new-password')).rejects.toThrow(); + expect((await loadKeystorePrivateKey(path, password)).toLowerCase()).toBe(privateKey.toLowerCase()); + }, 60_000); + + it('writes through a symlinked keystore path instead of replacing the link', async () => { + const real = join(dir, 'real.json'); + const link = join(dir, 'link.json'); + await saveKeystore(real, privateKey, ''); + symlinkSync(real, link); + await changeKeystorePassword(link, '', password); + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect((await loadKeystorePrivateKey(real, password)).toLowerCase()).toBe(privateKey.toLowerCase()); + }, 60_000); }); diff --git a/packages/docs/docs/pages/reference/radius-cli.mdx b/packages/docs/docs/pages/reference/radius-cli.mdx index 97bc953..71a4388 100644 --- a/packages/docs/docs/pages/reference/radius-cli.mdx +++ b/packages/docs/docs/pages/reference/radius-cli.mdx @@ -29,7 +29,7 @@ command -v radius-cli radius-cli --help ``` -Use `radius-cli` version `0.4.0` or later. From `0.3.0`, `wallet balance` reports {NATIVE_TOKEN} and {STABLECOIN} separately, and `wallet send` transfers any ERC-20. From `0.4.0`, `wallet pay` (formerly `wallet x402`, with `--threshold` and `--approve-permit2` formerly `--x402-threshold` and `--x402-approve-permit2`) also pays MPP challenges and USDC on Base. From `0.5.0`, `sell check` checks a seller's `402` before you deploy it. Verify that your installed binary exposes the payment command: +Use `radius-cli` version `0.4.0` or later. From `0.3.0`, `wallet balance` reports {NATIVE_TOKEN} and {STABLECOIN} separately, and `wallet send` transfers any ERC-20. From `0.4.0`, `wallet pay` (formerly `wallet x402`, with `--threshold` and `--approve-permit2` formerly `--x402-threshold` and `--x402-approve-permit2`) also pays MPP challenges and USDC on Base. From `0.5.0`, `sell check` checks a seller's `402` before you deploy it. From `0.7.0`, `receipt --event` decodes logs, `wallet password` adds or changes the keystore password, and `wallet balance` prints one line per asset. Verify that your installed binary exposes the payment command: ```bash radius-cli --version @@ -79,12 +79,20 @@ To opt into a password before creating a wallet, set `RADIUS_PASSWORD`: RADIUS_HOME=.radius RADIUS_NETWORK=testnet RADIUS_PASSWORD='use-a-secret-manager' radius-cli wallet new ``` +Creating the keystore never prompts, so agents and scripts do not block on it. A keystore created without a password can get one later, keeping the same key and address: + +```bash +RADIUS_HOME=.radius RADIUS_NEW_PASSWORD='use-a-secret-manager' radius-cli wallet password +``` + +Without `RADIUS_NEW_PASSWORD`, `wallet password` prompts for the new password. Either way it needs at least 8 characters; to remove the password, pass `--remove`. If the keystore already has a password, pass the current one in `RADIUS_PASSWORD` or at the prompt. `wallet new --force` replaces the key with a new one, so do not use it to add a password to a wallet that holds funds. + ## Configuration precedence `radius-cli` resolves configuration in this order: 1. CLI flags: `--network`, `--rpc-url`, `--sbc`, `--rusd`, `--json`, `--private-key` -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_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_PASSWORD`, `RADIUS_NEW_PASSWORD`, `RADIUS_KEYSTORE_PATH`, `RADIUS_HOME` 3. Radius config file in the active Radius home 4. Built-in defaults @@ -109,7 +117,7 @@ RADIUS_HOME=.radius RADIUS_NETWORK=testnet \ radius-cli wallet balance 0x0000000000000000000000000000000000000000 --json ``` -The JSON reports `sbc` and `rusd` separately (with `sbcWei` and `rusdWei`), their sum as `totalUsd`, and `aggregateWei`, the value `eth_getBalance` returns. On Radius `eth_getBalance` already includes {STABLECOIN}; see [The Turnstile and balances](/reference/ethereum-compatibility#the-turnstile-and-balances). +Without `--json`, each asset and the total print on their own line (`SBC:`, `RUSD:`, `Total:`), so you can `grep` one of them. The JSON reports `sbc` and `rusd` separately (with `sbcWei` and `rusdWei`), their sum as `totalUsd`, and `aggregateWei`, the value `eth_getBalance` returns. On Radius `eth_getBalance` already includes {STABLECOIN}; see [The Turnstile and balances](/reference/ethereum-compatibility#the-turnstile-and-balances). Sign and verify messages: @@ -168,6 +176,16 @@ radius-cli nonce 0xWalletAddress Function signatures use cast-style syntax: `name(args)` for writes and `name(args)(returns)` for decoded reads. +Decode the events in a receipt with `--event` and a cast-style event signature. Mark the `indexed` params: the decoder reads them from the topics and uses their count to tell events with the same selector apart, such as ERC-20 and ERC-721 `Transfer`. Repeat `--event` for more signatures: + +```bash +radius-cli receipt 0xTransactionHash --json \ + --event "Transfer(address indexed from, address indexed to, uint256 value)" \ + --event "Approval(address indexed owner, address indexed spender, uint256 value)" +``` + +With `--json`, the output is an array with one entry per log: `{address, logIndex, eventName, args}` for a decoded log, `{address, logIndex, topics, data}` for a log that matches no signature. Unnamed params are keyed by position. A log whose selector matches but whose topic count does not is left raw, with a warning on stderr. A log does not record which params are indexed, so a signature that marks the same number of params but different ones decodes to wrong values without a warning. If the transaction reverted, `receipt --event` prints an error on stderr and exits `1`. + ## Paid endpoints Use `radius-cli wallet pay ` to request an endpoint that charges over x402 or MPP. If the endpoint responds with `402 Payment Required`, the CLI checks the offer, signs and pays from the local wallet, then retries the request. The protocol side is [`radius-sdk`](/reference/radius-sdk), the same client applications use.