Skip to content

Commit c6df2d8

Browse files
authored
Merge pull request #63 from alchemyplatform/blake/bridge-command
feat: add bridge command for cross-chain transfers
2 parents 4440cd8 + 75e1f90 commit c6df2d8

File tree

5 files changed

+1051
-4
lines changed

5 files changed

+1051
-4
lines changed

src/commands/bridge.ts

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
import { Command } from "commander";
2+
import type { Address } from "viem";
3+
import {
4+
swapActions,
5+
type RequestQuoteV0Params,
6+
type RequestQuoteV0Result,
7+
} from "@alchemy/wallet-apis/experimental";
8+
import { buildWalletClient } from "../lib/smart-wallet.js";
9+
import type { PaymasterConfig } from "../lib/smart-wallet.js";
10+
import { validateAddress } from "../lib/validators.js";
11+
import { isJSONMode, printJSON } from "../lib/output.js";
12+
import { CLIError, exitWithError, errInvalidArgs } from "../lib/errors.js";
13+
import { withSpinner, printKeyValueBox, green } from "../lib/ui.js";
14+
import { nativeTokenSymbol } from "../lib/networks.js";
15+
import { networkToChain } from "../lib/chains.js";
16+
import { parseAmount, fetchTokenDecimals, formatTokenAmount } from "./send/shared.js";
17+
18+
const NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE" as Address;
19+
const NATIVE_DECIMALS = 18;
20+
21+
function isNativeToken(address: string): boolean {
22+
return address.toLowerCase() === NATIVE_TOKEN_ADDRESS.toLowerCase();
23+
}
24+
25+
function slippagePercentToBasisPoints(percent: number): bigint {
26+
return BigInt(Math.round(percent * 100));
27+
}
28+
29+
async function resolveTokenInfo(
30+
network: string,
31+
program: Command,
32+
tokenAddress: string,
33+
): Promise<{ decimals: number; symbol: string }> {
34+
if (isNativeToken(tokenAddress)) {
35+
return { decimals: NATIVE_DECIMALS, symbol: nativeTokenSymbol(network) };
36+
}
37+
38+
try {
39+
return await fetchTokenDecimals(program, tokenAddress, { network });
40+
} catch (err) {
41+
if (err instanceof CLIError && err.code === "INVALID_ARGS") {
42+
throw err;
43+
}
44+
const detail = err instanceof Error && err.message ? ` ${err.message}` : "";
45+
throw errInvalidArgs(`Failed to resolve token info for ${tokenAddress}.${detail}`);
46+
}
47+
}
48+
49+
interface BridgeOpts {
50+
from: string;
51+
to: string;
52+
amount: string;
53+
toNetwork: string;
54+
slippage?: string;
55+
}
56+
57+
type WalletClient = ReturnType<typeof buildWalletClient>["client"];
58+
type PaymasterPermitQuote = Extract<RequestQuoteV0Result, { type: "paymaster-permit" }>;
59+
type RawCallsQuote = Extract<RequestQuoteV0Result, { rawCalls: true }>;
60+
type ExecutablePreparedQuote = Parameters<WalletClient["signPreparedCalls"]>[0];
61+
type PreparedCallsRequest = Parameters<WalletClient["prepareCalls"]>[0];
62+
type SignatureRequest = Parameters<WalletClient["signSignatureRequest"]>[0];
63+
type ExecutableQuote = ExecutablePreparedQuote | RawCallsQuote;
64+
65+
function createBridgeQuoteRequest(
66+
fromToken: string,
67+
toToken: string,
68+
fromAmount: bigint,
69+
toChainId: number,
70+
slippagePercent: number | undefined,
71+
paymaster?: PaymasterConfig,
72+
): RequestQuoteV0Params {
73+
const request = {
74+
fromToken: fromToken as Address,
75+
toToken: toToken as Address,
76+
fromAmount,
77+
toChainId,
78+
...(slippagePercent !== undefined
79+
? { slippage: slippagePercentToBasisPoints(slippagePercent) }
80+
: {}),
81+
...(paymaster ? { capabilities: { paymaster } } : {}),
82+
} satisfies RequestQuoteV0Params;
83+
84+
return request;
85+
}
86+
87+
function validateBridgeNetworks(fromNetwork: string, toNetwork: string): void {
88+
if (fromNetwork === toNetwork) {
89+
throw errInvalidArgs(
90+
`Source and destination networks must differ for bridge. Use 'alchemy swap' for same-chain token exchanges on ${fromNetwork}.`,
91+
);
92+
}
93+
}
94+
95+
async function prepareQuoteForExecution(
96+
client: WalletClient,
97+
quote: RequestQuoteV0Result,
98+
): Promise<ExecutableQuote> {
99+
if (!("type" in quote) || quote.type !== "paymaster-permit" || !("modifiedRequest" in quote) || !("signatureRequest" in quote)) {
100+
return quote as ExecutableQuote;
101+
}
102+
103+
const permitQuote = quote as PaymasterPermitQuote & {
104+
modifiedRequest: PreparedCallsRequest;
105+
signatureRequest: SignatureRequest;
106+
};
107+
const permitSignature = await withSpinner(
108+
"Signing permit…",
109+
"Permit signed",
110+
() => client.signSignatureRequest(permitQuote.signatureRequest),
111+
);
112+
113+
const preparedQuote = await withSpinner(
114+
"Preparing bridge…",
115+
"Bridge prepared",
116+
() => client.prepareCalls({
117+
...permitQuote.modifiedRequest,
118+
paymasterPermitSignature: permitSignature,
119+
}),
120+
);
121+
122+
if ("type" in preparedQuote && preparedQuote.type === "paymaster-permit") {
123+
throw errInvalidArgs("Bridge quote still requires a paymaster permit after signing. The quote response format may be unsupported.");
124+
}
125+
126+
return preparedQuote as ExecutableQuote;
127+
}
128+
129+
function extractQuoteData(quote: RequestQuoteV0Result): { type: string; minimumOutput?: bigint } {
130+
const type = "type" in quote ? quote.type : "unknown";
131+
132+
if (quote.quote?.minimumToAmount !== undefined) {
133+
return { type, minimumOutput: BigInt(quote.quote.minimumToAmount) };
134+
}
135+
136+
return { type };
137+
}
138+
139+
export function registerBridge(program: Command) {
140+
const cmd = program
141+
.command("bridge")
142+
.description("Bridge tokens from the source -n/--network to a destination --to-network");
143+
144+
// ── bridge quote ──────────────────────────────────────────────────
145+
146+
cmd
147+
.command("quote")
148+
.description("Get a bridge quote without executing")
149+
.requiredOption("--from <token_address>", `Source token address (use ${NATIVE_TOKEN_ADDRESS} for the native token)`)
150+
.requiredOption("--to <token_address>", `Destination token address (use ${NATIVE_TOKEN_ADDRESS} for the native token)`)
151+
.requiredOption("--amount <number>", "Amount to bridge in decimal token units (for example, 1.5)")
152+
.requiredOption("--to-network <network>", "Destination network (e.g. base-mainnet)")
153+
.option("--slippage <percent>", "Max slippage percentage (omit to use the API default)")
154+
.addHelpText(
155+
"after",
156+
`
157+
Source network comes from the global -n/--network flag. Use --to-network for the destination chain.
158+
For same-chain token exchanges, use 'alchemy swap'.
159+
160+
Examples:
161+
alchemy bridge quote --from ${NATIVE_TOKEN_ADDRESS} --to ${NATIVE_TOKEN_ADDRESS} --amount 0.1 --to-network base-mainnet -n eth-mainnet
162+
alchemy bridge quote --from <eth_usdc_address> --to <arb_usdc_address> --amount 100 --to-network arb-mainnet -n eth-mainnet`,
163+
)
164+
.action(async (opts: BridgeOpts) => {
165+
try {
166+
await performBridgeQuote(program, opts);
167+
} catch (err) {
168+
exitWithError(err);
169+
}
170+
});
171+
172+
// ── bridge execute ────────────────────────────────────────────────
173+
174+
cmd
175+
.command("execute")
176+
.description("Execute a cross-chain bridge")
177+
.requiredOption("--from <token_address>", `Source token address (use ${NATIVE_TOKEN_ADDRESS} for the native token)`)
178+
.requiredOption("--to <token_address>", `Destination token address (use ${NATIVE_TOKEN_ADDRESS} for the native token)`)
179+
.requiredOption("--amount <number>", "Amount to bridge in decimal token units (for example, 1.5)")
180+
.requiredOption("--to-network <network>", "Destination network (e.g. base-mainnet)")
181+
.option("--slippage <percent>", "Max slippage percentage (omit to use the API default)")
182+
.addHelpText(
183+
"after",
184+
`
185+
Source network comes from the global -n/--network flag. Use --to-network for the destination chain.
186+
For same-chain token exchanges, use 'alchemy swap'.
187+
188+
Examples:
189+
alchemy bridge execute --from ${NATIVE_TOKEN_ADDRESS} --to ${NATIVE_TOKEN_ADDRESS} --amount 0.1 --to-network base-mainnet -n eth-mainnet
190+
alchemy bridge execute --from <eth_usdc_address> --to <arb_usdc_address> --amount 100 --to-network arb-mainnet --gas-sponsored --gas-policy-id <id> -n eth-mainnet`,
191+
)
192+
.action(async (opts: BridgeOpts) => {
193+
try {
194+
await performBridgeExecute(program, opts);
195+
} catch (err) {
196+
exitWithError(err);
197+
}
198+
});
199+
}
200+
201+
// ── Quote implementation ────────────────────────────────────────────
202+
203+
async function performBridgeQuote(program: Command, opts: BridgeOpts) {
204+
validateAddress(opts.from);
205+
validateAddress(opts.to);
206+
207+
const toChainId = networkToChain(opts.toNetwork).id;
208+
209+
const { client, network, paymaster } = buildWalletClient(program);
210+
validateBridgeNetworks(network, opts.toNetwork);
211+
const swapClient = client.extend(swapActions);
212+
213+
const fromInfo = await resolveTokenInfo(network, program, opts.from);
214+
const rawAmount = parseAmount(opts.amount, fromInfo.decimals);
215+
216+
const slippage = opts.slippage ? parseFloat(opts.slippage) : undefined;
217+
if (slippage !== undefined && (isNaN(slippage) || slippage < 0 || slippage > 100)) {
218+
throw errInvalidArgs("Slippage must be a number between 0 and 100.");
219+
}
220+
221+
const quote = await withSpinner(
222+
"Fetching bridge quote…",
223+
"Quote received",
224+
() => swapClient.requestQuoteV0(createBridgeQuoteRequest(opts.from, opts.to, rawAmount, toChainId, slippage, paymaster)),
225+
);
226+
227+
const toInfo = await resolveTokenInfo(opts.toNetwork, program, opts.to);
228+
const quoteData = extractQuoteData(quote);
229+
230+
if (isJSONMode()) {
231+
printJSON({
232+
fromToken: opts.from,
233+
toToken: opts.to,
234+
fromAmount: opts.amount,
235+
fromSymbol: fromInfo.symbol,
236+
toSymbol: toInfo.symbol,
237+
minimumOutput: quoteData.minimumOutput ? formatTokenAmount(quoteData.minimumOutput, toInfo.decimals) : null,
238+
slippage: slippage === undefined ? null : String(slippage),
239+
fromNetwork: network,
240+
toNetwork: opts.toNetwork,
241+
quoteType: quoteData.type,
242+
});
243+
} else {
244+
const pairs: [string, string][] = [
245+
["From", green(`${opts.amount} ${fromInfo.symbol}`)],
246+
];
247+
248+
if (quoteData.minimumOutput) {
249+
pairs.push(["Minimum Receive", green(`${formatTokenAmount(quoteData.minimumOutput, toInfo.decimals)} ${toInfo.symbol}`)]);
250+
} else {
251+
pairs.push(["To", toInfo.symbol]);
252+
}
253+
254+
pairs.push(
255+
["Slippage", slippage === undefined ? "API default" : `${slippage}%`],
256+
["From Network", network],
257+
["To Network", opts.toNetwork],
258+
);
259+
260+
printKeyValueBox(pairs);
261+
}
262+
}
263+
264+
// ── Execute implementation ──────────────────────────────────────────
265+
266+
async function performBridgeExecute(program: Command, opts: BridgeOpts) {
267+
validateAddress(opts.from);
268+
validateAddress(opts.to);
269+
270+
const toChainId = networkToChain(opts.toNetwork).id;
271+
272+
const { client, network, address: from, paymaster } = buildWalletClient(program);
273+
validateBridgeNetworks(network, opts.toNetwork);
274+
const swapClient = client.extend(swapActions);
275+
276+
const fromInfo = await resolveTokenInfo(network, program, opts.from);
277+
const rawAmount = parseAmount(opts.amount, fromInfo.decimals);
278+
279+
const slippage = opts.slippage ? parseFloat(opts.slippage) : undefined;
280+
if (slippage !== undefined && (isNaN(slippage) || slippage < 0 || slippage > 100)) {
281+
throw errInvalidArgs("Slippage must be a number between 0 and 100.");
282+
}
283+
284+
const quote = await withSpinner(
285+
"Fetching bridge quote…",
286+
"Quote received",
287+
() => swapClient.requestQuoteV0(createBridgeQuoteRequest(opts.from, opts.to, rawAmount, toChainId, slippage, paymaster)),
288+
);
289+
290+
const preparedQuote = await prepareQuoteForExecution(client, quote);
291+
292+
const { id } = await withSpinner(
293+
"Sending bridge transaction…",
294+
"Transaction submitted",
295+
async () => {
296+
if ("rawCalls" in preparedQuote && preparedQuote.rawCalls === true) {
297+
const rawCallsQuote = preparedQuote as RawCallsQuote;
298+
return client.sendCalls({
299+
calls: rawCallsQuote.calls,
300+
capabilities: paymaster ? { paymaster } : undefined,
301+
});
302+
}
303+
304+
const executablePreparedQuote = preparedQuote as ExecutablePreparedQuote;
305+
const signedQuote = await client.signPreparedCalls(executablePreparedQuote);
306+
return client.sendPreparedCalls(signedQuote);
307+
},
308+
);
309+
310+
const status = await withSpinner(
311+
"Waiting for confirmation…",
312+
"Bridge confirmed",
313+
() => client.waitForCallsStatus({ id }),
314+
);
315+
316+
const txHash = status.receipts?.[0]?.transactionHash;
317+
const confirmed = status.status === "success";
318+
const toInfo = await resolveTokenInfo(opts.toNetwork, program, opts.to);
319+
320+
if (isJSONMode()) {
321+
printJSON({
322+
from,
323+
fromToken: opts.from,
324+
toToken: opts.to,
325+
fromAmount: opts.amount,
326+
fromSymbol: fromInfo.symbol,
327+
toSymbol: toInfo.symbol,
328+
slippage: slippage === undefined ? null : String(slippage),
329+
fromNetwork: network,
330+
toNetwork: opts.toNetwork,
331+
sponsored: !!paymaster,
332+
txHash: txHash ?? null,
333+
callId: id,
334+
status: status.status,
335+
});
336+
} else {
337+
const pairs: [string, string][] = [
338+
["From", `${opts.amount} ${fromInfo.symbol}`],
339+
["To", toInfo.symbol],
340+
["Slippage", slippage === undefined ? "API default" : `${slippage}%`],
341+
["From Network", network],
342+
["To Network", opts.toNetwork],
343+
["Call ID", id],
344+
];
345+
346+
if (paymaster) {
347+
pairs.push(["Gas", green("Sponsored")]);
348+
}
349+
350+
if (txHash) {
351+
pairs.push(["Tx Hash", txHash]);
352+
}
353+
354+
pairs.push(["Status", confirmed ? green("Confirmed") : `Pending (${status.status})`]);
355+
356+
printKeyValueBox(pairs);
357+
}
358+
}

