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

`wallet` runs its ERC-20 interactions on `radius-sdk/client`'s `erc20Actions` and balance actions. `wallet send <to> <amount> 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`.
10 changes: 8 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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) |
Expand All @@ -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.

Expand Down
114 changes: 21 additions & 93 deletions packages/cli/src/commands/wallet.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)`,
);
}
});
Expand All @@ -294,6 +237,7 @@ export function registerWallet(program: Command): void {
'Send tokens. Forms:',
' radius-cli wallet send <to> <amount> RUSD — native value transfer',
' radius-cli wallet send <to> <amount> SBC — ERC-20 transfer of SBC',
' radius-cli wallet send <to> <amount> 0xToken — ERC-20 transfer of any token',
' radius-cli wallet send <token> "<sig>" [args…] — call any function',
].join('\n '),
)
Expand All @@ -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
Expand All @@ -339,6 +277,7 @@ export function registerWallet(program: Command): void {
'Supported forms:',
' radius-cli wallet send <to> <amount> RUSD — native value transfer',
' radius-cli wallet send <to> <amount> SBC — ERC-20 transfer of SBC',
' radius-cli wallet send <to> <amount> 0xToken — ERC-20 transfer of any token',
' radius-cli wallet send <token> "<sig>" [args…] — call any function',
].join('\n'),
);
Expand Down Expand Up @@ -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<typeof resolveConfig>,
token: Address,
token: TokenInput,
to: string,
amount: string,
decimals: number,
opts: GlobalOptions,
wait: boolean,
gas: bigint | undefined,
Expand All @@ -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);
}

Expand Down
15 changes: 13 additions & 2 deletions packages/cli/src/lib/client.ts
Original file line number Diff line number Diff line change
@@ -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) }));
}
85 changes: 85 additions & 0 deletions packages/cli/src/lib/erc20.ts
Original file line number Diff line number Diff line change
@@ -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<BalanceReport> {
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),
};
}
}
Loading
Loading