From cc665d6f2417a8b92bb009e26074306358ca4a71 Mon Sep 17 00:00:00 2001 From: blake duncan Date: Thu, 9 Apr 2026 15:56:21 -0400 Subject: [PATCH 1/4] feat: add token swap command Add swap quote and execute commands so the CLI can request quotes, handle permit flows, and submit same-chain token swaps with test coverage. Made-with: Cursor --- src/commands/swap.ts | 316 +++++++++++++++++++++++++++++ src/index.ts | 4 +- tests/commands/swap.test.ts | 384 ++++++++++++++++++++++++++++++++++++ 3 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 src/commands/swap.ts create mode 100644 tests/commands/swap.test.ts diff --git a/src/commands/swap.ts b/src/commands/swap.ts new file mode 100644 index 0000000..c4defa6 --- /dev/null +++ b/src/commands/swap.ts @@ -0,0 +1,316 @@ +import { Command } from "commander"; +import type { Address } from "viem"; +import { + swapActions, + type RequestQuoteV0Result, +} from "@alchemy/wallet-apis/experimental"; +import { buildWalletClient } from "../lib/smart-wallet.js"; +import { validateAddress } from "../lib/validators.js"; +import { isJSONMode, printJSON } from "../lib/output.js"; +import { 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; +const DEFAULT_SLIPPAGE_PERCENT = 0.5; + +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) }; + } + return fetchTokenDecimals(program, tokenAddress); +} + +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 PaymasterPermitQuote = Extract; +type RawCallsQuote = Extract; + +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") + .requiredOption("--amount ", "Amount to swap (human-readable)") + .option("--slippage ", "Max slippage percentage (default: 0.5)") + .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") + .requiredOption("--amount ", "Amount to swap (human-readable)") + .option("--slippage ", "Max slippage percentage (default: 0.5)") + .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) : DEFAULT_SLIPPAGE_PERCENT; + if (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({ + fromToken: opts.from as Address, + toToken: opts.to as Address, + fromAmount: rawAmount, + slippage: slippagePercentToBasisPoints(slippage), + capabilities: paymaster ? { paymaster } : undefined, + }), + ); + + // Resolve to-token info for display + const toInfo = await resolveTokenInfo(network, program, opts.to); + + // Extract expected output from quote response + const quoteData = extractQuoteData(quote); + + if (isJSONMode()) { + printJSON({ + fromToken: opts.from, + toToken: opts.to, + fromAmount: opts.amount, + fromSymbol: fromInfo.symbol, + toSymbol: toInfo.symbol, + expectedOutput: quoteData.expectedOutput ? formatTokenAmount(quoteData.expectedOutput, toInfo.decimals) : null, + slippage: String(slippage), + network, + quoteType: quoteData.type, + }); + } else { + const pairs: [string, string][] = [ + ["From", green(`${opts.amount} ${fromInfo.symbol}`)], + ]; + + if (quoteData.expectedOutput) { + pairs.push(["To", green(`~${formatTokenAmount(quoteData.expectedOutput, toInfo.decimals)} ${toInfo.symbol}`)]); + } else { + pairs.push(["To", `${toInfo.symbol}`]); + } + + pairs.push( + ["Slippage", `${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) : DEFAULT_SLIPPAGE_PERCENT; + if (isNaN(slippage) || slippage < 0 || slippage > 100) { + throw errInvalidArgs("Slippage must be a number between 0 and 100."); + } + + // Get quote with prepared calls + let quote = await withSpinner( + "Fetching quote…", + "Quote received", + () => swapClient.requestQuoteV0({ + fromToken: opts.from as Address, + toToken: opts.to as Address, + fromAmount: rawAmount, + slippage: slippagePercentToBasisPoints(slippage), + capabilities: paymaster ? { paymaster } : undefined, + }), + ); + + // If the quote requires an ERC-7597 permit, sign it and refresh the quote + // with the attached permit signature before preparing the final calls. + let preparedQuote: + | Parameters[0] + | RawCallsQuote + | undefined; + + if ("type" in quote && quote.type === "paymaster-permit" && "modifiedRequest" in quote && "signatureRequest" in quote) { + const permitQuote = quote as PaymasterPermitQuote & { + modifiedRequest: Parameters[0]; + signatureRequest: Parameters[0]; + }; + const permitSignature = await withSpinner( + "Signing permit…", + "Permit signed", + () => client.signSignatureRequest(permitQuote.signatureRequest), + ); + + preparedQuote = await withSpinner( + "Preparing swap…", + "Swap prepared", + () => client.prepareCalls({ + ...permitQuote.modifiedRequest, + paymasterPermitSignature: permitSignature, + }), + ); + } else { + preparedQuote = quote; + } + + 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."); + } + + // 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 Parameters[0]; + 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: 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}%`], + ["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: any): { type: string; expectedOutput?: bigint } { + const type = quote.type ?? "unknown"; + + if (quote.quote?.minimumToAmount !== undefined) { + return { type, expectedOutput: 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..e62a62c --- /dev/null +++ b/tests/commands/swap.test.ts @@ -0,0 +1,384 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Command } from "commander"; + +const NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; +const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +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, + slippage: 50n, + })); + + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + fromToken: NATIVE_TOKEN, + toToken: USDC, + fromAmount: "1.0", + fromSymbol: "POL", + toSymbol: "USDC", + expectedOutput: "30", + 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).toHaveBeenCalled(); + expect(signPreparedCalls).toHaveBeenCalledWith(quote); + expect(sendPreparedCalls).toHaveBeenCalledWith(signedQuote); + expect(sendCalls).not.toHaveBeenCalled(); + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + 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); + }); +}); From a708e6c6bee542343dd1b21dd4b71cc7c92c9e7c Mon Sep 17 00:00:00 2001 From: blake duncan Date: Thu, 9 Apr 2026 16:37:25 -0400 Subject: [PATCH 2/4] fix: omit default swap slippage Avoid sending the default slippage value to wallet quote requests so swap quotes and executions use the API default unless the user explicitly overrides it. Made-with: Cursor --- src/commands/swap.ts | 53 ++++++++++++++----------- tests/commands/swap.test.ts | 77 ++++++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 24 deletions(-) diff --git a/src/commands/swap.ts b/src/commands/swap.ts index c4defa6..32738f2 100644 --- a/src/commands/swap.ts +++ b/src/commands/swap.ts @@ -5,6 +5,7 @@ import { 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 { exitWithError, errInvalidArgs } from "../lib/errors.js"; @@ -52,6 +53,26 @@ interface SwapOpts { type PaymasterPermitQuote = Extract; type RawCallsQuote = Extract; +function createQuoteRequest( + fromToken: string, + toToken: string, + fromAmount: bigint, + slippagePercent: number | undefined, + paymaster?: PaymasterConfig, +) { + const request = { + fromToken: fromToken as Address, + toToken: toToken as Address, + fromAmount, + ...(slippagePercent !== undefined + ? { slippage: slippagePercentToBasisPoints(slippagePercent) } + : {}), + ...(paymaster ? { capabilities: { paymaster } } : {}), + }; + + return request as Parameters["client"]["extend"]>["requestQuoteV0"]>[0]; +} + export function registerSwap(program: Command) { const cmd = program.command("swap").description("Swap tokens on the same chain"); @@ -118,21 +139,15 @@ async function performSwapQuote(program: Command, opts: SwapOpts) { const fromInfo = await resolveTokenInfo(network, program, opts.from); const rawAmount = parseAmount(opts.amount, fromInfo.decimals); - const slippage = opts.slippage ? parseFloat(opts.slippage) : DEFAULT_SLIPPAGE_PERCENT; - if (isNaN(slippage) || slippage < 0 || slippage > 100) { + 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({ - fromToken: opts.from as Address, - toToken: opts.to as Address, - fromAmount: rawAmount, - slippage: slippagePercentToBasisPoints(slippage), - capabilities: paymaster ? { paymaster } : undefined, - }), + () => swapClient.requestQuoteV0(createQuoteRequest(opts.from, opts.to, rawAmount, slippage, paymaster)), ); // Resolve to-token info for display @@ -149,7 +164,7 @@ async function performSwapQuote(program: Command, opts: SwapOpts) { fromSymbol: fromInfo.symbol, toSymbol: toInfo.symbol, expectedOutput: quoteData.expectedOutput ? formatTokenAmount(quoteData.expectedOutput, toInfo.decimals) : null, - slippage: String(slippage), + slippage: String(slippage ?? DEFAULT_SLIPPAGE_PERCENT), network, quoteType: quoteData.type, }); @@ -165,7 +180,7 @@ async function performSwapQuote(program: Command, opts: SwapOpts) { } pairs.push( - ["Slippage", `${slippage}%`], + ["Slippage", `${slippage ?? DEFAULT_SLIPPAGE_PERCENT}%`], ["Network", network], ); @@ -185,8 +200,8 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { const fromInfo = await resolveTokenInfo(network, program, opts.from); const rawAmount = parseAmount(opts.amount, fromInfo.decimals); - const slippage = opts.slippage ? parseFloat(opts.slippage) : DEFAULT_SLIPPAGE_PERCENT; - if (isNaN(slippage) || slippage < 0 || slippage > 100) { + 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."); } @@ -194,13 +209,7 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { let quote = await withSpinner( "Fetching quote…", "Quote received", - () => swapClient.requestQuoteV0({ - fromToken: opts.from as Address, - toToken: opts.to as Address, - fromAmount: rawAmount, - slippage: slippagePercentToBasisPoints(slippage), - capabilities: paymaster ? { paymaster } : undefined, - }), + () => swapClient.requestQuoteV0(createQuoteRequest(opts.from, opts.to, rawAmount, slippage, paymaster)), ); // If the quote requires an ERC-7597 permit, sign it and refresh the quote @@ -274,7 +283,7 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { fromAmount: opts.amount, fromSymbol: fromInfo.symbol, toSymbol: toInfo.symbol, - slippage: String(slippage), + slippage: String(slippage ?? DEFAULT_SLIPPAGE_PERCENT), network, sponsored: !!paymaster, txHash: txHash ?? null, @@ -285,7 +294,7 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { const pairs: [string, string][] = [ ["From", `${opts.amount} ${fromInfo.symbol}`], ["To", toInfo.symbol], - ["Slippage", `${slippage}%`], + ["Slippage", `${slippage ?? DEFAULT_SLIPPAGE_PERCENT}%`], ["Network", network], ]; diff --git a/tests/commands/swap.test.ts b/tests/commands/swap.test.ts index e62a62c..d56d263 100644 --- a/tests/commands/swap.test.ts +++ b/tests/commands/swap.test.ts @@ -76,8 +76,8 @@ describe("swap command", () => { fromToken: NATIVE_TOKEN, toToken: USDC, fromAmount: 1000000000000000000n, - slippage: 50n, })); + expect(requestQuoteV0.mock.calls[0]?.[0]?.slippage).toBeUndefined(); expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ fromToken: NATIVE_TOKEN, @@ -176,7 +176,12 @@ describe("swap command", () => { "--amount", "1.0", ], { from: "node" }); - expect(requestQuoteV0).toHaveBeenCalled(); + 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(); @@ -381,4 +386,72 @@ describe("swap command", () => { expect(exitWithError).toHaveBeenCalledTimes(1); }); + + it("passes explicit slippage when provided", async () => { + 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.fn(), + })); + 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, + })); + }); }); From ad6bbd745d2890781976c529efd4a57e9ad9fce3 Mon Sep 17 00:00:00 2001 From: blake duncan Date: Thu, 9 Apr 2026 16:51:40 -0400 Subject: [PATCH 3/4] fix: clarify swap quote output Avoid reporting a slippage value the CLI did not send and label swap quote output as a minimum receive amount so the output matches the underlying quote semantics. Made-with: Cursor --- src/commands/swap.ts | 25 ++++++------ tests/commands/swap.test.ts | 79 ++++++++++++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/src/commands/swap.ts b/src/commands/swap.ts index 32738f2..b7f340b 100644 --- a/src/commands/swap.ts +++ b/src/commands/swap.ts @@ -15,7 +15,6 @@ import { parseAmount, fetchTokenDecimals } from "./send/shared.js"; const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as Address; const NATIVE_DECIMALS = 18; -const DEFAULT_SLIPPAGE_PERCENT = 0.5; function isNativeToken(address: string): boolean { return address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase(); @@ -84,7 +83,7 @@ export function registerSwap(program: Command) { .requiredOption("--from
", "Token address to swap from (use 0xEeee...EEeE for the native token)") .requiredOption("--to
", "Token address to swap to") .requiredOption("--amount ", "Amount to swap (human-readable)") - .option("--slippage ", "Max slippage percentage (default: 0.5)") + .option("--slippage ", "Max slippage percentage (omit to use the API default)") .addHelpText( "after", ` @@ -108,7 +107,7 @@ Examples: .requiredOption("--from
", "Token address to swap from (use 0xEeee...EEeE for the native token)") .requiredOption("--to
", "Token address to swap to") .requiredOption("--amount ", "Amount to swap (human-readable)") - .option("--slippage ", "Max slippage percentage (default: 0.5)") + .option("--slippage ", "Max slippage percentage (omit to use the API default)") .addHelpText( "after", ` @@ -153,7 +152,7 @@ async function performSwapQuote(program: Command, opts: SwapOpts) { // Resolve to-token info for display const toInfo = await resolveTokenInfo(network, program, opts.to); - // Extract expected output from quote response + // Extract the minimum receive amount from the quote response. const quoteData = extractQuoteData(quote); if (isJSONMode()) { @@ -163,8 +162,8 @@ async function performSwapQuote(program: Command, opts: SwapOpts) { fromAmount: opts.amount, fromSymbol: fromInfo.symbol, toSymbol: toInfo.symbol, - expectedOutput: quoteData.expectedOutput ? formatTokenAmount(quoteData.expectedOutput, toInfo.decimals) : null, - slippage: String(slippage ?? DEFAULT_SLIPPAGE_PERCENT), + minimumOutput: quoteData.minimumOutput ? formatTokenAmount(quoteData.minimumOutput, toInfo.decimals) : null, + slippage: slippage === undefined ? null : String(slippage), network, quoteType: quoteData.type, }); @@ -173,14 +172,14 @@ async function performSwapQuote(program: Command, opts: SwapOpts) { ["From", green(`${opts.amount} ${fromInfo.symbol}`)], ]; - if (quoteData.expectedOutput) { - pairs.push(["To", green(`~${formatTokenAmount(quoteData.expectedOutput, toInfo.decimals)} ${toInfo.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 ?? DEFAULT_SLIPPAGE_PERCENT}%`], + ["Slippage", slippage === undefined ? "API default" : `${slippage}%`], ["Network", network], ); @@ -283,7 +282,7 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { fromAmount: opts.amount, fromSymbol: fromInfo.symbol, toSymbol: toInfo.symbol, - slippage: String(slippage ?? DEFAULT_SLIPPAGE_PERCENT), + slippage: slippage === undefined ? null : String(slippage), network, sponsored: !!paymaster, txHash: txHash ?? null, @@ -294,7 +293,7 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { const pairs: [string, string][] = [ ["From", `${opts.amount} ${fromInfo.symbol}`], ["To", toInfo.symbol], - ["Slippage", `${slippage ?? DEFAULT_SLIPPAGE_PERCENT}%`], + ["Slippage", slippage === undefined ? "API default" : `${slippage}%`], ["Network", network], ]; @@ -314,11 +313,11 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { // ── Helpers ───────────────────────────────────────────────────────── -function extractQuoteData(quote: any): { type: string; expectedOutput?: bigint } { +function extractQuoteData(quote: any): { type: string; minimumOutput?: bigint } { const type = quote.type ?? "unknown"; if (quote.quote?.minimumToAmount !== undefined) { - return { type, expectedOutput: BigInt(quote.quote.minimumToAmount) }; + return { type, minimumOutput: BigInt(quote.quote.minimumToAmount) }; } return { type }; diff --git a/tests/commands/swap.test.ts b/tests/commands/swap.test.ts index d56d263..a725f8c 100644 --- a/tests/commands/swap.test.ts +++ b/tests/commands/swap.test.ts @@ -85,7 +85,8 @@ describe("swap command", () => { fromAmount: "1.0", fromSymbol: "POL", toSymbol: "USDC", - expectedOutput: "30", + minimumOutput: "30", + slippage: null, network: "polygon-mainnet", quoteType: "user-operation-v070", })); @@ -186,6 +187,7 @@ describe("swap command", () => { expect(sendPreparedCalls).toHaveBeenCalledWith(signedQuote); expect(sendCalls).not.toHaveBeenCalled(); expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + slippage: null, status: "success", txHash: "0xtxhash", })); @@ -388,6 +390,7 @@ describe("swap command", () => { }); it("passes explicit slippage when provided", async () => { + const printJSON = vi.fn(); const requestQuoteV0 = vi.fn().mockResolvedValue({ rawCalls: false, type: "user-operation-v070", @@ -423,7 +426,7 @@ describe("swap command", () => { })); vi.doMock("../../src/lib/output.js", () => ({ isJSONMode: () => true, - printJSON: vi.fn(), + printJSON, })); vi.doMock("../../src/lib/ui.js", () => ({ withSpinner: async (_label: string, _done: string, fn: () => Promise) => fn(), @@ -453,5 +456,77 @@ describe("swap command", () => { fromAmount: 1000000000000000000n, slippage: 125n, })); + expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({ + minimumOutput: "30", + slippage: "1.25", + })); + }); + + 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"], + ])); }); }); From 6b89fe8a454e995312f3ed8059cdcf59acb14ed3 Mon Sep 17 00:00:00 2001 From: blake duncan Date: Fri, 10 Apr 2026 09:33:39 -0400 Subject: [PATCH 4/4] fix: tighten swap typing and polish UX Type the swap quote helpers against the SDK surface so response and request shape changes fail fast, and clarify swap inputs and token metadata failures for operators. Made-with: Cursor --- src/commands/swap.ts | 114 ++++++++++++++++++-------------- tests/commands/swap.test.ts | 126 ++++++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 47 deletions(-) diff --git a/src/commands/swap.ts b/src/commands/swap.ts index b7f340b..8e7c5bc 100644 --- a/src/commands/swap.ts +++ b/src/commands/swap.ts @@ -2,13 +2,14 @@ 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 { exitWithError, errInvalidArgs } from "../lib/errors.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"; @@ -32,7 +33,19 @@ async function resolveTokenInfo( if (isNativeToken(tokenAddress)) { return { decimals: NATIVE_DECIMALS, symbol: nativeTokenSymbol(network) }; } - return fetchTokenDecimals(program, tokenAddress); + + 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 { @@ -49,8 +62,13 @@ interface SwapOpts { 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, @@ -58,7 +76,7 @@ function createQuoteRequest( fromAmount: bigint, slippagePercent: number | undefined, paymaster?: PaymasterConfig, -) { +): RequestQuoteV0Params { const request = { fromToken: fromToken as Address, toToken: toToken as Address, @@ -67,9 +85,43 @@ function createQuoteRequest( ? { 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 request as Parameters["client"]["extend"]>["requestQuoteV0"]>[0]; + return preparedQuote as ExecutableQuote; } export function registerSwap(program: Command) { @@ -80,9 +132,9 @@ export function registerSwap(program: Command) { 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") - .requiredOption("--amount ", "Amount to swap (human-readable)") + .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", @@ -104,9 +156,9 @@ Examples: 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") - .requiredOption("--amount ", "Amount to swap (human-readable)") + .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", @@ -205,45 +257,13 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { } // Get quote with prepared calls - let quote = await withSpinner( + const quote = await withSpinner( "Fetching quote…", "Quote received", () => swapClient.requestQuoteV0(createQuoteRequest(opts.from, opts.to, rawAmount, slippage, paymaster)), ); - // If the quote requires an ERC-7597 permit, sign it and refresh the quote - // with the attached permit signature before preparing the final calls. - let preparedQuote: - | Parameters[0] - | RawCallsQuote - | undefined; - - if ("type" in quote && quote.type === "paymaster-permit" && "modifiedRequest" in quote && "signatureRequest" in quote) { - const permitQuote = quote as PaymasterPermitQuote & { - modifiedRequest: Parameters[0]; - signatureRequest: Parameters[0]; - }; - const permitSignature = await withSpinner( - "Signing permit…", - "Permit signed", - () => client.signSignatureRequest(permitQuote.signatureRequest), - ); - - preparedQuote = await withSpinner( - "Preparing swap…", - "Swap prepared", - () => client.prepareCalls({ - ...permitQuote.modifiedRequest, - paymasterPermitSignature: permitSignature, - }), - ); - } else { - preparedQuote = quote; - } - - 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."); - } + const preparedQuote = await prepareQuoteForExecution(client, quote); // Send the quoted swap using the appropriate execution path. const { id } = await withSpinner( @@ -258,7 +278,7 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { }); } - const executablePreparedQuote = preparedQuote as Parameters[0]; + const executablePreparedQuote = preparedQuote as ExecutablePreparedQuote; const signedQuote = await client.signPreparedCalls(executablePreparedQuote); return client.sendPreparedCalls(signedQuote); }, @@ -313,8 +333,8 @@ async function performSwapExecute(program: Command, opts: SwapOpts) { // ── Helpers ───────────────────────────────────────────────────────── -function extractQuoteData(quote: any): { type: string; minimumOutput?: bigint } { - const type = quote.type ?? "unknown"; +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) }; diff --git a/tests/commands/swap.test.ts b/tests/commands/swap.test.ts index a725f8c..b92969d 100644 --- a/tests/commands/swap.test.ts +++ b/tests/commands/swap.test.ts @@ -3,6 +3,7 @@ import { Command } from "commander"; const NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; +const ZERO_DEC_TOKEN = "0x3333333333333333333333333333333333333333"; const FROM = "0x1111111111111111111111111111111111111111"; const ROUTER = "0x2222222222222222222222222222222222222222"; @@ -462,6 +463,79 @@ describe("swap command", () => { })); }); + 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({ @@ -529,4 +603,56 @@ describe("swap command", () => { ["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`, + })); + }); });