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
5 changes: 5 additions & 0 deletions .changeset/swidge-balance-check-receipt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@walletconnect/cli-sdk": patch
---

Add pre-flight balance check before swidge (errors early with "Insufficient balance: have X, need Y") and on-chain tx receipt confirmation (polls eth_getTransactionReceipt instead of treating submission as confirmation)
7 changes: 7 additions & 0 deletions packages/cli-sdk/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@ import { WalletConnectCLI } from "./client.js";
import { resolveProjectId, setConfigValue, getConfigValue } from "./config.js";
import { trySwidgeBeforeSend, swidgeViaWalletConnect } from "./swidge.js";

// Prevent unhandled WC relay errors from crashing the process with minified dumps
process.on("unhandledRejection", (err) => {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`Error: ${msg}\n`);
process.exit(1);
});

declare const __VERSION__: string;

const METADATA = {
Expand Down
84 changes: 79 additions & 5 deletions packages/cli-sdk/src/swidge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,39 @@ export async function getBalanceRpc(chainId: string, address: string): Promise<b
return BigInt(await rpcCall(url, "eth_getBalance", [address, "latest"]));
}

async function getTokenBalanceRpc(
chainId: string, tokenAddress: string, owner: string,
): Promise<bigint> {
const url = rpcUrl(chainId);
if (!url) return 0n;
// balanceOf(address) = 0x70a08231
const data = "0x70a08231" + owner.slice(2).toLowerCase().padStart(64, "0");
const result = await rpcCall(url, "eth_call", [{ to: tokenAddress, data }, "latest"]);
return BigInt(result);
}

interface TxReceipt { status: string; transactionHash: string; blockNumber: string }

async function waitForReceipt(
chainId: string, txHash: string, timeoutMs = 120_000,
): Promise<TxReceipt> {
const url = rpcUrl(chainId);
if (!url) throw new Error(`No RPC URL for chain ${chainId}`);
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getTransactionReceipt", params: [txHash] }),
});
const json = (await res.json()) as { result: TxReceipt | null; error?: { message: string } };
if (json.error) throw new Error(json.error.message);
if (json.result) return json.result;
await new Promise((r) => setTimeout(r, 3_000));
}
throw new Error(`Timed out waiting for receipt of ${txHash}`);
}

async function getAllowanceRpc(
chainId: string, tokenAddress: string,
owner: string, spender: string,
Expand Down Expand Up @@ -206,15 +239,32 @@ export async function swidgeViaWalletConnect(
`~${estimatedOut} ${toSymbol} (${chainName(options.toChain)})\n`,
);

// ERC-20 approval if needed
// Pre-flight balance check — fail early before sending to the wallet
const fromTokenAddr = quote.action.fromToken.address;
const quoteFromAmount = BigInt(quote.action.fromAmount);
if (rpcUrl(options.fromChain)) {
try {
const balance = isNativeToken(fromTokenAddr)
? await getBalanceRpc(options.fromChain, address)
: await getTokenBalanceRpc(options.fromChain, fromTokenAddr, address);
if (balance < quoteFromAmount) {
const have = formatAmount(balance, quote.action.fromToken.decimals);
throw new Error(
`Insufficient balance: have ${have} ${fromSymbol}, need ${options.amount} ${fromSymbol}`,
);
}
} catch (err) {
// Re-throw insufficient balance errors; swallow RPC lookup failures
if (err instanceof Error && err.message.startsWith("Insufficient balance")) throw err;
}
}

// ERC-20 approval if needed
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`);

Expand All @@ -223,7 +273,7 @@ export async function swidgeViaWalletConnect(
quote.estimate.approvalAddress.slice(2).toLowerCase().padStart(64, "0") +
quoteFromAmount.toString(16).padStart(64, "0");

await sdk.request<string>({
const approveTxHash = await sdk.request<string>({
chainId: options.fromChain,
request: {
method: "eth_sendTransaction",
Expand All @@ -236,6 +286,13 @@ export async function swidgeViaWalletConnect(
},
});

// Verify approval on-chain before proceeding to the bridge tx
if (rpcUrl(options.fromChain)) {
const receipt = await waitForReceipt(options.fromChain, approveTxHash);
if (receipt.status !== "0x1") {
throw new Error(`Approval transaction reverted: ${approveTxHash}`);
}
}
process.stderr.write(` Approval confirmed.\n`);
}
}
Expand All @@ -257,7 +314,24 @@ export async function swidgeViaWalletConnect(
},
});

process.stderr.write(` Bridge tx confirmed: ${txHash}\n`);
process.stderr.write(` Bridge tx submitted: ${txHash}\n`);

// Wait for on-chain confirmation
if (rpcUrl(options.fromChain)) {
process.stderr.write(` Waiting for on-chain confirmation...`);
try {
const receipt = await waitForReceipt(options.fromChain, txHash);
if (receipt.status === "0x1") {
process.stderr.write(` confirmed.\n`);
} else {
process.stderr.write(` reverted!\n`);
throw new Error(`Bridge transaction reverted: ${txHash}`);
}
} catch (err) {
if (err instanceof Error && err.message.includes("reverted")) throw err;
process.stderr.write(` failed to get receipt: ${err instanceof Error ? err.message : String(err)}\n`);
}
}

return {
fromChain: options.fromChain,
Expand Down