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/CHANGELOG.md +80 -0
- package/README.md +34 -1
- package/package.json +1 -1
- package/skills/nansen-trading/SKILL.md +49 -1
- package/src/api.js +3 -1
- package/src/bridge.js +1102 -0
- package/src/cli.js +130 -7
- package/src/hl-action.js +528 -0
- package/src/hl-client.js +168 -0
- package/src/hl-env.js +37 -0
- package/src/keychain.js +6 -2
- package/src/limit-order.js +18 -4
- package/src/perp.js +835 -0
- package/src/rpc-urls.js +18 -11
- package/src/schema.json +415 -6
- package/src/trading.js +162 -17
- package/src/wallet-signing.js +87 -0
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
|
+
}
|