nansen-cli 1.41.1 → 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/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
- 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(
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
- // 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
- }
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
- if (targetUrl.includes('/authorize')) {
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
- const types = signData.eip712Types || {};
524
- const primaryType = signData.eip712PrimaryType || 'HyperliquidTransaction';
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
- ...(signData.action.parameters || signData.action),
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
- ...(signData.action.parameters || signData.action),
575
- type: signData.action.type,
576
- signatureChainId: HL_SIGNATURE_CHAIN_ID,
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 || 'HyperliquidTransaction',
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: 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
- }
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
- if (targetUrl.includes('/authorize')) {
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
  }