Skip to content

Commit 6b89fe8

Browse files
committed
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
1 parent ad6bbd7 commit 6b89fe8

2 files changed

Lines changed: 193 additions & 47 deletions

File tree

src/commands/swap.ts

Lines changed: 67 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { Command } from "commander";
22
import type { Address } from "viem";
33
import {
44
swapActions,
5+
type RequestQuoteV0Params,
56
type RequestQuoteV0Result,
67
} from "@alchemy/wallet-apis/experimental";
78
import { buildWalletClient } from "../lib/smart-wallet.js";
89
import type { PaymasterConfig } from "../lib/smart-wallet.js";
910
import { validateAddress } from "../lib/validators.js";
1011
import { isJSONMode, printJSON } from "../lib/output.js";
11-
import { exitWithError, errInvalidArgs } from "../lib/errors.js";
12+
import { CLIError, exitWithError, errInvalidArgs } from "../lib/errors.js";
1213
import { withSpinner, printKeyValueBox, green } from "../lib/ui.js";
1314
import { nativeTokenSymbol } from "../lib/networks.js";
1415
import { parseAmount, fetchTokenDecimals } from "./send/shared.js";
@@ -32,7 +33,19 @@ async function resolveTokenInfo(
3233
if (isNativeToken(tokenAddress)) {
3334
return { decimals: NATIVE_DECIMALS, symbol: nativeTokenSymbol(network) };
3435
}
35-
return fetchTokenDecimals(program, tokenAddress);
36+
37+
try {
38+
return await fetchTokenDecimals(program, tokenAddress);
39+
} catch (err) {
40+
if (err instanceof CLIError && err.code === "INVALID_ARGS") {
41+
throw err;
42+
}
43+
44+
const detail = err instanceof Error && err.message
45+
? ` ${err.message}`
46+
: "";
47+
throw errInvalidArgs(`Failed to resolve token info for ${tokenAddress}.${detail}`);
48+
}
3649
}
3750

