diff --git a/.changeset/cli-swidge.md b/.changeset/cli-swidge.md new file mode 100644 index 0000000..d70b1b2 --- /dev/null +++ b/.changeset/cli-swidge.md @@ -0,0 +1,9 @@ +--- +"@walletconnect/cli-sdk": minor +--- + +feat: add swidge (swap/bridge) command to WalletConnect CLI via LI.FI + +New `walletconnect swidge` command for cross-chain bridging through the connected wallet. +Enhanced `send-transaction` to auto-detect insufficient ETH and offer to bridge from another chain. +Uses LI.FI REST API for quoting with zero new dependencies — transactions are sent through WalletConnect. diff --git a/packages/cli-sdk/src/cli.ts b/packages/cli-sdk/src/cli.ts index 2682da5..c8af652 100644 --- a/packages/cli-sdk/src/cli.ts +++ b/packages/cli-sdk/src/cli.ts @@ -1,5 +1,6 @@ import { WalletConnectCLI } from "./client.js"; import { resolveProjectId, setConfigValue, getConfigValue } from "./config.js"; +import { trySwidgeBeforeSend, swidgeViaWalletConnect } from "./swidge.js"; declare const __VERSION__: string; @@ -19,10 +20,18 @@ Commands: sign Sign a message with the connected wallet sign-typed-data Sign EIP-712 typed data (JSON string) send-transaction Send a transaction (EVM: to, data, value, gas; Solana: transaction, chainId) + swidge Bridge/swap tokens across chains via LI.FI disconnect Disconnect the current session config set Set a config value (e.g. project-id) config get Get a config value +Swidge options: + --from-chain Source chain (e.g. eip155:8453) + --to-chain Destination chain (e.g. eip155:10) + --from-token Source token symbol (e.g. ETH, USDC) + --to-token Destination token symbol (e.g. ETH, WCT) + --amount Amount to bridge (human-readable) + Options: --browser Use browser UI instead of terminal QR code --json Output as JSON (for whoami) @@ -228,8 +237,14 @@ async function cmdSendTransaction(jsonInput: string, browser: boolean): Promise< // EVM: existing eth_sendTransaction flow const from = tx.from || parseAccount(result.accounts[0]).address; - // Pre-flight balance check — non-blocking warning - const balanceWarning = warnIfInsufficientBalance(chainId, from, tx.value); + // Pre-flight balance check — bridge if needed + const swidgeResult = await trySwidgeBeforeSend(sdk, chainId, from, tx.value); + if (swidgeResult) { + process.stderr.write( + `Bridged: ${swidgeResult.fromAmount} ${swidgeResult.fromToken} (${swidgeResult.fromChain}) -> ` + + `${swidgeResult.toAmount} ${swidgeResult.toToken} (${swidgeResult.toChain})\n`, + ); + } const txHash = await sdk.request({ chainId, @@ -239,7 +254,6 @@ async function cmdSendTransaction(jsonInput: string, browser: boolean): Promise< }, }); - await balanceWarning; process.stdout.write(JSON.stringify({ transactionHash: txHash })); } } finally { @@ -247,46 +261,55 @@ async function cmdSendTransaction(jsonInput: string, browser: boolean): Promise< } } -const EVM_RPC_URLS: Record = { - "eip155:1": "https://eth.drpc.org", - "eip155:8453": "https://mainnet.base.org", - "eip155:10": "https://mainnet.optimism.io", -}; +async function cmdSwidge(browser: boolean, args: string[]): Promise { + const projectId = getProjectId(); -async function checkBalanceRpc(rpcUrl: string, address: string): Promise { - const res = await fetch(rpcUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "eth_getBalance", - params: [address, "latest"], - }), - }); - const json = (await res.json()) as { result: string }; - return BigInt(json.result); -} + // Parse swidge-specific flags + let fromChain = ""; + let toChain = ""; + let fromToken = "ETH"; + let toToken = "ETH"; + let amount = ""; -async function warnIfInsufficientBalance( - chainId: string, - address: string, - txValue: string | undefined, -): Promise { - const rpcUrl = EVM_RPC_URLS[chainId]; - if (!rpcUrl || !txValue) return; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + const next = args[i + 1]; + if (arg === "--from-chain" && next) { fromChain = next; i++; } + else if (arg === "--to-chain" && next) { toChain = next; i++; } + else if (arg === "--from-token" && next) { fromToken = next; i++; } + else if (arg === "--to-token" && next) { toToken = next; i++; } + else if (arg === "--amount" && next) { amount = next; i++; } + } + + if (!fromChain || !toChain || !amount) { + console.error("Usage: walletconnect swidge --from-chain --to-chain --amount [--from-token ] [--to-token ]"); + process.exit(1); + } + + const chains = [fromChain]; + if (!chains.includes(toChain)) chains.push(toChain); + const sdk = createSDK({ projectId, browser, chains }); try { - const balance = await checkBalanceRpc(rpcUrl, address); - const value = BigInt(txValue); - if (balance < value) { - process.stderr.write( - `\nWarning: Wallet may have insufficient balance on ${chainId}.\n` + - ` Consider: companion-wallet swidge --to-chain ${chainId} --to-token eth --amount \n\n`, - ); + let result = await sdk.tryRestore(); + if (!result) { + process.stderr.write("No existing session. Connecting...\n\n"); + result = await sdk.connect(); } - } catch { - // Silently ignore balance check failures + + const { address } = parseAccount(result.accounts[0]); + + const bridgeResult = await swidgeViaWalletConnect(sdk, address, { + fromChain, + toChain, + fromToken, + toToken, + amount, + }); + + process.stdout.write(JSON.stringify(bridgeResult)); + } finally { + await sdk.destroy(); } } @@ -327,7 +350,7 @@ async function main(): Promise { switch (command) { case "connect": - await cmdConnect(browser, chains.length > 0 ? chains : undefined); + await cmdConnect(browser, chains.length > 0 ? chains : ["evm"]); break; case "whoami": await cmdWhoami(json); @@ -359,6 +382,9 @@ async function main(): Promise { await cmdSendTransaction(txJson, browser); break; } + case "swidge": + await cmdSwidge(browser, filtered.slice(1)); + break; case "disconnect": await cmdDisconnect(); break; diff --git a/packages/cli-sdk/src/swidge.ts b/packages/cli-sdk/src/swidge.ts new file mode 100644 index 0000000..4a92b07 --- /dev/null +++ b/packages/cli-sdk/src/swidge.ts @@ -0,0 +1,387 @@ +/** + * Swidge (swap/bridge) module for WalletConnect CLI. + * Uses LI.FI REST API for quoting and WalletConnect for transaction execution. + * No additional dependencies — uses fetch() for all external calls. + */ + +import type { WalletConnectCLI } from "./client.js"; + +// --- Types --- + +interface LifiTransactionRequest { + to: string; + data: string; + value: string; + gasLimit?: string; + gasPrice?: string; +} + +interface LifiQuoteResponse { + transactionRequest: LifiTransactionRequest; + estimate: { + toAmount: string; + toAmountMin: string; + approvalAddress: string; + }; + action: { + fromToken: { address: string; symbol: string; decimals: number; chainId: number }; + toToken: { address: string; symbol: string; decimals: number; chainId: number }; + fromAmount: string; + }; +} + +export interface SwidgeCLIOptions { + fromChain: string; // CAIP-2 + toChain: string; // CAIP-2 + fromToken: string; // symbol (ETH, USDC, etc.) + toToken: string; // symbol + amount: string; // human-readable +} + +export interface SwidgeCLIResult { + fromChain: string; + toChain: string; + fromToken: string; + toToken: string; + fromAmount: string; + toAmount: string; + txHash: string; +} + +// --- Constants --- + +const EVM_CHAINS: Record = { + "eip155:1": { name: "Ethereum", rpc: "https://eth.drpc.org" }, + "eip155:8453": { name: "Base", rpc: "https://mainnet.base.org" }, + "eip155:10": { name: "Optimism", rpc: "https://mainnet.optimism.io" }, +}; + +const LIFI_API = "https://li.quest/v1"; + +const NATIVE_ADDRESSES = new Set([ + "0x0000000000000000000000000000000000000000", + "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", +]); + +// --- Helpers --- + +function parseChainId(caip2: string): number { + const parts = caip2.split(":"); + const id = parseInt(parts[1], 10); + if (parts.length !== 2 || isNaN(id)) { + throw new Error(`Invalid CAIP-2 chain ID: ${caip2}`); + } + return id; +} + +function chainName(caip2: string): string { + return EVM_CHAINS[caip2]?.name || caip2; +} + +function rpcUrl(caip2: string): string | undefined { + return EVM_CHAINS[caip2]?.rpc; +} + +/** Common token decimals for amount conversion. Defaults to 18 for unknown tokens. */ +const TOKEN_DECIMALS: Record = { + usdc: 6, usdt: 6, + wbtc: 8, + // 18 decimals: eth, weth, dai, wct, link, uni, etc. +}; + +function getTokenDecimals(token: string): number { + return TOKEN_DECIMALS[token.toLowerCase()] ?? 18; +} + +function isNativeToken(address: string): boolean { + return NATIVE_ADDRESSES.has(address.toLowerCase()); +} + +/** Parse human-readable amount to smallest unit */ +export function parseAmount(amount: string, decimals: number): bigint { + if (!amount || !/^\d+(\.\d+)?$/.test(amount)) { + throw new Error(`Invalid amount: ${amount}`); + } + const [whole = "0", frac = ""] = amount.split("."); + const paddedFrac = frac.padEnd(decimals, "0").slice(0, decimals); + return BigInt(whole) * 10n ** BigInt(decimals) + BigInt(paddedFrac); +} + +/** Format smallest unit to human-readable */ +export function formatAmount(value: bigint, decimals: number): string { + const str = value.toString().padStart(decimals + 1, "0"); + const whole = str.slice(0, str.length - decimals); + const frac = str.slice(str.length - decimals).replace(/0+$/, ""); + return frac ? `${whole}.${frac}` : whole; +} + +// --- RPC --- + +async function rpcCall(rpcUrl: string, method: string, params: unknown[]): Promise { + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }); + const json = (await res.json()) as { result: string; error?: { message: string } }; + if (json.error) throw new Error(json.error.message); + return json.result; +} + +export async function getBalanceRpc(chainId: string, address: string): Promise { + const url = rpcUrl(chainId); + if (!url) throw new Error(`No RPC URL for chain ${chainId}`); + return BigInt(await rpcCall(url, "eth_getBalance", [address, "latest"])); +} + +async function getAllowanceRpc( + chainId: string, tokenAddress: string, + owner: string, spender: string, +): Promise { + const url = rpcUrl(chainId); + if (!url) return 0n; + // allowance(address owner, address spender) = 0xdd62ed3e + const data = "0xdd62ed3e" + + owner.slice(2).toLowerCase().padStart(64, "0") + + spender.slice(2).toLowerCase().padStart(64, "0"); + const result = await rpcCall(url, "eth_call", [{ to: tokenAddress, data }, "latest"]); + return BigInt(result); +} + +// --- LI.FI API --- + +async function getLifiQuote( + fromChain: number, toChain: number, + fromToken: string, toToken: string, + fromAmount: string, fromAddress: string, +): Promise { + const url = new URL(`${LIFI_API}/quote`); + url.searchParams.set("fromChain", fromChain.toString()); + url.searchParams.set("toChain", toChain.toString()); + url.searchParams.set("fromToken", fromToken); + url.searchParams.set("toToken", toToken); + url.searchParams.set("fromAmount", fromAmount); + url.searchParams.set("fromAddress", fromAddress); + url.searchParams.set("integrator", "walletconnect-agent-sdk"); + + const res = await fetch(url.toString()); + if (!res.ok) { + const text = await res.text(); + throw new Error(`LI.FI quote failed (${res.status}): ${text}`); + } + return (await res.json()) as LifiQuoteResponse; +} + +// --- Core swidge --- + +/** + * Execute a swap/bridge via LI.FI, sending transactions through WalletConnect. + * The user's connected wallet approves each transaction. + */ +export async function swidgeViaWalletConnect( + sdk: WalletConnectCLI, + address: string, + options: SwidgeCLIOptions, +): Promise { + const fromChainId = parseChainId(options.fromChain); + const toChainId = parseChainId(options.toChain); + const decimals = getTokenDecimals(options.fromToken); + const fromAmount = parseAmount(options.amount, decimals); + + process.stderr.write(`\nFetching LI.FI quote...\n`); + + const quote = await getLifiQuote( + fromChainId, toChainId, + options.fromToken, options.toToken, + fromAmount.toString(), address, + ); + + const fromSymbol = quote.action.fromToken.symbol; + const toSymbol = quote.action.toToken.symbol; + const toDec = quote.action.toToken.decimals; + const estimatedOut = formatAmount(BigInt(quote.estimate.toAmount), toDec); + + process.stderr.write( + ` ${options.amount} ${fromSymbol} (${chainName(options.fromChain)}) -> ` + + `~${estimatedOut} ${toSymbol} (${chainName(options.toChain)})\n`, + ); + + // ERC-20 approval if needed + const fromTokenAddr = quote.action.fromToken.address; + if (!isNativeToken(fromTokenAddr) && quote.estimate.approvalAddress) { + const allowance = await getAllowanceRpc( + options.fromChain, fromTokenAddr, address, quote.estimate.approvalAddress, + ); + + // Use the amount from the LI.FI quote (what the router expects) + const quoteFromAmount = BigInt(quote.action.fromAmount); + if (allowance < quoteFromAmount) { + process.stderr.write(` Requesting token approval in wallet...\n`); + + // approve(address spender, uint256 amount) = 0x095ea7b3 + const approveData = "0x095ea7b3" + + quote.estimate.approvalAddress.slice(2).toLowerCase().padStart(64, "0") + + quoteFromAmount.toString(16).padStart(64, "0"); + + await sdk.request({ + chainId: options.fromChain, + request: { + method: "eth_sendTransaction", + params: [{ + from: address, + to: fromTokenAddr, + data: approveData, + value: "0x0", + }], + }, + }); + + process.stderr.write(` Approval confirmed.\n`); + } + } + + // Send bridge transaction + process.stderr.write(` Requesting bridge transaction in wallet...\n`); + + const txHash = await sdk.request({ + chainId: options.fromChain, + request: { + method: "eth_sendTransaction", + params: [{ + from: address, + to: quote.transactionRequest.to, + data: quote.transactionRequest.data, + value: quote.transactionRequest.value, + gas: quote.transactionRequest.gasLimit, + }], + }, + }); + + process.stderr.write(` Bridge tx confirmed: ${txHash}\n`); + + return { + fromChain: options.fromChain, + toChain: options.toChain, + fromToken: fromSymbol, + toToken: toSymbol, + fromAmount: options.amount, + toAmount: estimatedOut, + txHash, + }; +} + +/** + * Check if a send-transaction has insufficient ETH and offer to bridge. + * In TTY mode: prompts user. In pipe mode: auto-bridges. + * Returns the bridge result if bridging occurred, null otherwise. + */ +export async function trySwidgeBeforeSend( + sdk: WalletConnectCLI, + chainId: string, + address: string, + txValue: string | undefined, +): Promise { + if (!rpcUrl(chainId) || !txValue) return null; + + let balance: bigint; + let value: bigint; + try { + balance = await getBalanceRpc(chainId, address); + value = BigInt(txValue); + } catch { + return null; + } + if (balance >= value) return null; + + // Add 10% buffer for gas costs on the destination tx + const deficit = (value - balance) * 11n / 10n; + + // Find a source chain with funds (collect then reduce to avoid race) + const otherChains = Object.keys(EVM_CHAINS).filter((c) => c !== chainId); + const balances = await Promise.all( + otherChains.map(async (chain) => { + try { + return { chain, balance: await getBalanceRpc(chain, address) }; + } catch { + return { chain, balance: 0n }; + } + }), + ); + const best = balances.reduce( + (a, b) => (b.balance > a.balance ? b : a), + { chain: "", balance: 0n }, + ); + const sourceChain = best.balance > 0n ? best.chain : null; + + if (!sourceChain) { + process.stderr.write( + `\nWarning: Insufficient ETH on ${chainName(chainId)} and no funds found on other chains.\n` + + ` Consider: walletconnect swidge --from-chain --to-chain ${chainId} --from-token ETH --to-token ETH --amount \n\n`, + ); + return null; + } + + const deficitFormatted = formatAmount(deficit, 18); + + // TTY: prompt; pipe: auto-bridge + if (process.stdin.isTTY) { + const readline = await import("node:readline/promises"); + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + process.stderr.write( + `\nInsufficient ETH on ${chainName(chainId)}.\n` + + ` Bridge ~${deficitFormatted} ETH from ${chainName(sourceChain)}?\n`, + ); + const answer = await rl.question(" Proceed? (y/n) "); + rl.close(); + if (answer.trim().toLowerCase() !== "y") return null; + } else { + process.stderr.write( + `Auto-bridging ~${deficitFormatted} ETH from ${chainName(sourceChain)} to ${chainName(chainId)}...\n`, + ); + } + + try { + const result = await swidgeViaWalletConnect(sdk, address, { + fromChain: sourceChain, + toChain: chainId, + fromToken: "ETH", + toToken: "ETH", + amount: deficitFormatted, + }); + + // Wait for bridge funds to arrive on destination chain + process.stderr.write(` Waiting for bridge to complete...`); + const arrived = await waitForBalance(chainId, address, balance, 300_000); + if (arrived) { + process.stderr.write(` done.\n\n`); + } else { + process.stderr.write(` timed out. Proceeding anyway.\n\n`); + } + + return result; + } catch (err) { + process.stderr.write( + `\nBridge failed: ${err instanceof Error ? err.message : String(err)}\n` + + ` Proceeding with original transaction.\n\n`, + ); + return null; + } +} + +/** Poll destination chain balance until it increases above the initial value */ +async function waitForBalance( + chainId: string, address: string, + initialBalance: bigint, timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 5_000)); + try { + const current = await getBalanceRpc(chainId, address); + if (current > initialBalance) return true; + } catch { + // retry on RPC errors + } + } + return false; +} diff --git a/packages/cli-sdk/test/swidge.spec.ts b/packages/cli-sdk/test/swidge.spec.ts new file mode 100644 index 0000000..50fc262 --- /dev/null +++ b/packages/cli-sdk/test/swidge.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { parseAmount, formatAmount } from "../src/swidge.js"; + +describe("parseAmount", () => { + it("parses whole number with 18 decimals", () => { + expect(parseAmount("1", 18)).toBe(1_000_000_000_000_000_000n); + }); + + it("parses fractional amount with 18 decimals", () => { + expect(parseAmount("0.5", 18)).toBe(500_000_000_000_000_000n); + }); + + it("parses amount with 6 decimals (USDC)", () => { + expect(parseAmount("10", 6)).toBe(10_000_000n); + }); + + it("parses fractional USDC amount", () => { + expect(parseAmount("1.5", 6)).toBe(1_500_000n); + }); + + it("truncates excess decimal places", () => { + expect(parseAmount("1.1234567890", 6)).toBe(1_123_456n); + }); + + it("handles zero", () => { + expect(parseAmount("0", 18)).toBe(0n); + }); + + it("handles small amounts", () => { + expect(parseAmount("0.0001", 18)).toBe(100_000_000_000_000n); + }); + + it("rejects empty string", () => { + expect(() => parseAmount("", 18)).toThrow("Invalid amount"); + }); + + it("rejects negative numbers", () => { + expect(() => parseAmount("-1", 18)).toThrow("Invalid amount"); + }); + + it("rejects non-numeric strings", () => { + expect(() => parseAmount("abc", 18)).toThrow("Invalid amount"); + }); +}); + +describe("formatAmount", () => { + it("formats 1 ETH", () => { + expect(formatAmount(1_000_000_000_000_000_000n, 18)).toBe("1"); + }); + + it("formats 0.5 ETH", () => { + expect(formatAmount(500_000_000_000_000_000n, 18)).toBe("0.5"); + }); + + it("formats 10 USDC", () => { + expect(formatAmount(10_000_000n, 6)).toBe("10"); + }); + + it("formats 1.5 USDC", () => { + expect(formatAmount(1_500_000n, 6)).toBe("1.5"); + }); + + it("formats zero", () => { + expect(formatAmount(0n, 18)).toBe("0"); + }); + + it("formats small amounts without trailing zeros", () => { + expect(formatAmount(100_000_000_000_000n, 18)).toBe("0.0001"); + }); + + it("roundtrips with parseAmount", () => { + const original = "3.14"; + const parsed = parseAmount(original, 18); + const formatted = formatAmount(parsed, 18); + expect(formatted).toBe("3.14"); + }); +});