Skip to content

Commit d3f928d

Browse files
areinclaude
andauthored
fix(cli-sdk): add pre-flight balance check and on-chain tx confirmation (#42)
* fix(cli-sdk): add pre-flight balance check and on-chain tx confirmation to swidge 1. Pre-flight balance check: after the LI.FI quote resolves the token address, check balanceOf (ERC-20) or eth_getBalance (native) and error early with "Insufficient balance: have X, need Y" instead of sending a doomed tx to the wallet. 2. On-chain tx confirmation: poll eth_getTransactionReceipt after both approval and bridge transactions. Only report "confirmed" after receipt.status === 0x1. Throw on revert instead of silently reporting success. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(cli-sdk): add process-level unhandledRejection handler for clean error output Catches any relay/WC errors that escape the per-request handler and prints a clean error message instead of dumping minified source. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2f01406 commit d3f928d

File tree

3 files changed

+91
-5
lines changed

3 files changed

+91
-5
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@walletconnect/cli-sdk": patch
3+
---
4+
5+
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)

packages/cli-sdk/src/cli.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import { WalletConnectCLI } from "./client.js";
22
import { resolveProjectId, setConfigValue, getConfigValue } from "./config.js";
33
import { trySwidgeBeforeSend, swidgeViaWalletConnect } from "./swidge.js";
44

5+
// Prevent unhandled WC relay errors from crashing the process with minified dumps
6+
process.on("unhandledRejection", (err) => {
7+
const msg = err instanceof Error ? err.message : String(err);
8+
process.stderr.write(`Error: ${msg}\n`);
9+
process.exit(1);
10+
});
11+
512
declare const __VERSION__: string;
613

714
const METADATA = {

packages/cli-sdk/src/swidge.ts

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,39 @@ export async function getBalanceRpc(chainId: string, address: string): Promise<b
134134
return BigInt(await rpcCall(url, "eth_getBalance", [address, "latest"]));
135135
}
136136

137+
async function getTokenBalanceRpc(
138+
chainId: string, tokenAddress: string, owner: string,
139+
): Promise<bigint> {
140+
const url = rpcUrl(chainId);
141+
if (!url) return 0n;
142+
// balanceOf(address) = 0x70a08231
143+
const data = "0x70a08231" + owner.slice(2).toLowerCase().padStart(64, "0");
144+
const result = await rpcCall(url, "eth_call", [{ to: tokenAddress, data }, "latest"]);
145+
return BigInt(result);
146+
}
147+
148+
interface TxReceipt { status: string; transactionHash: string; blockNumber: string }
149+
150+
async function waitForReceipt(
151+
chainId: string, txHash: string, timeoutMs = 120_000,
152+
): Promise<TxReceipt> {
153+
const url = rpcUrl(chainId);
154+
if (!url) throw new Error(`No RPC URL for chain ${chainId}`);
155+
const deadline = Date.now() + timeoutMs;
156+
while (Date.now() < deadline) {
157+
const res = await fetch(url, {
158+
method: "POST",
159+
headers: { "Content-Type": "application/json" },
160+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getTransactionReceipt", params: [txHash] }),
161+
});
162+
const json = (await res.json()) as { result: TxReceipt | null; error?: { message: string } };
163+
if (json.error) throw new Error(json.error.message);
164+
if (json.result) return json.result;
165+
await new Promise((r) => setTimeout(r, 3_000));
166+
}
167+
throw new Error(`Timed out waiting for receipt of ${txHash}`);
168+
}
169+
137170
async function getAllowanceRpc(
138171
chainId: string, tokenAddress: string,
139172
owner: string, spender: string,
@@ -206,15 +239,32 @@ export async function swidgeViaWalletConnect(
206239
`~${estimatedOut} ${toSymbol} (${chainName(options.toChain)})\n`,
207240
);
208241

209-
// ERC-20 approval if needed
242+
// Pre-flight balance check — fail early before sending to the wallet
210243
const fromTokenAddr = quote.action.fromToken.address;
244+
const quoteFromAmount = BigInt(quote.action.fromAmount);
245+
if (rpcUrl(options.fromChain)) {
246+
try {
247+
const balance = isNativeToken(fromTokenAddr)
248+
? await getBalanceRpc(options.fromChain, address)
249+
: await getTokenBalanceRpc(options.fromChain, fromTokenAddr, address);
250+
if (balance < quoteFromAmount) {
251+
const have = formatAmount(balance, quote.action.fromToken.decimals);
252+
throw new Error(
253+
`Insufficient balance: have ${have} ${fromSymbol}, need ${options.amount} ${fromSymbol}`,
254+
);
255+
}
256+
} catch (err) {
257+
// Re-throw insufficient balance errors; swallow RPC lookup failures
258+
if (err instanceof Error && err.message.startsWith("Insufficient balance")) throw err;
259+
}
260+
}
261+
262+
// ERC-20 approval if needed
211263
if (!isNativeToken(fromTokenAddr) && quote.estimate.approvalAddress) {
212264
const allowance = await getAllowanceRpc(
213265
options.fromChain, fromTokenAddr, address, quote.estimate.approvalAddress,
214266
);
215267

216-
// Use the amount from the LI.FI quote (what the router expects)
217-
const quoteFromAmount = BigInt(quote.action.fromAmount);
218268
if (allowance < quoteFromAmount) {
219269
process.stderr.write(` Requesting token approval in wallet...\n`);
220270

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

226-
await sdk.request<string>({
276+
const approveTxHash = await sdk.request<string>({
227277
chainId: options.fromChain,
228278
request: {
229279
method: "eth_sendTransaction",
@@ -236,6 +286,13 @@ export async function swidgeViaWalletConnect(
236286
},
237287
});
238288

289+
// Verify approval on-chain before proceeding to the bridge tx
290+
if (rpcUrl(options.fromChain)) {
291+
const receipt = await waitForReceipt(options.fromChain, approveTxHash);
292+
if (receipt.status !== "0x1") {
293+
throw new Error(`Approval transaction reverted: ${approveTxHash}`);
294+
}
295+
}
239296
process.stderr.write(` Approval confirmed.\n`);
240297
}
241298
}
@@ -257,7 +314,24 @@ export async function swidgeViaWalletConnect(
257314
},
258315
});
259316

260-
process.stderr.write(` Bridge tx confirmed: ${txHash}\n`);
317+
process.stderr.write(` Bridge tx submitted: ${txHash}\n`);
318+
319+
// Wait for on-chain confirmation
320+
if (rpcUrl(options.fromChain)) {
321+
process.stderr.write(` Waiting for on-chain confirmation...`);
322+
try {
323+
const receipt = await waitForReceipt(options.fromChain, txHash);
324+
if (receipt.status === "0x1") {
325+
process.stderr.write(` confirmed.\n`);
326+
} else {
327+
process.stderr.write(` reverted!\n`);
328+
throw new Error(`Bridge transaction reverted: ${txHash}`);
329+
}
330+
} catch (err) {
331+
if (err instanceof Error && err.message.includes("reverted")) throw err;
332+
process.stderr.write(` failed to get receipt: ${err instanceof Error ? err.message : String(err)}\n`);
333+
}
334+
}
261335

262336
return {
263337
fromChain: options.fromChain,

0 commit comments

Comments
 (0)