Skip to content

Commit 4d091c0

Browse files
areinclaude
andauthored
fix(cli-sdk): add multi-chain gas support, refuel, and receipt verification (#45)
* fix(cli-sdk): add multi-chain gas support, refuel, and receipt verification - Expand EVM_CHAINS from 3 to 8 chains with gasToken and gasCost fields - Add automatic destination gas refuel before cross-chain bridges - Use _isRefuel flag to prevent recursive refuel operations - Add on-chain receipt verification after send-transaction (confirmed/reverted) - Export rpcUrl, waitForReceipt, and TxReceipt for use by CLI consumers - Prefer cheapest gas chains when selecting bridge source - Use per-chain gas token names instead of hardcoded "ETH" - Add Hyperliquid (HyperEVM) to default EVM chain aliases Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: add changeset for cli-sdk patch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1367159 commit 4d091c0

4 files changed

Lines changed: 180 additions & 27 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@walletconnect/cli-sdk": patch
3+
---
4+
5+
Add multi-chain gas support, automatic refuel, and on-chain receipt verification
6+
7+
- Expand EVM chain support from 3 to 8 chains with per-chain gasToken and gasCost rankings
8+
- Add automatic destination gas refuel before cross-chain bridges
9+
- Add on-chain receipt verification after send-transaction (confirmed/reverted)
10+
- Export rpcUrl, waitForReceipt, and TxReceipt for downstream CLI consumers
11+
- Add Hyperliquid (HyperEVM) to default EVM chain aliases

packages/cli-sdk/src/cli.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { WalletConnectCLI } from "./client.js";
22
import { resolveProjectId, setConfigValue, getConfigValue } from "./config.js";
3-
import { trySwidgeBeforeSend, swidgeViaWalletConnect } from "./swidge.js";
3+
import { trySwidgeBeforeSend, swidgeViaWalletConnect, rpcUrl, waitForReceipt } from "./swidge.js";
4+
import type { TxReceipt } from "./swidge.js";
45

56
// Prevent unhandled WC relay errors from crashing the process with minified dumps
67
process.on("unhandledRejection", (err) => {
@@ -273,7 +274,27 @@ async function cmdSendTransaction(jsonInput: string, browser: boolean): Promise<
273274
},
274275
});
275276

276-
process.stdout.write(JSON.stringify({ transactionHash: txHash }));
277+
// Verify on-chain receipt
278+
let reverted = false;
279+
if (rpcUrl(chainId)) {
280+
process.stderr.write(`\n Tx submitted: ${txHash}\n Waiting for confirmation...`);
281+
try {
282+
const receipt = await waitForReceipt(chainId, txHash, 60_000);
283+
if (receipt.status === "0x1") {
284+
process.stderr.write(` confirmed.\n`);
285+
} else {
286+
reverted = true;
287+
process.stderr.write(` reverted!\n`);
288+
process.stderr.write(` Gas used: ${parseInt(receipt.gasUsed, 16)}\n`);
289+
process.stderr.write(` Transaction failed on-chain. Check the tx on a block explorer.\n`);
290+
}
291+
} catch {
292+
process.stderr.write(` receipt not available yet.\n`);
293+
}
294+
}
295+
296+
process.stdout.write(JSON.stringify({ transactionHash: txHash, reverted }));
297+
if (reverted) process.exit(1);
277298
}
278299
} finally {
279300
await sdk.destroy();

packages/cli-sdk/src/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const CHAIN_ALIASES: Record<string, string | string[]> = {
5757
"eip155:1284", // Moonbeam
5858
"eip155:1285", // Moonriver
5959
"eip155:25", // Cronos
60+
"eip155:999", // Hyperliquid (HyperEVM)
6061
],
6162
};
6263

packages/cli-sdk/src/swidge.ts

