nansen-cli 1.35.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bridge.js ADDED
@@ -0,0 +1,1102 @@
1
+ /**
2
+ * nansen bridge — Hyperliquid bridge commands (EVM <-> Hyperliquid via Relay).
3
+ *
4
+ * Calls nansen-api /api/v1/perp/bridge/* endpoints. Transaction signing and
5
+ * EVM broadcasting happen client-side; HL withdrawal signatures are
6
+ * proxied through the API's /perp/bridge/execute endpoint.
7
+ */
8
+
9
+ import * as crypto from 'node:crypto';
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+
13
+ import { CommandError, validateAddress } from './api.js';
14
+ import { signSecp256k1 } from './crypto.js';
15
+ import {
16
+ convertToBaseUnits,
17
+ evmRpcCall,
18
+ getEvmNonce,
19
+ getQuotesDir,
20
+ resolveUsdPrice,
21
+ safeQuotesPath,
22
+ signEvmTransaction,
23
+ waitForReceipt,
24
+ } from './trading.js';
25
+ import { screenOrThrow } from './perp.js';
26
+ import { extractActionErrors } from './hl-client.js';
27
+ import { resolveEvmWallet, resolveSigningCredentials } from './wallet-signing.js';
28
+ import { hashTypedData } from './x402-evm.js';
29
+
30
+ const QUOTE_TTL_MS = 3600000; // 1 hour
31
+
32
+ // Hyperliquid user-signed actions (here, Relay's `sendAsset` withdrawal leg) are
33
+ // signed under the HyperliquidSignTransaction domain, whose chainId must equal
34
+ // the action's own signatureChainId — HL, and the API's OFAC signer-recovery
35
+ // screening, both reconstruct the domain from that field to recover the signer.
36
+ // Use 0x66eee (421614), matching hl-action.js and the API's prepare endpoints, so
37
+ // the whole codebase agrees on one value. (The previous 0x1 / chainId-1 pair was
38
+ // internally consistent too, so it recovered the correct signer — this is
39
+ // codebase consistency, not the withdrawal bug. That bug was a discarded HL error
40
+ // response; see assertHyperliquidStepAccepted.)
41
+ const HL_SIGNATURE_CHAIN_ID = '0x66eee';
42
+
43
+ const BRIDGE_TOKENS = {
44
+ ethereum: { USDC: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' },
45
+ base: { USDC: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913' },
46
+ arbitrum: { USDC: '0xaf88d065e77c8cc2239327c5edb3a432268e5831' },
47
+ hyperliquid: { USDC: '0x00000000000000000000000000000000' },
48
+ };
49
+
50
+ // Routes this CLI can actually complete, as ordered [origin, destination] pairs.
51
+ // Deliberately narrower than the API accepts, and asymmetric, because the two
52
+ // directions need different things from us:
53
+ //
54
+ // Deposits (EVM → HL) broadcast an EVM transaction locally, so the origin
55
+ // chain must be signable by signEvmTransaction — which only knows Base. The
56
+ // API accepts ethereum/arbitrum/polygon/bnb origins as well, but nothing here
57
+ // can sign them, so offering them would just fail at execute time. Base → HL
58
+ // is also the only deposit route with a real-money round-trip behind it.
59
+ //
60
+ // Withdrawals (HL → EVM) sign a Hyperliquid EIP-712 action instead and never
61
+ // touch the destination chain, so no local per-chain support is needed; these
62
+ // mirror the API's supported pairs.
63
+ //
64
+ // Widening the deposit side means teaching signEvmTransaction the chain AND
65
+ // putting real funds through it first.
66
+ const BRIDGE_ROUTES = [
67
+ ['base', 'hyperliquid'],
68
+ ['hyperliquid', 'base'],
69
+ ['hyperliquid', 'ethereum'],
70
+ ['hyperliquid', 'arbitrum'],
71
+ ];
72
+
73
+ function isSupportedBridgeRoute(originChain, destinationChain) {
74
+ return BRIDGE_ROUTES.some(([o, d]) => o === originChain && d === destinationChain);
75
+ }
76
+
77
+ export function formatBridgeRoutes() {
78
+ return BRIDGE_ROUTES.map(([o, d]) => `${o} -> ${d}`).join(', ');
79
+ }
80
+
81
+ function resolveBridgeToken(symbolOrAddress, chain) {
82
+ if (!symbolOrAddress || !chain) return symbolOrAddress;
83
+ const tokens = BRIDGE_TOKENS[chain.toLowerCase()];
84
+ if (!tokens) return symbolOrAddress;
85
+ return tokens[symbolOrAddress.toUpperCase()] || symbolOrAddress;
86
+ }
87
+
88
+ function isBridgeUsdc(tokenAddress, chain) {
89
+ const usdc = BRIDGE_TOKENS[chain.toLowerCase()]?.USDC;
90
+ return !!usdc && tokenAddress.toLowerCase() === usdc.toLowerCase();
91
+ }
92
+
93
+ // Resolve a token's on-chain decimals so a human --amount can be converted to
94
+ // base units. USDC is 6 on every supported EVM chain but 8 on Hyperliquid (the
95
+ // crux of the per-chain decimals trap); other EVM tokens fall back to decimals().
96
+ export async function resolveBridgeTokenDecimals(tokenAddress, chain) {
97
+ const normChain = chain.toLowerCase();
98
+ if (normChain === 'hyperliquid') {
99
+ if (isBridgeUsdc(tokenAddress, normChain)) return 8;
100
+ throw new Error(
101
+ `Cannot resolve decimals for ${tokenAddress} on hyperliquid. Pass --amount in base units (omit --amount-unit).`,
102
+ );
103
+ }
104
+ if (isBridgeUsdc(tokenAddress, normChain)) return 6;
105
+ // EVM fallback: decimals() selector 0x313ce567
106
+ const result = await evmRpcCall(normChain, 'eth_call', [{ to: tokenAddress, data: '0x313ce567' }, 'latest']);
107
+ const decimals = parseInt(result, 16);
108
+ if (isNaN(decimals) || decimals > 255) {
109
+ throw new Error(`Could not resolve decimals for ${tokenAddress} on ${normChain}.`);
110
+ }
111
+ return decimals;
112
+ }
113
+
114
+ // USDC's canonical precision the Relay bridge formats Hyperliquid sendAsset to.
115
+ const HYPERLIQUID_USDC_BRIDGE_DECIMALS = 6;
116
+
117
+ // Hyperliquid spot USDC is 8 decimals, but the Relay bridge rounds the sendAsset
118
+ // amount to USDC's 6 decimals (round-half-up). Submitting the full 8-decimal
119
+ // amount can round UP past the balance, which Hyperliquid rejects. Flooring to 6
120
+ // keeps the bridge's rounding a no-op. (Mirrors Superapp SUPER-13582.)
121
+ export function floorHyperliquidUsdcBridgeAmount(amountBaseUnits, decimals, tokenAddress, chain) {
122
+ if (chain.toLowerCase() !== 'hyperliquid' || !isBridgeUsdc(tokenAddress, chain)) {
123
+ return amountBaseUnits;
124
+ }
125
+ const dropped = decimals - HYPERLIQUID_USDC_BRIDGE_DECIMALS;
126
+ if (dropped <= 0) return amountBaseUnits;
127
+ const factor = 10n ** BigInt(dropped);
128
+ return ((BigInt(amountBaseUnits) / factor) * factor).toString();
129
+ }
130
+
131
+ // Slippage is whole basis points in [0, 10000] (50 = 0.5%, 10000 = 100%).
132
+ // Validate client-side so "abc" or "-1" fail here with a clear message instead
133
+ // of an opaque backend 422, matching how `perp` validates --slippage. parseInt
134
+ // alone would silently accept "999abc" (-> 999) or "-1", so check the string
135
+ // shape before parsing.
136
+ export function parseSlippageBps(raw) {
137
+ const s = String(raw).trim();
138
+ const bad = `Invalid --slippage "${raw}". Use whole basis points between 0 and 10000 (e.g. 50 = 0.5%).`;
139
+ if (!/^\d+$/.test(s)) throw new Error(bad);
140
+ const n = parseInt(s, 10);
141
+ if (!Number.isInteger(n) || n < 0 || n > 10000) throw new Error(bad);
142
+ return n;
143
+ }
144
+
145
+ // ── API helpers ──────────────────────────────────────────────────────
146
+
147
+ // cache: false — a quote carries live pricing, fees and per-step transaction
148
+ // data, and is cached locally as a quote file anyway. Replaying a stale one
149
+ // would mean signing against amounts the route no longer offers.
150
+ async function getBridgeQuote(apiInstance, params) {
151
+ return apiInstance.request('/api/v1/perp/bridge/quote', params, { cache: false });
152
+ }
153
+
154
+ // retry: false because this is not idempotent — it proxies to Relay's
155
+ // /authorize and to Hyperliquid's /exchange, so an automatic re-send on a 500
156
+ // or 502 can submit the same signed action twice. hl-client.js's submitExchange
157
+ // documents the same reasoning for the direct path; this is the proxied one.
158
+ async function postBridgeExecute(apiInstance, targetUrl, body) {
159
+ return apiInstance.request(
160
+ '/api/v1/perp/bridge/execute',
161
+ { target_url: targetUrl, body },
162
+ { cache: false, retry: false },
163
+ );
164
+ }
165
+
166
+ // Fail loudly on a Hyperliquid rejection instead of printing "Submitted".
167
+ //
168
+ // The /perp/bridge/execute proxy returns { success, data } where `data` is the
169
+ // upstream response verbatim; for the HL leg that is HL's { status, response }
170
+ // envelope. HL signals failure two ways that BOTH come back as HTTP 200, so the
171
+ // proxy forwards them without flagging (it only raises on HTTP errors):
172
+ // 1. top-level status "err" (response is the reason string), and
173
+ // 2. status "ok" with per-action errors in response.data.statuses[].error.
174
+ // Left uninspected — as it was — a rejected withdrawal printed "Submitted" and
175
+ // then polled to a 600s timeout with no reason. Mirrors the direct-path
176
+ // checks in hl-client.js::submitExchange.
177
+ function assertHyperliquidStepAccepted(result, stepId) {
178
+ const envelope = result?.data ?? result;
179
+ if (!envelope || typeof envelope !== 'object') return;
180
+ if (envelope.status === 'err') {
181
+ const reason =
182
+ typeof envelope.response === 'string'
183
+ ? envelope.response
184
+ : JSON.stringify(envelope.response);
185
+ throw new CommandError(
186
+ `Hyperliquid rejected bridge step "${stepId}": ${reason}`,
187
+ 'HL_ACTION_REJECTED',
188
+ );
189
+ }
190
+ const actionResults = extractActionErrors(envelope.response);
191
+ if (actionResults.failed.length > 0 && actionResults.succeeded.length > 0) {
192
+ throw new CommandError(
193
+ `Hyperliquid partially filled bridge step "${stepId}": succeeded ${actionResults.succeeded.join(', ')}; failed ${actionResults.failed.map(({ leg, error }) => `${leg}: ${error}`).join('; ')}`,
194
+ 'PARTIAL_FILL',
195
+ );
196
+ }
197
+ if (actionResults.failed.length > 0) {
198
+ throw new CommandError(
199
+ `Hyperliquid rejected bridge step "${stepId}": ${actionResults.failed.map(({ error }) => error).join('; ')}`,
200
+ 'HL_ACTION_REJECTED',
201
+ );
202
+ }
203
+ }
204
+
205
+ // cache: false, and here it is what makes polling work at all. The cache key is
206
+ // endpoint + body, so every poll for a given request id is the same key — under
207
+ // --cache the loop would re-read one cached verdict for the whole TTL and stay
208
+ // blind to a bridge that had already completed or failed.
209
+ async function getBridgeStatus(apiInstance, { requestId, txHash }) {
210
+ const params = new URLSearchParams();
211
+ if (requestId) params.set('request_id', requestId);
212
+ if (txHash) params.set('tx_hash', txHash);
213
+ return apiInstance.request(`/api/v1/perp/bridge/status?${params}`, {}, { method: 'GET', cache: false });
214
+ }
215
+
216
+ // ── Quote caching ────────────────────────────────────────────────────
217
+
218
+ function saveBridgeQuote(response, originChain, destinationChain, walletProvider, walletAddress, recipient) {
219
+ const dir = getQuotesDir();
220
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
221
+ const hash = crypto.randomBytes(4).toString('hex');
222
+ const quoteId = `bridge-${Date.now()}-${hash}`;
223
+ const data = {
224
+ quoteId,
225
+ type: 'bridge',
226
+ originChain,
227
+ destinationChain,
228
+ walletProvider,
229
+ walletAddress,
230
+ recipient,
231
+ timestamp: Date.now(),
232
+ response,
233
+ };
234
+ const filePath = path.join(dir, `${quoteId}.json`);
235
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
236
+ return quoteId;
237
+ }
238
+
239
+ export function loadBridgeQuote(quoteId) {
240
+ const filePath = safeQuotesPath(`${quoteId}.json`);
241
+ if (!filePath || !fs.existsSync(filePath)) {
242
+ throw new Error(`Bridge quote "${quoteId}" not found. Quotes expire after 1 hour.`);
243
+ }
244
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
245
+ if (Date.now() - data.timestamp > QUOTE_TTL_MS) {
246
+ fs.unlinkSync(filePath);
247
+ throw new Error('Bridge quote has expired. Please request a new quote.');
248
+ }
249
+ if (data.type !== 'bridge') {
250
+ throw new Error(`Quote "${quoteId}" is not a bridge quote. Use "nansen trade execute" for a swap quote.`);
251
+ }
252
+ if (data.executedAt) {
253
+ // Quotes are single-use: re-signing and re-broadcasting would move funds
254
+ // a second time. Refuse a quote that has already been executed.
255
+ const when = new Date(data.executedAt).toISOString();
256
+ const fullyDone = data.totalSteps && data.broadcastSteps >= data.totalSteps;
257
+ if (!fullyDone && data.broadcasts?.length) {
258
+ // Something is in flight but the run didn't finish — e.g. the tx was
259
+ // accepted and the receipt wait timed out. Re-running would re-send it, so
260
+ // refuse and name the hashes so the operator can check what landed.
261
+ const hashes = data.broadcasts.map(b => b.txHash).filter(Boolean);
262
+ const detail = hashes.length ? ` (${hashes.join(', ')})` : '';
263
+ throw new Error(
264
+ `Bridge quote "${quoteId}" partially executed at ${when}: ${data.broadcasts.length} transaction(s) already broadcast${detail}. Funds may be in flight — check "nansen bridge status" before requesting a new quote.`,
265
+ );
266
+ }
267
+ if (data.totalSteps && data.broadcastSteps && data.broadcastSteps < data.totalSteps) {
268
+ // Partial execution: a later step failed after an earlier one had already
269
+ // been broadcast. Re-running from step 0 would re-send what already went
270
+ // out, so refuse and say what landed — the funds may be in flight.
271
+ throw new Error(
272
+ `Bridge quote "${quoteId}" partially executed at ${when}: ${data.broadcastSteps} of ${data.totalSteps} steps were broadcast. Check "nansen bridge status" before requesting a new quote — the earlier step may already have moved funds.`,
273
+ );
274
+ }
275
+ throw new Error(
276
+ `Bridge quote "${quoteId}" was already executed at ${when}. Request a new quote to bridge again.`,
277
+ );
278
+ }
279
+ return data;
280
+ }
281
+
282
+ // Records that a broadcast has happened. `executedAt` is set on the first call
283
+ // and never moved, so the quote is consumed the moment any step goes out — a
284
+ // later step throwing must not leave the quote reusable. The step counters make
285
+ // a partial failure legible to the operator (see loadBridgeQuote).
286
+ export function markBridgeQuoteExecuted(quoteId, progress = {}) {
287
+ const filePath = safeQuotesPath(`${quoteId}.json`);
288
+ if (!filePath || !fs.existsSync(filePath)) return;
289
+ try {
290
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
291
+ data.executedAt = data.executedAt || Date.now();
292
+ if (progress.broadcast) {
293
+ data.broadcasts = [...(data.broadcasts || []), { ...progress.broadcast, at: Date.now() }];
294
+ }
295
+ if (progress.broadcastSteps !== undefined) {
296
+ data.broadcastSteps = Math.max(data.broadcastSteps || 0, progress.broadcastSteps);
297
+ }
298
+ if (progress.totalSteps !== undefined) data.totalSteps = progress.totalSteps;
299
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
300
+ } catch {
301
+ // Best-effort: if the marker can't be written, the next execute attempt
302
+ // will still proceed, but that's preferable to crashing after a successful
303
+ // broadcast.
304
+ }
305
+ }
306
+
307
+ // ── EIP-712 signing (for HL withdrawals) ─────────────────────────────
308
+
309
+ // `types[primaryType] || []` used to swallow a missing type definition: with no
310
+ // fields, hashStruct hashes typeHash("PrimaryType()") over none of the message's
311
+ // contents, so we would hand back a well-formed signature that commits to
312
+ // nothing about the action being authorised. Refuse instead — an omitted or
313
+ // misspelled type list is a bug or a tampered response, never something to sign
314
+ // through.
315
+ function signEip712Local(typedData, privateKeyHex, context = 'EIP-712 payload') {
316
+ const { domain, types, primaryType, message } = typedData;
317
+ const fields = (types?.[primaryType] || []).map(f => ({ name: f.name, type: f.type }));
318
+ if (fields.length === 0) {
319
+ throw new Error(
320
+ `${context} is missing its EIP-712 type definition for "${primaryType}", so the signature would not cover the action. Refusing to sign.`,
321
+ );
322
+ }
323
+ const msgHash = hashTypedData(domain, primaryType, fields, message);
324
+ const { r, s, v } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex'));
325
+ return '0x' + r.toString('hex') + s.toString('hex') + (27 + v).toString(16).padStart(2, '0');
326
+ }
327
+
328
+ // ── Step processors ──────────────────────────────────────────────────
329
+
330
+ // Headroom multiplier applied to the current base fee when setting maxFeePerGas.
331
+ // A type-2 transaction only ever pays baseFee + priority, so a generous cap
332
+ // costs nothing extra — it just buys tolerance for the base fee moving between
333
+ // signing and inclusion. Base's fee moved ~2x within minutes while this was
334
+ // being tested, so the tolerance is not theoretical.
335
+ const BASE_FEE_HEADROOM = 3n;
336
+
337
+ // Floor for maxPriorityFeePerGas, in wei (0.01 gwei).
338
+ //
339
+ // The priority fee is what orders a transaction for inclusion, and it is where a
340
+ // real deposit got stuck: Relay quotes ~0.0011 gwei, while Base was including at
341
+ // ~0.008 gwei and up, so the transaction sat in the mempool and burned the nonce.
342
+ // A cap alone does not fix that — the cap is only what you are *willing* to pay.
343
+ // At 21k-75k gas this floor is a small fraction of a cent per step.
344
+ const MIN_PRIORITY_FEE_WEI = 10000000n;
345
+
346
+ // Parse a --priority-fee / --max-fee override. Given in gwei, because that is
347
+ // the unit every fee tracker and block explorer quotes; returned in wei.
348
+ //
349
+ // Converted digit-wise rather than by multiplying a float, so 0.05 gwei is
350
+ // exactly 50000000 wei and not whatever the binary representation rounds to.
351
+ export function parseGweiToWei(raw, name) {
352
+ const s = String(raw).trim();
353
+ if (!/^\d*\.?\d+$/.test(s)) {
354
+ throw new CommandError(
355
+ `Invalid --${name} "${raw}". Give a fee in gwei (e.g. 0.05).`,
356
+ 'INVALID_INPUT',
357
+ );
358
+ }
359
+ const [int, frac = ''] = s.split('.');
360
+ const wei = BigInt(int || '0') * 1000000000n + BigInt((frac + '000000000').slice(0, 9));
361
+ if (wei <= 0n) {
362
+ throw new CommandError(`Invalid --${name} "${raw}". Must be greater than zero.`, 'INVALID_INPUT');
363
+ }
364
+ return wei;
365
+ }
366
+
367
+ // Decide the fee fields for an EVM bridge step.
368
+ //
369
+ // Relay's quote already carries maxFeePerGas/maxPriorityFeePerGas, which this
370
+ // used to discard in favour of a bare eth_gasPrice reading — producing a legacy
371
+ // transaction priced at roughly the current base fee with almost no priority
372
+ // fee. Keep Relay's intent, but raise the priority fee to something Base will
373
+ // actually schedule and lift the cap to cover it plus base-fee movement.
374
+ //
375
+ // `overrides` are the operator's explicit --priority-fee/--max-fee, in wei. They
376
+ // win outright, including over MIN_PRIORITY_FEE_WEI: their whole purpose is
377
+ // outbidding a transaction that is already stuck, which the computed values
378
+ // cannot do — they reproduce the same numbers that got stuck in the first place,
379
+ // and a replacement needs roughly +10% to be accepted at all.
380
+ export async function resolveEvmStepFees(chain, txData, overrides = {}) {
381
+ const { priorityFeeWei = null, maxFeeWei = null } = overrides;
382
+
383
+ if (txData.maxFeePerGas || priorityFeeWei || maxFeeWei) {
384
+ let maxPriorityFeePerGas = priorityFeeWei ?? BigInt(txData.maxPriorityFeePerGas ?? 0);
385
+ if (!priorityFeeWei && maxPriorityFeePerGas < MIN_PRIORITY_FEE_WEI) {
386
+ maxPriorityFeePerGas = MIN_PRIORITY_FEE_WEI;
387
+ }
388
+
389
+ // The cap must cover the raised priority fee, or the transaction is
390
+ // self-contradictory: maxFeePerGas < maxPriorityFeePerGas is rejected
391
+ // outright by every node.
392
+ if (maxFeeWei) {
393
+ if (maxFeeWei < maxPriorityFeePerGas) {
394
+ throw new CommandError(
395
+ `--max-fee is below --priority-fee (${maxFeeWei} wei < ${maxPriorityFeePerGas} wei); no node accepts that. Raise --max-fee.`,
396
+ 'INVALID_INPUT',
397
+ );
398
+ }
399
+ return {
400
+ maxFeePerGas: maxFeeWei.toString(),
401
+ maxPriorityFeePerGas: maxPriorityFeePerGas.toString(),
402
+ };
403
+ }
404
+
405
+ let maxFeePerGas = BigInt(txData.maxFeePerGas ?? 0);
406
+ try {
407
+ const block = await evmRpcCall(chain, 'eth_getBlockByNumber', ['latest', false]);
408
+ const baseFee = BigInt(block?.baseFeePerGas ?? 0);
409
+ const floor = baseFee * BASE_FEE_HEADROOM + maxPriorityFeePerGas;
410
+ if (floor > maxFeePerGas) maxFeePerGas = floor;
411
+ } catch {
412
+ // Base-fee lookup is best-effort, but the cap still has to clear the
413
+ // priority fee even without it.
414
+ if (maxFeePerGas < maxPriorityFeePerGas) maxFeePerGas = maxPriorityFeePerGas;
415
+ }
416
+
417
+ return {
418
+ maxFeePerGas: maxFeePerGas.toString(),
419
+ maxPriorityFeePerGas: maxPriorityFeePerGas.toString(),
420
+ };
421
+ }
422
+
423
+ // Pre-1559 shape (or a quote that only gave a flat price): fall back to the
424
+ // node's reading, which is what the legacy signer needs.
425
+ return { gasPrice: await evmRpcCall(chain, 'eth_gasPrice') };
426
+ }
427
+
428
+ async function processEvmStep(step, { chain, privateKeyHex, signerAddress, log, onBroadcast, feeOverrides, nonceSequence }) {
429
+ for (const item of step.items || []) {
430
+ if (item.status === 'complete') continue;
431
+ const txData = item.data;
432
+
433
+ // The nonce is fetched for txData.from, but the transaction is signed with
434
+ // our key — so a server-returned `from` that isn't our wallet would price
435
+ // the nonce against the wrong account and sign anyway. Assert it matches the
436
+ // signer before touching the nonce.
437
+ if (
438
+ signerAddress
439
+ && txData.from
440
+ && String(txData.from).toLowerCase() !== String(signerAddress).toLowerCase()
441
+ ) {
442
+ throw new CommandError(
443
+ `Bridge step "${step.id}" is addressed from ${txData.from}, but the signing wallet is ${signerAddress}. Request a new quote.`,
444
+ 'SIGNER_MISMATCH',
445
+ );
446
+ }
447
+
448
+ const fees = await resolveEvmStepFees(chain, txData, feeOverrides);
449
+ // getEvmNonce returns a decimal number and reconciles pending against the
450
+ // mined count. An explicit --nonce skips both: it is how an operator
451
+ // deliberately re-signs at the nonce of a stuck transaction to replace it,
452
+ // which is exactly the case the reconciliation refuses.
453
+ // Fetch the nonce for the signing wallet, not the server-returned `from`.
454
+ // The transaction is signed with our local key regardless of `from`, so its
455
+ // nonce must come from that account — and this stays correct even if a quote
456
+ // omits `from` (which would otherwise resolve a nonce for `undefined`).
457
+ const nonce = nonceSequence
458
+ ? nonceSequence.next++
459
+ : await getEvmNonce(chain, signerAddress);
460
+ if (nonceSequence) log(` Nonce: ${nonce} (from --nonce)`);
461
+
462
+ const signedTx = signEvmTransaction(
463
+ { ...txData, ...fees },
464
+ privateKeyHex,
465
+ chain,
466
+ nonce,
467
+ );
468
+
469
+ log(` Broadcasting ${step.id} on ${chain}...`);
470
+ const txHash = await evmRpcCall(chain, 'eth_sendRawTransaction', [signedTx]);
471
+ // In flight now: record it before waiting on the receipt, because a receipt
472
+ // timeout must not leave the quote reusable.
473
+ onBroadcast?.(step.id, txHash);
474
+ log(` Tx: ${txHash}`);
475
+
476
+ const receipt = await waitForReceipt(chain, txHash);
477
+ const status = parseInt(receipt.status, 16);
478
+ if (status !== 1) {
479
+ throw new Error(`Transaction reverted: ${txHash}`);
480
+ }
481
+ log(` Confirmed in block ${parseInt(receipt.blockNumber, 16)}`);
482
+ }
483
+ }
484
+
485
+ async function processSignatureStepLocal(step, { privateKeyHex, log, apiInstance, onBroadcast }) {
486
+ for (const item of step.items || []) {
487
+ if (item.status === 'complete') continue;
488
+ const { data: signData } = item;
489
+
490
+ if (signData.sign) {
491
+ const typedData = {
492
+ domain: signData.sign.domain,
493
+ types: signData.sign.types,
494
+ primaryType: signData.sign.primaryType,
495
+ message: signData.sign.value,
496
+ };
497
+ const signature = signEip712Local(typedData, privateKeyHex, `Bridge step "${step.id}"`);
498
+
499
+ let targetUrl = signData.post.endpoint;
500
+ if (!targetUrl.startsWith('http')) {
501
+ targetUrl = `https://api.relay.link${targetUrl}`;
502
+ }
503
+ const postBody = { ...signData.post.body };
504
+
505
+ if (targetUrl.includes('/authorize')) {
506
+ const sep = targetUrl.includes('?') ? '&' : '?';
507
+ targetUrl = `${targetUrl}${sep}signature=${signature}`;
508
+ } else {
509
+ postBody.signature = signature;
510
+ }
511
+
512
+ log(` Signing ${step.id} (EIP-712)...`);
513
+ await postBridgeExecute(apiInstance, targetUrl, postBody);
514
+ onBroadcast?.(step.id, null);
515
+ log(` Submitted to ${new URL(targetUrl).hostname}`);
516
+ } else if (signData.action) {
517
+ const domain = {
518
+ name: 'HyperliquidSignTransaction',
519
+ version: '1',
520
+ chainId: parseInt(HL_SIGNATURE_CHAIN_ID, 16),
521
+ verifyingContract: '0x0000000000000000000000000000000000000000',
522
+ };
523
+ const types = signData.eip712Types || {};
524
+ const primaryType = signData.eip712PrimaryType || 'HyperliquidTransaction';
525
+ // Sign and submit the SAME action object (matching the perp path in
526
+ // perp.js/hl-action.js). The extra `type`/`signatureChainId` keys are not in
527
+ // the EIP-712 type list so they don't affect the hash, but building one
528
+ // object rules out any signed-vs-submitted drift.
529
+ const action = {
530
+ ...(signData.action.parameters || signData.action),
531
+ type: signData.action.type,
532
+ signatureChainId: HL_SIGNATURE_CHAIN_ID,
533
+ };
534
+
535
+ const typedData = { domain, types, primaryType, message: action };
536
+ const signature = signEip712Local(typedData, privateKeyHex, `Bridge step "${step.id}"`);
537
+ const [rHex, sHex, vHex] = [signature.slice(2, 66), signature.slice(66, 130), signature.slice(130, 132)];
538
+
539
+ // vaultAddress omitted (not null): HL only expects it for vault trades, and
540
+ // the SDK/submitExchange serialize a normal-wallet action without it.
541
+ const hlBody = {
542
+ action,
543
+ nonce: signData.nonce,
544
+ signature: { r: '0x' + rHex, s: '0x' + sHex, v: parseInt(vHex, 16) },
545
+ };
546
+
547
+ log(` Signing ${step.id} (Hyperliquid deposit)...`);
548
+ const result = await postBridgeExecute(apiInstance, 'https://api.hyperliquid.xyz/exchange', hlBody);
549
+ assertHyperliquidStepAccepted(result, step.id);
550
+ onBroadcast?.(step.id, null);
551
+ log(` Submitted to api.hyperliquid.xyz`);
552
+ }
553
+ }
554
+ }
555
+
556
+ async function processSignatureStepPrivy(step, { privyClient, walletId, log, apiInstance, onBroadcast }) {
557
+ for (const item of step.items || []) {
558
+ if (item.status === 'complete') continue;
559
+ const { data: signData } = item;
560
+
561
+ let typedData;
562
+ // For the HL action leg, the exact object that is signed is also the object
563
+ // submitted (see the local path for why); hold onto it for the submit below.
564
+ let hlAction = null;
565
+ if (signData.sign) {
566
+ typedData = {
567
+ domain: signData.sign.domain,
568
+ types: signData.sign.types,
569
+ primaryType: signData.sign.primaryType,
570
+ message: signData.sign.value,
571
+ };
572
+ } else if (signData.action) {
573
+ hlAction = {
574
+ ...(signData.action.parameters || signData.action),
575
+ type: signData.action.type,
576
+ signatureChainId: HL_SIGNATURE_CHAIN_ID,
577
+ };
578
+ typedData = {
579
+ domain: {
580
+ name: 'HyperliquidSignTransaction',
581
+ version: '1',
582
+ chainId: parseInt(HL_SIGNATURE_CHAIN_ID, 16),
583
+ verifyingContract: '0x0000000000000000000000000000000000000000',
584
+ },
585
+ types: signData.eip712Types || {},
586
+ primaryType: signData.eip712PrimaryType || 'HyperliquidTransaction',
587
+ message: hlAction,
588
+ };
589
+ } else {
590
+ throw new Error(`Unexpected signature step format for ${step.id}`);
591
+ }
592
+
593
+ // Same guard the local path gets in signEip712Local: an empty type list for
594
+ // the primary type produces a valid-looking signature that commits to none of
595
+ // the action's contents. Refuse rather than delegate the check to Privy.
596
+ if ((typedData.types?.[typedData.primaryType] || []).length === 0) {
597
+ throw new Error(
598
+ `Bridge step "${step.id}" is missing its EIP-712 type definition for "${typedData.primaryType}", so the signature would not cover the action. Refusing to sign.`,
599
+ );
600
+ }
601
+
602
+ log(` Signing ${step.id} via Privy...`);
603
+ const result = await privyClient.ethSignTypedDataV4(walletId, typedData);
604
+ const signature = result.data?.signature || result.signature || result;
605
+
606
+ if (signData.sign) {
607
+ let targetUrl = signData.post.endpoint;
608
+ if (!targetUrl.startsWith('http')) targetUrl = `https://api.relay.link${targetUrl}`;
609
+ const postBody = { ...signData.post.body };
610
+ if (targetUrl.includes('/authorize')) {
611
+ const sep = targetUrl.includes('?') ? '&' : '?';
612
+ targetUrl = `${targetUrl}${sep}signature=${signature}`;
613
+ } else {
614
+ postBody.signature = signature;
615
+ }
616
+ await postBridgeExecute(apiInstance, targetUrl, postBody);
617
+ onBroadcast?.(step.id, null);
618
+ } else {
619
+ const [rHex, sHex, vHex] = [signature.slice(2, 66), signature.slice(66, 130), signature.slice(130, 132)];
620
+ const hlBody = {
621
+ action: hlAction,
622
+ nonce: signData.nonce,
623
+ signature: { r: '0x' + rHex, s: '0x' + sHex, v: parseInt(vHex, 16) },
624
+ };
625
+ const result = await postBridgeExecute(apiInstance, 'https://api.hyperliquid.xyz/exchange', hlBody);
626
+ assertHyperliquidStepAccepted(result, step.id);
627
+ onBroadcast?.(step.id, null);
628
+ }
629
+ log(` Submitted`);
630
+ }
631
+ }
632
+
633
+ // ── Status polling ───────────────────────────────────────────────────
634
+
635
+ // A not_found means the relayer has no record of the transfer yet — normal for a
636
+ // few seconds while it indexes, but terminal if it persists (an unknown/malformed
637
+ // handle, or a source tx that never landed). Tolerate it for this bounded window,
638
+ // then treat it as terminal instead of polling "pending" to the full timeout.
639
+ const NOT_FOUND_GRACE_MS = 60000;
640
+
641
+ async function pollBridgeCompletion(apiInstance, { requestId, txHash, timeoutMs = 600000, pollMs = 10000, log = console.log }) {
642
+ const start = Date.now();
643
+ let notFoundSince = null;
644
+ while (Date.now() - start < timeoutMs) {
645
+ try {
646
+ const status = await getBridgeStatus(apiInstance, { requestId, txHash });
647
+ log(` Bridge: ${status.status} (${status.raw_status || ''})`);
648
+ if (status.status === 'success') return status;
649
+ if (status.status === 'failure') {
650
+ throw Object.assign(new Error('Bridge failed'), { code: 'BRIDGE_FAILED', details: status });
651
+ }
652
+ if (status.status === 'refund') {
653
+ log(' Bridge: REFUNDED — funds returned on source chain');
654
+ return status;
655
+ }
656
+ if (status.status === 'not_found') {
657
+ notFoundSince ??= Date.now();
658
+ if (Date.now() - notFoundSince >= NOT_FOUND_GRACE_MS) {
659
+ throw Object.assign(
660
+ new Error(
661
+ `Bridge not found: the relayer has no record of this transfer after ${NOT_FOUND_GRACE_MS / 1000}s. `
662
+ + 'The source transaction likely never landed, or the handle is wrong.',
663
+ ),
664
+ { code: 'BRIDGE_NOT_FOUND', details: status },
665
+ );
666
+ }
667
+ } else {
668
+ // Any real status (including pending) clears the not_found streak.
669
+ notFoundSince = null;
670
+ }
671
+ } catch (err) {
672
+ if (err.code === 'BRIDGE_FAILED' || err.code === 'BRIDGE_NOT_FOUND') throw err;
673
+ // Say what went wrong. A silent "poll error" hides the difference between
674
+ // a transient 502 (worth waiting out) and a 401 or a bad request id, which
675
+ // will still be failing when the timeout arrives ten minutes later.
676
+ log(` Bridge: poll error — ${err.message} (retrying...)`);
677
+ }
678
+ await new Promise(r => setTimeout(r, pollMs));
679
+ }
680
+ // Name whichever handle the caller actually gave us. Interpolating a missing
681
+ // request_id produced "--request-id undefined", a command that cannot work.
682
+ const followUp = requestId
683
+ ? `nansen bridge status --request-id ${requestId}`
684
+ : txHash
685
+ ? `nansen bridge status --tx-hash ${txHash}`
686
+ : 'nansen bridge status --tx-hash <source tx hash>';
687
+ throw Object.assign(
688
+ new Error(`Bridge polling timed out after ${timeoutMs / 1000}s. Check manually: ${followUp}`),
689
+ { code: 'BRIDGE_TIMEOUT' },
690
+ );
691
+ }
692
+
693
+ // ── Wallet helpers ───────────────────────────────────────────────────
694
+
695
+ // Every route here has an EVM address on at least one side (Hyperliquid uses EVM
696
+ // addresses too), and both legs sign with the EVM key — so a wallet without a
697
+ // valid EVM address can't bridge at all. This used to pass `wallet.evm`
698
+ // through unchecked, so a Solana-only wallet reached the API as
699
+ // `wallet_address: null` and came back a 422.
700
+ function resolveWalletAddress(walletName) {
701
+ return resolveEvmWallet(walletName, 'Bridging');
702
+ }
703
+
704
+ // Destination address for --recipient. Validated against the EVM pattern rather
705
+ // than the destination chain's own rules: every supported destination
706
+ // (base/ethereum/arbitrum/hyperliquid) takes an EVM address, and passing
707
+ // 'hyperliquid' to validateAddress would fall through its unknown-chain branch
708
+ // and accept anything.
709
+ function assertRecipient(recipient) {
710
+ const { valid, error } = validateAddress(recipient, 'ethereum');
711
+ if (!valid) {
712
+ throw new CommandError(`Invalid --recipient "${recipient}". ${error}`, 'INVALID_ADDRESS');
713
+ }
714
+ }
715
+
716
+ // --amount is a base-unit integer by default, or a positive decimal with
717
+ // --amount-unit. Checked client-side because the two failure modes are both
718
+ // quiet: a decimal in base units is a units mix-up (5.5 meaning 5.5 USDC would
719
+ // bridge 5 base units, i.e. 0.000005 USDC), and trailing garbage would reach
720
+ // convertToBaseUnits rather than being rejected.
721
+ function parseBridgeAmount(raw, amountUnit) {
722
+ const s = String(raw).trim();
723
+ if (amountUnit === undefined) {
724
+ if (!/^\d+$/.test(s) || BigInt(s) <= 0n) {
725
+ throw new CommandError(
726
+ `Invalid --amount "${raw}". Base units must be a positive whole number (USDC is 6 decimals on EVM chains, 8 on Hyperliquid). Pass --amount-unit token to use a human amount instead.`,
727
+ 'INVALID_INPUT',
728
+ );
729
+ }
730
+ return s;
731
+ }
732
+ if (!/^\d*\.?\d+$/.test(s) || !(parseFloat(s) > 0)) {
733
+ throw new CommandError(
734
+ `Invalid --amount "${raw}". Must be a positive number when --amount-unit is ${amountUnit}.`,
735
+ 'INVALID_INPUT',
736
+ );
737
+ }
738
+ return s;
739
+ }
740
+
741
+ // ── Command builder ──────────────────────────────────────────────────
742
+
743
+ export function buildBridgeCommands(deps = {}) {
744
+ const { log = console.log } = deps;
745
+
746
+ return {
747
+ 'quote': async (args, apiInstance, flags, options) => {
748
+ const originChain = (options['from-chain'] || options.from || '').toLowerCase();
749
+ const destinationChain = (options['to-chain'] || options.to || '').toLowerCase();
750
+ const fromTokenRaw = options['from-token'] || options.token || '';
751
+ const toTokenRaw = options['to-token'] || '';
752
+ const amount = options.amount;
753
+ // Normalize so "Token"/"USD" etc. are accepted; reject anything unknown
754
+ // rather than silently falling back to base units (which would re-open the
755
+ // per-chain magnitude trap --amount-unit exists to prevent).
756
+ const amountUnit = options['amount-unit'] != null
757
+ ? String(options['amount-unit']).toLowerCase()
758
+ : undefined;
759
+ const slippageBps = options.slippage !== undefined ? parseSlippageBps(options.slippage) : 50;
760
+ const walletName = options.wallet;
761
+ const recipient = options.recipient;
762
+
763
+ if (amountUnit !== undefined && amountUnit !== 'token' && amountUnit !== 'usd') {
764
+ throw new Error(
765
+ `Invalid --amount-unit "${options['amount-unit']}". Must be "token" or "usd" (omit for base units).`,
766
+ );
767
+ }
768
+
769
+ if (!originChain || !destinationChain || !fromTokenRaw || !amount) {
770
+ throw new CommandError(
771
+ `Usage: nansen bridge quote --from-chain <chain> --to-chain <chain> --from-token <token> --amount <amount> [--wallet <name>]
772
+
773
+ SUPPORTED ROUTES:
774
+ ${BRIDGE_ROUTES.map(([o, d]) => `${o} -> ${d}`).join('\n ')}
775
+
776
+ OPTIONS:
777
+ --from-chain Source chain (see supported routes above)
778
+ --to-chain Destination chain (see supported routes above)
779
+ --from-token Source token (symbol like USDC, or address)
780
+ --to-token Destination token (defaults to USDC)
781
+ --amount Amount. Base units by default (decimals differ per chain:
782
+ USDC is 6 on EVM chains, 8 on Hyperliquid). Use --amount-unit
783
+ to pass human amounts instead.
784
+ --amount-unit token (human token amount) or usd. Omit for base units.
785
+ --slippage Slippage in bps (default 50 = 0.5%)
786
+ --wallet Wallet name
787
+ --recipient Destination wallet (defaults to same address)`,
788
+ 'MISSING_PARAM',
789
+ );
790
+ }
791
+
792
+ if (!isSupportedBridgeRoute(originChain, destinationChain)) {
793
+ throw new Error(
794
+ `Unsupported bridge route: ${originChain} -> ${destinationChain}. Supported routes: ${formatBridgeRoutes()}`,
795
+ );
796
+ }
797
+
798
+ if (recipient !== undefined) assertRecipient(recipient);
799
+ const amountInput = parseBridgeAmount(amount, amountUnit);
800
+
801
+ const originToken = resolveBridgeToken(fromTokenRaw, originChain);
802
+ const destinationToken = toTokenRaw
803
+ ? resolveBridgeToken(toTokenRaw, destinationChain)
804
+ : resolveBridgeToken('USDC', destinationChain);
805
+
806
+ const wallet = resolveWalletAddress(walletName);
807
+
808
+ // Default: --amount is base units. With --amount-unit, accept a human token
809
+ // or USD amount and convert client-side using the source token's decimals.
810
+ let resolvedAmount = amountInput;
811
+ if (amountUnit === 'token' || amountUnit === 'usd') {
812
+ try {
813
+ const decimals = await resolveBridgeTokenDecimals(originToken, originChain);
814
+ let humanAmount = amountInput;
815
+ if (amountUnit === 'usd') {
816
+ // USDC is USD-pegged ($1), so skip the price lookup — and Hyperliquid's
817
+ // USDC uses a sentinel address the price API can't resolve, which would
818
+ // otherwise make `--amount-unit usd` unusable from HL. Non-stable tokens
819
+ // still fetch a live price.
820
+ const price = isBridgeUsdc(originToken, originChain)
821
+ ? 1
822
+ : await resolveUsdPrice(apiInstance, originToken, originChain);
823
+ humanAmount = (parseFloat(amountInput) / price).toFixed(decimals);
824
+ }
825
+ resolvedAmount = convertToBaseUnits(humanAmount, decimals);
826
+ resolvedAmount = floorHyperliquidUsdcBridgeAmount(resolvedAmount, decimals, originToken, originChain);
827
+ } catch (err) {
828
+ throw new Error(`Error converting --amount: ${err.message}`, { cause: err });
829
+ }
830
+ }
831
+
832
+ log(`\n Fetching bridge quote: ${originChain} → ${destinationChain}...`);
833
+
834
+ const result = await getBridgeQuote(apiInstance, {
835
+ wallet_address: wallet.address,
836
+ origin_chain: originChain,
837
+ destination_chain: destinationChain,
838
+ origin_token: originToken,
839
+ destination_token: destinationToken,
840
+ amount: resolvedAmount,
841
+ slippage_bps: slippageBps,
842
+ ...(recipient && { recipient }),
843
+ });
844
+
845
+ const details = result.details || {};
846
+ const currIn = details.currencyIn || {};
847
+ const currOut = details.currencyOut || {};
848
+ const fees = result.fees || {};
849
+ const relayerFee = fees.relayer || {};
850
+
851
+ log(`\n Bridge Quote: ${originChain} → ${destinationChain}`);
852
+ log(` Type: ${result.execution_type}`);
853
+ log(` Send: ${currIn.amountFormatted || amount} ${currIn.currency?.symbol || originToken}`);
854
+ log(` Receive: ${currOut.amountFormatted || '?'} ${currOut.currency?.symbol || destinationToken}`);
855
+ if (relayerFee.amountUsd) {
856
+ log(` Fee: $${relayerFee.amountUsd}`);
857
+ }
858
+ log(` Steps: ${(result.steps || []).length}`);
859
+ for (const s of result.steps || []) {
860
+ log(` - ${s.id} (${s.kind})`);
861
+ }
862
+
863
+ const quoteId = saveBridgeQuote(
864
+ result,
865
+ originChain,
866
+ destinationChain,
867
+ wallet.provider,
868
+ wallet.address,
869
+ recipient,
870
+ );
871
+ log(`\n Quote ID: ${quoteId}`);
872
+ log(` Execute: nansen bridge execute --quote ${quoteId}`);
873
+ log('');
874
+ return undefined;
875
+ },
876
+
877
+ 'execute': async (args, apiInstance, flags, options) => {
878
+ const quoteId = options.quote || args[0];
879
+ const walletName = options.wallet;
880
+
881
+ if (!quoteId) {
882
+ throw new CommandError(
883
+ `Usage: nansen bridge execute --quote <quoteId> [--wallet <name>]
884
+
885
+ Execute a cached bridge quote. Signs transactions and broadcasts them.
886
+
887
+ RECOVERY OPTIONS (EVM deposit legs only):
888
+ --priority-fee Priority fee in gwei, overriding the quoted one
889
+ --max-fee Fee cap in gwei, overriding the computed one
890
+ --nonce Sign at this nonce instead of the next one
891
+
892
+ Use these to replace a transaction that is stuck in the mempool: a replacement
893
+ must reuse the stuck nonce and outbid it (roughly +10%), and the fees computed
894
+ from a quote are the same ones that got stuck. Check the stuck nonce with
895
+ "nansen wallet balance" or an explorer, then:
896
+
897
+ nansen bridge execute --quote <new quoteId> --nonce <stuck nonce> --priority-fee 0.05`,
898
+ 'MISSING_PARAM',
899
+ );
900
+ }
901
+
902
+ // Fee/nonce overrides. Parsed before the quote is touched so a typo can't
903
+ // consume it, and only applied to EVM legs — an HL withdrawal signs an
904
+ // action with no fee fields at all.
905
+ const feeOverrides = {
906
+ priorityFeeWei: options['priority-fee'] !== undefined
907
+ ? parseGweiToWei(options['priority-fee'], 'priority-fee')
908
+ : null,
909
+ maxFeeWei: options['max-fee'] !== undefined
910
+ ? parseGweiToWei(options['max-fee'], 'max-fee')
911
+ : null,
912
+ };
913
+ let nonceSequence = null;
914
+ if (options.nonce !== undefined) {
915
+ const s = String(options.nonce).trim();
916
+ if (!/^\d+$/.test(s)) {
917
+ throw new CommandError(
918
+ `Invalid --nonce "${options.nonce}". Must be a non-negative whole number.`,
919
+ 'INVALID_INPUT',
920
+ );
921
+ }
922
+ // A multi-step quote (approve then deposit) signs consecutive nonces, so
923
+ // this is a starting point rather than a single value.
924
+ nonceSequence = { next: parseInt(s, 10) };
925
+ }
926
+
927
+ const quoteData = loadBridgeQuote(quoteId);
928
+ // A truncated or hand-edited quote file can be missing `response.steps`
929
+ // entirely; guard before destructuring so the operator gets an actionable
930
+ // message rather than a raw TypeError on `steps.length` below.
931
+ if (!Array.isArray(quoteData.response?.steps)) {
932
+ throw new CommandError(
933
+ `Quote "${quoteId}" is malformed: no executable steps found. Request a fresh quote with "nansen bridge quote".`,
934
+ 'INVALID_INPUT',
935
+ );
936
+ }
937
+ const { execution_type, steps, request_id } = quoteData.response;
938
+ const { recipient } = quoteData;
939
+
940
+ // The overrides only mean something for an EVM broadcast. A withdrawal
941
+ // signs a Hyperliquid action with no fee or nonce fields, so there is
942
+ // nothing to apply them to — refuse rather than ignore them, since the
943
+ // operator passed them expecting a different outcome.
944
+ if (
945
+ execution_type !== 'evm_transaction'
946
+ && (feeOverrides.priorityFeeWei || feeOverrides.maxFeeWei || nonceSequence)
947
+ ) {
948
+ throw new CommandError(
949
+ `--priority-fee/--max-fee/--nonce apply only to EVM deposit legs, but quote "${quoteId}" is a ${execution_type} leg with no on-chain transaction to price.`,
950
+ 'INVALID_INPUT',
951
+ );
952
+ }
953
+
954
+ log(`\n Executing bridge: ${quoteData.originChain} → ${quoteData.destinationChain}`);
955
+ log(` Type: ${execution_type}`);
956
+ log(` Steps: ${steps.length}`);
957
+
958
+ // The quote was issued for one wallet, but the signing wallet is resolved
959
+ // separately from --wallet / the current default — which can have changed
960
+ // since. Signing with a different wallet than the quote was built for would
961
+ // screen one address and move funds from another, and the cached tx data
962
+ // (nonce, from) belongs to the quote's wallet regardless. Refuse instead.
963
+ const signer = resolveWalletAddress(walletName);
964
+ if (
965
+ quoteData.walletAddress &&
966
+ String(signer.address).toLowerCase() !== String(quoteData.walletAddress).toLowerCase()
967
+ ) {
968
+ throw new Error(
969
+ `Bridge quote "${quoteId}" was created for ${quoteData.walletAddress} but the signing wallet is ${signer.address}. Pass --wallet for the quote's wallet, or request a new quote.`,
970
+ );
971
+ }
972
+
973
+ // Re-screen the signer and any distinct recipient immediately before
974
+ // signing. Quotes live up to an hour, and the EVM leg broadcasts directly.
975
+ const screenAddresses = recipient
976
+ && String(recipient).toLowerCase() !== String(signer.address).toLowerCase()
977
+ ? [signer.address, recipient]
978
+ : [signer.address];
979
+ await screenOrThrow(apiInstance, screenAddresses);
980
+
981
+ // Signing material for the wallet resolved above — not a second lookup.
982
+ // Resolving twice re-read the wallet file and, worse, could pick a
983
+ // different wallet than the one just screened if the default changed in
984
+ // between.
985
+ const creds = resolveSigningCredentials(signer);
986
+
987
+ // Consume the quote at each INDIVIDUAL broadcast, before any receipt wait.
988
+ // A tx can be accepted by the network and then have waitForReceipt time
989
+ // out; if the quote were still unspent, a retry would re-sign and re-send
990
+ // it with a fresh nonce. markBridgeQuoteExecuted pins executedAt on the
991
+ // first call, so the quote is spent the instant anything is in flight.
992
+ const onBroadcast = (stepId, txHash) =>
993
+ markBridgeQuoteExecuted(quoteId, {
994
+ broadcast: { step: stepId, txHash: txHash || null },
995
+ totalSteps: steps.length,
996
+ });
997
+
998
+ // Step-level counter, recorded only once a step fully completes.
999
+ const markBroadcast = (index) =>
1000
+ markBridgeQuoteExecuted(quoteId, {
1001
+ broadcastSteps: index + 1,
1002
+ totalSteps: steps.length,
1003
+ });
1004
+
1005
+ if (execution_type === 'evm_transaction') {
1006
+ // Overrides move real money differently from what was quoted, so say so
1007
+ // rather than letting them apply silently.
1008
+ if (feeOverrides.priorityFeeWei || feeOverrides.maxFeeWei || nonceSequence) {
1009
+ const parts = [];
1010
+ if (feeOverrides.priorityFeeWei) parts.push(`priority fee ${feeOverrides.priorityFeeWei} wei`);
1011
+ if (feeOverrides.maxFeeWei) parts.push(`fee cap ${feeOverrides.maxFeeWei} wei`);
1012
+ if (nonceSequence) parts.push(`starting nonce ${nonceSequence.next}`);
1013
+ log(` Overrides: ${parts.join(', ')}`);
1014
+ }
1015
+ for (const [index, step] of steps.entries()) {
1016
+ await processEvmStep(step, {
1017
+ chain: quoteData.originChain,
1018
+ privateKeyHex: creds.privateKey,
1019
+ signerAddress: signer.address,
1020
+ log,
1021
+ onBroadcast,
1022
+ feeOverrides,
1023
+ nonceSequence,
1024
+ });
1025
+ markBroadcast(index);
1026
+ }
1027
+ } else if (execution_type === 'hyperliquid_signature') {
1028
+ if (creds.provider === 'privy') {
1029
+ const { PrivyClient } = await import('./privy.js');
1030
+ const privyClient = new PrivyClient(process.env.PRIVY_APP_ID, process.env.PRIVY_APP_SECRET);
1031
+ for (const [index, step] of steps.entries()) {
1032
+ await processSignatureStepPrivy(step, {
1033
+ privyClient,
1034
+ // `signer` above — the same resolution that was screened and
1035
+ // matched against the quote, rather than resolving a second time.
1036
+ walletId: signer.privyWalletIds?.evm,
1037
+ log,
1038
+ apiInstance,
1039
+ onBroadcast,
1040
+ });
1041
+ markBroadcast(index);
1042
+ }
1043
+ } else {
1044
+ for (const [index, step] of steps.entries()) {
1045
+ await processSignatureStepLocal(step, {
1046
+ privateKeyHex: creds.privateKey,
1047
+ log,
1048
+ apiInstance,
1049
+ onBroadcast,
1050
+ });
1051
+ markBroadcast(index);
1052
+ }
1053
+ }
1054
+ } else {
1055
+ throw new Error(`Unknown execution type: ${execution_type}`);
1056
+ }
1057
+
1058
+ // Every step is out; the marker above already consumed the quote.
1059
+ markBridgeQuoteExecuted(quoteId, { broadcastSteps: steps.length, totalSteps: steps.length });
1060
+
1061
+ log(`\n Bridge submitted. Polling for completion...`);
1062
+ const status = await pollBridgeCompletion(apiInstance, { requestId: request_id, log });
1063
+
1064
+ if (status.status === 'success') {
1065
+ log(`\n Bridge completed!`);
1066
+ if (status.destination_tx_hashes?.length) {
1067
+ log(` Destination tx: ${status.destination_tx_hashes[0]}`);
1068
+ }
1069
+ }
1070
+ log('');
1071
+ return undefined;
1072
+ },
1073
+
1074
+ 'status': async (args, apiInstance, flags, options) => {
1075
+ const requestId = options['request-id'] || args[0];
1076
+ const txHash = options['tx-hash'];
1077
+
1078
+ if (!requestId && !txHash) {
1079
+ throw new CommandError(
1080
+ `Usage: nansen bridge status --request-id <id> or --tx-hash <hash>
1081
+
1082
+ Check the status of a Hyperliquid bridge transaction.`,
1083
+ 'MISSING_PARAM',
1084
+ );
1085
+ }
1086
+
1087
+ const status = await getBridgeStatus(apiInstance, { requestId, txHash });
1088
+
1089
+ log(`\n Bridge Status: ${status.status}`);
1090
+ if (status.status === 'not_found') {
1091
+ log(' (relayer has no record of this transfer — an unknown/malformed handle, or');
1092
+ log(' a source tx that has not landed / is not yet indexed. Retry briefly, then');
1093
+ log(' treat as terminal.)');
1094
+ }
1095
+ if (status.raw_status) log(` Raw: ${status.raw_status}`);
1096
+ if (status.source_tx_hashes?.length) log(` Source: ${status.source_tx_hashes.join(', ')}`);
1097
+ if (status.destination_tx_hashes?.length) log(` Dest: ${status.destination_tx_hashes.join(', ')}`);
1098
+ log('');
1099
+ return undefined;
1100
+ },
1101
+ };
1102
+ }