nansen-cli 1.37.0 → 1.39.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 +36 -0
- package/README.md +58 -2
- package/package.json +1 -1
- package/skills/nansen-wallet-batch/SKILL.md +1 -1
- package/skills/nansen-wallet-keychain-migration/SKILL.md +14 -12
- package/skills/nansen-wallet-profiler/SKILL.md +1 -1
- package/src/api.js +4 -3
- package/src/cli.js +55 -13
- package/src/doctor.js +480 -0
- package/src/keychain.js +46 -0
- package/src/response-meta.js +2 -2
- package/src/rpc-urls.js +67 -0
- package/src/schema.json +68 -1
- package/src/swap-simulation.js +477 -0
- package/src/telemetry.js +9 -2
- package/src/trade-validation.js +653 -0
- package/src/trading.js +530 -20
- package/src/update-check.js +2 -1
- package/src/walletconnect-trading.js +11 -7
package/src/trade-validation.js
CHANGED
|
@@ -396,3 +396,656 @@ 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 swap or bridge whose transaction calldata is a bare ERC-20
|
|
825
|
+
* transfer/approve/transferFrom rather than a router call. Applies to both
|
|
826
|
+
* same-chain and cross-chain EVM quotes (a legit bridge also routes through a
|
|
827
|
+
* router). No-op when the calldata is absent or too short to carry a 4-byte
|
|
828
|
+
* selector.
|
|
829
|
+
*
|
|
830
|
+
* @param {string} data - The swap transaction's calldata (quote.transaction.data)
|
|
831
|
+
*/
|
|
832
|
+
export function assertSwapCalldataNotBareTransfer(data) {
|
|
833
|
+
if (!data || typeof data !== 'string' || data.length < 10) return;
|
|
834
|
+
const selector = data.slice(0, 10).toLowerCase();
|
|
835
|
+
const method = BARE_ERC20_OUTER_SELECTORS[selector];
|
|
836
|
+
if (method) {
|
|
837
|
+
throw new Error(
|
|
838
|
+
`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.`,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ============= Swap-outcome verification (balance-delta simulation) =============
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Assert that a SIMULATED swap's asset changes match the user's intent, failing
|
|
847
|
+
* closed on any mismatch. This is a defence-in-depth outcome check that
|
|
848
|
+
* complements the static calldata checks (validateSwapTarget /
|
|
849
|
+
* assertSwapCalldataNotBareTransfer): it verifies what the swap actually does to
|
|
850
|
+
* the wallet's balances, not just what the calldata looks like.
|
|
851
|
+
*
|
|
852
|
+
* Run it on the swap-call-alone simulation AFTER any required approval is
|
|
853
|
+
* confirmed on-chain, so the live allowance is reflected on `latest` and a
|
|
854
|
+
* single-transaction sim matches the broadcast swap (see swap-simulation.js).
|
|
855
|
+
*
|
|
856
|
+
* Four assertions, all derived from the persisted request intent + the quote:
|
|
857
|
+
* 1. the input token leaves the wallet by no MORE than maxInputAmount. Native
|
|
858
|
+
* input excludes gas: the sim deltas are log-based, so gas (not a transfer
|
|
859
|
+
* log) is never counted.
|
|
860
|
+
* 2. the output token arrives by AT LEAST minOut — exactOut: >= the requested
|
|
861
|
+
* output; exactIn: the quoted output reduced by the slippage in effect.
|
|
862
|
+
* 3. NO token other than the input leaves the wallet.
|
|
863
|
+
* 4. the wallet grants no Approval to a spender outside `expectedSpenders`.
|
|
864
|
+
*
|
|
865
|
+
* @param {object} request - persisted intent (quoteData.request); required
|
|
866
|
+
* @param {object} quote - the quote being executed
|
|
867
|
+
* @param {{deltas: Record<string, bigint|string|number>, approvals?: Array<{token?:string, spender?:string, amount?:any}>}} sim
|
|
868
|
+
* - the normalised result from simulateAssetChanges()
|
|
869
|
+
* @param {object} [ctx]
|
|
870
|
+
* @param {number} [ctx.slippage] - slippage fraction in effect (quoteData.slippage);
|
|
871
|
+
* defaults to 3% to match approvalAmountForSwap when omitted
|
|
872
|
+
* @param {Set<string>|string[]} [ctx.expectedSpenders] - spenders the wallet may
|
|
873
|
+
* legitimately (re)approve during the swap (e.g. the approval target and the
|
|
874
|
+
* router); anything else fails assertion 4. Compared case-insensitively.
|
|
875
|
+
* @param {bigint} [ctx.siblingDustThreshold=0n] - non-input outflow tolerated
|
|
876
|
+
* before assertion 3 fires (for fee-on-transfer / rounding). Strict 0 default.
|
|
877
|
+
* @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
|
|
878
|
+
*/
|
|
879
|
+
export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpenders, siblingDustThreshold = 0n } = {}) {
|
|
880
|
+
const fail = (detail) => {
|
|
881
|
+
const e = new Error(`Swap outcome mismatch (SWAP_OUTCOME_MISMATCH): ${detail} Refusing to sign.`);
|
|
882
|
+
e.code = 'SWAP_OUTCOME_MISMATCH';
|
|
883
|
+
return e;
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
if (!request) throw fail('no request intent to verify the outcome against.');
|
|
887
|
+
if (!sim || typeof sim !== 'object' || sim.deltas == null) {
|
|
888
|
+
throw fail('simulation returned no asset changes to verify.');
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Normalise deltas to a lowercased-key BigInt map. A non-integer delta is a
|
|
892
|
+
// corrupt sim result — fail closed rather than coerce it to 0.
|
|
893
|
+
const deltas = {};
|
|
894
|
+
for (const [k, v] of Object.entries(sim.deltas)) {
|
|
895
|
+
let amt;
|
|
896
|
+
try {
|
|
897
|
+
amt = typeof v === 'bigint' ? v : BigInt(v);
|
|
898
|
+
} catch {
|
|
899
|
+
throw fail(`simulated delta for ${k} (${v}) is not an integer.`);
|
|
900
|
+
}
|
|
901
|
+
deltas[k.toLowerCase()] = amt;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const inputToken = quote?.inputMint ? String(quote.inputMint).toLowerCase() : null;
|
|
905
|
+
const outputToken = quote?.outputMint ? String(quote.outputMint).toLowerCase() : null;
|
|
906
|
+
if (!inputToken || !outputToken) {
|
|
907
|
+
throw fail('quote is missing the input or output token address.');
|
|
908
|
+
}
|
|
909
|
+
// Fail closed on a same-token quote: assertion 3 skips the input token, so if
|
|
910
|
+
// output == input a drain of that token would slip past unverified. A real
|
|
911
|
+
// swap never sells and buys the same token (also rejected upstream).
|
|
912
|
+
if (inputToken === outputToken) {
|
|
913
|
+
throw fail(`quote input and output tokens are the same (${inputToken}); refusing to verify.`);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// --- Assertion 1: input outflow within the spend ceiling ---
|
|
917
|
+
// This bounds the outflow by maxInputAmount (the slippage-buffered ceiling),
|
|
918
|
+
// NOT the exact expected input: for exactOut the aggregator may legitimately
|
|
919
|
+
// pull anywhere up to that ceiling. The tighter exactIn bound (outflow ==
|
|
920
|
+
// request.amount) is enforced by assertQuoteMatchesRequest, which the execute
|
|
921
|
+
// paths run earlier in the same iteration. Keep that call ahead of this one on
|
|
922
|
+
// any new signing path — Assertion 1 alone does not re-check exactIn inflation.
|
|
923
|
+
if (request.maxInputAmount == null) {
|
|
924
|
+
throw fail('request has no maximum input to bound the outflow against.');
|
|
925
|
+
}
|
|
926
|
+
let cap;
|
|
927
|
+
try {
|
|
928
|
+
cap = BigInt(request.maxInputAmount);
|
|
929
|
+
} catch {
|
|
930
|
+
throw fail(`maximum input (${request.maxInputAmount}) is not an integer.`);
|
|
931
|
+
}
|
|
932
|
+
const inputDelta = deltas[inputToken] || 0n;
|
|
933
|
+
const outflow = inputDelta < 0n ? -inputDelta : 0n;
|
|
934
|
+
if (outflow > cap) {
|
|
935
|
+
throw fail(`the input token (${inputToken}) left the wallet by ${outflow}, exceeding your maximum input (${cap}).`);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// --- Assertion 2: output arrives at or above the minimum acceptable ---
|
|
939
|
+
const swapMode = request.swapMode ?? 'exactIn';
|
|
940
|
+
const outputDelta = deltas[outputToken] || 0n;
|
|
941
|
+
let minOut;
|
|
942
|
+
if (swapMode === 'exactOut') {
|
|
943
|
+
if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
|
|
944
|
+
try {
|
|
945
|
+
minOut = BigInt(request.amount);
|
|
946
|
+
} catch {
|
|
947
|
+
throw fail(`requested output amount (${request.amount}) is not an integer.`);
|
|
948
|
+
}
|
|
949
|
+
// Mirror the exactIn non-positive guard: a zero/negative requested output
|
|
950
|
+
// makes minOut <= 0 and turns assertion 2 into a no-op (outputDelta >= 0
|
|
951
|
+
// always holds), so a swap delivering nothing would pass. Upstream rejects
|
|
952
|
+
// zero amounts, but this helper is a self-contained fail-closed boundary.
|
|
953
|
+
if (minOut <= 0n) {
|
|
954
|
+
throw fail(`exactOut request has a non-positive output amount (${minOut}); cannot compute a minimum acceptable output.`);
|
|
955
|
+
}
|
|
956
|
+
} else {
|
|
957
|
+
const quotedRaw = quote.outAmount ?? quote.outputAmount;
|
|
958
|
+
if (quotedRaw == null) {
|
|
959
|
+
throw fail('quote is missing the quoted output amount; cannot compute the minimum acceptable output.');
|
|
960
|
+
}
|
|
961
|
+
let quoted;
|
|
962
|
+
try {
|
|
963
|
+
quoted = BigInt(quotedRaw);
|
|
964
|
+
} catch {
|
|
965
|
+
throw fail(`quoted output amount (${quotedRaw}) is not an integer.`);
|
|
966
|
+
}
|
|
967
|
+
// A non-positive quoted output makes minOut <= 0, so a sim receiving nothing
|
|
968
|
+
// (or losing the output token) would pass assertion 2 (outputDelta < minOut is
|
|
969
|
+
// false when minOut <= 0). exactIn has no upstream positive-output guard
|
|
970
|
+
// (unlike exactOut), so a rogue outAmount of "0" or a negative value would
|
|
971
|
+
// otherwise slip through.
|
|
972
|
+
if (quoted <= 0n) {
|
|
973
|
+
throw fail(`quote has a non-positive output amount (${quoted}); cannot compute a minimum acceptable output.`);
|
|
974
|
+
}
|
|
975
|
+
// Floor of quoted × (1 − slippage), in basis points to stay in BigInt. This
|
|
976
|
+
// mirrors the slippage the user actually set (quoteData.slippage), defaulting
|
|
977
|
+
// to 3% to match approvalAmountForSwap when it wasn't supplied.
|
|
978
|
+
//
|
|
979
|
+
// Cap the slippage used HERE at 50%, independent of what the user accepted:
|
|
980
|
+
// the upstream quote command allows --slippage up to 1.0 (100%), which would
|
|
981
|
+
// make minOut 0 and neuter this assertion — a route delivering nothing would
|
|
982
|
+
// pass (outputDelta >= 0). This is a defence-in-depth floor, not the user's
|
|
983
|
+
// execution tolerance; a real swap never loses more than half the quoted
|
|
984
|
+
// output, so requiring at least 50% keeps the guard meaningful while leaving
|
|
985
|
+
// enormous headroom over a normal few-percent deviation.
|
|
986
|
+
const rawSlip = Number.isFinite(slippage) && slippage >= 0 ? slippage : 0.03;
|
|
987
|
+
const slip = Math.min(rawSlip, 0.5);
|
|
988
|
+
const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
|
|
989
|
+
minOut = (quoted * (10000n - bps)) / 10000n;
|
|
990
|
+
}
|
|
991
|
+
if (outputDelta < minOut) {
|
|
992
|
+
throw fail(`the output token (${outputToken}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// --- Assertion 3: no token other than the input leaves the wallet ---
|
|
996
|
+
const dust = siblingDustThreshold > 0n ? siblingDustThreshold : 0n;
|
|
997
|
+
for (const [token, delta] of Object.entries(deltas)) {
|
|
998
|
+
if (token === inputToken) continue; // its outflow is bounded by assertion 1
|
|
999
|
+
if (delta < 0n && -delta > dust) {
|
|
1000
|
+
throw fail(`a token other than the one you are selling (${token}) left the wallet (delta ${delta}); a swap must not move any token except the input.`);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// --- Assertion 3b: no non-fungible asset leaves the wallet ---
|
|
1005
|
+
// The signed `deltas` map only models native + ERC-20 balances, so an NFT
|
|
1006
|
+
// drain is invisible to assertion 3. A DEX swap should never move an ERC-721 or
|
|
1007
|
+
// ERC-1155 out of the wallet, so fail closed if the sim surfaced one. (Inbound
|
|
1008
|
+
// NFTs are harmless and are not recorded by foldLogs.)
|
|
1009
|
+
for (const nft of sim.nftOut || []) {
|
|
1010
|
+
throw fail(
|
|
1011
|
+
`a non-fungible asset (${nft.standard}${nft.token ? ` ${nft.token}` : ''}) left the wallet; a swap must not transfer any NFT.`,
|
|
1012
|
+
);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// --- Assertion 3c: no non-fungible approval is granted ---
|
|
1016
|
+
// A DEX swap never needs to approve an NFT, so any ERC-721 / ERC-1155 approval
|
|
1017
|
+
// the wallet grants (single-token Approval or ApprovalForAll) is fail-closed —
|
|
1018
|
+
// it would let the operator move the NFT out AFTER the swap, invisibly to the
|
|
1019
|
+
// transfer checks above. The ERC-20 spender allowlist (assertion 4) does NOT
|
|
1020
|
+
// cover these: a single-NFT Approval folds in as a zero-amount "revoke" and an
|
|
1021
|
+
// ApprovalForAll is not an ERC-20 Approval at all.
|
|
1022
|
+
for (const ap of sim.nftApprovals || []) {
|
|
1023
|
+
throw fail(
|
|
1024
|
+
`the swap grants a non-fungible approval (${ap.standard}${ap.token ? ` ${ap.token}` : ''}) to ${ap.operator || 'an operator'}; a swap must not approve any NFT.`,
|
|
1025
|
+
);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// --- Assertion 4: no approval to an unexpected spender ---
|
|
1029
|
+
const allowed = new Set(
|
|
1030
|
+
(expectedSpenders instanceof Set ? [...expectedSpenders] : expectedSpenders || [])
|
|
1031
|
+
.filter(Boolean)
|
|
1032
|
+
.map((s) => String(s).toLowerCase()),
|
|
1033
|
+
);
|
|
1034
|
+
for (const ap of sim.approvals || []) {
|
|
1035
|
+
if (!ap || !ap.spender) continue;
|
|
1036
|
+
// A revoke (approve to 0) grants no allowance, so it is never a concern.
|
|
1037
|
+
if (ap.amount != null) {
|
|
1038
|
+
try {
|
|
1039
|
+
if (BigInt(ap.amount) === 0n) continue;
|
|
1040
|
+
} catch { /* non-integer amount → treat as a real approval below */ }
|
|
1041
|
+
}
|
|
1042
|
+
const spender = String(ap.spender).toLowerCase();
|
|
1043
|
+
if (!allowed.has(spender)) {
|
|
1044
|
+
throw fail(
|
|
1045
|
+
`the swap grants an approval to an unexpected spender (${spender}); a swap should only (re)approve ${allowed.size ? [...allowed].join(', ') : 'nothing'}.`,
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
return { verified: true };
|
|
1051
|
+
}
|