diff --git a/.changeset/cli-erc20-commands.md b/.changeset/cli-erc20-commands.md new file mode 100644 index 0000000..c1eda7d --- /dev/null +++ b/.changeset/cli-erc20-commands.md @@ -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 [token]` (`max` for unlimited), `wallet allowance [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. diff --git a/packages/cli/README.md b/packages/cli/README.md index 7a2752f..77dcc92 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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. @@ -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 | diff --git a/packages/cli/src/commands/wallet.ts b/packages/cli/src/commands/wallet.ts index baa5852..fb9528d 100644 --- a/packages/cli/src/commands/wallet.ts +++ b/packages/cli/src/commands/wallet.ts @@ -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) { @@ -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('', 'address allowed to spend') + .argument('', '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 ', '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('', 'address allowed to spend') + .argument('[token]', TOKEN_ARG_HELP, 'SBC') + .option('--owner
', '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
', 'only transfers sent by this address') + .option('--to
', 'only transfers received by this address') + .option('--address
', 'transfers sent or received by this address (default: local account)') + .option('--all', 'every transfer of the token, no address filter') + .option('--blocks ', 'look back this many blocks from the latest (default: 10000)') + .option('--from-block ', 'first block to search (overrides --blocks)') + .option('--to-block ', '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
', 'only transfers sent by this address') + .option('--to
', 'only transfers received by this address') + .option('--address
', 'transfers sent or received by this address (default: local account)') + .option('--all', 'every transfer of the token, no address filter') + .option('--poll ', '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(); + 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((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, opts: GlobalOptions, subOpts: TransferSideOptions): Promise { + 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; diff --git a/packages/cli/src/lib/erc20.ts b/packages/cli/src/lib/erc20.ts index 492e55a..6006f75 100644 --- a/packages/cli/src/lib/erc20.ts +++ b/packages/cli/src/lib/erc20.ts @@ -3,10 +3,10 @@ * 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 { formatUnits, isAddress, maxUint256, type Address, type Hex } 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 { BalanceClient, BalanceToken, TokenAmount, TokenInput, TokenTransfer } from 'radius-sdk/client'; +import { getTokenMetadata, getBalances, getAggregateBalance, getTransfers } 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. */ @@ -22,13 +22,23 @@ export function parseTokenArg(cfg: ResolvedConfig, arg: string): TokenInput { 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. */ +/** A display amount like `1.5` (parsed with the token's decimals by the SDK) or `max` for an unlimited approval. */ 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}`); + if (trimmed.toLowerCase() === 'max') return maxUint256; + if (!/^\d+(\.\d+)?$/.test(trimmed)) { + throw new Error(`Amount must be a decimal number like 1.5 (or "max" for an unlimited approval), got: ${arg}`); + } return trimmed; } +/** Symbol and decimals of a token argument, read on-chain for a bare address. */ +export async function describeToken(client: BalanceClient, token: TokenInput): Promise { + if (typeof token !== 'string') return token; + const { address, symbol, decimals } = await getTokenMetadata(client, { token }); + return { address, symbol, decimals }; +} + export interface BalanceReport { address: Address; /** Native RUSD plus SBC at face value, in display units (what the account can spend). */ @@ -83,3 +93,70 @@ export async function readBalances(client: BalanceClient, cfg: ResolvedConfig, a }; } } + +/** Which side(s) of a `Transfer` to match. From `--from` / `--to`, else both directions of `address`. */ +export interface TransferSides { + from?: Address; + to?: Address; + /** Match transfers sent or received by this address (two queries, merged). */ + address?: Address; +} + +/** The `{from, to}` filters a side selection expands to: one for explicit sides, two for `address`, one empty for everything. */ +export function transferFilters(sides: TransferSides): { from?: Address; to?: Address }[] { + if (sides.from || sides.to) return [{ from: sides.from, to: sides.to }]; + if (sides.address) return [{ from: sides.address }, { to: sides.address }]; + return [{}]; +} + +export interface TransferRow { + token: Address; + symbol: string; + from: Address; + to: Address; + amount: string; + amountWei: string; + transactionHash: Hex; + blockNumber: string; + logIndex: number; +} + +export function toTransferRow(t: TokenTransfer, token: BalanceToken): TransferRow { + return { + token: t.token, + symbol: token.symbol, + from: t.from, + to: t.to, + amount: formatUnits(t.amount, token.decimals), + amountWei: t.amount.toString(), + transactionHash: t.transactionHash, + blockNumber: t.blockNumber.toString(), + logIndex: t.logIndex, + }; +} + +/** Dedupe (a self-transfer matches both directions) and order by block, then log index. */ +export function sortTransfers(transfers: TokenTransfer[]): TokenTransfer[] { + const seen = new Set(); + const unique = transfers.filter((t) => { + const key = `${t.transactionHash}:${t.logIndex}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return unique.sort((a, b) => (a.blockNumber === b.blockNumber ? a.logIndex - b.logIndex : a.blockNumber < b.blockNumber ? -1 : 1)); +} + +export interface ListTransfersOptions extends TransferSides { + token: TokenInput; + fromBlock?: bigint; + toBlock?: bigint; +} + +/** `Transfer` logs of a token over a block range, one `eth_getLogs` per side filter, merged. */ +export async function listTransfers(client: BalanceClient, opts: ListTransfersOptions): Promise { + const results = await Promise.all( + transferFilters(opts).map((f) => getTransfers(client, { token: opts.token, ...f, fromBlock: opts.fromBlock, toBlock: opts.toBlock })), + ); + return sortTransfers(results.flat()); +} diff --git a/packages/cli/tests/erc20.test.ts b/packages/cli/tests/erc20.test.ts index 7174fc8..5b7b237 100644 --- a/packages/cli/tests/erc20.test.ts +++ b/packages/cli/tests/erc20.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { createPublicClient, custom, decodeFunctionData, erc20Abi, encodeAbiParameters, numberToHex, type Address, type Hex } from 'viem'; +import { createPublicClient, custom, decodeFunctionData, erc20Abi, encodeAbiParameters, encodeEventTopics, maxUint256, numberToHex, type Address, type Hex } from 'viem'; import { radiusTestnetChain, SBC } from 'radius-sdk'; -import { parseAmountArg, parseTokenArg, readBalances, sbcToken } from '../src/lib/erc20.js'; +import { describeToken, listTransfers, parseAmountArg, parseTokenArg, readBalances, sbcToken, sortTransfers, toTransferRow, transferFilters } from '../src/lib/erc20.js'; +import type { TokenTransfer } from 'radius-sdk/client'; import type { ResolvedConfig } from '../src/types.js'; const OWNER: Address = '0x4f2d8a3b1c0e5d9b8e7a6c5d4e3f2a1b0c9d8e7f'; @@ -13,7 +14,20 @@ 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 }) { +type FakeLog = { from: Address; to: Address; amount: bigint; block: bigint; logIndex: number; hash: Hex }; +const transferLog = (l: FakeLog) => ({ + address: SBC.address, + topics: encodeEventTopics({ abi: erc20Abi, eventName: 'Transfer', args: { from: l.from, to: l.to } }), + data: word(l.amount), + blockNumber: numberToHex(l.block), + transactionHash: l.hash, + logIndex: numberToHex(l.logIndex), + transactionIndex: '0x0', + blockHash: numberToHex(l.block, { size: 32 }), + removed: false, +}); + +function readNode(opts: { native: bigint; sbc: bigint; sbcFails?: boolean; logs?: FakeLog[]; filters?: unknown[] }) { const transport = custom( { async request({ method, params }: { method: string; params?: unknown[] }) { @@ -23,6 +37,13 @@ function readNode(opts: { native: bigint; sbc: bigint; sbcFails?: boolean }) { return numberToHex(radiusTestnetChain.id); case 'eth_getBalance': return numberToHex(opts.native + opts.sbc * 10n ** 12n); + case 'eth_getLogs': { + const filter = p[0] as { topics: (Hex | null)[] }; + opts.filters?.push(filter); + const [, fromTopic, toTopic] = filter.topics; + const matches = (topic: Hex | null | undefined, addr: Address) => !topic || topic.toLowerCase() === numberToHex(BigInt(addr), { size: 32 }).toLowerCase(); + return (opts.logs ?? []).filter((l) => matches(fromTopic, l.from) && matches(toTopic, l.to)).map(transferLog); + } 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 @@ -60,12 +81,18 @@ describe('token arguments', () => { 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', () => { + it('amounts stay display strings for the SDK to parse; "max" is an unlimited approval', () => { expect(parseAmountArg('1.5')).toBe('1.5'); expect(parseAmountArg(' 10 ')).toBe('10'); + expect(parseAmountArg('MAX')).toBe(maxUint256); expect(() => parseAmountArg('-1')).toThrow(/decimal number/); expect(() => parseAmountArg('1e6')).toThrow(/decimal number/); }); + it('describeToken reads symbol and decimals only for a bare address', async () => { + const client = readNode({ native: 0n, sbc: 0n }); + expect(await describeToken(client, sbcToken(cfg))).toEqual(sbcToken(cfg)); + expect(await describeToken(client, USDX)).toEqual({ address: USDX, symbol: 'USDX', decimals: 18 }); + }); }); describe('readBalances', () => { @@ -95,3 +122,40 @@ describe('readBalances', () => { expect(r.totalUsd).toBe(6); }); }); + +describe('transfers', () => { + const H = (n: number): Hex => numberToHex(BigInt(n), { size: 32 }); + const t = (o: Partial): TokenTransfer => ({ token: SBC.address, from: OWNER, to: USDX, amount: 1n, transactionHash: H(1), blockNumber: 1n, logIndex: 0, ...o }); + + it('explicit sides win, address expands to both directions, nothing means everything', () => { + expect(transferFilters({ from: OWNER, to: USDX, address: CUSTOM_SBC })).toEqual([{ from: OWNER, to: USDX }]); + expect(transferFilters({ to: USDX })).toEqual([{ from: undefined, to: USDX }]); + expect(transferFilters({ address: OWNER })).toEqual([{ from: OWNER }, { to: OWNER }]); + expect(transferFilters({})).toEqual([{}]); + }); + it('sortTransfers dedupes a self-transfer seen from both sides and orders by block then log index', () => { + const a = t({ blockNumber: 5n, logIndex: 2, transactionHash: H(5) }); + const b = t({ blockNumber: 5n, logIndex: 1, transactionHash: H(5) }); + const c = t({ blockNumber: 2n, logIndex: 9, transactionHash: H(2) }); + expect(sortTransfers([a, c, b, { ...a }])).toEqual([c, b, a]); + }); + it('toTransferRow formats with the token decimals and stringifies bigints', () => { + expect(toTransferRow(t({ amount: 1_500_000n, blockNumber: 77n }), sbcToken(cfg))).toEqual({ + token: SBC.address, symbol: 'SBC', from: OWNER, to: USDX, amount: '1.5', amountWei: '1500000', transactionHash: H(1), blockNumber: '77', logIndex: 0, + }); + }); + it('listTransfers queries each side and merges the decoded logs', async () => { + const filters: { topics: (Hex | null)[]; fromBlock?: Hex; toBlock?: Hex }[] = []; + const logs: FakeLog[] = [ + { from: OWNER, to: USDX, amount: 2_000_000n, block: 10n, logIndex: 0, hash: H(10) }, + { from: USDX, to: OWNER, amount: 500_000n, block: 8n, logIndex: 3, hash: H(8) }, + { from: USDX, to: CUSTOM_SBC, amount: 1n, block: 9n, logIndex: 0, hash: H(9) }, // not ours + { from: OWNER, to: OWNER, amount: 7n, block: 9n, logIndex: 1, hash: H(9) }, // matches both sides once + ]; + const client = readNode({ native: 0n, sbc: 0n, logs, filters }); + const got = await listTransfers(client, { token: sbcToken(cfg), address: OWNER, fromBlock: 5n, toBlock: 12n }); + expect(got.map((x) => [x.blockNumber, x.logIndex, x.amount])).toEqual([[8n, 3, 500_000n], [9n, 1, 7n], [10n, 0, 2_000_000n]]); + expect(filters).toHaveLength(2); + expect(filters.map((f) => [f.fromBlock, f.toBlock])).toEqual([[numberToHex(5n), numberToHex(12n)], [numberToHex(5n), numberToHex(12n)]]); + }); +});