Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
344 changes: 344 additions & 0 deletions src/commands/swap.ts
Original file line number Diff line number Diff line change
@@ -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");
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: formatTokenAmount works correctly when decimals is 0, but the behavior is subtle (the padStart / slice logic). A brief comment or a unit test for that edge case would help future maintainers.

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<typeof buildWalletClient>["client"];
type PaymasterPermitQuote = Extract<RequestQuoteV0Result, { type: "paymaster-permit" }>;
type RawCallsQuote = Extract<RequestQuoteV0Result, { rawCalls: true }>;
type ExecutablePreparedQuote = Parameters<WalletClient["signPreparedCalls"]>[0];
type PreparedCallsRequest = Parameters<WalletClient["prepareCalls"]>[0];
type SignatureRequest = Parameters<WalletClient["signSignatureRequest"]>[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<ExecutableQuote> {
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>", "Token address to swap from (use 0xEeee...EEeE for the native token)")
.requiredOption("--to <token_address>", "Token address to swap to (use 0xEeee...EEeE for the native token)")
.requiredOption("--amount <number>", "Amount to swap in decimal token units (for example, 1.5)")
.option("--slippage <percent>", "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>", "Token address to swap from (use 0xEeee...EEeE for the native token)")
.requiredOption("--to <token_address>", "Token address to swap to (use 0xEeee...EEeE for the native token)")
.requiredOption("--amount <number>", "Amount to swap in decimal token units (for example, 1.5)")
.option("--slippage <percent>", "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 <id>`,
)
.action(async (opts: SwapOpts) => {
try {
await performSwapExecute(program, opts);
} catch (err) {
exitWithError(err);
}
});
}

// ── Quote implementation ────────────────────────────────────────────

async function performSwapQuote(program: Command, opts: SwapOpts) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the token address is invalid or the RPC call to fetch decimals fails (e.g. non-ERC20 address), the error will bubble up with a potentially cryptic message. Worth confirming that fetchTokenDecimals provides a user-friendly error in this context, or wrapping with a clearer message like "Failed to resolve token info for

".

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);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quote is declared with let but never reassigned — can be const.

More broadly, the preparedQuote flow is a bit hard to follow with two conditional branches and a post-check for the permit type. Consider extracting the paymaster-permit handling into a small helper to reduce nesting in performSwapExecute.

// ── 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) };
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quote is typed any here, which bypasses the RequestQuoteV0Result type that's already imported. If the SDK response shape changes this would silently hide bugs. Consider using the actual SDK type:

function extractQuoteData(quote: RequestQuoteV0Result): { type: string; minimumOutput?: bigint }

}

return { type };
}
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -72,7 +73,7 @@ const ROOT_COMMAND_PILLARS = [
},
{
label: "Execution",
commands: ["send", "contract"],
commands: ["send", "contract", "swap"],
},
{
label: "Wallets",
Expand Down Expand Up @@ -450,6 +451,7 @@ registerSimulate(program);
// Execution
registerSend(program);
registerContract(program);
registerSwap(program);

// Wallets
registerWallet(program);
Expand Down
Loading
Loading