src/commands/send/shared.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@ export function formatTokenAmount(rawAmount: bigint, decimals: number): string {
5050
export async function fetchTokenDecimals(
5151
program: Command,
5252
tokenAddress: string,
53+
opts?: { network?: string },
5354
): Promise<{ decimals: number; symbol: string }> {
54-
const client = clientFromFlags(program);
55+
const client = clientFromFlags(
56+
program,
57+
opts?.network ? { forceNetwork: opts.network } : undefined,
58+
);
5559
const result = await client.call("alchemy_getTokenMetadata", [tokenAddress]) as {
5660
decimals: number | null;
5761
symbol: string | null;

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { registerContract } from "./commands/contract.js";
3535
import { registerSwap } from "./commands/swap.js";
3636
import { registerStatus } from "./commands/status.js";
3737
import { registerApprove } from "./commands/approve.js";
38+
import { registerBridge } from "./commands/bridge.js";
3839
import { registerAgentPrompt } from "./commands/agent-prompt.js";
3940
import { registerUpdateCheck } from "./commands/update-check.js";
4041
import { isInteractiveAllowed } from "./lib/interaction.js";
@@ -86,7 +87,7 @@ const ROOT_COMMAND_PILLARS = [
8687
},
8788
{
8889
label: "Execution",
89-
commands: ["send", "contract", "swap", "approve", "status"],
90+
commands: ["send", "contract", "swap", "bridge", "approve", "status"],
9091
},
9192
{
9293
label: "Wallets",
@@ -505,6 +506,7 @@ registerSimulate(program);
505506
registerSend(program);
506507
registerContract(program);
507508
registerSwap(program);
509+
registerBridge(program);
508510
registerStatus(program);
509511
registerApprove(program);
510512

0 commit comments

Comments
 (0)