nansen-cli 1.36.2 → 1.38.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.
@@ -396,3 +396,444 @@ export async function fetchTokenBalance(chain, tokenAddress, walletAddress, deci
396
396
  return null;
397
397
  }
398
398
  }
399
+
400
+ // ============= ERC-20 approval calldata (hardened) =============
401
+
402
+ // uint256 ceiling. An allowance must be strictly below this: MAX_UINT256 itself
403
+ // is the "unlimited" sentinel we refuse to sign, and anything larger cannot
404
+ // encode in a 32-byte ABI word without overflowing into adjacent calldata.
405
+ export const MAX_UINT256 = (1n << 256n) - 1n;
406
+
407
+ // ERC-20 approve(address spender, uint256 amount) selector.
408
+ const APPROVE_SELECTOR = '0x095ea7b3';
409
+
410
+ /**
411
+ * Validate that an approval spender is a well-formed, non-zero 20-byte EVM
412
+ * address. A quote supplies this verbatim and it is concatenated into approval
413
+ * calldata; anything other than `0x` + exactly 40 hex chars must be rejected
414
+ * before encoding, because an over-length value would silently shift the ABI
415
+ * word layout (turning a scoped approval into `approve(attacker, huge)`).
416
+ *
417
+ * @param {string} spender
418
+ */
419
+ export function assertValidApprovalSpender(spender) {
420
+ if (!spender || /^0x0+$/i.test(spender)) {
421
+ throw new Error(
422
+ `Approval spender is empty or the zero address (${spender ?? 'undefined'}). Refusing to sign an approval.`,
423
+ );
424
+ }
425
+ if (typeof spender !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(spender)) {
426
+ throw new Error(
427
+ `Approval spender is not a valid 20-byte address (${spender}). Refusing to sign an approval.`,
428
+ );
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Encode `approve(spender, amount)` calldata with strict, defense-in-depth
434
+ * bounds. This is the single encoder every signing path (local, Privy,
435
+ * WalletConnect) must use, so no boundary can construct an under-validated
436
+ * approval.
437
+ *
438
+ * Guarantees on the returned string:
439
+ * - spender is a valid 20-byte address (see assertValidApprovalSpender)
440
+ * - amount is a positive integer strictly below MAX_UINT256 (never unlimited)
441
+ * - amount does not exceed `maxAllowance` when the caller supplies one
442
+ * (the user's persisted request intent — see assertQuoteMatchesRequest)
443
+ * - the encoded calldata is exactly 68 bytes (4-byte selector + two 32-byte
444
+ * words), asserted after encoding so any width surprise fails closed
445
+ *
446
+ * @param {string} spender - Approval target (quote.approvalAddress)
447
+ * @param {bigint|string|number} amount - Allowance in base units
448
+ * @param {object} [opts]
449
+ * @param {bigint|string|number} [opts.maxAllowance] - Hard cap from request intent
450
+ * @returns {string} 0x-prefixed approve() calldata (exactly 68 bytes)
451
+ */
452
+ export function encodeApproveCalldata(spender, amount, { maxAllowance } = {}) {
453
+ assertValidApprovalSpender(spender);
454
+
455
+ let amt;
456
+ try {
457
+ amt = BigInt(amount);
458
+ } catch {
459
+ throw new Error(`Approval amount is not an integer (${amount}). Refusing to sign an approval.`);
460
+ }
461
+ if (amt <= 0n) {
462
+ throw new Error(`Approval amount must be positive (got ${amt}). Refusing to sign an approval.`);
463
+ }
464
+ if (amt >= MAX_UINT256) {
465
+ throw new Error(
466
+ `Approval amount ${amt} is at or above MAX_UINT256 (unlimited). Refusing to sign an unlimited approval.`,
467
+ );
468
+ }
469
+ if (maxAllowance != null) {
470
+ const cap = BigInt(maxAllowance);
471
+ if (amt > cap) {
472
+ throw new Error(
473
+ `Approval amount ${amt} exceeds the request's maximum input ${cap}. Refusing to sign.`,
474
+ );
475
+ }
476
+ }
477
+
478
+ const data = APPROVE_SELECTOR
479
+ + spender.slice(2).toLowerCase().padStart(64, '0')
480
+ + amt.toString(16).padStart(64, '0');
481
+
482
+ // 0x + 4-byte selector (8 hex) + two 32-byte words (128 hex) = 138 chars.
483
+ const EXPECTED_LEN = 2 + 8 + 64 + 64;
484
+ if (data.length !== EXPECTED_LEN) {
485
+ throw new Error(
486
+ `Encoded approval calldata is not 68 bytes (got ${(data.length - 2) / 2}). Refusing to sign.`,
487
+ );
488
+ }
489
+ return data;
490
+ }
491
+
492
+ /**
493
+ * Compute the ERC-20 allowance to grant for a swap — equivalently, the maximum
494
+ * number of sell-token base units that can leave the wallet for this trade.
495
+ *
496
+ * Scoping the approval to the trade amount (instead of an unlimited MAX approval)
497
+ * means a malicious or buggy quote can consume at most this one swap's input,
498
+ * never the wallet's full token balance. exactIn pulls exactly the input amount;
499
+ * exactOut can pull up to a slippage-bounded maximum, so that mode is buffered.
500
+ *
501
+ * This is the single definition of "what can leave the wallet": the approval
502
+ * encoder scopes ERC-20 approvals to it, and assertInputWithinMax validates it
503
+ * against the persisted spend ceiling. Keeping both on one function guarantees
504
+ * a quote that clears the ceiling check can always be signed (a refactor can't
505
+ * let the two drift onto different amounts).
506
+ *
507
+ * @param {object} p
508
+ * @param {bigint|string|number} p.inputAmount - The swap's input amount (base units)
509
+ * @param {string} [p.swapMode] - 'exactIn' (default) or 'exactOut'
510
+ * @param {number} [p.slippage] - Slippage fraction for the exactOut buffer (default 0.03)
511
+ * @returns {bigint} Allowance to approve, in base units
512
+ */
513
+ export function approvalAmountForSwap({ inputAmount, swapMode, slippage }) {
514
+ // Clamp non-positive / malformed amounts to 0n — a negative like "-5000000",
515
+ // or a non-integer string like "1.5" / "1.5e6" that BigInt() rejects — so
516
+ // callers can reject via a single `approveAmt <= 0n` check and a negative or
517
+ // invalid value never reaches hex encoding (which would mangle the calldata).
518
+ let amt;
519
+ try {
520
+ amt = BigInt(inputAmount ?? 0);
521
+ } catch {
522
+ return 0n;
523
+ }
524
+ if (amt <= 0n) return 0n;
525
+ if (swapMode === 'exactOut') {
526
+ // Honour an explicit slippage of 0 (tightest approval); only fall back to the
527
+ // 3% default when slippage wasn't provided (undefined/NaN) or is negative.
528
+ const slip = Number.isFinite(slippage) && slippage >= 0 ? slippage : 0.03;
529
+ // Buffer by slippage using basis-point integer math to stay in BigInt.
530
+ // Both steps round UP by design: this buffer must cover the router's max
531
+ // input for exactOut, so over-approving by a sub-token unit is harmless but
532
+ // under-approving by even 1 unit would revert the swap. Rounding up on both
533
+ // the bps conversion and the division guarantees we never land below
534
+ // (1 + slip) * amount.
535
+ const bps = BigInt(Math.ceil((1 + slip) * 10000));
536
+ const buffered = (amt * bps + 9999n) / 10000n; // ceil division
537
+ // Overflow guard: a huge input × slippage can exceed the uint256 ceiling,
538
+ // which encodeApproveCalldata would reject with a cryptic throw. Return 0n
539
+ // so the caller's `approveAmt <= 0n` check surfaces it as a clear
540
+ // zero/invalid-input skip instead.
541
+ if (buffered >= MAX_UINT256) return 0n;
542
+ return buffered;
543
+ }
544
+ return amt;
545
+ }
546
+
547
+ // ============= Quote vs. request-intent revalidation =============
548
+
549
+ /**
550
+ * Compare two token addresses for equality (case-insensitive on EVM, exact on
551
+ * Solana). Missing values never match.
552
+ */
553
+ function tokensEqual(a, b, chain) {
554
+ if (!a || !b) return false;
555
+ if (chain === 'solana') return a === b;
556
+ return a.toLowerCase() === b.toLowerCase();
557
+ }
558
+
559
+ /**
560
+ * Revalidate a quote against the immutable request intent that was persisted
561
+ * when the quote was fetched. The Trading API supplies the amounts, token
562
+ * pair, and transaction that the execute path signs; without this check a
563
+ * compromised or buggy API could inflate the input amount (and therefore the
564
+ * scoped approval and native value) and still pass the execute path's own
565
+ * self-consistent comparisons.
566
+ *
567
+ * Binds the fields we can verify from the quote:
568
+ * - chain identity
569
+ * - token pair (input == requested sell token, output == requested buy token)
570
+ * - exactIn: input amount EQUALS the requested amount (the spend ceiling)
571
+ * - exactOut: output amount EQUALS the requested amount (what you buy)
572
+ * - input <= request.maxInputAmount in BOTH modes (the spend ceiling). For
573
+ * exactOut this cap is the ONLY thing bounding the input, so it is
574
+ * mandatory — a quote with no persisted cap is refused (see
575
+ * assertInputWithinMax).
576
+ *
577
+ * Fails closed on a quote that is missing a field this check needs (input or
578
+ * output token address, or the bound amount): the field can't silently skip
579
+ * its comparison, because a compromised API omitting it would otherwise
580
+ * bypass the very binding meant to constrain it.
581
+ *
582
+ * Throws on a definitive mismatch. Callers run this inside the per-quote try so
583
+ * a mismatched quote falls through to the next candidate.
584
+ *
585
+ * @param {object|undefined} request - Persisted intent (quoteData.request)
586
+ * @param {object} quote - The quote being executed (allQuotes[i])
587
+ * @param {object} ctx
588
+ * @param {string} ctx.chain - Execute chain
589
+ * @param {string} [ctx.walletAddress] - The address that will actually sign at
590
+ * execute time. When both this and request.walletAddress are present they must
591
+ * match: the quote's transaction was built for a specific sender, so signing it
592
+ * from a different wallet (e.g. the default wallet changed since quoting) is
593
+ * refused. Omit when the signer isn't known (the check is then skipped).
594
+ * @param {number} [ctx.slippage] - Slippage fraction in effect (quoteData.slippage),
595
+ * forwarded to assertInputWithinMax so the exactOut spend ceiling is measured
596
+ * against the buffered approval, not the raw quote input.
597
+ * @returns {{ skipped: boolean }} skipped=true when no intent was persisted
598
+ */
599
+ export function assertQuoteMatchesRequest(request, quote, { chain, walletAddress, slippage } = {}) {
600
+ // Quotes saved by an older CLI version (pre-intent) legitimately lack a
601
+ // request block. Rather than brick an in-flight quote across an upgrade, we
602
+ // skip and let the caller warn; quotes expire in 1 hour so this is transient.
603
+ if (!request) return { skipped: true };
604
+
605
+ if (request.chain && request.chain.toLowerCase() !== String(chain).toLowerCase()) {
606
+ throw new Error(
607
+ `Quote chain (${chain}) does not match the requested chain (${request.chain}). Refusing to sign.`,
608
+ );
609
+ }
610
+
611
+ const tokenChain = String(chain).toLowerCase();
612
+
613
+ // Bind the signer to the wallet the quote was built for. The Trading API
614
+ // builds transaction `to`/`data` (and recipient) for a specific sender; a
615
+ // wallet swapped in between quote and execute would sign someone else's quote.
616
+ if (request.walletAddress && walletAddress) {
617
+ const addrsEqual = tokenChain === 'solana'
618
+ ? request.walletAddress === walletAddress
619
+ : request.walletAddress.toLowerCase() === walletAddress.toLowerCase();
620
+ if (!addrsEqual) {
621
+ throw new Error(
622
+ `Quote was built for wallet ${request.walletAddress} but the signer is ${walletAddress}. The default wallet may have changed since quoting. Re-quote with this wallet. Refusing to sign.`,
623
+ );
624
+ }
625
+ }
626
+ if (request.fromToken) {
627
+ // A missing sell-token address must fail closed: without it the token-pair
628
+ // binding below can't run, so we can't confirm the quote sells what was asked.
629
+ if (!quote.inputMint) {
630
+ throw new Error(
631
+ `Quote is missing the sell-token address (inputMint); cannot confirm it matches the requested ${request.fromToken}. Refusing to sign.`,
632
+ );
633
+ }
634
+ if (!tokensEqual(quote.inputMint, request.fromToken, tokenChain)) {
635
+ throw new Error(
636
+ `Quote sell token (${quote.inputMint}) does not match the requested token (${request.fromToken}). Refusing to sign.`,
637
+ );
638
+ }
639
+ }
640
+ // The output token lives on the destination chain, which differs from the
641
+ // source chain for cross-chain swaps. Compare it with the destination chain's
642
+ // case rules (Solana base58 is case-sensitive) to avoid false rejections.
643
+ const outTokenChain = request.toChain ? String(request.toChain).toLowerCase() : tokenChain;
644
+ if (request.toToken) {
645
+ // Fail closed on a missing buy-token address for the same reason. Previously
646
+ // a missing outputMint silently skipped this comparison — a compromised API
647
+ // could omit it to route the output somewhere else undetected.
648
+ if (!quote.outputMint) {
649
+ throw new Error(
650
+ `Quote is missing the buy-token address (outputMint); cannot confirm it matches the requested ${request.toToken}. Refusing to sign.`,
651
+ );
652
+ }
653
+ if (!tokensEqual(quote.outputMint, request.toToken, outTokenChain)) {
654
+ throw new Error(
655
+ `Quote buy token (${quote.outputMint}) does not match the requested token (${request.toToken}). Refusing to sign.`,
656
+ );
657
+ }
658
+ }
659
+
660
+ if (request.amount != null) {
661
+ const requested = BigInt(request.amount);
662
+ if (request.swapMode === 'exactOut') {
663
+ // Require the output amount to be present; a missing value must not
664
+ // default to 0 and coincidentally pass some other comparison.
665
+ const outRaw = quote.outAmount ?? quote.outputAmount;
666
+ if (outRaw == null) {
667
+ throw new Error(
668
+ `Quote is missing the output amount; cannot confirm it matches the requested output (${requested}). Refusing to sign.`,
669
+ );
670
+ }
671
+ const out = BigInt(outRaw);
672
+ // Require AT LEAST the requested output. More output for a capped input
673
+ // (see assertInputWithinMax) is pure upside, so only a shortfall is a
674
+ // mismatch — enforcing strict equality would false-reject benign rounding.
675
+ if (out < requested) {
676
+ throw new Error(
677
+ `Quote output amount (${out}) is less than the requested output (${requested}). Refusing to sign.`,
678
+ );
679
+ }
680
+ } else {
681
+ const inRaw = quote.inputAmount ?? quote.inAmount;
682
+ if (inRaw == null) {
683
+ throw new Error(
684
+ `Quote is missing the input amount; cannot confirm it matches the requested input (${requested}). Refusing to sign.`,
685
+ );
686
+ }
687
+ const input = BigInt(inRaw);
688
+ if (input !== requested) {
689
+ throw new Error(
690
+ `Quote input amount (${input}) does not match the requested input (${requested}). A larger input would enlarge the approval and native value beyond what you asked to spend. Refusing to sign.`,
691
+ );
692
+ }
693
+ }
694
+ }
695
+
696
+ // Independent spend ceiling on the input, enforced in both modes. This is the
697
+ // sole guard on exactOut input (which request.amount binds only on the output
698
+ // side), so it fails closed when an exactOut quote carries no persisted cap.
699
+ assertInputWithinMax(request, quote, slippage);
700
+
701
+ return { skipped: false };
702
+ }
703
+
704
+ /**
705
+ * Enforce the maximum input (the spend ceiling) persisted in the request intent
706
+ * against the quote the execute path is about to sign. This bounds the tokens
707
+ * that can leave the wallet independently of the output binding, closing the
708
+ * exactOut gap where the API chooses the input and nothing capped it.
709
+ *
710
+ * The amount compared against the cap is the maximum that can actually leave the
711
+ * wallet — for exactOut that is the slippage-buffered approval, NOT the raw quote
712
+ * input. The approval encoder (encodeApproveCalldata) scopes the ERC-20 approval
713
+ * to that same buffered amount and caps it at maxInputAmount, so validating the
714
+ * raw input here would let a quote pass this check and then be refused at signing
715
+ * (a 1,000,000 input at 3% slippage needs a 1,030,000 approval, which a 1,000,000
716
+ * cap rejects). Comparing the same amount approvalAmountForSwap produces keeps
717
+ * this check and the encoder in lockstep.
718
+ *
719
+ * Behaviour:
720
+ * - exactOut with no persisted `maxInputAmount` → throws (fail closed). The
721
+ * input is otherwise unbounded, so signing without a cap is refused.
722
+ * - a persisted cap with a missing/invalid quote input → throws. A cap you
723
+ * can't compare against is not a cap.
724
+ * - buffered spend > cap → throws. A larger approval/native value would let
725
+ * more than the user approved leave the wallet.
726
+ * - exactIn with no cap → no-op (request.amount already binds the input).
727
+ *
728
+ * Applies to native and ERC-20 swaps alike; the caller runs it before any
729
+ * approval, transaction signing, or WalletConnect call.
730
+ *
731
+ * @param {object} request - Persisted intent (quoteData.request)
732
+ * @param {object} quote - The quote being executed
733
+ * @param {number} [slippage] - Slippage fraction actually in effect (quoteData.slippage),
734
+ * used to reconstruct the exactOut buffer. Defaults to approvalAmountForSwap's
735
+ * 3% when omitted, matching the approval the execute path would build.
736
+ */
737
+ export function assertInputWithinMax(request, quote, slippage) {
738
+ if (!request) return;
739
+ const swapMode = request.swapMode ?? 'exactIn';
740
+ if (request.maxInputAmount == null) {
741
+ if (swapMode === 'exactOut') {
742
+ throw new Error(
743
+ 'exactOut quote has no persisted maximum input (maxInputAmount); the input is otherwise unbounded. Re-quote to enable the spend cap. Refusing to sign.',
744
+ );
745
+ }
746
+ return; // exactIn input is already bound by request.amount.
747
+ }
748
+
749
+ let cap;
750
+ try {
751
+ cap = BigInt(request.maxInputAmount);
752
+ } catch {
753
+ throw new Error(
754
+ `Persisted maximum input (${request.maxInputAmount}) is not an integer. Refusing to sign.`,
755
+ );
756
+ }
757
+
758
+ const inRaw = quote.inputAmount ?? quote.inAmount;
759
+ if (inRaw == null) {
760
+ throw new Error(
761
+ 'Quote is missing the input amount; cannot enforce the maximum input. Refusing to sign.',
762
+ );
763
+ }
764
+ let input;
765
+ try {
766
+ input = BigInt(inRaw);
767
+ } catch {
768
+ throw new Error(
769
+ `Quote input amount (${inRaw}) is not an integer; cannot enforce the maximum input. Refusing to sign.`,
770
+ );
771
+ }
772
+
773
+ // The tokens that can actually leave the wallet: exactIn pulls the raw input,
774
+ // exactOut pulls up to the slippage-buffered approval. Bound THAT against the
775
+ // cap so this check agrees with the approval encoder (see docstring).
776
+ const spend = approvalAmountForSwap({ inputAmount: input, swapMode, slippage });
777
+ if (spend <= 0n && input > 0n) {
778
+ // exactOut buffer overflowed the uint256 ceiling (approvalAmountForSwap
779
+ // returns 0n) — an unbounded approval, never signable.
780
+ throw new Error(
781
+ `Quote input amount (${input}) plus the slippage buffer overflows the uint256 approval ceiling; cannot enforce the maximum input. Refusing to sign.`,
782
+ );
783
+ }
784
+ if (spend > cap) {
785
+ throw new Error(
786
+ swapMode === 'exactOut'
787
+ ? `Quote needs an approval of ${spend} base units (input ${input} + slippage buffer) to guarantee the exact output, which exceeds your maximum input (${cap}). Raise --max-input or lower the requested output. Refusing to sign.`
788
+ : `Quote input amount (${input}) exceeds your maximum input (${cap}). A larger input would enlarge the approval and native value beyond what you approved. Refusing to sign.`,
789
+ );
790
+ }
791
+ }
792
+
793
+ // ============= Swap-calldata shape guard (same-chain) =============
794
+
795
+ // Bare ERC-20 methods a legitimate same-chain swap's OUTER call never uses. A
796
+ // real swap routes through an aggregator/router (swap/execute/multicall); if the
797
+ // quote's swap `transaction.data` starts with one of these selectors, the call
798
+ // is a direct token transfer/approval disguised as a swap — the drain shape.
799
+ //
800
+ // Because the user's wallet is msg.sender, `transfer`/`transferFrom(from=user)`
801
+ // move the user's own tokens with no prior allowance, and `approve` hands an
802
+ // attacker a fresh allowance — so blocking these outer selectors closes the
803
+ // direct sibling-token drain. It does NOT catch a custom contract exploiting a
804
+ // pre-existing allowance; that needs outcome (balance-delta) simulation.
805
+ //
806
+ // The caller runs this SELECTOR check on same-chain swaps only. Cross-chain
807
+ // routes are excluded from THIS check because a bridge deposit can, in
808
+ // principle, encode as a plain `transfer` — but that does NOT mean a cross-chain
809
+ // bare transfer is waved through: a bare ERC-20 `transfer`/`approve` targets the
810
+ // token contract itself, so `validateSwapTarget`'s `to === inputMint` gate still
811
+ // refuses it, for cross-chain and same-chain alike (fail closed). In practice
812
+ // the bridge routes this CLI uses (Relay/Li.Fi) route ERC-20 deposits through a
813
+ // router contract (to != token), so neither guard fires on a legitimate bridge.
814
+ // Safely supporting a genuine deposit-as-transfer bridge would require positive
815
+ // recipient/amount validation (balance-delta outcome simulation), not a blanket
816
+ // exemption — tracked as a follow-up.
817
+ const BARE_ERC20_OUTER_SELECTORS = {
818
+ '0xa9059cbb': 'transfer(address,uint256)',
819
+ '0x095ea7b3': 'approve(address,uint256)',
820
+ '0x23b872dd': 'transferFrom(address,address,uint256)',
821
+ };
822
+
823
+ /**
824
+ * Reject a same-chain swap whose transaction calldata is a bare ERC-20
825
+ * transfer/approve/transferFrom rather than a router call. No-op when the
826
+ * calldata is absent or too short to carry a 4-byte selector.
827
+ *
828
+ * @param {string} data - The swap transaction's calldata (quote.transaction.data)
829
+ */
830
+ export function assertSwapCalldataNotBareTransfer(data) {
831
+ if (!data || typeof data !== 'string' || data.length < 10) return;
832
+ const selector = data.slice(0, 10).toLowerCase();
833
+ const method = BARE_ERC20_OUTER_SELECTORS[selector];
834
+ if (method) {
835
+ throw new Error(
836
+ `Swap transaction is a bare ERC-20 ${method}, not a routed swap. A real swap routes through an aggregator, not a direct token transfer/approval. Refusing to sign.`,
837
+ );
838
+ }
839
+ }