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.
- package/CHANGELOG.md +42 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/skills/nansen-trading/SKILL.md +2 -0
- package/skills/nansen-wallet-keychain-migration/SKILL.md +14 -12
- package/src/api.js +11 -0
- package/src/cli.js +122 -58
- package/src/cost-cache.js +19 -1
- package/src/doctor.js +480 -0
- package/src/keychain.js +46 -0
- package/src/perp.js +134 -5
- package/src/schema.json +93 -0
- package/src/telemetry.js +59 -2
- package/src/trade-validation.js +441 -0
- package/src/trading.js +387 -18
- package/src/update-check.js +45 -24
- package/src/walletconnect-trading.js +11 -7
package/src/trade-validation.js
CHANGED
|
@@ -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
|
+
}
|