|
| 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 | +} |
0 commit comments