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.
- package/CHANGELOG.md +35 -0
- package/README.md +56 -1
- package/package.json +1 -1
- package/skills/nansen-wallet-batch/SKILL.md +1 -1
- package/skills/nansen-wallet-profiler/SKILL.md +1 -1
- package/src/api.js +4 -3
- package/src/cli.js +8 -3
- package/src/limit-order.js +30 -7
- package/src/response-meta.js +2 -2
- package/src/rpc-urls.js +67 -0
- package/src/schema.json +20 -3
- package/src/swap-simulation.js +477 -0
- package/src/trade-validation.js +237 -6
- package/src/trading.js +566 -70
- package/src/transfer.js +25 -3
- package/src/walletconnect-trading.js +4 -2
package/src/trade-validation.js
CHANGED
|
@@ -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
|
|
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
|
|
825
|
-
* transfer/approve/transferFrom rather than a router call.
|
|
826
|
-
*
|
|
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
|
+
}
|