Lines changed: 145 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,16 @@ export interface SwidgeCLIResult {
5050

5151
// --- Constants ---
5252

53-
const EVM_CHAINS: Record<string, { name: string; rpc: string }> = {
54-
"eip155:1": { name: "Ethereum", rpc: "https://eth.drpc.org" },
55-
"eip155:8453": { name: "Base", rpc: "https://mainnet.base.org" },
56-
"eip155:10": { name: "Optimism", rpc: "https://mainnet.optimism.io" },
53+
// gasCost: relative gas cost ranking (lower = cheaper, used to prefer cheap chains for refuel)
54+
const EVM_CHAINS: Record<string, { name: string; rpc: string; gasToken?: string; gasCost: number }> = {
55+
"eip155:42161": { name: "Arbitrum One", rpc: "https://arb1.arbitrum.io/rpc", gasCost: 1 },
56+
"eip155:8453": { name: "Base", rpc: "https://mainnet.base.org", gasCost: 1 },
57+
"eip155:10": { name: "Optimism", rpc: "https://mainnet.optimism.io", gasCost: 1 },
58+
"eip155:137": { name: "Polygon", rpc: "https://1rpc.io/matic", gasToken: "POL", gasCost: 1 },
59+
"eip155:56": { name: "BNB Chain", rpc: "https://bsc-dataseed.binance.org", gasCost: 2 },
60+
"eip155:43114": { name: "Avalanche", rpc: "https://api.avax.network/ext/bc/C/rpc", gasCost: 2 },
61+
"eip155:999": { name: "Hyperliquid", rpc: "https://rpc.hyperliquid.xyz/evm", gasToken: "HYPE", gasCost: 1 },
62+
"eip155:1": { name: "Ethereum", rpc: "https://eth.drpc.org", gasCost: 10 },
5763
};
5864

5965
const LIFI_API = "https://li.quest/v1";
@@ -78,7 +84,7 @@ function chainName(caip2: string): string {
7884
return EVM_CHAINS[caip2]?.name || caip2;
7985
}
8086

81-
function rpcUrl(caip2: string): string | undefined {
87+
export function rpcUrl(caip2: string): string | undefined {
8288
return EVM_CHAINS[caip2]?.rpc;
8389
}
8490

@@ -145,9 +151,9 @@ async function getTokenBalanceRpc(
145151
return BigInt(result);
146152
}
147153

148-
interface TxReceipt { status: string; transactionHash: string; blockNumber: string }
154+
export interface TxReceipt { status: string; transactionHash: string; blockNumber: string; gasUsed: string }
149155

150-
async function waitForReceipt(
156+
export async function waitForReceipt(
151157
chainId: string, txHash: string, timeoutMs = 120_000,
152158
): Promise<TxReceipt> {
153159
const url = rpcUrl(chainId);
@@ -215,6 +221,7 @@ export async function swidgeViaWalletConnect(
215221
sdk: WalletConnectCLI,
216222
address: string,
217223
options: SwidgeCLIOptions,
224+
_isRefuel = false,
218225
): Promise<SwidgeCLIResult> {
219226
const fromChainId = parseChainId(options.fromChain);
220227
const toChainId = parseChainId(options.toChain);
@@ -259,6 +266,12 @@ export async function swidgeViaWalletConnect(
259266
}
260267
}
261268

269+
// Refuel check — ensure destination chain has gas for subsequent transactions
270+
// Skip if this is already a refuel operation (explicit flag prevents recursion)
271+
if (options.fromChain !== options.toChain && !_isRefuel) {
272+
await refuelIfNeeded(sdk, address, options.fromChain, options.toChain, options.fromToken);
273+
}
274+
262275
// ERC-20 approval if needed
263276
if (!isNativeToken(fromTokenAddr) && quote.estimate.approvalAddress) {
264277
const allowance = await getAllowanceRpc(
@@ -344,9 +357,110 @@ export async function swidgeViaWalletConnect(
344357
};
345358
}
346359

360+
// --- Destination gas refuel ---
361+
362+
/** Minimum gas balance threshold (in wei). Below this, we refuel. */
363+
const MIN_GAS_WEI = 10n ** 15n; // 0.001 ETH/native token
364+
365+
/** Approximate ~$1 refuel amount by token symbol. Avoids decimal-based guessing. */
366+
const REFUEL_AMOUNTS: Record<string, string> = {
367+
usdc: "1", usdt: "1", dai: "1", busd: "1", // stablecoins: $1 = 1 token
368+
wbtc: "0.00002", // ~$1 at $50k/BTC
369+
// Everything else (ETH, POL, AVAX, HYPE, etc.) uses default "0.001" below
370+
};
371+
372+
function getRefuelAmount(token: string): string {
373+
return REFUEL_AMOUNTS[token.toLowerCase()] ?? "0.001";
374+
}
375+
347376
/**
348-
* Check if a send-transaction has insufficient ETH and offer to bridge.
349-
* In TTY mode: prompts user. In pipe mode: auto-bridges.
377+
* Check if the destination chain has enough native gas token.
378+
* If not, bridge a small amount of the user's fromToken (e.g. USDC) → dest gas token.
379+
* This avoids requiring native gas on the source chain.
380+
*
381+
* Note: In pipe mode (non-TTY), auto-refuel proceeds without prompting.
382+
* This is safe for WalletConnect because the user's wallet always prompts
383+
* for manual approval of each transaction.
384+
*
385+
* @param fromToken - the token the user is already bridging (e.g. "USDC")
386+
*/
387+
async function refuelIfNeeded(
388+
sdk: WalletConnectCLI,
389+
address: string,
390+
fromChain: string,
391+
toChain: string,
392+
fromToken?: string,
393+
): Promise<void> {
394+
const destRpc = rpcUrl(toChain);
395+
if (!destRpc) return; // can't check, skip
396+
397+
let destBalance: bigint;
398+
try {
399+
destBalance = await getBalanceRpc(toChain, address);
400+
} catch {
401+
return; // RPC failed, skip
402+
}
403+
404+
if (destBalance >= MIN_GAS_WEI) return; // has enough gas
405+
406+
const destChain = EVM_CHAINS[toChain];
407+
const destGasToken = destChain?.gasToken || "ETH";
408+
409+
// Use the same token the user is bridging (e.g. USDC) as refuel source,
410+
// so we don't need native gas on the source chain. Fall back to source gas token.
411+
const refuelFromToken = fromToken || EVM_CHAINS[fromChain]?.gasToken || "ETH";
412+
const refuelAmount = getRefuelAmount(refuelFromToken);
413+
414+
process.stderr.write(
415+
`\n ⛽ No ${destGasToken} on ${chainName(toChain)} for gas.\n`,
416+
);
417+
418+
// TTY: prompt; pipe: auto-refuel (safe — WalletConnect always requires wallet approval)
419+
if (process.stdin.isTTY) {
420+
const readline = await import("node:readline/promises");
421+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
422+
const answer = await rl.question(
423+
` Bridge ~${refuelAmount} ${refuelFromToken} from ${chainName(fromChain)}${destGasToken} for gas? (y/n) `,
424+
);
425+
rl.close();
426+
if (answer.trim().toLowerCase() !== "y") {
427+
process.stderr.write(` Skipping refuel — transactions on ${chainName(toChain)} may fail without gas.\n\n`);
428+
return;
429+
}
430+
} else {
431+
process.stderr.write(
432+
` Auto-bridging ~${refuelAmount} ${refuelFromToken} from ${chainName(fromChain)}${destGasToken} for gas...\n`,
433+
);
434+
}
435+
436+
try {
437+
await swidgeViaWalletConnect(sdk, address, {
438+
fromChain,
439+
toChain,
440+
fromToken: refuelFromToken,
441+
toToken: destGasToken,
442+
amount: refuelAmount,
443+
}, true); // _isRefuel=true prevents recursion
444+
445+
// Wait for gas to arrive
446+
process.stderr.write(` Waiting for gas to arrive...`);
447+
const arrived = await waitForBalance(toChain, address, destBalance, 120_000);
448+
if (arrived) {
449+
process.stderr.write(` done.\n\n`);
450+
} else {
451+
process.stderr.write(` timed out. Proceeding anyway.\n\n`);
452+
}
453+
} catch (err) {
454+
process.stderr.write(
455+
`\n Refuel failed: ${err instanceof Error ? err.message : String(err)}\n` +
456+
` Proceeding — you may need to manually bridge gas to ${chainName(toChain)}.\n\n`,
457+
);
458+
}
459+
}
460+
461+
/**
462+
* Check if a send-transaction has insufficient gas token and offer to bridge.
463+
* In TTY mode: prompts user. In pipe mode: auto-bridges (safe — wallet always approves).
350464
* Returns the bridge result if bridging occurred, null otherwise.
351465
*/
352466
export async function trySwidgeBeforeSend(
@@ -357,6 +471,8 @@ export async function trySwidgeBeforeSend(
357471
): Promise<SwidgeCLIResult | null> {
358472
if (!rpcUrl(chainId) || !txValue) return null;
359473

474+
const gasToken = EVM_CHAINS[chainId]?.gasToken || "ETH";
475+
360476
let balance: bigint;
361477
let value: bigint;
362478
try {
@@ -370,56 +486,60 @@ export async function trySwidgeBeforeSend(
370486
// Add 10% buffer for gas costs on the destination tx
371487
const deficit = (value - balance) * 11n / 10n;
372488

373-
// Find a source chain with funds (collect then reduce to avoid race)
374-
const otherChains = Object.keys(EVM_CHAINS).filter((c) => c !== chainId);
489+
// Find a source chain with funds — prefer cheapest gas chains first
490+
const otherChains = Object.keys(EVM_CHAINS)
491+
.filter((c) => c !== chainId)
492+
.sort((a, b) => (EVM_CHAINS[a].gasCost - EVM_CHAINS[b].gasCost));
375493
const balances = await Promise.all(
376494
otherChains.map(async (chain) => {
377495
try {
378-
return { chain, balance: await getBalanceRpc(chain, address) };
496+
return { chain, balance: await getBalanceRpc(chain, address), gasCost: EVM_CHAINS[chain].gasCost };
379497
} catch {
380-
return { chain, balance: 0n };
498+
return { chain, balance: 0n, gasCost: EVM_CHAINS[chain].gasCost };
381499
}
382500
}),
383501
);
384-
const best = balances.reduce(
385-
(a, b) => (b.balance > a.balance ? b : a),
386-
{ chain: "", balance: 0n },
387-
);
502+
// Pick cheapest chain that has sufficient balance; fall back to richest
503+
const sufficient = balances.filter((b) => b.balance >= deficit);
504+
const best = sufficient.length > 0
505+
? sufficient.sort((a, b) => a.gasCost - b.gasCost)[0]
506+
: balances.reduce((a, b) => (b.balance > a.balance ? b : a), { chain: "", balance: 0n, gasCost: 99 });
388507
const sourceChain = best.balance > 0n ? best.chain : null;
389508

390509
if (!sourceChain) {
391510
process.stderr.write(
392-
`\nWarning: Insufficient ETH on ${chainName(chainId)} and no funds found on other chains.\n` +
393-
` Consider: walletconnect swidge --from-chain <chain> --to-chain ${chainId} --from-token ETH --to-token ETH --amount <needed>\n\n`,
511+
`\nWarning: Insufficient ${gasToken} on ${chainName(chainId)} and no funds found on other chains.\n` +
512+
` Consider: walletconnect swidge --from-chain <chain> --to-chain ${chainId} --from-token ${gasToken} --to-token ${gasToken} --amount <needed>\n\n`,
394513
);
395514
return null;
396515
}
397516

398517
const deficitFormatted = formatAmount(deficit, 18);
518+
const sourceGasToken = EVM_CHAINS[sourceChain]?.gasToken || "ETH";
399519

400-
// TTY: prompt; pipe: auto-bridge
520+
// TTY: prompt; pipe: auto-bridge (safe — wallet always approves)
401521
if (process.stdin.isTTY) {
402522
const readline = await import("node:readline/promises");
403523
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
404524
process.stderr.write(
405-
`\nInsufficient ETH on ${chainName(chainId)}.\n` +
406-
` Bridge ~${deficitFormatted} ETH from ${chainName(sourceChain)}?\n`,
525+
`\nInsufficient ${gasToken} on ${chainName(chainId)}.\n` +
526+
` Bridge ~${deficitFormatted} ${sourceGasToken} from ${chainName(sourceChain)}?\n`,
407527
);
408528
const answer = await rl.question(" Proceed? (y/n) ");
409529
rl.close();
410530
if (answer.trim().toLowerCase() !== "y") return null;
411531
} else {
412532
process.stderr.write(
413-
`Auto-bridging ~${deficitFormatted} ETH from ${chainName(sourceChain)} to ${chainName(chainId)}...\n`,
533+
`Auto-bridging ~${deficitFormatted} ${sourceGasToken} from ${chainName(sourceChain)} to ${chainName(chainId)}...\n`,
414534
);
415535
}
416536

417537
try {
418538
const result = await swidgeViaWalletConnect(sdk, address, {
419539
fromChain: sourceChain,
420540
toChain: chainId,
421-
fromToken: "ETH",
422-
toToken: "ETH",
541+
fromToken: sourceGasToken,
542+
toToken: gasToken,
423543
amount: deficitFormatted,
424544
});
425545

0 commit comments

Comments
 (0)