nansen-cli 1.38.0 → 1.40.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.
@@ -437,7 +437,8 @@ export function assertValidApprovalSpender(spender) {
437
437
  *
438
438
  * Guarantees on the returned string:
439
439
  * - spender is a valid 20-byte address (see assertValidApprovalSpender)
440
- * - amount is a positive integer strictly below MAX_UINT256 (never unlimited)
440
+ * - amount is a positive integer strictly below MAX_UINT256 (never unlimited),
441
+ * unless `allowZero` is explicitly set for a revoke-to-zero approval
441
442
  * - amount does not exceed `maxAllowance` when the caller supplies one
442
443
  * (the user's persisted request intent — see assertQuoteMatchesRequest)
443
444
  * - the encoded calldata is exactly 68 bytes (4-byte selector + two 32-byte
@@ -447,9 +448,10 @@ export function assertValidApprovalSpender(spender) {
447
448
  * @param {bigint|string|number} amount - Allowance in base units
448
449
  * @param {object} [opts]
449
450
  * @param {bigint|string|number} [opts.maxAllowance] - Hard cap from request intent
451
+ * @param {boolean} [opts.allowZero=false] - Allow encoding a zero-amount revoke approval
450
452
  * @returns {string} 0x-prefixed approve() calldata (exactly 68 bytes)
451
453
  */
452
- export function encodeApproveCalldata(spender, amount, { maxAllowance } = {}) {
454
+ export function encodeApproveCalldata(spender, amount, { maxAllowance, allowZero = false } = {}) {
453
455
  assertValidApprovalSpender(spender);
454
456
 
455
457
  let amt;
@@ -458,7 +460,7 @@ export function encodeApproveCalldata(spender, amount, { maxAllowance } = {}) {
458
460
  } catch {
459
461
  throw new Error(`Approval amount is not an integer (${amount}). Refusing to sign an approval.`);
460
462
  }
461
- if (amt <= 0n) {
463
+ if (amt < 0n || (amt === 0n && !allowZero)) {
462
464
  throw new Error(`Approval amount must be positive (got ${amt}). Refusing to sign an approval.`);
463
465
  }
464
466
  if (amt >= MAX_UINT256) {
@@ -544,6 +546,23 @@ export function approvalAmountForSwap({ inputAmount, swapMode, slippage }) {
544
546
  return amt;
545
547
  }
546
548
 
549
+ // Existing allowances above this multiple of the current trade's scoped amount
550
+ // are treated as stale/oversized rather than reusable dust from a prior swap.
551
+ export const OVERSIZED_ALLOWANCE_MULTIPLIER = 10n;
552
+
553
+ /**
554
+ * Decide whether an existing on-chain ERC-20 allowance should be revoked before
555
+ * granting the current trade's scoped approval.
556
+ *
557
+ * @param {bigint} existingAllowance - Current on-chain allowance
558
+ * @param {bigint} approveAmt - This trade's scoped approval amount
559
+ * @returns {boolean}
560
+ */
561
+ export function needsAllowanceRevoke(existingAllowance, approveAmt) {
562
+ if (approveAmt <= 0n) return false;
563
+ return existingAllowance > approveAmt * OVERSIZED_ALLOWANCE_MULTIPLIER;
564
+ }
565
+
547
566
  // ============= Quote vs. request-intent revalidation =============
548
567
 
549
568
  /**
@@ -821,9 +840,11 @@ const BARE_ERC20_OUTER_SELECTORS = {
821
840
  };
822
841
 
823
842
  /**
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.
843
+ * Reject a swap or bridge whose transaction calldata is a bare ERC-20
844
+ * transfer/approve/transferFrom rather than a router call. Applies to both
845
+ * same-chain and cross-chain EVM quotes (a legit bridge also routes through a
846
+ * router). No-op when the calldata is absent or too short to carry a 4-byte
847
+ * selector.
827
848
  *
828
849
  * @param {string} data - The swap transaction's calldata (quote.transaction.data)
829
850
  */
@@ -837,3 +858,213 @@ export function assertSwapCalldataNotBareTransfer(data) {
837
858
  );
838
859
  }
839
860
  }
861
+
862
+ // ============= Swap-outcome verification (balance-delta simulation) =============
863
+
864
+ /**
865
+ * Assert that a SIMULATED swap's asset changes match the user's intent, failing
866
+ * closed on any mismatch. This is a defence-in-depth outcome check that
867
+ * complements the static calldata checks (validateSwapTarget /
868
+ * assertSwapCalldataNotBareTransfer): it verifies what the swap actually does to
869
+ * the wallet's balances, not just what the calldata looks like.
870
+ *
871
+ * Run it on the swap-call-alone simulation AFTER any required approval is
872
+ * confirmed on-chain, so the live allowance is reflected on `latest` and a
873
+ * single-transaction sim matches the broadcast swap (see swap-simulation.js).
874
+ *
875
+ * Four assertions, all derived from the persisted request intent + the quote:
876
+ * 1. the input token leaves the wallet by no MORE than maxInputAmount. Native
877
+ * input excludes gas: the sim deltas are log-based, so gas (not a transfer
878
+ * log) is never counted.
879
+ * 2. the output token arrives by AT LEAST minOut — exactOut: >= the requested
880
+ * output; exactIn: the quoted output reduced by the slippage in effect.
881
+ * 3. NO token other than the input leaves the wallet.
882
+ * 4. the wallet grants no Approval to a spender outside `expectedSpenders`.
883
+ *
884
+ * @param {object} request - persisted intent (quoteData.request); required
885
+ * @param {object} quote - the quote being executed
886
+ * @param {{deltas: Record<string, bigint|string|number>, approvals?: Array<{token?:string, spender?:string, amount?:any}>}} sim
887
+ * - the normalised result from simulateAssetChanges()
888
+ * @param {object} [ctx]
889
+ * @param {number} [ctx.slippage] - slippage fraction in effect (quoteData.slippage);
890
+ * defaults to 3% to match approvalAmountForSwap when omitted
891
+ * @param {Set<string>|string[]} [ctx.expectedSpenders] - spenders the wallet may
892
+ * legitimately (re)approve during the swap (e.g. the approval target and the
893
+ * router); anything else fails assertion 4. Compared case-insensitively.
894
+ * @param {bigint} [ctx.siblingDustThreshold=0n] - non-input outflow tolerated
895
+ * before assertion 3 fires (for fee-on-transfer / rounding). Strict 0 default.
896
+ * @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
897
+ */
898
+ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpenders, siblingDustThreshold = 0n } = {}) {
899
+ const fail = (detail) => {
900
+ const e = new Error(`Swap outcome mismatch (SWAP_OUTCOME_MISMATCH): ${detail} Refusing to sign.`);
901
+ e.code = 'SWAP_OUTCOME_MISMATCH';
902
+ return e;
903
+ };
904
+
905
+ if (!request) throw fail('no request intent to verify the outcome against.');
906
+ if (!sim || typeof sim !== 'object' || sim.deltas == null) {
907
+ throw fail('simulation returned no asset changes to verify.');
908
+ }
909
+
910
+ // Normalise deltas to a lowercased-key BigInt map. A non-integer delta is a
911
+ // corrupt sim result — fail closed rather than coerce it to 0.
912
+ const deltas = {};
913
+ for (const [k, v] of Object.entries(sim.deltas)) {
914
+ let amt;
915
+ try {
916
+ amt = typeof v === 'bigint' ? v : BigInt(v);
917
+ } catch {
918
+ throw fail(`simulated delta for ${k} (${v}) is not an integer.`);
919
+ }
920
+ deltas[k.toLowerCase()] = amt;
921
+ }
922
+
923
+ const inputToken = quote?.inputMint ? String(quote.inputMint).toLowerCase() : null;
924
+ const outputToken = quote?.outputMint ? String(quote.outputMint).toLowerCase() : null;
925
+ if (!inputToken || !outputToken) {
926
+ throw fail('quote is missing the input or output token address.');
927
+ }
928
+ // Fail closed on a same-token quote: assertion 3 skips the input token, so if
929
+ // output == input a drain of that token would slip past unverified. A real
930
+ // swap never sells and buys the same token (also rejected upstream).
931
+ if (inputToken === outputToken) {
932
+ throw fail(`quote input and output tokens are the same (${inputToken}); refusing to verify.`);
933
+ }
934
+
935
+ // --- Assertion 1: input outflow within the spend ceiling ---
936
+ // This bounds the outflow by maxInputAmount (the slippage-buffered ceiling),
937
+ // NOT the exact expected input: for exactOut the aggregator may legitimately
938
+ // pull anywhere up to that ceiling. The tighter exactIn bound (outflow ==
939
+ // request.amount) is enforced by assertQuoteMatchesRequest, which the execute
940
+ // paths run earlier in the same iteration. Keep that call ahead of this one on
941
+ // any new signing path — Assertion 1 alone does not re-check exactIn inflation.
942
+ if (request.maxInputAmount == null) {
943
+ throw fail('request has no maximum input to bound the outflow against.');
944
+ }
945
+ let cap;
946
+ try {
947
+ cap = BigInt(request.maxInputAmount);
948
+ } catch {
949
+ throw fail(`maximum input (${request.maxInputAmount}) is not an integer.`);
950
+ }
951
+ const inputDelta = deltas[inputToken] || 0n;
952
+ const outflow = inputDelta < 0n ? -inputDelta : 0n;
953
+ if (outflow > cap) {
954
+ throw fail(`the input token (${inputToken}) left the wallet by ${outflow}, exceeding your maximum input (${cap}).`);
955
+ }
956
+
957
+ // --- Assertion 2: output arrives at or above the minimum acceptable ---
958
+ const swapMode = request.swapMode ?? 'exactIn';
959
+ const outputDelta = deltas[outputToken] || 0n;
960
+ let minOut;
961
+ if (swapMode === 'exactOut') {
962
+ if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
963
+ try {
964
+ minOut = BigInt(request.amount);
965
+ } catch {
966
+ throw fail(`requested output amount (${request.amount}) is not an integer.`);
967
+ }
968
+ // Mirror the exactIn non-positive guard: a zero/negative requested output
969
+ // makes minOut <= 0 and turns assertion 2 into a no-op (outputDelta >= 0
970
+ // always holds), so a swap delivering nothing would pass. Upstream rejects
971
+ // zero amounts, but this helper is a self-contained fail-closed boundary.
972
+ if (minOut <= 0n) {
973
+ throw fail(`exactOut request has a non-positive output amount (${minOut}); cannot compute a minimum acceptable output.`);
974
+ }
975
+ } else {
976
+ const quotedRaw = quote.outAmount ?? quote.outputAmount;
977
+ if (quotedRaw == null) {
978
+ throw fail('quote is missing the quoted output amount; cannot compute the minimum acceptable output.');
979
+ }
980
+ let quoted;
981
+ try {
982
+ quoted = BigInt(quotedRaw);
983
+ } catch {
984
+ throw fail(`quoted output amount (${quotedRaw}) is not an integer.`);
985
+ }
986
+ // A non-positive quoted output makes minOut <= 0, so a sim receiving nothing
987
+ // (or losing the output token) would pass assertion 2 (outputDelta < minOut is
988
+ // false when minOut <= 0). exactIn has no upstream positive-output guard
989
+ // (unlike exactOut), so a rogue outAmount of "0" or a negative value would
990
+ // otherwise slip through.
991
+ if (quoted <= 0n) {
992
+ throw fail(`quote has a non-positive output amount (${quoted}); cannot compute a minimum acceptable output.`);
993
+ }
994
+ // Floor of quoted × (1 − slippage), in basis points to stay in BigInt. This
995
+ // mirrors the slippage the user actually set (quoteData.slippage), defaulting
996
+ // to 3% to match approvalAmountForSwap when it wasn't supplied.
997
+ //
998
+ // Cap the slippage used HERE at 50%, independent of what the user accepted:
999
+ // the upstream quote command allows --slippage up to 1.0 (100%), which would
1000
+ // make minOut 0 and neuter this assertion — a route delivering nothing would
1001
+ // pass (outputDelta >= 0). This is a defence-in-depth floor, not the user's
1002
+ // execution tolerance; a real swap never loses more than half the quoted
1003
+ // output, so requiring at least 50% keeps the guard meaningful while leaving
1004
+ // enormous headroom over a normal few-percent deviation.
1005
+ const rawSlip = Number.isFinite(slippage) && slippage >= 0 ? slippage : 0.03;
1006
+ const slip = Math.min(rawSlip, 0.5);
1007
+ const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
1008
+ minOut = (quoted * (10000n - bps)) / 10000n;
1009
+ }
1010
+ if (outputDelta < minOut) {
1011
+ throw fail(`the output token (${outputToken}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1012
+ }
1013
+
1014
+ // --- Assertion 3: no token other than the input leaves the wallet ---
1015
+ const dust = siblingDustThreshold > 0n ? siblingDustThreshold : 0n;
1016
+ for (const [token, delta] of Object.entries(deltas)) {
1017
+ if (token === inputToken) continue; // its outflow is bounded by assertion 1
1018
+ if (delta < 0n && -delta > dust) {
1019
+ 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.`);
1020
+ }
1021
+ }
1022
+
1023
+ // --- Assertion 3b: no non-fungible asset leaves the wallet ---
1024
+ // The signed `deltas` map only models native + ERC-20 balances, so an NFT
1025
+ // drain is invisible to assertion 3. A DEX swap should never move an ERC-721 or
1026
+ // ERC-1155 out of the wallet, so fail closed if the sim surfaced one. (Inbound
1027
+ // NFTs are harmless and are not recorded by foldLogs.)
1028
+ for (const nft of sim.nftOut || []) {
1029
+ throw fail(
1030
+ `a non-fungible asset (${nft.standard}${nft.token ? ` ${nft.token}` : ''}) left the wallet; a swap must not transfer any NFT.`,
1031
+ );
1032
+ }
1033
+
1034
+ // --- Assertion 3c: no non-fungible approval is granted ---
1035
+ // A DEX swap never needs to approve an NFT, so any ERC-721 / ERC-1155 approval
1036
+ // the wallet grants (single-token Approval or ApprovalForAll) is fail-closed —
1037
+ // it would let the operator move the NFT out AFTER the swap, invisibly to the
1038
+ // transfer checks above. The ERC-20 spender allowlist (assertion 4) does NOT
1039
+ // cover these: a single-NFT Approval folds in as a zero-amount "revoke" and an
1040
+ // ApprovalForAll is not an ERC-20 Approval at all.
1041
+ for (const ap of sim.nftApprovals || []) {
1042
+ throw fail(
1043
+ `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.`,
1044
+ );
1045
+ }
1046
+
1047
+ // --- Assertion 4: no approval to an unexpected spender ---
1048
+ const allowed = new Set(
1049
+ (expectedSpenders instanceof Set ? [...expectedSpenders] : expectedSpenders || [])
1050
+ .filter(Boolean)
1051
+ .map((s) => String(s).toLowerCase()),
1052
+ );
1053
+ for (const ap of sim.approvals || []) {
1054
+ if (!ap || !ap.spender) continue;
1055
+ // A revoke (approve to 0) grants no allowance, so it is never a concern.
1056
+ if (ap.amount != null) {
1057
+ try {
1058
+ if (BigInt(ap.amount) === 0n) continue;
1059
+ } catch { /* non-integer amount → treat as a real approval below */ }
1060
+ }
1061
+ const spender = String(ap.spender).toLowerCase();
1062
+ if (!allowed.has(spender)) {
1063
+ throw fail(
1064
+ `the swap grants an approval to an unexpected spender (${spender}); a swap should only (re)approve ${allowed.size ? [...allowed].join(', ') : 'nothing'}.`,
1065
+ );
1066
+ }
1067
+ }
1068
+
1069
+ return { verified: true };
1070
+ }