From ef215c4244840f157d1441c78ef4f9605608fea8 Mon Sep 17 00:00:00 2001 From: Kyle Crawshaw Date: Wed, 23 Sep 2026 15:12:01 -0400 Subject: [PATCH] Run the CLI's ERC-20 interactions on the SDK's erc20Actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `makePublicClient` / `makeWalletClient` extend their viem clients with `radiusActions` and `erc20Actions` from `radius-sdk/client`, defaulting to the configured SBC contract. `wallet send … SBC` goes through the SDK's `transfer` (amount parsing, calldata, `--gas-limit` via `gas`), and any 0x token address works in the same position, with decimals read on-chain. `wallet balance` now uses the SDK's `getBalances`, which keeps native RUSD apart from SBC instead of adding SBC on top of the aggregate `eth_getBalance` that already includes it. The JSON gains `aggregateWei` and `rusdSource`. The hand- written ERC-20 ABI in wallet.ts is gone. Co-Authored-By: Claude Fable 5.1 --- .changeset/cli-erc20-actions.md | 5 ++ packages/cli/README.md | 10 ++- packages/cli/src/commands/wallet.ts | 114 +++++----------------------- packages/cli/src/lib/client.ts | 15 +++- packages/cli/src/lib/erc20.ts | 85 +++++++++++++++++++++ packages/cli/tests/erc20.test.ts | 97 +++++++++++++++++++++++ 6 files changed, 229 insertions(+), 97 deletions(-) create mode 100644 .changeset/cli-erc20-actions.md create mode 100644 packages/cli/src/lib/erc20.ts create mode 100644 packages/cli/tests/erc20.test.ts diff --git a/.changeset/cli-erc20-actions.md b/.changeset/cli-erc20-actions.md new file mode 100644 index 0000000..499440b --- /dev/null +++ b/.changeset/cli-erc20-actions.md @@ -0,0 +1,5 @@ +--- +"radius-cli": minor +--- + +`wallet` runs its ERC-20 interactions on `radius-sdk/client`'s `erc20Actions` and balance actions. `wallet send 0xToken` transfers any ERC-20 (decimals read on-chain), next to `SBC` and `RUSD`. `wallet balance` now reports native RUSD and SBC separately instead of adding SBC to the aggregate `eth_getBalance` (which already includes it), so `totalUsd` no longer double counts; the JSON gains `aggregateWei` and `rusdSource`. diff --git a/packages/cli/README.md b/packages/cli/README.md index 4100b75..7a2752f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -49,9 +49,12 @@ radius-cli wallet verify "hello" 0xSig # verify against own address 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 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 ``` +`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. + `--private-key 0xHEX` overrides the keystore on any command. ## x402 HTTP payments @@ -110,10 +113,12 @@ $ radius-cli --json wallet balance 0x4F2D8a3b1c0E5d9b8e7a6c5d4e3f2a1b0c9d8e7f { "address": "0x4F2D8a3b1c0E5d9b8e7a6c5d4e3f2a1b0c9d8e7f", "totalUsd": 12.345678, - "sbc": "10.000000", + "sbc": "10", "rusd": "2.345678", "sbcWei": "10000000", "rusdWei": "2345678000000000000", + "aggregateWei": "12345678000000000000", + "rusdSource": "evm", "sbcError": null } @@ -139,7 +144,7 @@ Per-command JSON shapes: | `wallet export` | `{address, privateKey}` | | `wallet sign` | `{address, signature}` | | `wallet verify` | `{address, valid}` (exit 1 when invalid) | -| `wallet balance` | `{address, totalUsd, sbc, rusd, sbcWei, rusdWei, sbcError}` | +| `wallet balance` | `{address, totalUsd, sbc, rusd, sbcWei, rusdWei, aggregateWei, rusdSource, sbcError}` | | `wallet send` | `{hash, receipt?}` (no `receipt` with `--no-wait`) | | `wallet x402` | `{status, headers, body, bodyEncoding, payment}` | | `call` | decoded return value (single value or array) | @@ -166,6 +171,7 @@ SBC defaults to `0x33ad9e4BD16B69B5BFdED37D8B5D9fF9aba014Fb`, its address on bot - **RUSD** is the native gas token (18 decimals). `wallet send … RUSD` is a native value transfer. - **SBC** is an ERC-20 stablecoin (6 decimals). `wallet send … SBC` calls `transfer(address,uint256)` on the SBC contract. +- `eth_getBalance` on Radius reports native RUSD **plus** SBC valued 1:1. `wallet balance` reads the two apart (`rusd` is native only, `rusdSource` says how) and `totalUsd` is the spendable total, so SBC is never counted twice. - Radius uses **fixed gas pricing**. All transactions will execute with the network gas price (n.b. they will fail if the requested gas price is too low). - If the account holds SBC but lacks RUSD, the network's Turnstile auto-converts SBC to RUSD inline for zero additional gas. diff --git a/packages/cli/src/commands/wallet.ts b/packages/cli/src/commands/wallet.ts index d3f044d..baa5852 100644 --- a/packages/cli/src/commands/wallet.ts +++ b/packages/cli/src/commands/wallet.ts @@ -1,47 +1,18 @@ import { Command } from 'commander'; import { confirm, password as promptPassword } from '@inquirer/prompts'; import { readFileSync } from 'node:fs'; -import { - encodeFunctionData, - formatEther, - formatUnits, - isAddress, - parseEther, - parseUnits, - verifyMessage, - type Address, - type Hex, -} from 'viem'; +import { encodeFunctionData, isAddress, parseEther, verifyMessage, type Address, type Hex } from 'viem'; 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 { 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'; - -const SBC_DECIMALS = 6; -const ERC20_TRANSFER_ABI = [ - { - type: 'function', - name: 'transfer', - stateMutability: 'nonpayable', - inputs: [ - { name: 'to', type: 'address' }, - { name: 'amount', type: 'uint256' }, - ], - outputs: [{ name: '', type: 'bool' }], - }, - { - type: 'function', - name: 'balanceOf', - stateMutability: 'view', - inputs: [{ name: 'owner', type: 'address' }], - outputs: [{ name: '', type: 'uint256' }], - }, -] as const; +import type { TokenInput } from 'radius-sdk/client'; function readMessageArg(arg: string, raw: boolean): string | { raw: Hex } { const text = arg === '-' ? readFileSync(0, 'utf8') : arg; @@ -243,46 +214,18 @@ export function registerWallet(program: Command): void { } const client = makePublicClient(cfg); - const rusdWei = await client.getBalance({ address }); - const rusd = formatEther(rusdWei); - - let sbc = '0'; - let sbcRawWei = 0n; - let sbcError: string | null = null; - try { - sbcRawWei = await client.readContract({ - address: cfg.sbcAddress!, - abi: ERC20_TRANSFER_ABI, - functionName: 'balanceOf', - args: [address], - }); - sbc = formatUnits(sbcRawWei, SBC_DECIMALS); - } catch (e) { - sbcError = e instanceof Error ? e.message : String(e); - } - - const total = Number(rusd) + Number(sbc); + const report = await readBalances(client, cfg, address); if (opts.json) { - console.log( - jsonStringify({ - address, - totalUsd: total, - sbc, - rusd, - sbcWei: sbcRawWei.toString(), - rusdWei: rusdWei.toString(), - sbcError, - }), - ); + console.log(jsonStringify(report)); return; } console.log(`Address: ${address}`); - if (sbcError) { - console.log(`Balance: $${rusd} ($${formatUsd(rusd)} RUSD; SBC unavailable)`); + if (report.sbcError) { + console.log(`Balance: $${formatUsdShort(report.totalUsd)} ($${formatUsd(report.rusd)} RUSD; SBC unavailable: ${report.sbcError})`); } else { console.log( - `Balance: $${formatUsdShort(total)} ($${formatUsd(sbc)} SBC + $${formatUsd(rusd)} RUSD)`, + `Balance: $${formatUsdShort(report.totalUsd)} ($${formatUsd(report.sbc)} SBC + $${formatUsd(report.rusd)} RUSD)`, ); } }); @@ -294,6 +237,7 @@ export function registerWallet(program: Command): void { 'Send tokens. Forms:', ' radius-cli wallet send RUSD — native value transfer', ' radius-cli wallet send SBC — ERC-20 transfer of SBC', + ' radius-cli wallet send 0xToken — ERC-20 transfer of any token', ' radius-cli wallet send "" [args…] — call any function', ].join('\n '), ) @@ -312,21 +256,15 @@ export function registerWallet(program: Command): void { return; } - // Form B: symbol form — exactly 3 args, last is RUSD or SBC. + // Form B: symbol form — exactly 3 args, last is RUSD, SBC or an ERC-20 address. if (args.length === 3) { const [to, amount, rawSymbol] = args; - const symbol = rawSymbol.toUpperCase(); - if (symbol === 'RUSD') { + if (rawSymbol.toUpperCase() === 'RUSD') { await sendNative(cfg, to, amount, opts, wait, gas); return; } - if (symbol === 'SBC') { - if (!cfg.sbcAddress) { - throw new Error('SBC contract address is not configured. Set RADIUS_SBC_ADDRESS or pass --sbc.'); - } - await sendErc20(cfg, cfg.sbcAddress, to, amount, SBC_DECIMALS, opts, wait, gas); - return; - } + await sendErc20(cfg, parseTokenArg(cfg, rawSymbol), to, amount, opts, wait, gas); + return; } const header = args.length === 0 @@ -339,6 +277,7 @@ export function registerWallet(program: Command): void { 'Supported forms:', ' radius-cli wallet send RUSD — native value transfer', ' radius-cli wallet send SBC — ERC-20 transfer of SBC', + ' radius-cli wallet send 0xToken — ERC-20 transfer of any token', ' radius-cli wallet send "" [args…] — call any function', ].join('\n'), ); @@ -386,12 +325,16 @@ async function sendNative( await reportTx(publicClient, hash, opts, wait); } +/** + * ERC-20 transfer through the SDK's `transfer` action: it parses the display amount with the token's + * decimals (read on-chain for a bare address) and encodes the call. The receipt is awaited here, not + * by the SDK, so `--no-wait` and the `{hash, receipt}` output stay the same as for a native send. + */ async function sendErc20( cfg: ReturnType, - token: Address, + token: TokenInput, to: string, amount: string, - decimals: number, opts: GlobalOptions, wait: boolean, gas: bigint | undefined, @@ -400,22 +343,7 @@ async function sendErc20( const account = await requireAccount(cfg, opts.privateKey); const publicClient = makePublicClient(cfg); const walletClient = makeWalletClient(cfg, account); - const data = encodeFunctionData({ - abi: ERC20_TRANSFER_ABI, - functionName: 'transfer', - args: [to as Address, parseUnits(amount, decimals)], - }); - const gasPrice = await publicClient.getGasPrice(); - - const hash = await walletClient.sendTransaction({ - account, - to: token, - data, - gasPrice, - gas, - type: 'legacy', - chain: cfg.chain, - }); + const { hash } = await walletClient.transfer({ token, to: to as Address, amount: parseAmountArg(amount), gas, wait: false }); await reportTx(publicClient, hash, opts, wait); } diff --git a/packages/cli/src/lib/client.ts b/packages/cli/src/lib/client.ts index df52044..65a3faf 100644 --- a/packages/cli/src/lib/client.ts +++ b/packages/cli/src/lib/client.ts @@ -1,17 +1,28 @@ import { createPublicClient, createWalletClient, http, type Account } from 'viem'; +import { erc20Actions, radiusActions } from 'radius-sdk/client'; import type { ResolvedConfig } from '../types.js'; +import { sbcToken } from './erc20.js'; +/** + * Public client extended with the SDK's balance (`getBalances`, `getTokenBalance`, …) and ERC-20 + * read actions (`getAllowance`, `getTokenMetadata`, `getTransfers`, …). ERC-20 actions default to + * the configured SBC contract when no `token` is given. + */ export function makePublicClient(cfg: ResolvedConfig) { + const token = sbcToken(cfg); return createPublicClient({ chain: cfg.chain, transport: http(cfg.rpcUrl), - }); + }) + .extend(radiusActions({ tokens: [token] })) + .extend(erc20Actions({ token })); } +/** Wallet client extended with the SDK's ERC-20 write actions (`transfer`, `approve`, `transferFrom`). */ export function makeWalletClient(cfg: ResolvedConfig, account: Account) { return createWalletClient({ account, chain: cfg.chain, transport: http(cfg.rpcUrl), - }); + }).extend(erc20Actions({ token: sbcToken(cfg) })); } diff --git a/packages/cli/src/lib/erc20.ts b/packages/cli/src/lib/erc20.ts new file mode 100644 index 0000000..492e55a --- /dev/null +++ b/packages/cli/src/lib/erc20.ts @@ -0,0 +1,85 @@ +/** + * Token arguments and balance reads for the wallet commands, on top of `radius-sdk/client`. + * A token argument is either the symbol `SBC` (the configured SBC contract, 6 decimals known up + * front) or any 0x address, whose decimals and symbol the SDK reads on-chain when needed. + */ +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 type { ResolvedConfig } from '../types.js'; + +/** The configured SBC contract as a balance/ERC-20 token: `--sbc` / `RADIUS_SBC_ADDRESS`, else the canonical address. */ +export function sbcToken(cfg: ResolvedConfig): BalanceToken { + return { address: cfg.sbcAddress ?? SBC.address, symbol: SBC.symbol, decimals: SBC.decimals, convertible: true }; +} + +/** `SBC` (any case) or a 0x token address. */ +export function parseTokenArg(cfg: ResolvedConfig, arg: string): TokenInput { + const trimmed = arg.trim(); + if (trimmed.toUpperCase() === 'SBC') return sbcToken(cfg); + if (isAddress(trimmed)) return trimmed as Address; + throw new Error(`Token must be SBC or a 0x contract address, got: ${arg}`); +} + +/** A display amount like `1.5`, parsed with the token's decimals by the SDK. */ +export function parseAmountArg(arg: string): TokenAmount { + const trimmed = arg.trim(); + if (!/^\d+(\.\d+)?$/.test(trimmed)) throw new Error(`Amount must be a decimal number like 1.5, got: ${arg}`); + return trimmed; +} + +export interface BalanceReport { + address: Address; + /** Native RUSD plus SBC at face value, in display units (what the account can spend). */ + totalUsd: number; + /** SBC only, display units. */ + sbc: string; + /** Native RUSD only (what the EVM `BALANCE` opcode sees), display units. */ + rusd: string; + sbcWei: string; + rusdWei: string; + /** What `eth_getBalance` reports: native RUSD plus convertible SBC, in wei. */ + aggregateWei: string; + /** How the native RUSD figure was obtained: `evm` (read via init code), `derived` (aggregate minus SBC), or `aggregate` when the SBC read failed. */ + rusdSource: 'evm' | 'derived' | 'aggregate'; + sbcError: string | null; +} + +/** + * Native and SBC balances kept apart. On Radius `eth_getBalance` already counts SBC 1:1, so the + * SDK's `getBalances` is what keeps the total from double counting. If the SBC read fails the + * aggregate is reported as RUSD, as `eth_getBalance` would, with the error attached. + */ +export async function readBalances(client: BalanceClient, cfg: ResolvedConfig, address: Address): Promise { + const token = sbcToken(cfg); + try { + const { native, tokens, totalFormatted } = await getBalances(client, { address, tokens: [token] }); + const sbc = tokens[0]; + return { + address, + totalUsd: Number(totalFormatted), + sbc: sbc.formatted, + rusd: native.rawFormatted, + sbcWei: sbc.atomic.toString(), + rusdWei: native.raw.toString(), + aggregateWei: native.aggregate.toString(), + rusdSource: native.rawSource, + sbcError: null, + }; + } catch (e) { + const aggregate = await getAggregateBalance(client, { address }); + const rusd = formatUnits(aggregate, cfg.chain.nativeCurrency.decimals); + return { + address, + totalUsd: Number(rusd), + sbc: '0', + rusd, + sbcWei: '0', + rusdWei: aggregate.toString(), + aggregateWei: aggregate.toString(), + rusdSource: 'aggregate', + sbcError: e instanceof Error ? e.message : String(e), + }; + } +} diff --git a/packages/cli/tests/erc20.test.ts b/packages/cli/tests/erc20.test.ts new file mode 100644 index 0000000..7174fc8 --- /dev/null +++ b/packages/cli/tests/erc20.test.ts @@ -0,0 +1,97 @@ +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 type { ResolvedConfig } from '../src/types.js'; + +const OWNER: Address = '0x4f2d8a3b1c0e5d9b8e7a6c5d4e3f2a1b0c9d8e7f'; +const USDX: Address = '0x2222222222222222222222222222222222222222'; +const CUSTOM_SBC: Address = '0x3333333333333333333333333333333333333333'; + +const cfg: ResolvedConfig = { network: 'testnet', chain: radiusTestnetChain, rpcUrl: 'http://unused', sbcAddress: SBC.address, keystorePath: '', password: '' }; +const word = (v: bigint | number) => numberToHex(BigInt(v), { size: 32 }); +const str = (s: string) => encodeAbiParameters([{ type: 'string' }], [s]); + +/** A read-only Radius node: eth_getBalance aggregates native + SBC, the init-code eth_call returns native only. */ +function readNode(opts: { native: bigint; sbc: bigint; sbcFails?: boolean }) { + const transport = custom( + { + async request({ method, params }: { method: string; params?: unknown[] }) { + const p = (params ?? []) as never[]; + switch (method) { + case 'eth_chainId': + return numberToHex(radiusTestnetChain.id); + case 'eth_getBalance': + return numberToHex(opts.native + opts.sbc * 10n ** 12n); + case 'eth_call': { + const tx = p[0] as { to?: Address; data?: Hex }; + if (!tx.to) return word(opts.native); // nativeBalanceBytecode: EVM BALANCE of the account + const { functionName } = decodeFunctionData({ abi: erc20Abi, data: tx.data! }); + const isSbc = tx.to.toLowerCase() === SBC.address.toLowerCase(); + switch (functionName) { + case 'balanceOf': + if (opts.sbcFails) throw new Error('execution reverted'); + return word(opts.sbc); + case 'decimals': return word(isSbc ? 6 : 18); + case 'symbol': return str(isSbc ? 'SBC' : 'USDX'); + case 'name': return str(isSbc ? 'Stable Coin' : 'USDX Token'); + case 'totalSupply': return word(1n); + default: throw new Error(`unexpected view ${functionName}`); + } + } + default: + throw new Error(`unexpected RPC method ${method}`); + } + }, + }, + { retryCount: 0 }, + ); + return createPublicClient({ chain: radiusTestnetChain, transport }); +} + +describe('token arguments', () => { + it('SBC resolves to the configured contract with its decimals known up front', () => { + expect(parseTokenArg(cfg, 'SBC')).toEqual({ address: SBC.address, symbol: 'SBC', decimals: 6, convertible: true }); + expect(parseTokenArg({ ...cfg, sbcAddress: CUSTOM_SBC }, 'sbc')).toMatchObject({ address: CUSTOM_SBC, decimals: 6 }); + expect(sbcToken({ ...cfg, sbcAddress: undefined }).address).toBe(SBC.address); + }); + it('a 0x address is passed through for the SDK to read on-chain; anything else is rejected', () => { + expect(parseTokenArg(cfg, USDX)).toBe(USDX); + expect(() => parseTokenArg(cfg, 'RUSD')).toThrow(/SBC or a 0x contract address/); + expect(() => parseTokenArg(cfg, '0x1234')).toThrow(/SBC or a 0x contract address/); + }); + it('amounts stay display strings for the SDK to parse with the token decimals', () => { + expect(parseAmountArg('1.5')).toBe('1.5'); + expect(parseAmountArg(' 10 ')).toBe('10'); + expect(() => parseAmountArg('-1')).toThrow(/decimal number/); + expect(() => parseAmountArg('1e6')).toThrow(/decimal number/); + }); +}); + +describe('readBalances', () => { + it('keeps native RUSD and SBC apart so the total is not double counted', async () => { + const client = readNode({ native: 2_345_678_000_000_000_000n, sbc: 10_000_000n }); + const r = await readBalances(client, cfg, OWNER); + expect(r).toEqual({ + address: OWNER, + totalUsd: 12.345678, + sbc: '10', + rusd: '2.345678', + sbcWei: '10000000', + rusdWei: '2345678000000000000', + aggregateWei: '12345678000000000000', + rusdSource: 'evm', + sbcError: null, + }); + }); + it('falls back to the aggregate balance, with the error, when the SBC read fails', async () => { + const client = readNode({ native: 1_000_000_000_000_000_000n, sbc: 5_000_000n, sbcFails: true }); + const r = await readBalances(client, cfg, OWNER); + expect(r.sbcError).toMatch(/execution reverted/); + expect(r.rusdSource).toBe('aggregate'); + expect(r.rusd).toBe('6'); + expect(r.rusdWei).toBe('6000000000000000000'); + expect(r.sbc).toBe('0'); + expect(r.totalUsd).toBe(6); + }); +});