diff --git a/src/commands/approve.ts b/src/commands/approve.ts new file mode 100644 index 0000000..fb98474 --- /dev/null +++ b/src/commands/approve.ts @@ -0,0 +1,353 @@ +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, formatTokenAmount } from "./send/shared.js"; + +const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as Address; + +interface ApproveOpts { + tokenAddress: 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 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("", "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)") + .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 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 (spenderArg: string, opts: ApproveOpts) => { + try { + await performApprove(program, spenderArg, opts); + } catch (err) { + exitWithError(err); + } + }); +} + +async function performApprove( + program: Command, + spenderArg: string, + opts: ApproveOpts, +) { + validateAddress(spenderArg); + validateAddress(opts.tokenAddress); + + if (isNativeToken(opts.tokenAddress)) { + 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, opts.tokenAddress); + const approval = buildApprovalRequest(opts, tokenMeta); + + if (!await confirmUnlimitedApproval(program, tokenMeta.symbol, spenderArg, opts)) { + return; + } + + 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); + + 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: opts.tokenAddress as Address, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [spenderArg as Address, 0n], + }), + }, + { + to: opts.tokenAddress as Address, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [spenderArg as Address, approval.rawAmount], + }), + }, + ] + : [ + { + to: opts.tokenAddress as Address, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: "approve", + args: [spenderArg 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: opts.tokenAddress, + tokenSymbol: tokenMeta.symbol, + tokenDecimals: tokenMeta.decimals, + spender: spenderArg, + 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} (${opts.tokenAddress})`], + ["Spender", spenderArg], + ["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/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/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..775f3c5 --- /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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", TOKEN, + ], { 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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", TOKEN, + "--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", SPENDER, + "--token-address", NATIVE_TOKEN, + "--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..5d24fcb --- /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.evmRecipient, + "--token-address", + config.evmContractAddress, + "--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.evmRecipient, + "--token-address", + config.evmContractAddress, + "--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]*$/); + }); +});