diff --git a/src/commands/swap.ts b/src/commands/swap.ts new file mode 100644 index 0000000..8e7c5bc --- /dev/null +++ b/src/commands/swap.ts @@ -0,0 +1,344 @@ +import { Command } from "commander"; +import type { Address } from "viem"; +import { + swapActions, + type RequestQuoteV0Params, + type RequestQuoteV0Result, +} from "@alchemy/wallet-apis/experimental"; +import { buildWalletClient } from "../lib/smart-wallet.js"; +import type { PaymasterConfig } from "../lib/smart-wallet.js"; +import { validateAddress } from "../lib/validators.js"; +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"; + +const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as Address; +const NATIVE_DECIMALS = 18; + +function isNativeToken(address: string): boolean { + return address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); +} + +function slippagePercentToBasisPoints(percent: number): bigint { + return BigInt(Math.round(percent * 100)); +} + +async function resolveTokenInfo( + network: string, + program: Command, + tokenAddress: string, +): Promise<{ decimals: number; symbol: string }> { + if (isNativeToken(tokenAddress)) { + return { decimals: NATIVE_DECIMALS, symbol: nativeTokenSymbol(network) }; + } + + try { + return await fetchTokenDecimals(program, tokenAddress); + } catch (err) { + if (err instanceof CLIError && err.code === "INVALID_ARGS") { + throw err; + } + + const detail = err instanceof Error && err.message + ? ` ${err.message}` + : ""; + throw errInvalidArgs(`Failed to resolve token info for ${tokenAddress}.${detail}`); + } +} + +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; + amount: string; + slippage?: string; +} + +type WalletClient = ReturnType["client"]; +type PaymasterPermitQuote = Extract; +type RawCallsQuote = Extract; +type ExecutablePreparedQuote = Parameters[0]; +type PreparedCallsRequest = Parameters[0]; +type SignatureRequest = Parameters[0]; +type ExecutableQuote = ExecutablePreparedQuote | RawCallsQuote; + +function createQuoteRequest( + fromToken: string, + toToken: string, + fromAmount: bigint, + slippagePercent: number | undefined, + paymaster?: PaymasterConfig, +): RequestQuoteV0Params { + const request = { + fromToken: fromToken as Address, + toToken: toToken as Address, + fromAmount, + ...(slippagePercent !== undefined + ? { slippage: slippagePercentToBasisPoints(slippagePercent) } + : {}), + ...(paymaster ? { capabilities: { paymaster } } : {}), + } satisfies RequestQuoteV0Params; + + return request; +} + +async function prepareQuoteForExecution( + client: WalletClient, + quote: RequestQuoteV0Result, +): Promise { + if (!("type" in quote) || quote.type !== "paymaster-permit" || !("modifiedRequest" in quote) || !("signatureRequest" in quote)) { + return quote as ExecutableQuote; + } + + const permitQuote = quote as PaymasterPermitQuote & { + modifiedRequest: PreparedCallsRequest; + signatureRequest: SignatureRequest; + }; + const permitSignature = await withSpinner( + "Signing permit…", + "Permit signed", + () => client.signSignatureRequest(permitQuote.signatureRequest), + ); + + const preparedQuote = await withSpinner( + "Preparing swap…", + "Swap prepared", + () => client.prepareCalls({ + ...permitQuote.modifiedRequest, + paymasterPermitSignature: permitSignature, + }), + ); + + if ("type" in preparedQuote && preparedQuote.type === "paymaster-permit") { + throw errInvalidArgs("Swap quote still requires a paymaster permit after signing. The quote response format may be unsupported."); + } + + return preparedQuote as ExecutableQuote; +} + +export function registerSwap(program: Command) { + const cmd = program.command("swap").description("Swap tokens on the same chain"); + + // ── swap quote ──────────────────────────────────────────────────── + + cmd + .command("quote") + .description("Get a swap quote without executing") + .requiredOption("--from ", "Token address to swap from (use 0xEeee...EEeE for the native token)") + .requiredOption("--to ", "Token address to swap to (use 0xEeee...EEeE for the native token)") + .requiredOption("--amount ", "Amount to swap in decimal token units (for example, 1.5)") + .option("--slippage ", "Max slippage percentage (omit to use the API default)") + .addHelpText( + "after", + ` +Examples: + alchemy swap quote --from 0xEeee...EEeE --to 0xA0b8...USDC --amount 1.0 -n eth-mainnet + alchemy swap quote --from 0xUSDC --to 0xDAI --amount 100 --slippage 1.0`, + ) + .action(async (opts: SwapOpts) => { + try { + await performSwapQuote(program, opts); + } catch (err) { + exitWithError(err); + } + }); + + // ── swap execute ────────────────────────────────────────────────── + + cmd + .command("execute") + .description("Execute a token swap") + .requiredOption("--from ", "Token address to swap from (use 0xEeee...EEeE for the native token)") + .requiredOption("--to ", "Token address to swap to (use 0xEeee...EEeE for the native token)") + .requiredOption("--amount ", "Amount to swap in decimal token units (for example, 1.5)") + .option("--slippage ", "Max slippage percentage (omit to use the API default)") + .addHelpText( + "after", + ` +Examples: + alchemy swap execute --from 0xEeee...EEeE --to 0xA0b8...USDC --amount 1.0 -n eth-mainnet + alchemy swap execute --from 0xUSDC --to 0xDAI --amount 100 --slippage 1.0 + alchemy swap execute --from 0xEeee...EEeE --to 0xUSDC --amount 0.1 --gas-sponsored --gas-policy-id `, + ) + .action(async (opts: SwapOpts) => { + try { + await performSwapExecute(program, opts); + } catch (err) { + exitWithError(err); + } + }); +} + +// ── Quote implementation ──────────────────────────────────────────── + +async function performSwapQuote(program: Command, opts: SwapOpts) { + validateAddress(opts.from); + validateAddress(opts.to); + + const { client, network, paymaster } = buildWalletClient(program); + const swapClient = client.extend(swapActions); + + // Resolve from-token decimals and parse amount + const fromInfo = await resolveTokenInfo(network, program, opts.from); + const rawAmount = parseAmount(opts.amount, fromInfo.decimals); + + const slippage = opts.slippage ? parseFloat(opts.slippage) : undefined; + if (slippage !== undefined && (isNaN(slippage) || slippage < 0 || slippage > 100)) { + throw errInvalidArgs("Slippage must be a number between 0 and 100."); + } + + const quote = await withSpinner( + "Fetching quote…", + "Quote received", + () => swapClient.requestQuoteV0(createQuoteRequest(opts.from, opts.to, rawAmount, slippage, paymaster)), + ); + + // Resolve to-token info for display + const toInfo = await resolveTokenInfo(network, program, opts.to); + + // Extract the minimum receive amount from the quote response. + const quoteData = extractQuoteData(quote); + + if (isJSONMode()) { + printJSON({ + fromToken: opts.from, + toToken: opts.to, + fromAmount: opts.amount, + fromSymbol: fromInfo.symbol, + toSymbol: toInfo.symbol, + minimumOutput: quoteData.minimumOutput ? formatTokenAmount(quoteData.minimumOutput, toInfo.decimals) : null, + slippage: slippage === undefined ? null : String(slippage), + network, + quoteType: quoteData.type, + }); + } else { + const pairs: [string, string][] = [ + ["From", green(`${opts.amount} ${fromInfo.symbol}`)], + ]; + + if (quoteData.minimumOutput) { + pairs.push(["Minimum Receive", green(`${formatTokenAmount(quoteData.minimumOutput, toInfo.decimals)} ${toInfo.symbol}`)]); + } else { + pairs.push(["To", `${toInfo.symbol}`]); + } + + pairs.push( + ["Slippage", slippage === undefined ? "API default" : `${slippage}%`], + ["Network", network], + ); + + printKeyValueBox(pairs); + } +} + +// ── Execute implementation ────────────────────────────────────────── + +async function performSwapExecute(program: Command, opts: SwapOpts) { + validateAddress(opts.from); + validateAddress(opts.to); + + const { client, network, address: from, paymaster } = buildWalletClient(program); + const swapClient = client.extend(swapActions); + + const fromInfo = await resolveTokenInfo(network, program, opts.from); + const rawAmount = parseAmount(opts.amount, fromInfo.decimals); + + const slippage = opts.slippage ? parseFloat(opts.slippage) : undefined; + if (slippage !== undefined && (isNaN(slippage) || slippage < 0 || slippage > 100)) { + throw errInvalidArgs("Slippage must be a number between 0 and 100."); + } + + // Get quote with prepared calls + const quote = await withSpinner( + "Fetching quote…", + "Quote received", + () => swapClient.requestQuoteV0(createQuoteRequest(opts.from, opts.to, rawAmount, slippage, paymaster)), + ); + + const preparedQuote = await prepareQuoteForExecution(client, quote); + + // Send the quoted swap using the appropriate execution path. + const { id } = await withSpinner( + "Sending swap transaction…", + "Transaction submitted", + async () => { + if ("rawCalls" in preparedQuote && preparedQuote.rawCalls === true) { + const rawCallsQuote = preparedQuote as RawCallsQuote; + return client.sendCalls({ + calls: rawCallsQuote.calls, + capabilities: paymaster ? { paymaster } : undefined, + }); + } + + const executablePreparedQuote = preparedQuote as ExecutablePreparedQuote; + const signedQuote = await client.signPreparedCalls(executablePreparedQuote); + return client.sendPreparedCalls(signedQuote); + }, + ); + + const status = await withSpinner( + "Waiting for confirmation…", + "Swap confirmed", + () => client.waitForCallsStatus({ id }), + ); + + const txHash = status.receipts?.[0]?.transactionHash; + const confirmed = status.status === "success"; + const toInfo = await resolveTokenInfo(network, program, opts.to); + + if (isJSONMode()) { + printJSON({ + from: from, + fromToken: opts.from, + toToken: opts.to, + fromAmount: opts.amount, + fromSymbol: fromInfo.symbol, + toSymbol: toInfo.symbol, + slippage: slippage === undefined ? null : String(slippage), + network, + sponsored: !!paymaster, + txHash: txHash ?? null, + callId: id, + status: status.status, + }); + } else { + const pairs: [string, string][] = [ + ["From", `${opts.amount} ${fromInfo.symbol}`], + ["To", toInfo.symbol], + ["Slippage", slippage === undefined ? "API default" : `${slippage}%`], + ["Network", network], + ]; + + if (paymaster) { + pairs.push(["Gas", green("Sponsored")]); + } + + if (txHash) { + pairs.push(["Tx Hash", txHash]); + } + + pairs.push(["Status", confirmed ? green("Confirmed") : `Pending (${status.status})`]); + + printKeyValueBox(pairs); + } +} + +// ── Helpers ───────────────────────────────────────────────────────── + +function extractQuoteData(quote: RequestQuoteV0Result): { type: string; minimumOutput?: bigint } { + const type = "type" in quote ? quote.type : "unknown"; + + if (quote.quote?.minimumToAmount !== undefined) { + return { type, minimumOutput: BigInt(quote.quote.minimumToAmount) }; + } + + return { type }; +} diff --git a/src/index.ts b/src/index.ts index e241412..1441a6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,7 @@ import { registerLogs } from "./commands/logs.js"; 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 { registerAgentPrompt } from "./commands/agent-prompt.js"; import { registerUpdateCheck } from "./commands/update-check.js"; import { isInteractiveAllowed } from "./lib/interaction.js"; @@ -72,7 +73,7 @@ const ROOT_COMMAND_PILLARS = [ }, { label: "Execution", - commands: ["send", "contract"], + commands: ["send", "contract", "swap"], }, { label: "Wallets", @@ -450,6 +451,7 @@ registerSimulate(program); // Execution registerSend(program); registerContract(program); +registerSwap(program); // Wallets registerWallet(program); diff --git a/tests/commands/swap.test.ts b/tests/commands/swap.test.ts new file mode 100644 index 0000000..b92969d --- /dev/null +++ b/tests/commands/swap.test.ts @@ -0,0 +1,658 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; + +const NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const ZERO_DEC_TOKEN = "0x3333333333333333333333333333333333333333"; +const FROM = "0x1111111111111111111111111111111111111111"; +const ROUTER = "0x2222222222222222222222222222222222222222"; + +describe("swap command", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it("swap quote uses minimum output from the SDK quote and native network symbols", async () => { + const printJSON = vi.fn(); + const requestQuoteV0 = vi.fn().mockResolvedValue({ + rawCalls: false, + type: "user-operation-v070", + quote: { + fromAmount: 1000000000000000000n, + minimumToAmount: 30000000n, + expiry: 123, + }, + chainId: 137, + data: {}, + feePayment: { + sponsored: false, + tokenAddress: USDC, + maxAmount: 0n, + }, + }); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { + extend: () => ({ requestQuoteV0 }), + }, + network: "polygon-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ + call: vi.fn().mockResolvedValue({ decimals: 6, symbol: "USDC" }), + }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_label: string, _done: 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 { registerSwap } = await import("../../src/commands/swap.js"); + const program = new Command(); + registerSwap(program); + + await program.parseAsync([ + "node", "test", "swap", "quote", + "--from", NATIVE_TOKEN, + "--to", USDC, + "--amount", "1.0", + ], { from: "node" }); + + expect(requestQuoteV0).toHaveBeenCalledWith(expect.objectContaining({ + fromToken: NATIVE_TOKEN, + toToken: USDC, + fromAmount: 1000000000000000000n, + })); + expect(requestQuoteV0.mock.calls[0]?.[0]?.slippage).toBeUndefined(); + + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + fromToken: NATIVE_TOKEN, + toToken: USDC, + fromAmount: "1.0", + fromSymbol: "POL", + toSymbol: "USDC", + minimumOutput: "30", + slippage: null, + network: "polygon-mainnet", + quoteType: "user-operation-v070", + })); + }); + + it("swap execute signs prepared calls before sending", async () => { + const printJSON = vi.fn(); + const quote = { + rawCalls: false, + type: "user-operation-v070", + quote: { + fromAmount: 1000000000000000000n, + minimumToAmount: 30000000n, + expiry: 123, + }, + chainId: 1, + data: {}, + signatureRequest: { + type: "personal_sign", + data: "swap quote", + rawPayload: "0x1234", + }, + feePayment: { + sponsored: false, + tokenAddress: USDC, + maxAmount: 0n, + }, + }; + const signedQuote = { + type: "user-operation-v070", + chainId: 1, + data: {}, + signature: { type: "secp256k1", data: "0xsigned" }, + }; + const requestQuoteV0 = vi.fn().mockResolvedValue(quote); + const signPreparedCalls = vi.fn().mockResolvedValue(signedQuote); + const signSignatureRequest = vi.fn(); + const sendPreparedCalls = vi.fn().mockResolvedValue({ id: "call-123" }); + const sendCalls = vi.fn().mockResolvedValue({ id: "call-123" }); + const waitForCallsStatus = vi.fn().mockResolvedValue({ + status: "success", + receipts: [{ transactionHash: "0xtxhash" }], + }); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { + extend: () => ({ requestQuoteV0 }), + signPreparedCalls, + signSignatureRequest, + sendPreparedCalls, + sendCalls, + waitForCallsStatus, + }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ + call: vi.fn().mockResolvedValue({ decimals: 6, symbol: "USDC" }), + }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_label: string, _done: 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 { registerSwap } = await import("../../src/commands/swap.js"); + const program = new Command(); + registerSwap(program); + + await program.parseAsync([ + "node", "test", "swap", "execute", + "--from", NATIVE_TOKEN, + "--to", USDC, + "--amount", "1.0", + ], { from: "node" }); + + expect(requestQuoteV0).toHaveBeenCalledWith(expect.objectContaining({ + fromToken: NATIVE_TOKEN, + toToken: USDC, + fromAmount: 1000000000000000000n, + })); + expect(requestQuoteV0.mock.calls[0]?.[0]?.slippage).toBeUndefined(); + expect(signPreparedCalls).toHaveBeenCalledWith(quote); + expect(sendPreparedCalls).toHaveBeenCalledWith(signedQuote); + expect(sendCalls).not.toHaveBeenCalled(); + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + slippage: null, + status: "success", + txHash: "0xtxhash", + })); + }); + + it("swap execute signs paymaster permits and refreshes the quote before submission", async () => { + const printJSON = vi.fn(); + const permitSignature = { type: "secp256k1", data: "0xpermitsig" }; + const permitQuote = { + rawCalls: false, + type: "paymaster-permit", + quote: { + fromAmount: 1000000000000000000n, + minimumToAmount: 30000000n, + expiry: 123, + }, + chainId: 1, + modifiedRequest: { + account: FROM, + fromToken: NATIVE_TOKEN, + toToken: USDC, + fromAmount: 1000000000000000000n, + slippage: 50n, + }, + data: { + domain: { + chainId: 1, + name: "Permit", + version: "1", + verifyingContract: ROUTER, + }, + types: { + Permit: [{ name: "owner", type: "address" }], + }, + primaryType: "Permit", + message: { + owner: FROM, + }, + }, + signatureRequest: { + type: "eth_signTypedData_v4", + data: { + domain: { + chainId: 1, + name: "Permit", + version: "1", + verifyingContract: ROUTER, + }, + types: { + Permit: [{ name: "owner", type: "address" }], + }, + primaryType: "Permit", + message: { + owner: FROM, + }, + }, + rawPayload: "0x1234", + }, + }; + const executableQuote = { + rawCalls: false, + type: "user-operation-v070", + quote: { + fromAmount: 1000000000000000000n, + minimumToAmount: 30000000n, + expiry: 123, + }, + chainId: 1, + data: {}, + signatureRequest: { + type: "personal_sign", + data: "swap quote", + rawPayload: "0x5678", + }, + feePayment: { + sponsored: false, + tokenAddress: USDC, + maxAmount: 0n, + }, + }; + const signedQuote = { + type: "user-operation-v070", + chainId: 1, + data: {}, + signature: { type: "secp256k1", data: "0xsigned" }, + }; + const requestQuoteV0 = vi + .fn() + .mockResolvedValueOnce(permitQuote); + const signSignatureRequest = vi.fn().mockResolvedValue(permitSignature); + const prepareCalls = vi.fn().mockResolvedValue(executableQuote); + const signPreparedCalls = vi.fn().mockResolvedValue(signedQuote); + const sendPreparedCalls = vi.fn().mockResolvedValue({ id: "call-123" }); + const waitForCallsStatus = vi.fn().mockResolvedValue({ + status: "success", + receipts: [{ transactionHash: "0xtxhash" }], + }); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { + extend: () => ({ requestQuoteV0 }), + prepareCalls, + signSignatureRequest, + signPreparedCalls, + sendPreparedCalls, + sendCalls: vi.fn(), + waitForCallsStatus, + }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ + call: vi.fn().mockResolvedValue({ decimals: 6, symbol: "USDC" }), + }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_label: string, _done: string, fn: () => Promise) => fn(), + printKeyValueBox: vi.fn(), + green: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + + const { registerSwap } = await import("../../src/commands/swap.js"); + const program = new Command(); + registerSwap(program); + + await program.parseAsync([ + "node", "test", "swap", "execute", + "--from", NATIVE_TOKEN, + "--to", USDC, + "--amount", "1.0", + ], { from: "node" }); + + expect(signSignatureRequest).toHaveBeenCalledWith(permitQuote.signatureRequest); + expect(prepareCalls).toHaveBeenCalledWith(expect.objectContaining({ + ...permitQuote.modifiedRequest, + paymasterPermitSignature: permitSignature, + })); + expect(signPreparedCalls).toHaveBeenCalledWith(executableQuote); + expect(sendPreparedCalls).toHaveBeenCalledWith(signedQuote); + }); + + it("rejects invalid slippage values", async () => { + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { extend: () => ({}) }, + network: "eth-mainnet", + address: "0xfrom", + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call: vi.fn() }), + resolveNetwork: () => "eth-mainnet", + })); + 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 { registerSwap } = await import("../../src/commands/swap.js"); + const program = new Command(); + registerSwap(program); + + await program.parseAsync([ + "node", "test", "swap", "quote", + "--from", NATIVE_TOKEN, + "--to", USDC, + "--amount", "1.0", + "--slippage", "150", + ], { from: "node" }); + + expect(exitWithError).toHaveBeenCalledTimes(1); + }); + + it("passes explicit slippage when provided", async () => { + const printJSON = vi.fn(); + const requestQuoteV0 = vi.fn().mockResolvedValue({ + rawCalls: false, + type: "user-operation-v070", + quote: { + fromAmount: 1000000000000000000n, + minimumToAmount: 30000000n, + expiry: 123, + }, + chainId: 137, + data: {}, + feePayment: { + sponsored: false, + tokenAddress: USDC, + maxAmount: 0n, + }, + }); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { + extend: () => ({ requestQuoteV0 }), + }, + network: "polygon-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ + call: vi.fn().mockResolvedValue({ decimals: 6, symbol: "USDC" }), + }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_label: string, _done: 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 { registerSwap } = await import("../../src/commands/swap.js"); + const program = new Command(); + registerSwap(program); + + await program.parseAsync([ + "node", "test", "swap", "quote", + "--from", NATIVE_TOKEN, + "--to", USDC, + "--amount", "1.0", + "--slippage", "1.25", + ], { from: "node" }); + + expect(requestQuoteV0).toHaveBeenCalledWith(expect.objectContaining({ + fromToken: NATIVE_TOKEN, + toToken: USDC, + fromAmount: 1000000000000000000n, + slippage: 125n, + })); + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + minimumOutput: "30", + slippage: "1.25", + })); + }); + + it("formats minimum output correctly for zero-decimal tokens", async () => { + const printJSON = vi.fn(); + const requestQuoteV0 = vi.fn().mockResolvedValue({ + rawCalls: false, + type: "user-operation-v070", + quote: { + fromAmount: 1000000n, + minimumToAmount: 42n, + expiry: 123, + }, + chainId: 1, + data: {}, + feePayment: { + sponsored: false, + tokenAddress: USDC, + maxAmount: 0n, + }, + }); + const call = vi.fn().mockImplementation((_method: string, [tokenAddress]: [string]) => { + if (tokenAddress === USDC) { + return Promise.resolve({ decimals: 6, symbol: "USDC" }); + } + if (tokenAddress === ZERO_DEC_TOKEN) { + return Promise.resolve({ decimals: 0, symbol: "POINTS" }); + } + return Promise.reject(new Error(`Unexpected token ${tokenAddress}`)); + }); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { + extend: () => ({ requestQuoteV0 }), + }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ call }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => true, + printJSON, + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_label: string, _done: 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 { registerSwap } = await import("../../src/commands/swap.js"); + const program = new Command(); + registerSwap(program); + + await program.parseAsync([ + "node", "test", "swap", "quote", + "--from", USDC, + "--to", ZERO_DEC_TOKEN, + "--amount", "1.0", + ], { from: "node" }); + + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + minimumOutput: "42", + toSymbol: "POINTS", + })); + }); + + it("renders minimum receive and API-default slippage in human output", async () => { + const printKeyValueBox = vi.fn(); + const requestQuoteV0 = vi.fn().mockResolvedValue({ + rawCalls: false, + type: "user-operation-v070", + quote: { + fromAmount: 1000000000000000000n, + minimumToAmount: 30000000n, + expiry: 123, + }, + chainId: 137, + data: {}, + feePayment: { + sponsored: false, + tokenAddress: USDC, + maxAmount: 0n, + }, + }); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { + extend: () => ({ requestQuoteV0 }), + }, + network: "polygon-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ + call: vi.fn().mockResolvedValue({ decimals: 6, symbol: "USDC" }), + }), + resolveNetwork: () => "eth-mainnet", + })); + vi.doMock("../../src/lib/output.js", () => ({ + isJSONMode: () => false, + printJSON: vi.fn(), + })); + vi.doMock("../../src/lib/ui.js", () => ({ + withSpinner: async (_label: string, _done: string, fn: () => Promise) => fn(), + printKeyValueBox, + green: (s: string) => s, + dim: (s: string) => s, + })); + vi.doMock("../../src/lib/validators.js", () => ({ + validateAddress: vi.fn(), + })); + + const { registerSwap } = await import("../../src/commands/swap.js"); + const program = new Command(); + registerSwap(program); + + await program.parseAsync([ + "node", "test", "swap", "quote", + "--from", NATIVE_TOKEN, + "--to", USDC, + "--amount", "1.0", + ], { from: "node" }); + + expect(printKeyValueBox).toHaveBeenCalledWith(expect.arrayContaining([ + ["From", "1.0 POL"], + ["Minimum Receive", "30 USDC"], + ["Slippage", "API default"], + ["Network", "polygon-mainnet"], + ])); + }); + + it("wraps unexpected token metadata failures with token context", async () => { + const exitWithError = vi.fn(); + + vi.doMock("../../src/lib/smart-wallet.js", () => ({ + buildWalletClient: () => ({ + client: { extend: () => ({ requestQuoteV0: vi.fn() }) }, + network: "eth-mainnet", + address: FROM, + paymaster: undefined, + }), + })); + vi.doMock("../../src/lib/resolve.js", () => ({ + clientFromFlags: () => ({ + call: vi.fn().mockRejectedValue(new Error("RPC offline")), + }), + resolveNetwork: () => "eth-mainnet", + })); + 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 { registerSwap } = await import("../../src/commands/swap.js"); + const program = new Command(); + registerSwap(program); + + await program.parseAsync([ + "node", "test", "swap", "quote", + "--from", USDC, + "--to", NATIVE_TOKEN, + "--amount", "1.0", + ], { from: "node" }); + + expect(exitWithError).toHaveBeenCalledWith(expect.objectContaining({ + code: "INVALID_ARGS", + message: `Failed to resolve token info for ${USDC}. RPC offline`, + })); + }); +});