From 775d4626766a0231d271c535b18cf4d71f641d3d Mon Sep 17 00:00:00 2001 From: blake duncan Date: Fri, 10 Apr 2026 10:51:55 -0400 Subject: [PATCH 1/2] feat: add safe ERC-20 approve command Add an approve command that makes ERC-20 allowance updates explicit and safer for developers, with current-allowance readback, unlimited confirmation, and overwrite protection for tokens that require zeroing first. Made-with: Cursor --- src/commands/approve.ts | 360 ++++++++++++++++ src/index.ts | 4 +- tests/commands/approve.test.ts | 735 ++++++++++++++++++++++++++++++++ tests/live/approve.live.test.ts | 165 +++++++ 4 files changed, 1263 insertions(+), 1 deletion(-) create mode 100644 src/commands/approve.ts create mode 100644 tests/commands/approve.test.ts create mode 100644 tests/live/approve.live.test.ts diff --git a/src/commands/approve.ts b/src/commands/approve.ts new file mode 100644 index 0000000..d8d2867 --- /dev/null +++ b/src/commands/approve.ts @@ -0,0 +1,360 @@ +import { Command } from "commander"; +import { + decodeFunctionResult, + encodeFunctionData, + erc20Abi, + maxUint256, + type Address, +} from "viem"; +import { buildWalletClient } from "../lib/smart-wallet.js"; +import { clientFromFlags } from "../lib/resolve.js"; +import type { AlchemyClient } from "../lib/client-interface.js"; +import { isInteractiveAllowed } from "../lib/interaction.js"; +import { validateAddress } from "../lib/validators.js"; +import { isJSONMode, printJSON } from "../lib/output.js"; +import { + CLIError, + ErrorCode, + exitWithError, + errInvalidArgs, +} from "../lib/errors.js"; +import { promptConfirm } from "../lib/terminal-ui.js"; +import { withSpinner, printKeyValueBox, green, dim } from "../lib/ui.js"; +import { parseAmount, fetchTokenDecimals } from "./send/shared.js"; + +const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as Address; + +interface ApproveOpts { + spender: string; + amount?: string; + unlimited?: boolean; + revoke?: boolean; + resetFirst?: boolean; + yes?: boolean; +} + +type TokenMeta = { + decimals: number; + symbol: string; +}; + +type ApprovalRequest = + | { + kind: "exact"; + inputAmount: string; + rawAmount: bigint; + displayAmount: string; + } + | { + kind: "unlimited"; + inputAmount: null; + rawAmount: bigint; + displayAmount: string; + } + | { + kind: "revoke"; + inputAmount: null; + rawAmount: bigint; + displayAmount: string; + }; + +function isNativeToken(address: string): boolean { + return address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); +} + +function formatTokenAmount(rawAmount: bigint, decimals: number): string { + const str = rawAmount.toString().padStart(decimals + 1, "0"); + const whole = str.slice(0, str.length - decimals) || "0"; + const frac = str.slice(str.length - decimals).replace(/0+$/, ""); + return frac ? `${whole}.${frac}` : whole; +} + +function buildApprovalRequest(opts: ApproveOpts, tokenMeta: TokenMeta): ApprovalRequest { + validateApprovalMode(opts); + + if (opts.revoke) { + return { + kind: "revoke", + inputAmount: null, + rawAmount: 0n, + displayAmount: `0 ${tokenMeta.symbol} (revoke)`, + }; + } + + if (opts.unlimited) { + return { + kind: "unlimited", + inputAmount: null, + rawAmount: maxUint256, + displayAmount: `Unlimited ${tokenMeta.symbol}`, + }; + } + + const inputAmount = opts.amount ?? ""; + return { + kind: "exact", + inputAmount, + rawAmount: parseAmount(inputAmount, tokenMeta.decimals), + displayAmount: `${inputAmount} ${tokenMeta.symbol}`, + }; +} + +function validateApprovalMode(opts: ApproveOpts): void { + const modeCount = [ + opts.amount !== undefined, + opts.unlimited === true, + opts.revoke === true, + ].filter(Boolean).length; + + if (modeCount !== 1) { + throw errInvalidArgs("Provide exactly one of --amount, --unlimited, or --revoke."); + } + + if (opts.resetFirst && opts.revoke) { + throw errInvalidArgs("Do not use --reset-first with --revoke. Revoking already sets allowance to 0."); + } +} + +function createApproveStatusError( + id: string, + status: string, + txHash: string | undefined, +): CLIError { + const details = [ + `Status: ${status}`, + `Call ID: ${id}`, + txHash ? `Transaction hash: ${txHash}` : undefined, + ] + .filter((line): line is string => Boolean(line)) + .join("\n"); + + return new CLIError( + ErrorCode.RPC_ERROR, + `Approval failed with status "${status}".`, + undefined, + details, + { + callId: id, + status, + txHash: txHash ?? null, + }, + ); +} + +async function readCurrentAllowance( + client: AlchemyClient, + tokenAddress: string, + owner: Address, + spender: string, +): Promise { + const data = encodeFunctionData({ + abi: erc20Abi, + functionName: "allowance", + args: [owner, spender as Address], + }); + + try { + const raw = await client.call("eth_call", [{ to: tokenAddress, data }, "latest"]) as `0x${string}`; + return decodeFunctionResult({ + abi: erc20Abi, + functionName: "allowance", + data: raw, + }) as bigint; + } catch (err) { + throw errInvalidArgs( + `Failed to read current allowance for ${tokenAddress}. ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +async function confirmUnlimitedApproval( + program: Command, + tokenSymbol: string, + spender: string, + opts: ApproveOpts, +): Promise { + if (!opts.unlimited) return true; + if (opts.yes) return true; + + if (!isInteractiveAllowed(program)) { + throw errInvalidArgs("Unlimited approval requires confirmation. Re-run with --yes to confirm."); + } + + const proceed = await promptConfirm({ + message: `Grant unlimited ${tokenSymbol} allowance to ${spender}?`, + initialValue: false, + cancelMessage: "Cancelled unlimited approval.", + }); + if (proceed === null) return false; + if (!proceed) { + console.log(` ${dim("Skipped unlimited approval.")}`); + return false; + } + return true; +} + +function requiresAllowanceReset( + currentAllowance: bigint, + requestedAllowance: bigint, +): boolean { + return currentAllowance > 0n && requestedAllowance > 0n && currentAllowance !== requestedAllowance; +} + +export function registerApprove(program: Command) { + program + .command("approve") + .description("Approve an ERC-20 token allowance for a spender") + .argument("", "ERC-20 token contract address") + .requiredOption("--spender ", "Address to approve spending") + .option("--amount ", "Amount to approve in decimal token units (for example, 100.5)") + .option("--unlimited", "Approve the maximum allowance") + .option("--revoke", "Revoke approval (set allowance to 0)") + .option("--reset-first", "Clear an existing non-zero allowance before setting a new non-zero allowance") + .option("-y, --yes", "Skip confirmation prompt for unlimited approval") + .addHelpText( + "after", + ` +Examples: + alchemy approve 0xUSDC --spender 0xRouter --amount 100 + alchemy approve 0xUSDC --spender 0xRouter --amount 100 --reset-first + alchemy approve 0xUSDC --spender 0xRouter --unlimited + alchemy approve 0xUSDC --spender 0xRouter --unlimited --yes + alchemy approve 0xUSDC --spender 0xRouter --revoke`, + ) + .action(async (tokenArg: string, opts: ApproveOpts) => { + try { + await performApprove(program, tokenArg, opts); + } catch (err) { + exitWithError(err); + } + }); +} + +async function performApprove( + program: Command, + tokenArg: string, + opts: ApproveOpts, +) { + validateAddress(tokenArg); + validateAddress(opts.spender); + + if (isNativeToken(tokenArg)) { + throw errInvalidArgs("Native tokens do not support ERC-20 approvals. Provide an ERC-20 token contract address."); + } + + validateApprovalMode(opts); + + const { client, network, address: from, paymaster } = buildWalletClient(program); + const rpcClient = clientFromFlags(program); + const tokenMeta = await fetchTokenDecimals(program, tokenArg); + const approval = buildApprovalRequest(opts, tokenMeta); + + if (!await confirmUnlimitedApproval(program, tokenMeta.symbol, opts.spender, opts)) { + return; + } + + const currentAllowance = await readCurrentAllowance(rpcClient, tokenArg, from, opts.spender); + const currentAllowanceDisplay = `${formatTokenAmount(currentAllowance, tokenMeta.decimals)} ${tokenMeta.symbol}`; + const shouldResetFirst = opts.resetFirst === true && requiresAllowanceReset(currentAllowance, approval.rawAmount); + + if (requiresAllowanceReset(currentAllowance, approval.rawAmount) && !opts.resetFirst) { + throw errInvalidArgs( + `Current allowance for ${tokenMeta.symbol} is already non-zero. Some ERC-20 tokens reject changing a non-zero allowance directly. Re-run with --reset-first to set the allowance to 0 before applying the new value.`, + ); + } + + const calls = shouldResetFirst + ? [ + { + to: tokenArg as Address, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [opts.spender as Address, 0n], + }), + }, + { + to: tokenArg as Address, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [opts.spender as Address, approval.rawAmount], + }), + }, + ] + : [ + { + to: tokenArg as Address, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [opts.spender as Address, approval.rawAmount], + }), + }, + ]; + + const { id } = await withSpinner( + "Sending approval…", + "Approval submitted", + () => client.sendCalls({ + calls, + capabilities: paymaster ? { paymaster } : undefined, + }), + ); + + const status = await withSpinner( + "Waiting for confirmation…", + "Approval confirmed", + () => client.waitForCallsStatus({ id }), + ); + + const txHash = status.receipts?.[0]?.transactionHash; + const approvalStatus = status.status ?? "unknown"; + if (approvalStatus !== "success") { + throw createApproveStatusError(id, approvalStatus, txHash); + } + + if (isJSONMode()) { + printJSON({ + from, + token: tokenArg, + tokenSymbol: tokenMeta.symbol, + tokenDecimals: tokenMeta.decimals, + spender: opts.spender, + approvalType: approval.kind, + inputAmount: approval.inputAmount, + requestedAllowanceRaw: approval.rawAmount.toString(), + requestedAllowanceDisplay: approval.displayAmount, + currentAllowanceRaw: currentAllowance.toString(), + currentAllowanceDisplay, + resetFirst: shouldResetFirst, + network, + sponsored: !!paymaster, + txHash: txHash ?? null, + callId: id, + status: approvalStatus, + }); + } else { + const pairs: [string, string][] = [ + ["From", from], + ["Token", `${tokenMeta.symbol} (${tokenArg})`], + ["Spender", opts.spender], + ["Current Allowance", currentAllowanceDisplay], + ["Requested Allowance", green(approval.displayAmount)], + ...(shouldResetFirst ? [["Allowance Update", "Reset to 0, then approve"]] as [string, string][] : []), + ["Network", network], + ]; + + if (paymaster) { + pairs.push(["Gas", green("Sponsored")]); + } + + if (txHash) { + pairs.push(["Tx Hash", txHash]); + } + + pairs.push(["Status", green("Confirmed")]); + + printKeyValueBox(pairs); + } +} diff --git a/src/index.ts b/src/index.ts index 4fb405d..64391a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,7 @@ import { registerCompletions } from "./commands/completions.js"; import { registerSend } from "./commands/send/index.js"; import { registerContract } from "./commands/contract.js"; import { registerSwap } from "./commands/swap.js"; +import { registerApprove } from "./commands/approve.js"; import { registerAgentPrompt } from "./commands/agent-prompt.js"; import { registerUpdateCheck } from "./commands/update-check.js"; import { isInteractiveAllowed } from "./lib/interaction.js"; @@ -71,7 +72,7 @@ const ROOT_COMMAND_PILLARS = [ }, { label: "Execution", - commands: ["send", "contract", "swap"], + commands: ["send", "contract", "swap", "approve"], }, { label: "Wallets", @@ -450,6 +451,7 @@ registerSimulate(program); registerSend(program); registerContract(program); registerSwap(program); +registerApprove(program); // Wallets registerWallet(program); diff --git a/tests/commands/approve.test.ts b/tests/commands/approve.test.ts new file mode 100644 index 0000000..3e6516e --- /dev/null +++ b/tests/commands/approve.test.ts @@ -0,0 +1,735 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; +import { decodeFunctionData, erc20Abi, maxUint256 } from "viem"; + +const TOKEN = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +const SPENDER = "0x1111111111111111111111111111111111111111"; +const FROM = "0x2222222222222222222222222222222222222222"; + +function encodeUint256Result(value: bigint): `0x${string}` { + return `0x${value.toString(16).padStart(64, "0")}`; +} + +function mockRpcClient(currentAllowance = 0n) { + return { + call: vi.fn().mockImplementation((method: string) => { + if (method === "alchemy_getTokenMetadata") { + return Promise.resolve({ decimals: 6, symbol: "USDC" }); + } + if (method === "eth_call") { + return Promise.resolve(encodeUint256Result(currentAllowance)); + } + return Promise.reject(new Error(`Unexpected method: ${method}`)); + }), + }; +} + +describe("approve command", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("approves an explicit amount, includes allowance context, and encodes approve calldata", async () => { + const printJSON = vi.fn(); + const sendCalls = vi.fn().mockResolvedValue({ id: "call-123" }); + const waitForCallsStatus = vi.fn().mockResolvedValue({ + status: "success", + receipts: [{ transactionHash: "0xtxhash" }], + }); + const rpcClient = mockRpcClient(0n); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { sendCalls, waitForCallsStatus }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => rpcClient, + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_l: string, _d: string, fn: () => Promise) => fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--amount", "100", + ], { from: "node" }); + + const sentCall = sendCalls.mock.calls[0]?.[0]?.calls?.[0]; + const decoded = decodeFunctionData({ abi: erc20Abi, data: sentCall.data }); + + expect(sentCall).toEqual(expect.objectContaining({ to: TOKEN })); + expect(decoded.functionName).toBe("approve"); + expect(decoded.args).toEqual([SPENDER, 100000000n]); + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + token: TOKEN, + spender: SPENDER, + approvalType: "exact", + inputAmount: "100", + requestedAllowanceRaw: "100000000", + currentAllowanceRaw: "0", + currentAllowanceDisplay: "0 USDC", + resetFirst: false, + status: "success", + txHash: "0xtxhash", + })); + }); + + it("requires --reset-first when replacing a non-zero allowance with a different non-zero value", async () => { + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: {}, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => mockRpcClient(50000000n), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: vi.fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => ({ + ...(await vi.importActual("../../src/lib/errors.js")), + exitWithError, + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--amount", "100", + ], { from: "node" }); + + expect(exitWithError).toHaveBeenCalledWith(expect.objectContaining({ + code: "INVALID_ARGS", + message: expect.stringContaining("Re-run with --reset-first"), + })); + }); + + it("requires an explicit confirmation bypass for unlimited approval in non-interactive mode", async () => { + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: {}, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => mockRpcClient(), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: vi.fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => ({ + ...(await vi.importActual("../../src/lib/errors.js")), + exitWithError, + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--unlimited", + ], { from: "node" }); + + expect(exitWithError).toHaveBeenCalledWith(expect.objectContaining({ + code: "INVALID_ARGS", + message: "Unlimited approval requires confirmation. Re-run with --yes to confirm.", + })); + }); + + it("submits unlimited approval when explicitly requested", async () => { + const printJSON = vi.fn(); + const sendCalls = vi.fn().mockResolvedValue({ id: "call-456" }); + const waitForCallsStatus = vi.fn().mockResolvedValue({ + status: "success", + receipts: [{ transactionHash: "0xtx2" }], + }); + const rpcClient = mockRpcClient(0n); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { sendCalls, waitForCallsStatus }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => rpcClient, + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_l: string, _d: string, fn: () => Promise) => fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--unlimited", + "--yes", + ], { from: "node" }); + + const sentCall = sendCalls.mock.calls[0]?.[0]?.calls?.[0]; + const decoded = decodeFunctionData({ abi: erc20Abi, data: sentCall.data }); + + expect(decoded.args).toEqual([SPENDER, maxUint256]); + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + approvalType: "unlimited", + inputAmount: null, + requestedAllowanceRaw: maxUint256.toString(), + currentAllowanceRaw: "0", + resetFirst: false, + })); + }); + + it("prompts before unlimited approval in interactive mode and submits when confirmed", async () => { + const printKeyValueBox = vi.fn(); + const sendCalls = vi.fn().mockResolvedValue({ id: "call-interactive" }); + const waitForCallsStatus = vi.fn().mockResolvedValue({ + status: "success", + receipts: [{ transactionHash: "0xtx-interactive" }], + }); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { sendCalls, waitForCallsStatus }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => mockRpcClient(0n), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/interaction.js", () => ({ + isInteractiveAllowed: () => true, + })); + vi.doMock("../../src/lib/terminal-ui.js", () => ({ + promptConfirm: vi.fn().mockResolvedValue(true), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_l: string, _d: string, fn: () => Promise) => fn(), + printKeyValueBox, + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const { promptConfirm } = await import("../../src/lib/terminal-ui.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--unlimited", + ], { from: "node" }); + + expect(promptConfirm).toHaveBeenCalledWith(expect.objectContaining({ + message: `Grant unlimited USDC allowance to ${SPENDER}?`, + initialValue: false, + })); + const sentCall = sendCalls.mock.calls[0]?.[0]?.calls?.[0]; + const decoded = decodeFunctionData({ abi: erc20Abi, data: sentCall.data }); + expect(decoded.args).toEqual([SPENDER, maxUint256]); + expect(printKeyValueBox).toHaveBeenCalledWith(expect.arrayContaining([ + ["Current Allowance", "0 USDC"], + ["Requested Allowance", "Unlimited USDC"], + ["Status", "Confirmed"], + ])); + }); + + it("skips unlimited approval when the interactive confirmation is declined", async () => { + const printKeyValueBox = vi.fn(); + const sendCalls = vi.fn(); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { sendCalls, waitForCallsStatus: vi.fn() }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => mockRpcClient(0n), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/interaction.js", () => ({ + isInteractiveAllowed: () => true, + })); + vi.doMock("../../src/lib/terminal-ui.js", () => ({ + promptConfirm: vi.fn().mockResolvedValue(false), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_l: string, _d: string, fn: () => Promise) => fn(), + printKeyValueBox, + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--unlimited", + ], { from: "node" }); + + expect(sendCalls).not.toHaveBeenCalled(); + expect(printKeyValueBox).not.toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalled(); + }); + + it("uses a zero-then-approve sequence when --reset-first is requested", async () => { + const printJSON = vi.fn(); + const sendCalls = vi.fn().mockResolvedValue({ id: "call-457" }); + const waitForCallsStatus = vi.fn().mockResolvedValue({ + status: "success", + receipts: [{ transactionHash: "0xtx-reset" }], + }); + const rpcClient = mockRpcClient(50000000n); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { sendCalls, waitForCallsStatus }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => rpcClient, + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_l: string, _d: string, fn: () => Promise) => fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--amount", "100", + "--reset-first", + ], { from: "node" }); + + const sentCalls = sendCalls.mock.calls[0]?.[0]?.calls; + expect(sentCalls).toHaveLength(2); + + const first = decodeFunctionData({ abi: erc20Abi, data: sentCalls[0].data }); + const second = decodeFunctionData({ abi: erc20Abi, data: sentCalls[1].data }); + + expect(first.args).toEqual([SPENDER, 0n]); + expect(second.args).toEqual([SPENDER, 100000000n]); + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + approvalType: "exact", + resetFirst: true, + requestedAllowanceRaw: "100000000", + currentAllowanceRaw: "50000000", + })); + }); + + it("revokes approval with --revoke and encodes zero allowance", async () => { + const printJSON = vi.fn(); + const sendCalls = vi.fn().mockResolvedValue({ id: "call-789" }); + const waitForCallsStatus = vi.fn().mockResolvedValue({ + status: "success", + receipts: [{ transactionHash: "0xtx3" }], + }); + const rpcClient = mockRpcClient(25000000n); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { sendCalls, waitForCallsStatus }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => rpcClient, + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_l: string, _d: string, fn: () => Promise) => fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--revoke", + ], { from: "node" }); + + const sentCall = sendCalls.mock.calls[0]?.[0]?.calls?.[0]; + const decoded = decodeFunctionData({ abi: erc20Abi, data: sentCall.data }); + + expect(decoded.args).toEqual([SPENDER, 0n]); + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + approvalType: "revoke", + requestedAllowanceRaw: "0", + currentAllowanceRaw: "25000000", + resetFirst: false, + })); + }); + + it("fails with an RPC error when the approval transaction status is not success", async () => { + const exitWithError = vi.fn(); + const sendCalls = vi.fn().mockResolvedValue({ id: "call-fail" }); + const waitForCallsStatus = vi.fn().mockResolvedValue({ + status: "reverted", + receipts: [{ transactionHash: "0xtx-fail" }], + }); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { sendCalls, waitForCallsStatus }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => mockRpcClient(0n), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_l: string, _d: string, fn: () => Promise) => fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => ({ + ...(await vi.importActual("../../src/lib/errors.js")), + exitWithError, + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--amount", "100", + ], { from: "node" }); + + expect(exitWithError).toHaveBeenCalledWith(expect.objectContaining({ + code: "RPC_ERROR", + message: 'Approval failed with status "reverted".', + data: expect.objectContaining({ + callId: "call-fail", + status: "reverted", + txHash: "0xtx-fail", + }), + })); + }); + + it("rejects missing approval mode flags", async () => { + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: {}, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => mockRpcClient(), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: vi.fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => ({ + ...(await vi.importActual("../../src/lib/errors.js")), + exitWithError, + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + ], { from: "node" }); + + expect(exitWithError).toHaveBeenCalledWith(expect.objectContaining({ + code: "INVALID_ARGS", + message: "Provide exactly one of --amount, --unlimited, or --revoke.", + })); + }); + + it("rejects combining approval modes", async () => { + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: {}, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => mockRpcClient(), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: vi.fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => ({ + ...(await vi.importActual("../../src/lib/errors.js")), + exitWithError, + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--amount", "100", + "--revoke", + ], { from: "node" }); + + expect(exitWithError).toHaveBeenCalledWith(expect.objectContaining({ + code: "INVALID_ARGS", + message: "Provide exactly one of --amount, --unlimited, or --revoke.", + })); + }); + + it("rejects --reset-first with --revoke", async () => { + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: {}, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => mockRpcClient(), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: vi.fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => ({ + ...(await vi.importActual("../../src/lib/errors.js")), + exitWithError, + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", TOKEN, + "--spender", SPENDER, + "--revoke", + "--reset-first", + ], { from: "node" }); + + expect(exitWithError).toHaveBeenCalledWith(expect.objectContaining({ + code: "INVALID_ARGS", + message: "Do not use --reset-first with --revoke. Revoking already sets allowance to 0.", + })); + }); + + it("rejects the native token sentinel", async () => { + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: {}, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => mockRpcClient(), + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: vi.fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + vi.doMock("../../src/lib/errors.js", async () => ({ + ...(await vi.importActual("../../src/lib/errors.js")), + exitWithError, + })); + + const { registerApprove } = await import("../../src/commands/approve.js"); + const program = new Command(); + registerApprove(program); + + await program.parseAsync([ + "node", "test", "approve", NATIVE_TOKEN, + "--spender", SPENDER, + "--amount", "1", + ], { from: "node" }); + + expect(exitWithError).toHaveBeenCalledWith(expect.objectContaining({ + code: "INVALID_ARGS", + message: "Native tokens do not support ERC-20 approvals. Provide an ERC-20 token contract address.", + })); + }); +}); diff --git a/tests/live/approve.live.test.ts b/tests/live/approve.live.test.ts new file mode 100644 index 0000000..daac114 --- /dev/null +++ b/tests/live/approve.live.test.ts @@ -0,0 +1,165 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { parseUnits } from "viem"; +import { parseJSON, requireLiveConfig, runLiveEvmCLI } from "./helpers/live-harness.js"; +import type { LiveConfig } from "./helpers/live-env.js"; + +const sponsoredIt = process.env.ALCHEMY_LIVE_EVM_GAS_POLICY_ID?.trim() ? it : it.skip; + +interface ApprovePayload { + from: string; + token: string; + tokenSymbol: string; + tokenDecimals: number; + spender: string; + approvalType: "exact" | "unlimited" | "revoke"; + inputAmount: string | null; + requestedAllowanceRaw: string; + requestedAllowanceDisplay: string; + currentAllowanceRaw: string; + currentAllowanceDisplay: string; + resetFirst: boolean; + network: string; + sponsored: boolean; + txHash: string | null; + callId: string; + status: string; +} + +interface ContractReadPayload { + contract: string; + function: string; + network: string; + block: string; + result?: unknown; + raw: string; +} + +function buildApproveAmount(seed: number): string { + const fractional = String(seed % 1_000_000).padStart(6, "0"); + return `0.000001${fractional}`; +} + +describe("live approve command", () => { + let config: LiveConfig; + + beforeAll(async () => { + config = await requireLiveConfig("evm"); + }); + + it("writes an allowance on Sepolia and reads it back", async () => { + const amount = buildApproveAmount(Date.now()); + const write = await runLiveEvmCLI( + [ + "approve", + config.evmContractAddress, + "--spender", + config.evmRecipient, + "--amount", + amount, + "--reset-first", + ], + config, + ); + + expect(write.exitCode).toBe(0); + expect(write.stderr).toBe(""); + + const writePayload = parseJSON(write.stdout); + const expectedAllowance = parseUnits(amount, writePayload.tokenDecimals).toString(); + + expect(writePayload).toMatchObject({ + from: config.evmAddress, + token: config.evmContractAddress, + spender: config.evmRecipient, + approvalType: "exact", + inputAmount: amount, + requestedAllowanceRaw: expectedAllowance, + network: config.evmNetwork, + sponsored: false, + status: "success", + }); + expect(writePayload.callId).toEqual(expect.any(String)); + expect(writePayload.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/); + + const read = await runLiveEvmCLI( + [ + "contract", + "read", + config.evmContractAddress, + "allowance(address,address)(uint256)", + "--args", + JSON.stringify([config.evmAddress, config.evmRecipient]), + ], + config, + ); + + expect(read.exitCode).toBe(0); + expect(read.stderr).toBe(""); + + const readPayload = parseJSON(read.stdout); + expect(readPayload).toMatchObject({ + contract: config.evmContractAddress, + function: "allowance", + network: config.evmNetwork, + block: "latest", + result: expectedAllowance, + }); + expect(readPayload.raw).toMatch(/^0x[0-9a-fA-F]*$/); + }); + + sponsoredIt("writes a sponsored allowance update on Sepolia and reads it back", async () => { + const amount = buildApproveAmount(Date.now() + 1); + const write = await runLiveEvmCLI( + [ + "approve", + config.evmContractAddress, + "--spender", + config.evmRecipient, + "--amount", + amount, + "--reset-first", + ], + config, + { sponsored: true }, + ); + + expect(write.exitCode).toBe(0); + expect(write.stderr).toBe(""); + + const writePayload = parseJSON(write.stdout); + const expectedAllowance = parseUnits(amount, writePayload.tokenDecimals).toString(); + + expect(writePayload).toMatchObject({ + from: config.evmAddress, + token: config.evmContractAddress, + spender: config.evmRecipient, + approvalType: "exact", + inputAmount: amount, + requestedAllowanceRaw: expectedAllowance, + network: config.evmNetwork, + sponsored: true, + status: "success", + }); + expect(writePayload.callId).toEqual(expect.any(String)); + expect(writePayload.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/); + + const read = await runLiveEvmCLI( + [ + "contract", + "read", + config.evmContractAddress, + "allowance(address,address)(uint256)", + "--args", + JSON.stringify([config.evmAddress, config.evmRecipient]), + ], + config, + ); + + expect(read.exitCode).toBe(0); + expect(read.stderr).toBe(""); + + const readPayload = parseJSON(read.stdout); + expect(readPayload.result).toBe(expectedAllowance); + expect(readPayload.raw).toMatch(/^0x[0-9a-fA-F]*$/); + }); +}); From f15a7755507a3f8d4bb72c3b4d0ae3c80e36506f Mon Sep 17 00:00:00 2001 From: blake duncan Date: Fri, 10 Apr 2026 13:57:13 -0400 Subject: [PATCH 2/2] refactor: revise approve command inputs Make the approve CLI read more naturally by taking the spender as the positional argument, and extract shared token amount formatting so approve and swap use the same display logic. Made-with: Cursor --- src/commands/approve.ts | 63 +++++++++++++++------------------ src/commands/send/shared.ts | 7 ++++ src/commands/swap.ts | 9 +---- tests/commands/approve.test.ts | 52 +++++++++++++-------------- tests/live/approve.live.test.ts | 8 ++--- 5 files changed, 66 insertions(+), 73 deletions(-) diff --git a/src/commands/approve.ts b/src/commands/approve.ts index d8d2867..fb98474 100644 --- a/src/commands/approve.ts +++ b/src/commands/approve.ts @@ -20,12 +20,12 @@ import { } from "../lib/errors.js"; import { promptConfirm } from "../lib/terminal-ui.js"; import { withSpinner, printKeyValueBox, green, dim } from "../lib/ui.js"; -import { parseAmount, fetchTokenDecimals } from "./send/shared.js"; +import { parseAmount, fetchTokenDecimals, formatTokenAmount } from "./send/shared.js"; const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as Address; interface ApproveOpts { - spender: string; + tokenAddress: string; amount?: string; unlimited?: boolean; revoke?: boolean; @@ -62,13 +62,6 @@ function isNativeToken(address: string): boolean { return address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); } -function formatTokenAmount(rawAmount: bigint, decimals: number): string { - const str = rawAmount.toString().padStart(decimals + 1, "0"); - const whole = str.slice(0, str.length - decimals) || "0"; - const frac = str.slice(str.length - decimals).replace(/0+$/, ""); - return frac ? `${whole}.${frac}` : whole; -} - function buildApprovalRequest(opts: ApproveOpts, tokenMeta: TokenMeta): ApprovalRequest { validateApprovalMode(opts); @@ -204,8 +197,8 @@ export function registerApprove(program: Command) { program .command("approve") .description("Approve an ERC-20 token allowance for a spender") - .argument("", "ERC-20 token contract address") - .requiredOption("--spender ", "Address to approve spending") + .argument("", "Address to approve spending") + .requiredOption("--token-address ", "ERC-20 token contract address") .option("--amount ", "Amount to approve in decimal token units (for example, 100.5)") .option("--unlimited", "Approve the maximum allowance") .option("--revoke", "Revoke approval (set allowance to 0)") @@ -215,15 +208,15 @@ export function registerApprove(program: Command) { "after", ` Examples: - alchemy approve 0xUSDC --spender 0xRouter --amount 100 - alchemy approve 0xUSDC --spender 0xRouter --amount 100 --reset-first - alchemy approve 0xUSDC --spender 0xRouter --unlimited - alchemy approve 0xUSDC --spender 0xRouter --unlimited --yes - alchemy approve 0xUSDC --spender 0xRouter --revoke`, + alchemy approve 0xRouter --token-address 0xUSDC --amount 100 + alchemy approve 0xRouter --token-address 0xUSDC --amount 100 --reset-first + alchemy approve 0xRouter --token-address 0xUSDC --unlimited + alchemy approve 0xRouter --token-address 0xUSDC --unlimited --yes + alchemy approve 0xRouter --token-address 0xUSDC --revoke`, ) - .action(async (tokenArg: string, opts: ApproveOpts) => { + .action(async (spenderArg: string, opts: ApproveOpts) => { try { - await performApprove(program, tokenArg, opts); + await performApprove(program, spenderArg, opts); } catch (err) { exitWithError(err); } @@ -232,13 +225,13 @@ Examples: async function performApprove( program: Command, - tokenArg: string, + spenderArg: string, opts: ApproveOpts, ) { - validateAddress(tokenArg); - validateAddress(opts.spender); + validateAddress(spenderArg); + validateAddress(opts.tokenAddress); - if (isNativeToken(tokenArg)) { + if (isNativeToken(opts.tokenAddress)) { throw errInvalidArgs("Native tokens do not support ERC-20 approvals. Provide an ERC-20 token contract address."); } @@ -246,14 +239,14 @@ async function performApprove( const { client, network, address: from, paymaster } = buildWalletClient(program); const rpcClient = clientFromFlags(program); - const tokenMeta = await fetchTokenDecimals(program, tokenArg); + const tokenMeta = await fetchTokenDecimals(program, opts.tokenAddress); const approval = buildApprovalRequest(opts, tokenMeta); - if (!await confirmUnlimitedApproval(program, tokenMeta.symbol, opts.spender, opts)) { + if (!await confirmUnlimitedApproval(program, tokenMeta.symbol, spenderArg, opts)) { return; } - const currentAllowance = await readCurrentAllowance(rpcClient, tokenArg, from, opts.spender); + const currentAllowance = await readCurrentAllowance(rpcClient, opts.tokenAddress, from, spenderArg); const currentAllowanceDisplay = `${formatTokenAmount(currentAllowance, tokenMeta.decimals)} ${tokenMeta.symbol}`; const shouldResetFirst = opts.resetFirst === true && requiresAllowanceReset(currentAllowance, approval.rawAmount); @@ -266,29 +259,29 @@ async function performApprove( const calls = shouldResetFirst ? [ { - to: tokenArg as Address, + to: opts.tokenAddress as Address, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", - args: [opts.spender as Address, 0n], + args: [spenderArg as Address, 0n], }), }, { - to: tokenArg as Address, + to: opts.tokenAddress as Address, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", - args: [opts.spender as Address, approval.rawAmount], + args: [spenderArg as Address, approval.rawAmount], }), }, ] : [ { - to: tokenArg as Address, + to: opts.tokenAddress as Address, data: encodeFunctionData({ abi: erc20Abi, functionName: "approve", - args: [opts.spender as Address, approval.rawAmount], + args: [spenderArg as Address, approval.rawAmount], }), }, ]; @@ -317,10 +310,10 @@ async function performApprove( if (isJSONMode()) { printJSON({ from, - token: tokenArg, + token: opts.tokenAddress, tokenSymbol: tokenMeta.symbol, tokenDecimals: tokenMeta.decimals, - spender: opts.spender, + spender: spenderArg, approvalType: approval.kind, inputAmount: approval.inputAmount, requestedAllowanceRaw: approval.rawAmount.toString(), @@ -337,8 +330,8 @@ async function performApprove( } else { const pairs: [string, string][] = [ ["From", from], - ["Token", `${tokenMeta.symbol} (${tokenArg})`], - ["Spender", opts.spender], + ["Token", `${tokenMeta.symbol} (${opts.tokenAddress})`], + ["Spender", spenderArg], ["Current Allowance", currentAllowanceDisplay], ["Requested Allowance", green(approval.displayAmount)], ...(shouldResetFirst ? [["Allowance Update", "Reset to 0, then approve"]] as [string, string][] : []), diff --git a/src/commands/send/shared.ts b/src/commands/send/shared.ts index b87dc2f..59c1af3 100644 --- a/src/commands/send/shared.ts +++ b/src/commands/send/shared.ts @@ -40,6 +40,13 @@ export function parseAmount(amount: string, decimals: number): bigint { } } +export function formatTokenAmount(rawAmount: bigint, decimals: number): string { + const str = rawAmount.toString().padStart(decimals + 1, "0"); + const whole = str.slice(0, str.length - decimals) || "0"; + const frac = str.slice(str.length - decimals).replace(/0+$/, ""); + return frac ? `${whole}.${frac}` : whole; +} + export async function fetchTokenDecimals( program: Command, tokenAddress: string, diff --git a/src/commands/swap.ts b/src/commands/swap.ts index 8e7c5bc..0d10a52 100644 --- a/src/commands/swap.ts +++ b/src/commands/swap.ts @@ -12,7 +12,7 @@ import { isJSONMode, printJSON } from "../lib/output.js"; import { CLIError, exitWithError, errInvalidArgs } from "../lib/errors.js"; import { withSpinner, printKeyValueBox, green } from "../lib/ui.js"; import { nativeTokenSymbol } from "../lib/networks.js"; -import { parseAmount, fetchTokenDecimals } from "./send/shared.js"; +import { parseAmount, fetchTokenDecimals, formatTokenAmount } from "./send/shared.js"; const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as Address; const NATIVE_DECIMALS = 18; @@ -48,13 +48,6 @@ async function resolveTokenInfo( } } -function formatTokenAmount(rawAmount: bigint, decimals: number): string { - const str = rawAmount.toString().padStart(decimals + 1, "0"); - const whole = str.slice(0, str.length - decimals) || "0"; - const frac = str.slice(str.length - decimals).replace(/0+$/, ""); - return frac ? `${whole}.${frac}` : whole; -} - interface SwapOpts { from: string; to: string; diff --git a/tests/commands/approve.test.ts b/tests/commands/approve.test.ts index 3e6516e..775f3c5 100644 --- a/tests/commands/approve.test.ts +++ b/tests/commands/approve.test.ts @@ -70,8 +70,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--amount", "100", ], { from: "node" }); @@ -132,8 +132,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--amount", "100", ], { from: "node" }); @@ -180,8 +180,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--unlimited", ], { from: "node" }); @@ -230,8 +230,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--unlimited", "--yes", ], { from: "node" }); @@ -294,8 +294,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--unlimited", ], { from: "node" }); @@ -355,8 +355,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--unlimited", ], { from: "node" }); @@ -404,8 +404,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--amount", "100", "--reset-first", ], { from: "node" }); @@ -465,8 +465,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--revoke", ], { from: "node" }); @@ -524,8 +524,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--amount", "100", ], { from: "node" }); @@ -577,8 +577,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, ], { from: "node" }); expect(exitWithError).toHaveBeenCalledWith(expect.objectContaining({ @@ -624,8 +624,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--amount", "100", "--revoke", ], { from: "node" }); @@ -673,8 +673,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", TOKEN, "--revoke", "--reset-first", ], { from: "node" }); @@ -722,8 +722,8 @@ describe("approve command", () => { registerApprove(program); await program.parseAsync([ - "node", "test", "approve", NATIVE_TOKEN, - "--spender", SPENDER, + "node", "test", "approve", SPENDER, + "--token-address", NATIVE_TOKEN, "--amount", "1", ], { from: "node" }); diff --git a/tests/live/approve.live.test.ts b/tests/live/approve.live.test.ts index daac114..5d24fcb 100644 --- a/tests/live/approve.live.test.ts +++ b/tests/live/approve.live.test.ts @@ -51,9 +51,9 @@ describe("live approve command", () => { const write = await runLiveEvmCLI( [ "approve", - config.evmContractAddress, - "--spender", config.evmRecipient, + "--token-address", + config.evmContractAddress, "--amount", amount, "--reset-first", @@ -112,9 +112,9 @@ describe("live approve command", () => { const write = await runLiveEvmCLI( [ "approve", - config.evmContractAddress, - "--spender", config.evmRecipient, + "--token-address", + config.evmContractAddress, "--amount", amount, "--reset-first",