3851
function formatTokenAmount(rawAmount: bigint, decimals: number): string {
@@ -49,16 +62,21 @@ interface SwapOpts {
4962
slippage?: string;
5063
}
5164

65+
type WalletClient = ReturnType<typeof buildWalletClient>["client"];
5266
type PaymasterPermitQuote = Extract<RequestQuoteV0Result, { type: "paymaster-permit" }>;
5367
type RawCallsQuote = Extract<RequestQuoteV0Result, { rawCalls: true }>;
68+
type ExecutablePreparedQuote = Parameters<WalletClient["signPreparedCalls"]>[0];
69+
type PreparedCallsRequest = Parameters<WalletClient["prepareCalls"]>[0];
70+
type SignatureRequest = Parameters<WalletClient["signSignatureRequest"]>[0];
71+
type ExecutableQuote = ExecutablePreparedQuote | RawCallsQuote;
5472

5573
function createQuoteRequest(
5674
fromToken: string,
5775
toToken: string,
5876
fromAmount: bigint,
5977
slippagePercent: number | undefined,
6078
paymaster?: PaymasterConfig,
61-
) {
79+
): RequestQuoteV0Params {
6280
const request = {
6381
fromToken: fromToken as Address,
6482
toToken: toToken as Address,
@@ -67,9 +85,43 @@ function createQuoteRequest(
6785
? { slippage: slippagePercentToBasisPoints(slippagePercent) }
6886
: {}),
6987
...(paymaster ? { capabilities: { paymaster } } : {}),
88+
} satisfies RequestQuoteV0Params;
89+
90+
return request;
91+
}
92+
93+
async function prepareQuoteForExecution(
94+
client: WalletClient,
95+
quote: RequestQuoteV0Result,
96+
): Promise<ExecutableQuote> {
97+
if (!("type" in quote) || quote.type !== "paymaster-permit" || !("modifiedRequest" in quote) || !("signatureRequest" in quote)) {
98+
return quote as ExecutableQuote;
99+
}
100+
101+
const permitQuote = quote as PaymasterPermitQuote & {
102+
modifiedRequest: PreparedCallsRequest;
103+
signatureRequest: SignatureRequest;
70104
};
105+
const permitSignature = await withSpinner(
106+
"Signing permit…",
107+
"Permit signed",
108+
() => client.signSignatureRequest(permitQuote.signatureRequest),
109+
);
110+
111+
const preparedQuote = await withSpinner(
112+
"Preparing swap…",
113+
"Swap prepared",
114+
() => client.prepareCalls({
115+
...permitQuote.modifiedRequest,
116+
paymasterPermitSignature: permitSignature,
117+
}),
118+
);
119+
120+
if ("type" in preparedQuote && preparedQuote.type === "paymaster-permit") {
121+
throw errInvalidArgs("Swap quote still requires a paymaster permit after signing. The quote response format may be unsupported.");
122+
}
71123

72-
return request as Parameters<ReturnType<ReturnType<typeof buildWalletClient>["client"]["extend"]>["requestQuoteV0"]>[0];
124+
return preparedQuote as ExecutableQuote;
73125
}
74126

75127
export function registerSwap(program: Command) {
@@ -80,9 +132,9 @@ export function registerSwap(program: Command) {
80132
cmd
81133
.command("quote")
82134
.description("Get a swap quote without executing")
83-
.requiredOption("--from <address>", "Token address to swap from (use 0xEeee...EEeE for the native token)")
84-
.requiredOption("--to <address>", "Token address to swap to")
85-
.requiredOption("--amount <number>", "Amount to swap (human-readable)")
135+
.requiredOption("--from <token_address>", "Token address to swap from (use 0xEeee...EEeE for the native token)")
136+
.requiredOption("--to <token_address>", "Token address to swap to (use 0xEeee...EEeE for the native token)")
137+
.requiredOption("--amount <number>", "Amount to swap in decimal token units (for example, 1.5)")
86138
.option("--slippage <percent>", "Max slippage percentage (omit to use the API default)")
87139
.addHelpText(
88140
"after",
@@ -104,9 +156,9 @@ Examples:
104156
cmd
105157
.command("execute")
106158
.description("Execute a token swap")
107-
.requiredOption("--from <address>", "Token address to swap from (use 0xEeee...EEeE for the native token)")
108-
.requiredOption("--to <address>", "Token address to swap to")
109-
.requiredOption("--amount <number>", "Amount to swap (human-readable)")
159+
.requiredOption("--from <token_address>", "Token address to swap from (use 0xEeee...EEeE for the native token)")
160+
.requiredOption("--to <token_address>", "Token address to swap to (use 0xEeee...EEeE for the native token)")
161+
.requiredOption("--amount <number>", "Amount to swap in decimal token units (for example, 1.5)")
110162
.option("--slippage <percent>", "Max slippage percentage (omit to use the API default)")
111163
.addHelpText(
112164
"after",
@@ -205,45 +257,13 @@ async function performSwapExecute(program: Command, opts: SwapOpts) {
205257
}
206258

207259
// Get quote with prepared calls
208-
let quote = await withSpinner(
260+
const quote = await withSpinner(
209261
"Fetching quote…",
210262
"Quote received",
211263
() => swapClient.requestQuoteV0(createQuoteRequest(opts.from, opts.to, rawAmount, slippage, paymaster)),
212264
);
213265

214-
// If the quote requires an ERC-7597 permit, sign it and refresh the quote
215-
// with the attached permit signature before preparing the final calls.
216-
let preparedQuote:
217-
| Parameters<typeof client.signPreparedCalls>[0]
218-
| RawCallsQuote
219-
| undefined;
220-
221-
if ("type" in quote && quote.type === "paymaster-permit" && "modifiedRequest" in quote && "signatureRequest" in quote) {
222-
const permitQuote = quote as PaymasterPermitQuote & {
223-
modifiedRequest: Parameters<typeof client.prepareCalls>[0];
224-
signatureRequest: Parameters<typeof client.signSignatureRequest>[0];
225-
};
226-
const permitSignature = await withSpinner(
227-
"Signing permit…",
228-
"Permit signed",
229-
() => client.signSignatureRequest(permitQuote.signatureRequest),
230-
);
231-
232-
preparedQuote = await withSpinner(
233-
"Preparing swap…",
234-
"Swap prepared",
235-
() => client.prepareCalls({
236-
...permitQuote.modifiedRequest,
237-
paymasterPermitSignature: permitSignature,
238-
}),
239-
);
240-
} else {
241-
preparedQuote = quote;
242-
}
243-
244-
if ("type" in preparedQuote && preparedQuote.type === "paymaster-permit") {
245-
throw errInvalidArgs("Swap quote still requires a paymaster permit after signing. The quote response format may be unsupported.");
246-
}
266+
const preparedQuote = await prepareQuoteForExecution(client, quote);
247267

248268
// Send the quoted swap using the appropriate execution path.
249269
const { id } = await withSpinner(
@@ -258,7 +278,7 @@ async function performSwapExecute(program: Command, opts: SwapOpts) {
258278
});
259279
}
260280

261-
const executablePreparedQuote = preparedQuote as Parameters<typeof client.signPreparedCalls>[0];
281+
const executablePreparedQuote = preparedQuote as ExecutablePreparedQuote;
262282
const signedQuote = await client.signPreparedCalls(executablePreparedQuote);
263283
return client.sendPreparedCalls(signedQuote);
264284
},
@@ -313,8 +333,8 @@ async function performSwapExecute(program: Command, opts: SwapOpts) {
313333

314334
// ── Helpers ─────────────────────────────────────────────────────────
315335

316-
function extractQuoteData(quote: any): { type: string; minimumOutput?: bigint } {
317-
const type = quote.type ?? "unknown";
336+
function extractQuoteData(quote: RequestQuoteV0Result): { type: string; minimumOutput?: bigint } {
337+
const type = "type" in quote ? quote.type : "unknown";
318338

319339
if (quote.quote?.minimumToAmount !== undefined) {
320340
return { type, minimumOutput: BigInt(quote.quote.minimumToAmount) };

tests/commands/swap.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Command } from "commander";
33

44
const NATIVE_TOKEN = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
55
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
6+
const ZERO_DEC_TOKEN = "0x3333333333333333333333333333333333333333";
67
const FROM = "0x1111111111111111111111111111111111111111";
78
const ROUTER = "0x2222222222222222222222222222222222222222";
89

@@ -462,6 +463,79 @@ describe("swap command", () => {
462463
}));
463464
});
464465

466+
it("formats minimum output correctly for zero-decimal tokens", async () => {
467+
const printJSON = vi.fn();
468+
const requestQuoteV0 = vi.fn().mockResolvedValue({
469+
rawCalls: false,
470+
type: "user-operation-v070",
471+
quote: {
472+
fromAmount: 1000000n,
473+
minimumToAmount: 42n,
474+
expiry: 123,
475+
},
476+
chainId: 1,
477+
data: {},
478+
feePayment: {
479+
sponsored: false,
480+
tokenAddress: USDC,
481+
maxAmount: 0n,
482+
},
483+
});
484+
const call = vi.fn().mockImplementation((_method: string, [tokenAddress]: [string]) => {
485+
if (tokenAddress === USDC) {
486+
return Promise.resolve({ decimals: 6, symbol: "USDC" });
487+
}
488+
if (tokenAddress === ZERO_DEC_TOKEN) {
489+
return Promise.resolve({ decimals: 0, symbol: "POINTS" });
490+
}
491+
return Promise.reject(new Error(`Unexpected token ${tokenAddress}`));
492+
});
493+
494+
vi.doMock("../../src/lib/smart-wallet.js", () => ({
495+
buildWalletClient: () => ({
496+
client: {
497+
extend: () => ({ requestQuoteV0 }),
498+
},
499+
network: "eth-mainnet",
500+
address: FROM,
501+
paymaster: undefined,
502+
}),
503+
}));
504+
vi.doMock("../../src/lib/resolve.js", () => ({
505+
clientFromFlags: () => ({ call }),
506+
resolveNetwork: () => "eth-mainnet",
507+
}));
508+
vi.doMock("../../src/lib/output.js", () => ({
509+
isJSONMode: () => true,
510+
printJSON,
511+
}));
512+
vi.doMock("../../src/lib/ui.js", () => ({
513+
withSpinner: async (_label: string, _done: string, fn: () => Promise<unknown>) => fn(),
514+
printKeyValueBox: vi.fn(),
515+
green: (s: string) => s,
516+
dim: (s: string) => s,
517+
}));
518+
vi.doMock("../../src/lib/validators.js", () => ({
519+
validateAddress: vi.fn(),
520+
}));
521+
522+
const { registerSwap } = await import("../../src/commands/swap.js");
523+
const program = new Command();
524+
registerSwap(program);
525+
526+
await program.parseAsync([
527+
"node", "test", "swap", "quote",
528+
"--from", USDC,
529+
"--to", ZERO_DEC_TOKEN,
530+
"--amount", "1.0",
531+
], { from: "node" });
532+
533+
expect(printJSON).toHaveBeenCalledWith(expect.objectContaining({
534+
minimumOutput: "42",
535+
toSymbol: "POINTS",
536+
}));
537+
});
538+
465539
it("renders minimum receive and API-default slippage in human output", async () => {
466540
const printKeyValueBox = vi.fn();
467541
const requestQuoteV0 = vi.fn().mockResolvedValue({
@@ -529,4 +603,56 @@ describe("swap command", () => {
529603
["Network", "polygon-mainnet"],
530604
]));
531605
});
606+
607+
it("wraps unexpected token metadata failures with token context", async () => {
608+
const exitWithError = vi.fn();
609+
610+
vi.doMock("../../src/lib/smart-wallet.js", () => ({
611+
buildWalletClient: () => ({
612+
client: { extend: () => ({ requestQuoteV0: vi.fn() }) },
613+
network: "eth-mainnet",
614+
address: FROM,
615+
paymaster: undefined,
616+
}),
617+
}));
618+
vi.doMock("../../src/lib/resolve.js", () => ({
619+
clientFromFlags: () => ({
620+
call: vi.fn().mockRejectedValue(new Error("RPC offline")),
621+
}),
622+
resolveNetwork: () => "eth-mainnet",
623+
}));
624+
vi.doMock("../../src/lib/output.js", () => ({
625+
isJSONMode: () => true,
626+
printJSON: vi.fn(),
627+
}));
628+
vi.doMock("../../src/lib/ui.js", () => ({
629+
withSpinner: vi.fn(),
630+
printKeyValueBox: vi.fn(),
631+
green: (s: string) => s,
632+
dim: (s: string) => s,
633+
}));
634+
vi.doMock("../../src/lib/validators.js", () => ({
635+
validateAddress: vi.fn(),
636+
}));
637+
vi.doMock("../../src/lib/errors.js", async () => ({
638+
...(await vi.importActual("../../src/lib/errors.js")),
639+
exitWithError,
640+
}));
641+
642+
const { registerSwap } = await import("../../src/commands/swap.js");
643+
const program = new Command();
644+
registerSwap(program);
645+
646+
await program.parseAsync([
647+
"node", "test", "swap", "quote",
648+
"--from", USDC,
649+
"--to", NATIVE_TOKEN,
650+
"--amount", "1.0",
651+
], { from: "node" });
652+
653+
expect(exitWithError).toHaveBeenCalledWith(expect.objectContaining({
654+
code: "INVALID_ARGS",
655+
message: `Failed to resolve token info for ${USDC}. RPC offline`,
656+
}));
657+
});
532658
});

0 commit comments

Comments
 (0)