nansen-cli 1.41.0 → 1.42.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 +47 -0
- package/README.md +33 -7
- package/package.json +1 -1
- package/scripts/postinstall.js +3 -3
- package/skills/nansen-wallet-manager/SKILL.md +5 -5
- package/src/api.js +11 -5
- package/src/bridge.js +791 -55
- package/src/cli.js +68 -41
- package/src/commands/agent.js +4 -0
- package/src/commands/mcp.js +373 -0
- package/src/doctor.js +16 -7
- package/src/limit-order.js +16 -2
- package/src/mcp-verify.js +292 -0
- package/src/privy.js +12 -2
- package/src/schema.json +55 -0
- package/src/swap-simulation.js +6 -0
- package/src/trade-validation.js +185 -37
- package/src/trading.js +208 -31
- package/src/wallet.js +20 -14
- package/src/x402-svm.js +9 -5
package/src/bridge.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from './trading.js';
|
|
25
25
|
import { screenOrThrow } from './perp.js';
|
|
26
26
|
import { extractActionErrors } from './hl-client.js';
|
|
27
|
+
import { encodeApproveCalldata } from './trade-validation.js';
|
|
27
28
|
import { resolveEvmWallet, resolveSigningCredentials } from './wallet-signing.js';
|
|
28
29
|
import { hashTypedData } from './x402-evm.js';
|
|
29
30
|
|
|
@@ -215,7 +216,7 @@ async function getBridgeStatus(apiInstance, { requestId, txHash }) {
|
|
|
215
216
|
|
|
216
217
|
// ── Quote caching ────────────────────────────────────────────────────
|
|
217
218
|
|
|
218
|
-
function saveBridgeQuote(response, originChain, destinationChain, walletProvider, walletAddress, recipient) {
|
|
219
|
+
function saveBridgeQuote(response, originChain, destinationChain, walletProvider, walletAddress, recipient, requestedAmountBaseUnits) {
|
|
219
220
|
const dir = getQuotesDir();
|
|
220
221
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
221
222
|
const hash = crypto.randomBytes(4).toString('hex');
|
|
@@ -228,6 +229,7 @@ function saveBridgeQuote(response, originChain, destinationChain, walletProvider
|
|
|
228
229
|
walletProvider,
|
|
229
230
|
walletAddress,
|
|
230
231
|
recipient,
|
|
232
|
+
requestedAmountBaseUnits,
|
|
231
233
|
timestamp: Date.now(),
|
|
232
234
|
response,
|
|
233
235
|
};
|
|
@@ -311,22 +313,672 @@ export function markBridgeQuoteExecuted(quoteId, progress = {}) {
|
|
|
311
313
|
// contents, so we would hand back a well-formed signature that commits to
|
|
312
314
|
// nothing about the action being authorised. Refuse instead — an omitted or
|
|
313
315
|
// misspelled type list is a bug or a tampered response, never something to sign
|
|
314
|
-
// through.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
throw new
|
|
316
|
+
// through. Shared by signEip712Local (right before hashing), the Privy path
|
|
317
|
+
// (right before delegating to ethSignTypedDataV4), and the preflight pass
|
|
318
|
+
// (before any step signs at all).
|
|
319
|
+
function assertEip712TypeListNonEmpty(types, primaryType, context) {
|
|
320
|
+
if ((types?.[primaryType] || []).length === 0) {
|
|
321
|
+
throw new CommandError(
|
|
320
322
|
`${context} is missing its EIP-712 type definition for "${primaryType}", so the signature would not cover the action. Refusing to sign.`,
|
|
323
|
+
'UNEXPECTED_ACTION',
|
|
321
324
|
);
|
|
322
325
|
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function signEip712Local(typedData, privateKeyHex, context = 'EIP-712 payload') {
|
|
329
|
+
const { domain, types, primaryType, message } = typedData;
|
|
330
|
+
assertEip712TypeListNonEmpty(types, primaryType, context);
|
|
331
|
+
const fields = (types?.[primaryType] || []).map(f => ({ name: f.name, type: f.type }));
|
|
323
332
|
const msgHash = hashTypedData(domain, primaryType, fields, message);
|
|
324
333
|
const { r, s, v } = signSecp256k1(msgHash, Buffer.from(privateKeyHex, 'hex'));
|
|
325
334
|
return '0x' + r.toString('hex') + s.toString('hex') + (27 + v).toString(16).padStart(2, '0');
|
|
326
335
|
}
|
|
327
336
|
|
|
337
|
+
// Hyperliquid action types this bridge path is designed to produce and sign. The
|
|
338
|
+
// server supplies the action; we refuse to sign anything outside this set so a
|
|
339
|
+
// tampered response can't swap the intended withdrawal for a different
|
|
340
|
+
// fund-moving action whose fields we don't validate. Confirmed from real quotes
|
|
341
|
+
// across every supported withdrawal route (HL -> base/ethereum/arbitrum).
|
|
342
|
+
const ALLOWED_HL_BRIDGE_ACTION_TYPES = new Set(['sendAsset']);
|
|
343
|
+
|
|
344
|
+
// Hyperliquid's on-chain identifier for spot USDC — the only source token this
|
|
345
|
+
// bridge path can ever legitimately request (resolveBridgeTokenDecimals never
|
|
346
|
+
// resolves any other HL-origin token). Confirmed identical across all three
|
|
347
|
+
// captured withdrawal routes (HL -> base/ethereum/arbitrum): it names the
|
|
348
|
+
// SOURCE token, which does not vary by destination.
|
|
349
|
+
const HYPERLIQUID_BRIDGE_USDC_TOKEN_ID = 'USDC:0x6d1e7cde53ba9467b783cb7c530ce054';
|
|
350
|
+
|
|
351
|
+
// The relayer authorize step signs a "NonceMapping" typed-data payload whose
|
|
352
|
+
// domain/types/primaryType/value ALL come from the server response — unlike
|
|
353
|
+
// the deposit action, nothing about this one is pinned client-side. Left
|
|
354
|
+
// unchecked, a malicious response could ask the wallet to sign a COMPLETELY
|
|
355
|
+
// different EIP-712 message — a different protocol's Permit or approval,
|
|
356
|
+
// anything with a non-empty type list — and relay the resulting signature to
|
|
357
|
+
// an endpoint of its choosing. Pin the exact shape (domain fields, and field
|
|
358
|
+
// name+type+ORDER — order affects the EIP-712 struct hash) so this can only
|
|
359
|
+
// ever produce a signature over a genuine NonceMapping. Captured from a real
|
|
360
|
+
// read-only `bridge quote` response (2026-08-28) — the exact domain
|
|
361
|
+
// (RelayNonceMapping, not the plainer "Relay" name used elsewhere in this
|
|
362
|
+
// file's comments/URLs) and field types (wallet/depositor are `address`, not
|
|
363
|
+
// `string`; id is `bytes32`; nonce is `uint256`) only became visible once
|
|
364
|
+
// captured directly — treat this as the source of truth over guesses.
|
|
365
|
+
const RELAY_AUTHORIZE_PRIMARY_TYPE = 'NonceMapping';
|
|
366
|
+
const RELAY_AUTHORIZE_DOMAIN = {
|
|
367
|
+
name: 'RelayNonceMapping',
|
|
368
|
+
version: '2',
|
|
369
|
+
chainId: 1,
|
|
370
|
+
verifyingContract: '0x0000000000000000000000000000000000000000',
|
|
371
|
+
};
|
|
372
|
+
const RELAY_AUTHORIZE_FIELDS = [
|
|
373
|
+
{ name: 'chainId', type: 'string' },
|
|
374
|
+
{ name: 'wallet', type: 'address' },
|
|
375
|
+
{ name: 'depositor', type: 'address' },
|
|
376
|
+
{ name: 'id', type: 'bytes32' },
|
|
377
|
+
{ name: 'nonce', type: 'uint256' },
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
// The exact (relative) endpoint the authorize signature is POSTed to. Pinned
|
|
381
|
+
// literally rather than merely host-allowlisted: the target URL is always
|
|
382
|
+
// built from this constant, never from the server-supplied `post.endpoint`,
|
|
383
|
+
// so there is nothing left for a malicious response to redirect.
|
|
384
|
+
const RELAY_AUTHORIZE_ENDPOINT_PATH = '/authorize';
|
|
385
|
+
const RELAY_AUTHORIZE_BASE_URL = 'https://api.relay.link';
|
|
386
|
+
|
|
387
|
+
// The deposit (sendAsset) leg signs a Hyperliquid action under the
|
|
388
|
+
// HyperliquidSignTransaction domain, which the processors pin client-side. But
|
|
389
|
+
// that domain is shared by EVERY HL user-signed action, and the primaryType +
|
|
390
|
+
// type list come off the wire (signData.eip712PrimaryType / .eip712Types). The
|
|
391
|
+
// EIP-712 struct hash covers EXACTLY the fields named in types[primaryType],
|
|
392
|
+
// reading their values from the action object — so the pinned domain binds
|
|
393
|
+
// nothing about WHAT is signed. Without pinning the type list, a response could
|
|
394
|
+
// keep an action object that passes every assertHlBridgeActionIntent check yet
|
|
395
|
+
// supply a different primaryType + fields (e.g. an ApproveAgent shape) whose
|
|
396
|
+
// digest commits to attacker-chosen fields on that same object — a valid
|
|
397
|
+
// approval this bridge never intended, and NOT bounded by the amount cap (agent
|
|
398
|
+
// approval moves no amount, so the cap is no protection). Pin the primaryType
|
|
399
|
+
// and the exact ordered field list so the digest can only ever cover the
|
|
400
|
+
// sendAsset fields assertHlBridgeActionIntent validates. Captured from real
|
|
401
|
+
// read-only `bridge quote` responses (2026-08-28) and confirmed byte-identical
|
|
402
|
+
// across all three withdrawal routes (HL -> base/ethereum/arbitrum) — the
|
|
403
|
+
// sendAsset shape is a Hyperliquid-side action schema, so it does not vary by
|
|
404
|
+
// destination. Same source of truth as the authorize shape above.
|
|
405
|
+
const HL_SENDASSET_PRIMARY_TYPE = 'HyperliquidTransaction:SendAsset';
|
|
406
|
+
const HL_SENDASSET_FIELDS = [
|
|
407
|
+
{ name: 'hyperliquidChain', type: 'string' },
|
|
408
|
+
{ name: 'destination', type: 'string' },
|
|
409
|
+
{ name: 'sourceDex', type: 'string' },
|
|
410
|
+
{ name: 'destinationDex', type: 'string' },
|
|
411
|
+
{ name: 'token', type: 'string' },
|
|
412
|
+
{ name: 'amount', type: 'string' },
|
|
413
|
+
{ name: 'fromSubAccount', type: 'string' },
|
|
414
|
+
{ name: 'nonce', type: 'uint64' },
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
// Parse a non-negative decimal string ("2", "2.000000", "1.97859") to an integer
|
|
418
|
+
// scaled by `decimals`, via digit slicing (never parseFloat) so it is exact.
|
|
419
|
+
export function decimalToScaled(raw, decimals = 8) {
|
|
420
|
+
const s = String(raw).trim();
|
|
421
|
+
if (!/^\d*\.?\d+$/.test(s)) {
|
|
422
|
+
throw new CommandError(`Cannot parse amount "${raw}". Request a new quote.`, 'INVALID_INPUT');
|
|
423
|
+
}
|
|
424
|
+
const [int, frac = ''] = s.split('.');
|
|
425
|
+
const scaledFrac = (frac + '0'.repeat(decimals)).slice(0, decimals);
|
|
426
|
+
return BigInt(int || '0') * 10n ** BigInt(decimals) + BigInt(scaledFrac);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Bind a server-supplied Hyperliquid bridge ACTION to the user's intent before
|
|
430
|
+
// signing. The pinned EIP-712 domain only proves this is *a* Hyperliquid
|
|
431
|
+
// action; it says nothing about what the action does. Static only, no RPC.
|
|
432
|
+
//
|
|
433
|
+
// NOTE ON DESTINATION: in the relayer-mediated flow, action.destination is the
|
|
434
|
+
// RELAYER's deposit address, not the user's — so it is deliberately NOT bound to
|
|
435
|
+
// the recipient (doing so would reject every legitimate withdrawal). The final
|
|
436
|
+
// payout recipient is held off-chain by the relayer and is not in anything we
|
|
437
|
+
// sign; the amount cap below is what bounds the loss.
|
|
438
|
+
//
|
|
439
|
+
// Every check below fails CLOSED (throws) rather than silently skipping when
|
|
440
|
+
// its anchor is missing. An earlier version skipped the amount cap whenever
|
|
441
|
+
// the server's response omitted the field it compared against — which let a
|
|
442
|
+
// malicious response null out just that one field to strip the cap while
|
|
443
|
+
// still passing every other check. The anchors here are never legitimately
|
|
444
|
+
// absent, so a missing one is itself a signal to refuse.
|
|
445
|
+
//
|
|
446
|
+
// intent.reviewedAmountBaseUnits — the amount the CLIENT persisted at quote
|
|
447
|
+
// time from the user's own --amount (HL USDC is
|
|
448
|
+
// 8-decimal base units). Anchored to client-recorded
|
|
449
|
+
// intent, not a server-supplied display field, so it
|
|
450
|
+
// can't be nulled out by a tampered response. Cap
|
|
451
|
+
// anchor for the amount leaving HL.
|
|
452
|
+
// intent.hlNetwork — 'Mainnet' (what the exchange POST targets).
|
|
453
|
+
export function assertHlBridgeActionIntent(action, intent, context = 'Bridge action') {
|
|
454
|
+
if (!action || typeof action !== 'object') {
|
|
455
|
+
throw new CommandError(`${context}: no action to verify. Request a new quote.`, 'INVALID_INPUT');
|
|
456
|
+
}
|
|
457
|
+
// A. type allowlist
|
|
458
|
+
if (!ALLOWED_HL_BRIDGE_ACTION_TYPES.has(action.type)) {
|
|
459
|
+
throw new CommandError(
|
|
460
|
+
`${context} has an unexpected action type "${action.type}". Refusing to sign. Request a new quote.`,
|
|
461
|
+
'UNEXPECTED_ACTION',
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
// C. network
|
|
465
|
+
const net = action.hyperliquidChain ?? action.parameters?.hyperliquidChain;
|
|
466
|
+
if (net == null || net !== intent.hlNetwork) {
|
|
467
|
+
throw new CommandError(
|
|
468
|
+
`${context} targets Hyperliquid "${net}", expected "${intent.hlNetwork}". Refusing to sign.`,
|
|
469
|
+
'UNEXPECTED_NETWORK',
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
// B. amount cap — required, not optional. sendAsset's entire purpose is
|
|
473
|
+
// moving a nonzero amount; an action.type we allowlisted but with no amount
|
|
474
|
+
// to check is not a smaller withdrawal, it is a signal something is wrong
|
|
475
|
+
// with the response, so refuse rather than let it through unchecked.
|
|
476
|
+
const signed = action.amount ?? action.parameters?.amount;
|
|
477
|
+
if (signed == null) {
|
|
478
|
+
throw new CommandError(
|
|
479
|
+
`${context} has no amount to verify. Refusing to sign. Request a new quote.`,
|
|
480
|
+
'AMOUNT_MISMATCH',
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (intent.reviewedAmountBaseUnits == null) {
|
|
484
|
+
// Expected for a quote saved by an older CLI version before this field
|
|
485
|
+
// existed, not a sign of a bad response — but it can't be verified, so
|
|
486
|
+
// still refuse. Name the likely cause so it doesn't read as a bug.
|
|
487
|
+
throw new CommandError(
|
|
488
|
+
`${context}: no reviewed amount recorded to check ${signed} against (this quote may predate a nansen-cli update). `
|
|
489
|
+
+ `Refusing to sign. Request a new quote.`,
|
|
490
|
+
'AMOUNT_MISMATCH',
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
// decimalToScaled's default (8) matches Hyperliquid USDC's native decimals,
|
|
494
|
+
// the same scale reviewedAmountBaseUnits is already expressed in.
|
|
495
|
+
const signedScaled = decimalToScaled(signed);
|
|
496
|
+
const reviewedScaled = BigInt(intent.reviewedAmountBaseUnits);
|
|
497
|
+
// +1n is a fixed 1-base-unit slack (0.00000001 USDC) absorbing rounding when
|
|
498
|
+
// the server's 6-dp `amount` string is compared against the 8-dp reviewed
|
|
499
|
+
// value. It is one-sided, so the most it ever permits is over-signing by a
|
|
500
|
+
// single base unit — economically nothing. This is a rounding margin, NOT a
|
|
501
|
+
// user-tunable tolerance: do not widen it.
|
|
502
|
+
if (signedScaled > reviewedScaled + 1n) {
|
|
503
|
+
throw new CommandError(
|
|
504
|
+
`${context} would send ${signed}, more than the ${intent.reviewedAmountBaseUnits} base units you requested. `
|
|
505
|
+
+ `Refusing to sign. Request a new quote.`,
|
|
506
|
+
'AMOUNT_MISMATCH',
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
// F. token / source fields. Only USDC is ever a legitimate HL-origin source
|
|
510
|
+
// token, and every captured route used empty dex/sub-account fields (no
|
|
511
|
+
// dex-sourced or sub-account withdrawals are supported) — so anything else
|
|
512
|
+
// is off the only shape this bridge path is designed to produce.
|
|
513
|
+
const token = action.token ?? action.parameters?.token;
|
|
514
|
+
if (token !== HYPERLIQUID_BRIDGE_USDC_TOKEN_ID) {
|
|
515
|
+
throw new CommandError(
|
|
516
|
+
`${context} names an unexpected source token "${token}". Refusing to sign. Request a new quote.`,
|
|
517
|
+
'UNEXPECTED_ACTION',
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
for (const key of ['sourceDex', 'destinationDex', 'fromSubAccount']) {
|
|
521
|
+
const v = action[key] ?? action.parameters?.[key];
|
|
522
|
+
if (v !== '') {
|
|
523
|
+
throw new CommandError(
|
|
524
|
+
`${context} has an unexpected ${key} "${v}" (expected empty). Refusing to sign. Request a new quote.`,
|
|
525
|
+
'UNEXPECTED_ACTION',
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Bind the relayer NonceMapping authorize payload (the FULL EIP-712 sign
|
|
532
|
+
// object — domain, types, primaryType, value — not just its value fields) to
|
|
533
|
+
// the user's intent before signing. See the constants above for why every
|
|
534
|
+
// part of the shape needs pinning: nothing about this payload is fixed
|
|
535
|
+
// client-side otherwise.
|
|
536
|
+
export function assertHlBridgeAuthorizeIntent(sign, signerAddress, context = 'Bridge authorize') {
|
|
537
|
+
if (!sign || typeof sign !== 'object') {
|
|
538
|
+
throw new CommandError(`${context}: no authorize payload to verify. Request a new quote.`, 'INVALID_INPUT');
|
|
539
|
+
}
|
|
540
|
+
if (sign.primaryType !== RELAY_AUTHORIZE_PRIMARY_TYPE) {
|
|
541
|
+
throw new CommandError(
|
|
542
|
+
`${context} has an unexpected EIP-712 type "${sign.primaryType}". `
|
|
543
|
+
+ `Refusing to sign. Update nansen-cli if Relay changed its authorize shape.`,
|
|
544
|
+
'UNEXPECTED_ACTION',
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
const domain = sign.domain || {};
|
|
548
|
+
const domainMatches = domain.name === RELAY_AUTHORIZE_DOMAIN.name
|
|
549
|
+
&& domain.version === RELAY_AUTHORIZE_DOMAIN.version
|
|
550
|
+
&& domain.chainId === RELAY_AUTHORIZE_DOMAIN.chainId
|
|
551
|
+
&& String(domain.verifyingContract ?? '').toLowerCase() === RELAY_AUTHORIZE_DOMAIN.verifyingContract.toLowerCase();
|
|
552
|
+
if (!domainMatches) {
|
|
553
|
+
throw new CommandError(
|
|
554
|
+
`${context} has an unexpected signing domain ${JSON.stringify(domain)}. `
|
|
555
|
+
+ `Refusing to sign. Update nansen-cli if Relay changed its authorize shape.`,
|
|
556
|
+
'UNEXPECTED_ACTION',
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
// Exact, ORDERED name+type comparison — field order affects the EIP-712
|
|
560
|
+
// struct hash, so a reordering is a different (if superficially similar)
|
|
561
|
+
// message, not a cosmetic difference.
|
|
562
|
+
const fields = sign.types?.[sign.primaryType] || [];
|
|
563
|
+
const fieldsMatch = fields.length === RELAY_AUTHORIZE_FIELDS.length
|
|
564
|
+
&& fields.every((f, i) => f?.name === RELAY_AUTHORIZE_FIELDS[i].name && f?.type === RELAY_AUTHORIZE_FIELDS[i].type);
|
|
565
|
+
if (!fieldsMatch) {
|
|
566
|
+
throw new CommandError(
|
|
567
|
+
`${context} has an unexpected field set for "${RELAY_AUTHORIZE_PRIMARY_TYPE}". `
|
|
568
|
+
+ `Refusing to sign. Update nansen-cli if Relay changed its authorize shape.`,
|
|
569
|
+
'UNEXPECTED_ACTION',
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
const value = sign.value || {};
|
|
573
|
+
if (value.chainId !== 'hyperliquid') {
|
|
574
|
+
throw new CommandError(
|
|
575
|
+
`${context} targets chain "${value.chainId}", expected "hyperliquid". Refusing to sign.`,
|
|
576
|
+
'UNEXPECTED_NETWORK',
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
for (const key of ['wallet', 'depositor']) {
|
|
580
|
+
const v = value[key];
|
|
581
|
+
if (!v || String(v).toLowerCase() !== String(signerAddress).toLowerCase()) {
|
|
582
|
+
throw new CommandError(
|
|
583
|
+
`${context} names ${key} ${v}, but the signing wallet is ${signerAddress}. `
|
|
584
|
+
+ `Refusing to sign. Request a new quote.`,
|
|
585
|
+
'SIGNER_MISMATCH',
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Pin the deposit leg's EIP-712 primaryType + ordered field list (see the
|
|
592
|
+
// HL_SENDASSET_* constants for why the pinned domain alone is not enough).
|
|
593
|
+
// Complements assertHlBridgeActionIntent: that validates the action object's
|
|
594
|
+
// VALUES; this validates the type list that decides which of those values the
|
|
595
|
+
// signature actually commits to. Subsumes the empty-type-list guard for this
|
|
596
|
+
// leg — an exact match is necessarily non-empty.
|
|
597
|
+
function assertHlSendAssetEip712Shape(eip712Types, eip712PrimaryType, context) {
|
|
598
|
+
if (eip712PrimaryType !== HL_SENDASSET_PRIMARY_TYPE) {
|
|
599
|
+
throw new CommandError(
|
|
600
|
+
`${context} has an unexpected EIP-712 type "${eip712PrimaryType}" for the deposit action. `
|
|
601
|
+
+ `Refusing to sign. Update nansen-cli if Hyperliquid changed its action shape.`,
|
|
602
|
+
'UNEXPECTED_ACTION',
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
const fields = eip712Types?.[eip712PrimaryType] || [];
|
|
606
|
+
const matches = fields.length === HL_SENDASSET_FIELDS.length
|
|
607
|
+
&& fields.every((f, i) => f?.name === HL_SENDASSET_FIELDS[i].name && f?.type === HL_SENDASSET_FIELDS[i].type);
|
|
608
|
+
if (!matches) {
|
|
609
|
+
throw new CommandError(
|
|
610
|
+
`${context} has an unexpected field set for "${HL_SENDASSET_PRIMARY_TYPE}". `
|
|
611
|
+
+ `Refusing to sign. Update nansen-cli if Hyperliquid changed its action shape.`,
|
|
612
|
+
'UNEXPECTED_ACTION',
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Resolve the relayer POST target. The target URL is always built from
|
|
618
|
+
// RELAY_AUTHORIZE_BASE_URL + RELAY_AUTHORIZE_ENDPOINT_PATH — never from the
|
|
619
|
+
// server-supplied `post.endpoint` — so a malicious response has nothing to
|
|
620
|
+
// redirect: it can, at most, cause this to refuse by not matching the one
|
|
621
|
+
// endpoint this bridge path is designed to POST to.
|
|
622
|
+
function resolveRelayTargetUrl(endpoint, context = 'Bridge step') {
|
|
623
|
+
if (endpoint !== RELAY_AUTHORIZE_ENDPOINT_PATH) {
|
|
624
|
+
throw new CommandError(
|
|
625
|
+
`${context} has an unexpected authorize endpoint "${endpoint}". Refusing to sign. Request a new quote.`,
|
|
626
|
+
'UNEXPECTED_ACTION',
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
return `${RELAY_AUTHORIZE_BASE_URL}${RELAY_AUTHORIZE_ENDPOINT_PATH}`;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Build the exact same action object that gets signed AND submitted for a HL
|
|
633
|
+
// action step (see processSignatureStepLocal for why: one object rules out
|
|
634
|
+
// signed-vs-submitted drift).
|
|
635
|
+
function buildHlBridgeAction(signData) {
|
|
636
|
+
return {
|
|
637
|
+
...(signData.action.parameters || signData.action),
|
|
638
|
+
type: signData.action.type,
|
|
639
|
+
signatureChainId: HL_SIGNATURE_CHAIN_ID,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Validate every signature step against user intent BEFORE any step is
|
|
644
|
+
// signed or posted. Steps run in server-supplied order — the captured shape
|
|
645
|
+
// is [authorize, sendAsset] — so without this preflight, an earlier step
|
|
646
|
+
// would already be signed and POSTed by the time a bad LATER step (either
|
|
647
|
+
// leg — a mistargeted authorize endpoint or an under-specified action) is
|
|
648
|
+
// reached and rejected. Covers every check that would otherwise gate signing
|
|
649
|
+
// at that step's own point of use — the intent binding, the authorize
|
|
650
|
+
// endpoint pin, and the EIP-712 type-list guard — so this is a strict
|
|
651
|
+
// superset of the per-step processors' checks, run up front across the whole
|
|
652
|
+
// plan first.
|
|
653
|
+
function preflightHlBridgeSteps(steps, intent) {
|
|
654
|
+
for (const step of steps) {
|
|
655
|
+
for (const item of step.items || []) {
|
|
656
|
+
// Skip already-signed-and-submitted items, matching the processors
|
|
657
|
+
// (:769/:838): on resume of a partially-executed bridge, re-validating a
|
|
658
|
+
// completed step against these now-stricter checks could wedge it.
|
|
659
|
+
if (item.status === 'complete') continue;
|
|
660
|
+
const signData = item.data;
|
|
661
|
+
if (!signData || typeof signData !== 'object') {
|
|
662
|
+
throw new CommandError(
|
|
663
|
+
`Bridge step "${step.id}" has a malformed item with no signable data. Request a new quote.`,
|
|
664
|
+
'INVALID_INPUT',
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
if (signData.sign) {
|
|
668
|
+
assertHlBridgeAuthorizeIntent(signData.sign, intent.signerAddress, `Bridge step "${step.id}"`);
|
|
669
|
+
resolveRelayTargetUrl(signData.post?.endpoint, `Bridge step "${step.id}"`);
|
|
670
|
+
assertEip712TypeListNonEmpty(signData.sign.types, signData.sign.primaryType, `Bridge step "${step.id}"`);
|
|
671
|
+
} else if (signData.action) {
|
|
672
|
+
assertHlBridgeActionIntent(buildHlBridgeAction(signData), intent, `Bridge step "${step.id}"`);
|
|
673
|
+
assertHlSendAssetEip712Shape(signData.eip712Types, signData.eip712PrimaryType, `Bridge step "${step.id}"`);
|
|
674
|
+
} else {
|
|
675
|
+
// A leg matching neither is never something to sign — the local
|
|
676
|
+
// processor would silently no-op it (reporting success having signed
|
|
677
|
+
// nothing) while Privy throws. Refuse consistently, up front.
|
|
678
|
+
throw new CommandError(
|
|
679
|
+
`Bridge step "${step.id}" has an unrecognized signature format. Request a new quote.`,
|
|
680
|
+
'INVALID_INPUT',
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
328
687
|
// ── Step processors ──────────────────────────────────────────────────
|
|
329
688
|
|
|
689
|
+
// ── EVM deposit-leg intent binding (Base → Hyperliquid) ──────────────
|
|
690
|
+
//
|
|
691
|
+
// The bridge EVM deposit path signs server-supplied transactions. Pin the
|
|
692
|
+
// only shape this path is designed to produce so a tampered response cannot
|
|
693
|
+
// swap in an arbitrary drain call or an unbounded approval.
|
|
694
|
+
//
|
|
695
|
+
// Captured from real read-only `bridge quote` responses (base → hyperliquid,
|
|
696
|
+
// USDC, 2026-08-31), confirmed stable across three quotes (amounts 2/3/2 USDC,
|
|
697
|
+
// one with a distinct --recipient). base → hyperliquid is the ONLY supported
|
|
698
|
+
// deposit route (BRIDGE_ROUTES), so this allowlist is complete. Re-capture and
|
|
699
|
+
// update if Relay changes its deposit contract.
|
|
700
|
+
const ERC20_APPROVE_SELECTOR = '0x095ea7b3';
|
|
701
|
+
|
|
702
|
+
// Relay deposit router for the Base → HL route. It is BOTH the approve spender
|
|
703
|
+
// and the deposit call target (confirmed identical across every capture), so
|
|
704
|
+
// one constant covers both. Lower-cased for comparison.
|
|
705
|
+
//
|
|
706
|
+
// Keyed by origin chain, in lockstep with the deposit rows of BRIDGE_ROUTES:
|
|
707
|
+
// widening the EVM deposit side (a new signable origin chain) MUST add that
|
|
708
|
+
// chain's router here too, or every deposit on the new route fails closed.
|
|
709
|
+
const BRIDGE_DEPOSIT_TARGETS = {
|
|
710
|
+
base: new Set(['0x4cd00e387622c35bddb9b4c962c136462338bc31']),
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
// The deposit call selector on that router. Its calldata is a fixed 4-arg ABI
|
|
714
|
+
// layout: deposit(address depositor, address token, uint256 amount, bytes32 id).
|
|
715
|
+
const BRIDGE_DEPOSIT_SELECTOR = '0xe8017952';
|
|
716
|
+
|
|
717
|
+
// True when calldata is an ERC-20 approve(spender, amount). 0x + 4-byte
|
|
718
|
+
// selector + two 32-byte words = 138 hex chars; anything else is not a
|
|
719
|
+
// well-formed approve and must not be treated as one.
|
|
720
|
+
function isErc20Approve(data) {
|
|
721
|
+
return typeof data === 'string'
|
|
722
|
+
&& data.length === 138
|
|
723
|
+
&& data.slice(0, 10).toLowerCase() === ERC20_APPROVE_SELECTOR;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Decode approve(spender, amount) from validated calldata. Only call after
|
|
727
|
+
// isErc20Approve() is true. Returns null if the amount word isn't valid hex —
|
|
728
|
+
// length/selector alone don't guarantee that, and BigInt throws a raw
|
|
729
|
+
// SyntaxError rather than failing closed with an actionable message.
|
|
730
|
+
function decodeErc20Approve(data) {
|
|
731
|
+
const spender = '0x' + data.slice(34, 74); // last 20 bytes of word 1
|
|
732
|
+
try {
|
|
733
|
+
const amount = BigInt('0x' + data.slice(74, 138)); // word 2
|
|
734
|
+
return { spender, amount };
|
|
735
|
+
} catch {
|
|
736
|
+
return null;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function selectorOf(data) {
|
|
741
|
+
return typeof data === 'string' ? data.slice(0, 10).toLowerCase() : '';
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Decode the Relay deposit call's fixed 4-arg layout:
|
|
745
|
+
// deposit(address depositor, address token, uint256 amount, bytes32 id)
|
|
746
|
+
// 0x + selector(8) + 4 words(4 * 64) = 266 hex chars. Only call after the
|
|
747
|
+
// selector matched BRIDGE_DEPOSIT_SELECTOR; a wrong length is itself a refusal.
|
|
748
|
+
function decodeBridgeDeposit(data) {
|
|
749
|
+
if (typeof data !== 'string' || data.length !== 266) return null;
|
|
750
|
+
const w = i => data.slice(10 + i * 64, 10 + (i + 1) * 64);
|
|
751
|
+
let amount;
|
|
752
|
+
try {
|
|
753
|
+
amount = BigInt('0x' + w(2));
|
|
754
|
+
} catch {
|
|
755
|
+
// Right length and selector, but the amount word isn't valid hex — off-shape,
|
|
756
|
+
// same as a length mismatch. Fail closed via the caller's null check rather
|
|
757
|
+
// than a raw SyntaxError.
|
|
758
|
+
return null;
|
|
759
|
+
}
|
|
760
|
+
return {
|
|
761
|
+
depositor: '0x' + w(0).slice(24), // last 20 bytes of word 0
|
|
762
|
+
token: '0x' + w(1).slice(24),
|
|
763
|
+
amount,
|
|
764
|
+
// w(3) is the opaque relay id — intentionally not returned / not bound.
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Bind a server-supplied EVM bridge transaction to the user's intent before
|
|
769
|
+
// signing. Static only, no RPC. Fails CLOSED — a missing anchor or an
|
|
770
|
+
// unrecognized target is itself the signal to refuse.
|
|
771
|
+
//
|
|
772
|
+
// intent.chain — origin chain ('base')
|
|
773
|
+
// intent.signerAddress — the wallet that will sign (arg0 must match)
|
|
774
|
+
// intent.requestedAmountBaseUnits — the amount the CLIENT persisted at quote
|
|
775
|
+
// time from the user's own --amount (USDC on
|
|
776
|
+
// Base is 6 decimals). The approval cap AND
|
|
777
|
+
// the deposit-amount cap.
|
|
778
|
+
//
|
|
779
|
+
// Both the approve and deposit branches cap against the same client-persisted
|
|
780
|
+
// anchor and refuse identically when it's missing; shared so the two copies
|
|
781
|
+
// can't drift (a prior version of each had its own wording).
|
|
782
|
+
function requireAmountAnchor(intent, context) {
|
|
783
|
+
if (intent.requestedAmountBaseUnits == null) {
|
|
784
|
+
throw new CommandError(
|
|
785
|
+
`${context}: no reviewed amount recorded to check the transaction against `
|
|
786
|
+
+ `(this quote may predate a nansen-cli update). Refusing to sign. Request a new quote.`,
|
|
787
|
+
'AMOUNT_MISMATCH',
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Returns { data } — for an approve step, `data` is RE-ENCODED via
|
|
793
|
+
// encodeApproveCalldata (rejects MAX_UINT256, caps to requestedAmountBaseUnits,
|
|
794
|
+
// re-validates the spender width). For a deposit step, `data` is returned
|
|
795
|
+
// unchanged after the to/selector allowlist AND the decoded-arg binding pass.
|
|
796
|
+
export function assertEvmBridgeStepIntent(txData, intent, context = 'Bridge EVM step') {
|
|
797
|
+
if (!txData || typeof txData !== 'object' || typeof txData.data !== 'string') {
|
|
798
|
+
throw new CommandError(`${context}: no transaction data to verify. Request a new quote.`, 'INVALID_INPUT');
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// The nonce/signing key are the local wallet's regardless of what `from` the
|
|
802
|
+
// server sent, so a mismatched `from` would price this step against the wrong
|
|
803
|
+
// account while still signing it as ours. Checked here — not just per-step at
|
|
804
|
+
// broadcast time — so preflightEvmBridgeSteps catches it on EVERY step before
|
|
805
|
+
// any of them sign or broadcast; otherwise an earlier legitimate step in the
|
|
806
|
+
// same plan (e.g. the approve) could already be on-chain by the time a later
|
|
807
|
+
// step's `from` mismatch is caught.
|
|
808
|
+
if (
|
|
809
|
+
intent.signerAddress
|
|
810
|
+
&& txData.from
|
|
811
|
+
&& String(txData.from).toLowerCase() !== String(intent.signerAddress).toLowerCase()
|
|
812
|
+
) {
|
|
813
|
+
throw new CommandError(
|
|
814
|
+
`${context} is addressed from ${txData.from}, but the signing wallet is ${intent.signerAddress}. `
|
|
815
|
+
+ `Refusing to sign. Request a new quote.`,
|
|
816
|
+
'SIGNER_MISMATCH',
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const to = String(txData.to || '').toLowerCase();
|
|
821
|
+
|
|
822
|
+
// A deposit carries no native value (confirmed value === 0 on every capture).
|
|
823
|
+
// A non-zero value on either leg is an ETH-drain vector — refuse it. (BigInt
|
|
824
|
+
// parses both '0' and '0x0'.) A value that isn't parseable at all is just as
|
|
825
|
+
// much a reason to refuse as a non-zero one.
|
|
826
|
+
if (txData.value != null) {
|
|
827
|
+
let value;
|
|
828
|
+
try {
|
|
829
|
+
value = BigInt(txData.value);
|
|
830
|
+
} catch {
|
|
831
|
+
throw new CommandError(
|
|
832
|
+
`${context} has a malformed native value ${txData.value}. Refusing to sign. Request a new quote.`,
|
|
833
|
+
'INVALID_INPUT',
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
if (value !== 0n) {
|
|
837
|
+
throw new CommandError(
|
|
838
|
+
`${context} carries a non-zero native value ${txData.value}; this bridge path never sends native ETH. `
|
|
839
|
+
+ `Refusing to sign. Request a new quote.`,
|
|
840
|
+
'UNEXPECTED_ACTION',
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// AC1: ERC-20 approve → re-scope through the hardened encoder.
|
|
846
|
+
if (isErc20Approve(txData.data)) {
|
|
847
|
+
requireAmountAnchor(intent, context);
|
|
848
|
+
// The approve call itself must target the origin chain's USDC contract —
|
|
849
|
+
// otherwise a spender/amount that both look legitimate could still grant
|
|
850
|
+
// the router an allowance over an unrelated token the wallet holds.
|
|
851
|
+
const usdc = BRIDGE_TOKENS[intent.chain]?.USDC;
|
|
852
|
+
if (!usdc || to !== usdc.toLowerCase()) {
|
|
853
|
+
throw new CommandError(
|
|
854
|
+
`${context} sends an approve to an unexpected contract ${txData.to}. Refusing to sign. Request a new quote.`,
|
|
855
|
+
'UNEXPECTED_ACTION',
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
const decoded = decodeErc20Approve(txData.data);
|
|
859
|
+
if (!decoded) {
|
|
860
|
+
throw new CommandError(
|
|
861
|
+
`${context} has a malformed approve calldata. Refusing to sign. Request a new quote.`,
|
|
862
|
+
'INVALID_INPUT',
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
const { spender, amount } = decoded;
|
|
866
|
+
// The approve target you grant an allowance to must be the known deposit
|
|
867
|
+
// router for this route — otherwise you are approving an attacker.
|
|
868
|
+
if (!BRIDGE_DEPOSIT_TARGETS[intent.chain]?.has(spender.toLowerCase())) {
|
|
869
|
+
throw new CommandError(
|
|
870
|
+
`${context} approves an unexpected spender ${spender}. Refusing to sign. Request a new quote.`,
|
|
871
|
+
'UNEXPECTED_ACTION',
|
|
872
|
+
);
|
|
873
|
+
}
|
|
874
|
+
// encodeApproveCalldata rejects >= MAX_UINT256 and amount > maxAllowance,
|
|
875
|
+
// and re-validates the 20-byte spender width. Cap to the requested input.
|
|
876
|
+
const scoped = encodeApproveCalldata(spender, amount, {
|
|
877
|
+
maxAllowance: BigInt(intent.requestedAmountBaseUnits),
|
|
878
|
+
});
|
|
879
|
+
return { data: scoped };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// AC2: deposit call → to + selector must both be on the route's allowlist.
|
|
883
|
+
if (!BRIDGE_DEPOSIT_TARGETS[intent.chain]?.has(to)) {
|
|
884
|
+
throw new CommandError(
|
|
885
|
+
`${context} targets an unexpected contract ${txData.to}. Refusing to sign. Request a new quote.`,
|
|
886
|
+
'UNEXPECTED_ACTION',
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
if (selectorOf(txData.data) !== BRIDGE_DEPOSIT_SELECTOR) {
|
|
890
|
+
throw new CommandError(
|
|
891
|
+
`${context} calls an unexpected method ${selectorOf(txData.data)} on ${txData.to}. `
|
|
892
|
+
+ `Refusing to sign. Request a new quote.`,
|
|
893
|
+
'UNEXPECTED_ACTION',
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// AC3: bind the decodable deposit args. The layout is fixed (see
|
|
898
|
+
// decodeBridgeDeposit); a call that doesn't decode is off-shape → refuse.
|
|
899
|
+
const dep = decodeBridgeDeposit(txData.data);
|
|
900
|
+
if (!dep) {
|
|
901
|
+
throw new CommandError(
|
|
902
|
+
`${context} has a malformed deposit calldata. Refusing to sign. Request a new quote.`,
|
|
903
|
+
'INVALID_INPUT',
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
// arg0 (depositor) is the on-chain credit/refund address; in every capture it
|
|
907
|
+
// is the signer, even when --recipient differed. Binding it to the signer
|
|
908
|
+
// stops a tampered response from redirecting the credited deposit while the
|
|
909
|
+
// scoped approval still lets the router pull the funds.
|
|
910
|
+
if (String(dep.depositor).toLowerCase() !== String(intent.signerAddress).toLowerCase()) {
|
|
911
|
+
throw new CommandError(
|
|
912
|
+
`${context} deposits on behalf of ${dep.depositor}, but the signing wallet is ${intent.signerAddress}. `
|
|
913
|
+
+ `Refusing to sign. Request a new quote.`,
|
|
914
|
+
'SIGNER_MISMATCH',
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
// arg1 (token) must be the origin chain's USDC — the only token this path bridges.
|
|
918
|
+
const usdc = BRIDGE_TOKENS[intent.chain]?.USDC;
|
|
919
|
+
if (!usdc || String(dep.token).toLowerCase() !== usdc.toLowerCase()) {
|
|
920
|
+
throw new CommandError(
|
|
921
|
+
`${context} deposits an unexpected token ${dep.token}. Refusing to sign. Request a new quote.`,
|
|
922
|
+
'UNEXPECTED_ACTION',
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
// arg2 (amount) must not exceed what the user requested (defense in depth —
|
|
926
|
+
// the scoped approval already bounds the pull; captures show exact equality).
|
|
927
|
+
requireAmountAnchor(intent, context);
|
|
928
|
+
if (dep.amount > BigInt(intent.requestedAmountBaseUnits)) {
|
|
929
|
+
throw new CommandError(
|
|
930
|
+
`${context} would deposit ${dep.amount}, more than the ${intent.requestedAmountBaseUnits} base units you requested. `
|
|
931
|
+
+ `Refusing to sign. Request a new quote.`,
|
|
932
|
+
'AMOUNT_MISMATCH',
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
// arg3 (relay id) is an opaque off-chain handle and the --recipient never
|
|
936
|
+
// appears on-chain — both are the accepted relayer-trust residual, bounded by
|
|
937
|
+
// the checks above.
|
|
938
|
+
|
|
939
|
+
return { data: txData.data };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Validate every EVM step's calldata against intent BEFORE any step is signed
|
|
943
|
+
// or broadcast. Without this, a good approve step would already be on-chain by
|
|
944
|
+
// the time a poisoned deposit step is reached and refused.
|
|
945
|
+
//
|
|
946
|
+
// Also bounds the PLAN, not just each item: a legitimate Base → HL deposit is
|
|
947
|
+
// [approve?, deposit] — at most one approve, and EXACTLY one deposit. The
|
|
948
|
+
// per-item amount cap alone does not stop two kinds of tampered plan:
|
|
949
|
+
// - repeated [approve(requested), deposit(requested)] pairs — each pair
|
|
950
|
+
// passes every per-item check, but ERC-20 approve OVERWRITES the
|
|
951
|
+
// allowance, so N pairs pull N × the reviewed amount and drain the whole
|
|
952
|
+
// balance despite the scoped approval;
|
|
953
|
+
// - an approve with NO deposit at all — the CLI would sign and broadcast a
|
|
954
|
+
// live router allowance with no reviewed transaction ever pulling it, and
|
|
955
|
+
// the compromised API/Relay this defends against should not be able to
|
|
956
|
+
// make the CLI emit a standalone approval.
|
|
957
|
+
// Requiring exactly one deposit (approve optional, at most one) keeps the loss
|
|
958
|
+
// bounded to the requested amount and rules out an allowance with nothing
|
|
959
|
+
// behind it.
|
|
960
|
+
export function preflightEvmBridgeSteps(steps, intent) {
|
|
961
|
+
let approveCount = 0;
|
|
962
|
+
let depositCount = 0;
|
|
963
|
+
for (const step of steps) {
|
|
964
|
+
for (const item of step.items || []) {
|
|
965
|
+
if (item.status === 'complete') continue; // don't re-check / re-count resumed steps
|
|
966
|
+
assertEvmBridgeStepIntent(item.data, intent, `Bridge step "${step.id}"`);
|
|
967
|
+
// assertEvmBridgeStepIntent above already proved each item is exactly one
|
|
968
|
+
// of these two shapes, so this classification is total.
|
|
969
|
+
if (isErc20Approve(item.data.data)) approveCount++;
|
|
970
|
+
else depositCount++;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (approveCount > 1 || depositCount !== 1) {
|
|
974
|
+
throw new CommandError(
|
|
975
|
+
`Bridge plan has ${approveCount} approve and ${depositCount} deposit transaction(s); a legitimate deposit is at most one approve and exactly one deposit. `
|
|
976
|
+
+ `Refusing to sign — an approve with no deposit would leave a live allowance behind with nothing pulling it, and repeated legs could move more than the amount you requested. Request a new quote.`,
|
|
977
|
+
'UNEXPECTED_ACTION',
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
330
982
|
// Headroom multiplier applied to the current base fee when setting maxFeePerGas.
|
|
331
983
|
// A type-2 transaction only ever pays baseFee + priority, so a generous cap
|
|
332
984
|
// costs nothing extra — it just buys tolerance for the base fee moving between
|
|
@@ -425,25 +1077,20 @@ export async function resolveEvmStepFees(chain, txData, overrides = {}) {
|
|
|
425
1077
|
return { gasPrice: await evmRpcCall(chain, 'eth_gasPrice') };
|
|
426
1078
|
}
|
|
427
1079
|
|
|
428
|
-
async function processEvmStep(step, { chain, privateKeyHex, signerAddress, log, onBroadcast, feeOverrides, nonceSequence }) {
|
|
1080
|
+
async function processEvmStep(step, { chain, privateKeyHex, signerAddress, log, onBroadcast, feeOverrides, nonceSequence, intent }) {
|
|
429
1081
|
for (const item of step.items || []) {
|
|
430
1082
|
if (item.status === 'complete') continue;
|
|
431
1083
|
const txData = item.data;
|
|
432
1084
|
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
}
|
|
1085
|
+
// Bind the server-supplied calldata to intent before signing: re-scope
|
|
1086
|
+
// approvals through the hardened encoder, pin the deposit to/selector, and
|
|
1087
|
+
// check `from` against the signer (the nonce is fetched for the signer, but
|
|
1088
|
+
// the transaction is signed with our key regardless of `from`, so a
|
|
1089
|
+
// mismatch would price the nonce against the wrong account and sign
|
|
1090
|
+
// anyway). preflightEvmBridgeSteps already ran this same check on every
|
|
1091
|
+
// step up front; this call is what makes it a fail-closed invariant rather
|
|
1092
|
+
// than trusting the preflight pass.
|
|
1093
|
+
const bound = assertEvmBridgeStepIntent(txData, intent, `Bridge step "${step.id}"`);
|
|
447
1094
|
|
|
448
1095
|
const fees = await resolveEvmStepFees(chain, txData, feeOverrides);
|
|
449
1096
|
// getEvmNonce returns a decimal number and reconciles pending against the
|
|
@@ -460,7 +1107,7 @@ async function processEvmStep(step, { chain, privateKeyHex, signerAddress, log,
|
|
|
460
1107
|
if (nonceSequence) log(` Nonce: ${nonce} (from --nonce)`);
|
|
461
1108
|
|
|
462
1109
|
const signedTx = signEvmTransaction(
|
|
463
|
-
{ ...txData, ...fees },
|
|
1110
|
+
{ ...txData, ...fees, data: bound.data },
|
|
464
1111
|
privateKeyHex,
|
|
465
1112
|
chain,
|
|
466
1113
|
nonce,
|
|
@@ -482,12 +1129,14 @@ async function processEvmStep(step, { chain, privateKeyHex, signerAddress, log,
|
|
|
482
1129
|
}
|
|
483
1130
|
}
|
|
484
1131
|
|
|
485
|
-
async function processSignatureStepLocal(step, { privateKeyHex, log, apiInstance, onBroadcast }) {
|
|
1132
|
+
async function processSignatureStepLocal(step, { privateKeyHex, log, apiInstance, onBroadcast, intent }) {
|
|
486
1133
|
for (const item of step.items || []) {
|
|
487
1134
|
if (item.status === 'complete') continue;
|
|
488
1135
|
const { data: signData } = item;
|
|
489
1136
|
|
|
490
1137
|
if (signData.sign) {
|
|
1138
|
+
assertHlBridgeAuthorizeIntent(signData.sign, intent.signerAddress, `Bridge step "${step.id}"`);
|
|
1139
|
+
let targetUrl = resolveRelayTargetUrl(signData.post?.endpoint, `Bridge step "${step.id}"`);
|
|
491
1140
|
const typedData = {
|
|
492
1141
|
domain: signData.sign.domain,
|
|
493
1142
|
types: signData.sign.types,
|
|
@@ -496,13 +1145,12 @@ async function processSignatureStepLocal(step, { privateKeyHex, log, apiInstance
|
|
|
496
1145
|
};
|
|
497
1146
|
const signature = signEip712Local(typedData, privateKeyHex, `Bridge step "${step.id}"`);
|
|
498
1147
|
|
|
499
|
-
let targetUrl = signData.post.endpoint;
|
|
500
|
-
if (!targetUrl.startsWith('http')) {
|
|
501
|
-
targetUrl = `https://api.relay.link${targetUrl}`;
|
|
502
|
-
}
|
|
503
1148
|
const postBody = { ...signData.post.body };
|
|
504
1149
|
|
|
505
|
-
|
|
1150
|
+
// Coupled to the same constant resolveRelayTargetUrl validates against
|
|
1151
|
+
// (not a hardcoded substring), so the two can't silently drift apart if
|
|
1152
|
+
// that constant ever changes.
|
|
1153
|
+
if (targetUrl.endsWith(RELAY_AUTHORIZE_ENDPOINT_PATH)) {
|
|
506
1154
|
const sep = targetUrl.includes('?') ? '&' : '?';
|
|
507
1155
|
targetUrl = `${targetUrl}${sep}signature=${signature}`;
|
|
508
1156
|
} else {
|
|
@@ -520,17 +1168,20 @@ async function processSignatureStepLocal(step, { privateKeyHex, log, apiInstance
|
|
|
520
1168
|
chainId: parseInt(HL_SIGNATURE_CHAIN_ID, 16),
|
|
521
1169
|
verifyingContract: '0x0000000000000000000000000000000000000000',
|
|
522
1170
|
};
|
|
523
|
-
|
|
524
|
-
|
|
1171
|
+
// Pin the type list, not just the domain: the EIP-712 digest covers only
|
|
1172
|
+
// the fields named in types[primaryType], and both come off the wire — see
|
|
1173
|
+
// assertHlSendAssetEip712Shape. Without this, an action that passes every
|
|
1174
|
+
// value check below could still be signed under a different (e.g. agent-
|
|
1175
|
+
// approval) shape the amount cap can't bound.
|
|
1176
|
+
assertHlSendAssetEip712Shape(signData.eip712Types, signData.eip712PrimaryType, `Bridge step "${step.id}"`);
|
|
1177
|
+
const types = signData.eip712Types;
|
|
1178
|
+
const primaryType = signData.eip712PrimaryType;
|
|
525
1179
|
// Sign and submit the SAME action object (matching the perp path in
|
|
526
1180
|
// perp.js/hl-action.js). The extra `type`/`signatureChainId` keys are not in
|
|
527
1181
|
// the EIP-712 type list so they don't affect the hash, but building one
|
|
528
1182
|
// object rules out any signed-vs-submitted drift.
|
|
529
|
-
const action =
|
|
530
|
-
|
|
531
|
-
type: signData.action.type,
|
|
532
|
-
signatureChainId: HL_SIGNATURE_CHAIN_ID,
|
|
533
|
-
};
|
|
1183
|
+
const action = buildHlBridgeAction(signData);
|
|
1184
|
+
assertHlBridgeActionIntent(action, intent, `Bridge step "${step.id}"`);
|
|
534
1185
|
|
|
535
1186
|
const typedData = { domain, types, primaryType, message: action };
|
|
536
1187
|
const signature = signEip712Local(typedData, privateKeyHex, `Bridge step "${step.id}"`);
|
|
@@ -549,11 +1200,16 @@ async function processSignatureStepLocal(step, { privateKeyHex, log, apiInstance
|
|
|
549
1200
|
assertHyperliquidStepAccepted(result, step.id);
|
|
550
1201
|
onBroadcast?.(step.id, null);
|
|
551
1202
|
log(` Submitted to api.hyperliquid.xyz`);
|
|
1203
|
+
} else {
|
|
1204
|
+
// Neither leg: never sign or submit and silently report success (Privy
|
|
1205
|
+
// already throws on this shape). preflightHlBridgeSteps catches it first
|
|
1206
|
+
// in practice; this keeps the two signer paths consistent regardless.
|
|
1207
|
+
throw new CommandError(`Unexpected signature step format for ${step.id}`, 'INVALID_INPUT');
|
|
552
1208
|
}
|
|
553
1209
|
}
|
|
554
1210
|
}
|
|
555
1211
|
|
|
556
|
-
async function processSignatureStepPrivy(step, { privyClient, walletId, log, apiInstance, onBroadcast }) {
|
|
1212
|
+
async function processSignatureStepPrivy(step, { privyClient, walletId, log, apiInstance, onBroadcast, intent }) {
|
|
557
1213
|
for (const item of step.items || []) {
|
|
558
1214
|
if (item.status === 'complete') continue;
|
|
559
1215
|
const { data: signData } = item;
|
|
@@ -562,7 +1218,12 @@ async function processSignatureStepPrivy(step, { privyClient, walletId, log, api
|
|
|
562
1218
|
// For the HL action leg, the exact object that is signed is also the object
|
|
563
1219
|
// submitted (see the local path for why); hold onto it for the submit below.
|
|
564
1220
|
let hlAction = null;
|
|
1221
|
+
// For the authorize leg, resolved up front (before signing) so a bad
|
|
1222
|
+
// target refuses without ever calling out to Privy.
|
|
1223
|
+
let targetUrl = null;
|
|
565
1224
|
if (signData.sign) {
|
|
1225
|
+
assertHlBridgeAuthorizeIntent(signData.sign, intent.signerAddress, `Bridge step "${step.id}"`);
|
|
1226
|
+
targetUrl = resolveRelayTargetUrl(signData.post?.endpoint, `Bridge step "${step.id}"`);
|
|
566
1227
|
typedData = {
|
|
567
1228
|
domain: signData.sign.domain,
|
|
568
1229
|
types: signData.sign.types,
|
|
@@ -570,11 +1231,11 @@ async function processSignatureStepPrivy(step, { privyClient, walletId, log, api
|
|
|
570
1231
|
message: signData.sign.value,
|
|
571
1232
|
};
|
|
572
1233
|
} else if (signData.action) {
|
|
573
|
-
hlAction =
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
};
|
|
1234
|
+
hlAction = buildHlBridgeAction(signData);
|
|
1235
|
+
assertHlBridgeActionIntent(hlAction, intent, `Bridge step "${step.id}"`);
|
|
1236
|
+
// Pin the type list, not just the domain — see the local path and
|
|
1237
|
+
// assertHlSendAssetEip712Shape for why the domain alone binds nothing.
|
|
1238
|
+
assertHlSendAssetEip712Shape(signData.eip712Types, signData.eip712PrimaryType, `Bridge step "${step.id}"`);
|
|
578
1239
|
typedData = {
|
|
579
1240
|
domain: {
|
|
580
1241
|
name: 'HyperliquidSignTransaction',
|
|
@@ -582,32 +1243,28 @@ async function processSignatureStepPrivy(step, { privyClient, walletId, log, api
|
|
|
582
1243
|
chainId: parseInt(HL_SIGNATURE_CHAIN_ID, 16),
|
|
583
1244
|
verifyingContract: '0x0000000000000000000000000000000000000000',
|
|
584
1245
|
},
|
|
585
|
-
types: signData.eip712Types
|
|
586
|
-
primaryType: signData.eip712PrimaryType
|
|
1246
|
+
types: signData.eip712Types,
|
|
1247
|
+
primaryType: signData.eip712PrimaryType,
|
|
587
1248
|
message: hlAction,
|
|
588
1249
|
};
|
|
589
1250
|
} else {
|
|
590
1251
|
throw new Error(`Unexpected signature step format for ${step.id}`);
|
|
591
1252
|
}
|
|
592
1253
|
|
|
593
|
-
// Same guard the local path gets in signEip712Local
|
|
594
|
-
// the
|
|
595
|
-
|
|
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
|
-
}
|
|
1254
|
+
// Same guard the local path gets in signEip712Local. Refuse rather than
|
|
1255
|
+
// delegate the check to Privy.
|
|
1256
|
+
assertEip712TypeListNonEmpty(typedData.types, typedData.primaryType, `Bridge step "${step.id}"`);
|
|
601
1257
|
|
|
602
1258
|
log(` Signing ${step.id} via Privy...`);
|
|
603
1259
|
const result = await privyClient.ethSignTypedDataV4(walletId, typedData);
|
|
604
1260
|
const signature = result.data?.signature || result.signature || result;
|
|
605
1261
|
|
|
606
1262
|
if (signData.sign) {
|
|
607
|
-
let targetUrl = signData.post.endpoint;
|
|
608
|
-
if (!targetUrl.startsWith('http')) targetUrl = `https://api.relay.link${targetUrl}`;
|
|
609
1263
|
const postBody = { ...signData.post.body };
|
|
610
|
-
|
|
1264
|
+
// Coupled to the same constant resolveRelayTargetUrl validates against
|
|
1265
|
+
// (not a hardcoded substring), so the two can't silently drift apart if
|
|
1266
|
+
// that constant ever changes.
|
|
1267
|
+
if (targetUrl.endsWith(RELAY_AUTHORIZE_ENDPOINT_PATH)) {
|
|
611
1268
|
const sep = targetUrl.includes('?') ? '&' : '?';
|
|
612
1269
|
targetUrl = `${targetUrl}${sep}signature=${signature}`;
|
|
613
1270
|
} else {
|
|
@@ -808,6 +1465,16 @@ OPTIONS:
|
|
|
808
1465
|
// Default: --amount is base units. With --amount-unit, accept a human token
|
|
809
1466
|
// or USD amount and convert client-side using the source token's decimals.
|
|
810
1467
|
let resolvedAmount = amountInput;
|
|
1468
|
+
// HL-USDC flooring drops sub-6dp digits so the persisted amount matches the
|
|
1469
|
+
// 6-decimal precision the bridge actually signs (see
|
|
1470
|
+
// floorHyperliquidUsdcBridgeAmount). The adjustment is tiny (< 1e-6 USDC)
|
|
1471
|
+
// but it changes what gets signed, so announce it rather than adjusting
|
|
1472
|
+
// silently. No-op notice when nothing was dropped.
|
|
1473
|
+
const notifyIfFloored = (before, after) => {
|
|
1474
|
+
if (after !== before) {
|
|
1475
|
+
log(` Note: amount floored ${before} → ${after} base units to match the 6-decimal USDC precision the bridge signs.`);
|
|
1476
|
+
}
|
|
1477
|
+
};
|
|
811
1478
|
if (amountUnit === 'token' || amountUnit === 'usd') {
|
|
812
1479
|
try {
|
|
813
1480
|
const decimals = await resolveBridgeTokenDecimals(originToken, originChain);
|
|
@@ -823,10 +1490,27 @@ OPTIONS:
|
|
|
823
1490
|
humanAmount = (parseFloat(amountInput) / price).toFixed(decimals);
|
|
824
1491
|
}
|
|
825
1492
|
resolvedAmount = convertToBaseUnits(humanAmount, decimals);
|
|
1493
|
+
const beforeFloor = resolvedAmount;
|
|
826
1494
|
resolvedAmount = floorHyperliquidUsdcBridgeAmount(resolvedAmount, decimals, originToken, originChain);
|
|
1495
|
+
notifyIfFloored(beforeFloor, resolvedAmount);
|
|
827
1496
|
} catch (err) {
|
|
828
1497
|
throw new Error(`Error converting --amount: ${err.message}`, { cause: err });
|
|
829
1498
|
}
|
|
1499
|
+
} else if (/^\d+$/.test(String(resolvedAmount))) {
|
|
1500
|
+
// Default (base-units) path: the user passes 8-dp HL-USDC base units, but
|
|
1501
|
+
// Relay formats the sendAsset amount to USDC's 6 dp (see
|
|
1502
|
+
// floorHyperliquidUsdcBridgeAmount). The pre-signing amount checks at
|
|
1503
|
+
// execute time compare the server's 6-dp amount against this persisted
|
|
1504
|
+
// value, so an unfloored request whose last two base-unit digits are
|
|
1505
|
+
// non-zero would be rejected as a mismatch on a withdrawal the user
|
|
1506
|
+
// legitimately asked for. Floor here too, exactly as the --amount-unit
|
|
1507
|
+
// branch does, so the two representations line up. No-op for non-HL-USDC
|
|
1508
|
+
// origins (floorHyperliquidUsdcBridgeAmount only acts on HL USDC), where
|
|
1509
|
+
// HL USDC's 8 decimals are the only case; the guard skips non-integer
|
|
1510
|
+
// input so a malformed base-units amount still surfaces the API's error.
|
|
1511
|
+
const beforeFloor = resolvedAmount;
|
|
1512
|
+
resolvedAmount = floorHyperliquidUsdcBridgeAmount(resolvedAmount, 8, originToken, originChain);
|
|
1513
|
+
notifyIfFloored(beforeFloor, resolvedAmount);
|
|
830
1514
|
}
|
|
831
1515
|
|
|
832
1516
|
log(`\n Fetching bridge quote: ${originChain} → ${destinationChain}...`);
|
|
@@ -867,6 +1551,7 @@ OPTIONS:
|
|
|
867
1551
|
wallet.provider,
|
|
868
1552
|
wallet.address,
|
|
869
1553
|
recipient,
|
|
1554
|
+
resolvedAmount,
|
|
870
1555
|
);
|
|
871
1556
|
log(`\n Quote ID: ${quoteId}`);
|
|
872
1557
|
log(` Execute: nansen bridge execute --quote ${quoteId}`);
|
|
@@ -1003,6 +1688,15 @@ from a quote are the same ones that got stuck. Check the stuck nonce with
|
|
|
1003
1688
|
});
|
|
1004
1689
|
|
|
1005
1690
|
if (execution_type === 'evm_transaction') {
|
|
1691
|
+
const evmIntent = {
|
|
1692
|
+
chain: quoteData.originChain,
|
|
1693
|
+
signerAddress: signer.address,
|
|
1694
|
+
requestedAmountBaseUnits: quoteData.requestedAmountBaseUnits ?? null,
|
|
1695
|
+
};
|
|
1696
|
+
// Validate every step's calldata against intent before any step is
|
|
1697
|
+
// signed or broadcast — see preflightEvmBridgeSteps for why this can't
|
|
1698
|
+
// just be the per-step check processEvmStep already does.
|
|
1699
|
+
preflightEvmBridgeSteps(steps, evmIntent);
|
|
1006
1700
|
// Overrides move real money differently from what was quoted, so say so
|
|
1007
1701
|
// rather than letting them apply silently.
|
|
1008
1702
|
if (feeOverrides.priorityFeeWei || feeOverrides.maxFeeWei || nonceSequence) {
|
|
@@ -1021,10 +1715,50 @@ from a quote are the same ones that got stuck. Check the stuck nonce with
|
|
|
1021
1715
|
onBroadcast,
|
|
1022
1716
|
feeOverrides,
|
|
1023
1717
|
nonceSequence,
|
|
1718
|
+
intent: evmIntent,
|
|
1024
1719
|
});
|
|
1025
1720
|
markBroadcast(index);
|
|
1026
1721
|
}
|
|
1027
1722
|
} else if (execution_type === 'hyperliquid_signature') {
|
|
1723
|
+
// Check E: the quote's own currencyIn.amount — the amount it displayed
|
|
1724
|
+
// and will send through /perp/bridge/quote — must equal what was
|
|
1725
|
+
// actually requested at quote time. The amount cap below (check B) is
|
|
1726
|
+
// anchored to requestedAmountBaseUnits directly, not to this display
|
|
1727
|
+
// field, so this check is UI-consistency defense-in-depth: it catches a
|
|
1728
|
+
// quote whose displayed send amount has drifted from the request,
|
|
1729
|
+
// rather than gating the cap itself.
|
|
1730
|
+
const currencyIn = quoteData.response.details?.currencyIn;
|
|
1731
|
+
if (quoteData.requestedAmountBaseUnits != null && currencyIn?.amount != null) {
|
|
1732
|
+
let currencyInScaled, requestedScaled;
|
|
1733
|
+
try {
|
|
1734
|
+
currencyInScaled = BigInt(currencyIn.amount);
|
|
1735
|
+
requestedScaled = BigInt(quoteData.requestedAmountBaseUnits);
|
|
1736
|
+
} catch {
|
|
1737
|
+
throw new CommandError(
|
|
1738
|
+
`Quote input "${currencyIn.amount}" is not a valid amount. Request a new quote.`,
|
|
1739
|
+
'AMOUNT_MISMATCH',
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
if (currencyInScaled !== requestedScaled) {
|
|
1743
|
+
throw new CommandError(
|
|
1744
|
+
`Quote input ${currencyIn.amount} does not match the requested ${quoteData.requestedAmountBaseUnits}. Request a new quote.`,
|
|
1745
|
+
'AMOUNT_MISMATCH',
|
|
1746
|
+
);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
const hlIntent = {
|
|
1750
|
+
// Anchored to what the CLIENT persisted at quote time from the
|
|
1751
|
+
// user's own --amount, not to any server-supplied display field —
|
|
1752
|
+
// see assertHlBridgeActionIntent for why.
|
|
1753
|
+
reviewedAmountBaseUnits: quoteData.requestedAmountBaseUnits ?? null,
|
|
1754
|
+
hlNetwork: 'Mainnet',
|
|
1755
|
+
signerAddress: signer.address,
|
|
1756
|
+
};
|
|
1757
|
+
// Validate every step's payload (both the authorize leg and the HL
|
|
1758
|
+
// action leg) before any of them are signed or posted — see
|
|
1759
|
+
// preflightHlBridgeSteps for why this can't just be the per-step
|
|
1760
|
+
// check the loops below already do.
|
|
1761
|
+
preflightHlBridgeSteps(steps, hlIntent);
|
|
1028
1762
|
if (creds.provider === 'privy') {
|
|
1029
1763
|
const { PrivyClient } = await import('./privy.js');
|
|
1030
1764
|
const privyClient = new PrivyClient(process.env.PRIVY_APP_ID, process.env.PRIVY_APP_SECRET);
|
|
@@ -1037,6 +1771,7 @@ from a quote are the same ones that got stuck. Check the stuck nonce with
|
|
|
1037
1771
|
log,
|
|
1038
1772
|
apiInstance,
|
|
1039
1773
|
onBroadcast,
|
|
1774
|
+
intent: hlIntent,
|
|
1040
1775
|
});
|
|
1041
1776
|
markBroadcast(index);
|
|
1042
1777
|
}
|
|
@@ -1047,6 +1782,7 @@ from a quote are the same ones that got stuck. Check the stuck nonce with
|
|
|
1047
1782
|
log,
|
|
1048
1783
|
apiInstance,
|
|
1049
1784
|
onBroadcast,
|
|
1785
|
+
intent: hlIntent,
|
|
1050
1786
|
});
|
|
1051
1787
|
markBroadcast(index);
|
|
1052
1788
|
}
|