nansen-cli 1.41.0 → 1.42.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.
@@ -8,6 +8,7 @@ import { validateAddress } from './api.js';
8
8
  import { CHAIN_RPCS } from './rpc-urls.js';
9
9
  import { parseTransactionMessage, resolveStaticAccount } from './solana-tx.js';
10
10
  import { SOL_SENTINEL } from './solana-simulation.js';
11
+ import { EVM_NATIVE_SENTINEL } from './swap-simulation.js';
11
12
 
12
13
  const SUPPORTED_CHAINS = ['solana', 'base'];
13
14
 
@@ -146,6 +147,21 @@ const NATIVE_TOKEN_ADDRESSES = {
146
147
  base: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
147
148
  };
148
149
 
150
+ // Upper bound on native (ETH) that may leave the wallet as a *sibling* of a
151
+ // cross-chain bridge — some bridges pay a network fee via msg.value on a
152
+ // token-input route, which surfaces as a native outflow distinct from the input
153
+ // token. assertSwapOutcome's no-sibling-drain check (assertion 3) is otherwise
154
+ // strict-zero, so without a bound such a fee would false-block a legitimate
155
+ // bridge. The tolerance is the smaller of the transaction's declared native
156
+ // value and THIS cap (see verifySwapOutcome); capping it — rather than trusting
157
+ // the quote's value outright — is what keeps the check from being turned into a
158
+ // native-ETH drain (a hostile quote could otherwise set value to the whole
159
+ // balance). Conservative and gas-independent (gas is already excluded from the
160
+ // simulated native delta); a real bridge fee is far below it. This is a
161
+ // defence-in-depth loss ceiling, not a fee estimate — revisit if a route ever
162
+ // legitimately needs more.
163
+ export const EVM_BRIDGE_NATIVE_FEE_SLACK = 2_000_000_000_000_000n; // 0.002 ETH
164
+
149
165
  // Native SOL has two on-chain spellings that denote the same asset: the
150
166
  // canonical wrapped-SOL mint (what the CLI resolves `SOL` to and persists as
151
167
  // the request intent) and the System Program address that aggregators and
@@ -912,6 +928,18 @@ export function assertSwapCalldataNotBareTransfer(data) {
912
928
 
913
929
  // ============= Swap-outcome verification (balance-delta simulation) =============
914
930
 
931
+ /**
932
+ * A cross-chain bridge's output settles on the destination chain, invisible to
933
+ * a source-chain simulation, so the output-arrival assertion is meaningless
934
+ * for one and both assert...SwapOutcome functions skip it via this check.
935
+ * Derived from the immutable persisted request intent (not the loose
936
+ * quote/quoteData) so it can't drift between calls or across chains.
937
+ */
938
+ export function isBridgeRequest(request) {
939
+ return request.toChain != null
940
+ && String(request.toChain).toLowerCase() !== String(request.chain).toLowerCase();
941
+ }
942
+
915
943
  /**
916
944
  * Assert that a SIMULATED swap's asset changes match the user's intent, failing
917
945
  * closed on any mismatch. This is a defence-in-depth outcome check that
@@ -929,6 +957,8 @@ export function assertSwapCalldataNotBareTransfer(data) {
929
957
  * log) is never counted.
930
958
  * 2. the output token arrives by AT LEAST minOut — exactOut: >= the requested
931
959
  * output; exactIn: the quoted output reduced by the slippage in effect.
960
+ * SKIPPED for a cross-chain bridge: the output settles on the destination
961
+ * chain and can never appear in a source-chain simulation.
932
962
  * 3. NO token other than the input leaves the wallet.
933
963
  * 4. the wallet grants no Approval to a spender outside `expectedSpenders`.
934
964
  *
@@ -942,8 +972,14 @@ export function assertSwapCalldataNotBareTransfer(data) {
942
972
  * @param {Set<string>|string[]} [ctx.expectedSpenders] - spenders the wallet may
943
973
  * legitimately (re)approve during the swap (e.g. the approval target and the
944
974
  * router); anything else fails assertion 4. Compared case-insensitively.
945
- * @param {bigint} [ctx.siblingDustThreshold=0n] - non-input outflow tolerated
946
- * before assertion 3 fires (for fee-on-transfer / rounding). Strict 0 default.
975
+ * @param {bigint} [ctx.siblingDustThreshold=0n] - native-token (ETH) sibling
976
+ * outflow tolerated before assertion 3 fires, and only for a cross-chain
977
+ * bridge (some bridges pay a fee via msg.value on a token-input route). ERC-20
978
+ * siblings and every same-chain swap stay strict 0. The caller must pass a
979
+ * bounded value (see EVM_BRIDGE_NATIVE_FEE_SLACK). Strict 0 default.
980
+ * @returns {{verified: true, outputAssertionSkipped: boolean}} outputAssertionSkipped
981
+ * is true for a cross-chain bridge, meaning assertion 2 did not run — the
982
+ * caller should surface this.
947
983
  * @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
948
984
  */
949
985
  export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpenders, siblingDustThreshold = 0n } = {}) {
@@ -983,6 +1019,9 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
983
1019
  throw fail(`quote input and output tokens are the same (${inputToken}); refusing to verify.`);
984
1020
  }
985
1021
 
1022
+ // Bridges skip only assertion 2 (output arrival) below — see isBridgeRequest.
1023
+ const isBridge = isBridgeRequest(request);
1024
+
986
1025
  // --- Assertion 1: input outflow within the spend ceiling ---
987
1026
  // This bounds the outflow by maxInputAmount (the slippage-buffered ceiling),
988
1027
  // NOT the exact expected input: for exactOut the aggregator may legitimately
@@ -1005,9 +1044,50 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
1005
1044
  throw fail(`the input token (${inputToken}) left the wallet by ${outflow}, exceeding your maximum input (${cap}).`);
1006
1045
  }
1007
1046
 
1008
- // --- Assertion 2: output arrives at or above the minimum acceptable ---
1047
+ // Fail closed on an unrecognized swap mode. `?? 'exactIn'` only defaults a
1048
+ // missing mode; a persisted request with a garbage value (an edited/older
1049
+ // quote record) must not silently fall into the exactOut branch below, which
1050
+ // would drop the intent-relative input floor. The CLI validates --swap-mode
1051
+ // against this same enum, so a well-formed quote never reaches here invalid.
1009
1052
  const swapMode = request.swapMode ?? 'exactIn';
1010
- const outputDelta = deltas[outputToken] || 0n;
1053
+ if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
1054
+ throw fail(`unrecognized swap mode "${swapMode}"; expected exactIn or exactOut.`);
1055
+ }
1056
+
1057
+ // A bridge drops assertion 2 (its output settles on the destination chain),
1058
+ // and for a normal swap that positive-output check is what implicitly proves
1059
+ // the input was actually consumed. Restore that with an intent-relative LOWER
1060
+ // bound on the source-chain outflow: a bare `outflow > 0` is too weak — a
1061
+ // fee-only or 1-unit no-op would still verify a bridge that never funded its
1062
+ // input. For exactIn the outflow must be ~the requested input (assertion 1
1063
+ // already caps it above); for exactOut the input is variable up to the cap, so
1064
+ // only a positive-outflow floor is meaningful. EVM native input delta is the
1065
+ // transferred value with gas excluded, so the outflow is exact — no fee slack.
1066
+ if (isBridge) {
1067
+ if (swapMode === 'exactIn') {
1068
+ if (request.amount == null) throw fail('bridge exactIn request is missing the requested input amount.');
1069
+ let requested;
1070
+ try {
1071
+ requested = BigInt(request.amount);
1072
+ } catch {
1073
+ throw fail(`requested input amount (${request.amount}) is not an integer.`);
1074
+ }
1075
+ if (requested <= 0n) throw fail(`bridge exactIn request has a non-positive input amount (${requested}).`);
1076
+ if (outflow < requested) {
1077
+ throw fail(`the bridge moved only ${outflow} of the input token (${inputToken}) out of the wallet, below the requested input (${requested}); a bridge must spend its full input on the source chain.`);
1078
+ }
1079
+ } else if (outflow <= 0n) {
1080
+ throw fail(`the bridge moved no input token (${inputToken}) out of the wallet; a bridge must spend its input on the source chain.`);
1081
+ }
1082
+ }
1083
+
1084
+ // --- Assertion 2: output arrives at or above the minimum acceptable ---
1085
+ // The minimum-output computation below — and its quote-integrity checks (the
1086
+ // output amount must be present, an integer, and positive) — runs for EVERY
1087
+ // swap, bridges included: a bridge quote with a missing or zero output is
1088
+ // still malformed. Only the final delta comparison is bridge-skipped, because
1089
+ // the output settles on the destination chain and can never appear in this
1090
+ // source-chain simulation.
1011
1091
  let minOut;
1012
1092
  if (swapMode === 'exactOut') {
1013
1093
  if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
@@ -1058,15 +1138,26 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
1058
1138
  const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
1059
1139
  minOut = (quoted * (10000n - bps)) / 10000n;
1060
1140
  }
1061
- if (outputDelta < minOut) {
1062
- throw fail(`the output token (${outputToken}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1141
+ if (!isBridge) {
1142
+ const outputDelta = deltas[outputToken] || 0n;
1143
+ if (outputDelta < minOut) {
1144
+ throw fail(`the output token (${outputToken}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1145
+ }
1063
1146
  }
1064
1147
 
1065
1148
  // --- Assertion 3: no token other than the input leaves the wallet ---
1066
- const dust = siblingDustThreshold > 0n ? siblingDustThreshold : 0n;
1149
+ // A bridge may pay a network fee in native ETH via msg.value on a
1150
+ // token-input route, which shows up here as a native sibling outflow. Tolerate
1151
+ // that — but only native, only for a bridge, and only up to the bounded
1152
+ // threshold the caller passes (gas is already excluded from the native delta).
1153
+ // ERC-20 siblings and every same-chain swap keep strict-zero. This mirrors the
1154
+ // native-only carve-out assertSolanaSwapOutcome already applies for SOL.
1155
+ const nativeDust = isBridge && siblingDustThreshold > 0n ? siblingDustThreshold : 0n;
1067
1156
  for (const [token, delta] of Object.entries(deltas)) {
1068
1157
  if (token === inputToken) continue; // its outflow is bounded by assertion 1
1069
- if (delta < 0n && -delta > dust) {
1158
+ if (delta >= 0n) continue;
1159
+ const dust = token === EVM_NATIVE_SENTINEL ? nativeDust : 0n;
1160
+ if (-delta > dust) {
1070
1161
  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.`);
1071
1162
  }
1072
1163
  }
@@ -1117,7 +1208,7 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
1117
1208
  }
1118
1209
  }
1119
1210
 
1120
- return { verified: true };
1211
+ return { verified: true, outputAssertionSkipped: isBridge };
1121
1212
  }
1122
1213
 
1123
1214
  // Native-SOL dust tolerated on a non-input sibling in assertSolanaSwapOutcome —
@@ -1170,6 +1261,8 @@ const NATIVE_FEE_RENT_SLACK_LAMPORTS = MAX_PRIORITY_FEE_LAMPORTS + NATIVE_SIBLIN
1170
1261
  * Native-SOL output relaxes this floor by NATIVE_FEE_RENT_SLACK_LAMPORTS
1171
1262
  * because its lamport delta also nets out the base fee, priority fee, and
1172
1263
  * ATA rent (same noise as native input); SPL output keeps the exact floor.
1264
+ * SKIPPED for a cross-chain bridge: the output settles on the destination
1265
+ * chain and can never appear in a source-chain simulation.
1173
1266
  * 3. no OTHER tracked asset leaves the wallet. SPL-token siblings get zero
1174
1267
  * tolerance; native SOL, when it's a sibling (not the input), tolerates
1175
1268
  * NATIVE_FEE_RENT_SLACK_LAMPORTS of fee/rent dust. All three assertions
@@ -1186,10 +1279,12 @@ const NATIVE_FEE_RENT_SLACK_LAMPORTS = MAX_PRIORITY_FEE_LAMPORTS + NATIVE_SIBLIN
1186
1279
  * @param {object} [ctx]
1187
1280
  * @param {number} [ctx.slippage] - slippage fraction in effect; defaults to 3%
1188
1281
  * @param {bigint} [ctx.siblingDustThreshold] - overrides NATIVE_FEE_RENT_SLACK_LAMPORTS
1189
- * @returns {{verified: true, inputAssertionSkipped: boolean}} inputAssertionSkipped
1190
- * is true when the input was native SOL, meaning assertion 1 ran with the
1191
- * fee/rent slack applied instead of an exact bound (see assertion 1's
1192
- * rationale above) the caller should surface this.
1282
+ * @returns {{verified: true, inputAssertionSkipped: boolean, outputAssertionSkipped: boolean}}
1283
+ * inputAssertionSkipped is true when the input was native SOL, meaning
1284
+ * assertion 1 ran with the fee/rent slack applied instead of an exact bound
1285
+ * (see assertion 1's rationale above). outputAssertionSkipped is true for a
1286
+ * cross-chain bridge, meaning assertion 2 did not run. The caller should
1287
+ * surface both.
1193
1288
  * @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
1194
1289
  */
1195
1290
  export function assertSolanaSwapOutcome(request, quote, sim, { slippage, siblingDustThreshold } = {}) {
@@ -1227,6 +1322,9 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
1227
1322
 
1228
1323
  const inputIsNative = inputAsset === SOL_SENTINEL;
1229
1324
 
1325
+ // Bridges skip only assertion 2 (output arrival) below — see isBridgeRequest.
1326
+ const isBridge = isBridgeRequest(request);
1327
+
1230
1328
  // --- Assertion 1: input outflow within the spend ceiling ---
1231
1329
  if (request.maxInputAmount == null) {
1232
1330
  throw fail('request has no maximum input to bound the outflow against.');
@@ -1252,11 +1350,55 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
1252
1350
  if (outflow > effectiveCap) {
1253
1351
  throw fail(`the input token (${inputAsset}) left the wallet by ${outflow}, exceeding your maximum input (${cap}${inputIsNative ? ` plus fee/rent slack` : ''}).`);
1254
1352
  }
1353
+ // Fail closed on an unrecognized swap mode (mirrors assertSwapOutcome). A
1354
+ // persisted request with a garbage value must not fall into the exactOut
1355
+ // branch below and drop the intent-relative input floor.
1356
+ const swapMode = request.swapMode ?? 'exactIn';
1357
+ if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
1358
+ throw fail(`unrecognized swap mode "${swapMode}"; expected exactIn or exactOut.`);
1359
+ }
1360
+
1361
+ // A bridge drops assertion 2 (its output settles on the destination chain),
1362
+ // and for a normal swap that positive-output check is what implicitly proves
1363
+ // the input was actually consumed. Restore that with an intent-relative LOWER
1364
+ // bound on the source-chain outflow: a bare `outflow > 0` is too weak — a
1365
+ // native-SOL leg always burns a fee, so a fee-only no-op (and any partial SPL
1366
+ // outflow) would otherwise verify a bridge that never funded its input. For
1367
+ // exactIn the outflow must be ~the requested input (assertion 1 caps it
1368
+ // above); for exactOut the input is variable up to the cap, so only a
1369
+ // positive-outflow floor is meaningful. Native-SOL input carries fee/rent
1370
+ // noise (bridged amount + base/priority fee − reclaimed ATA rent), so relax
1371
+ // the floor by the same slack assertion 1 adds to the ceiling; SPL is exact.
1372
+ if (isBridge) {
1373
+ if (swapMode === 'exactIn') {
1374
+ if (request.amount == null) throw fail('bridge exactIn request is missing the requested input amount.');
1375
+ let requested;
1376
+ try {
1377
+ requested = BigInt(request.amount);
1378
+ } catch {
1379
+ throw fail(`requested input amount (${request.amount}) is not an integer.`);
1380
+ }
1381
+ if (requested <= 0n) throw fail(`bridge exactIn request has a non-positive input amount (${requested}).`);
1382
+ const floorSlack = inputIsNative ? NATIVE_FEE_RENT_SLACK_LAMPORTS : 0n;
1383
+ // Clamp the floor to a positive minimum: for a native bridge smaller than
1384
+ // the fee/rent slack a real leg is indistinguishable from a fee-only no-op,
1385
+ // so the tightest we can still require is a non-zero outflow.
1386
+ const minOutflow = requested > floorSlack ? requested - floorSlack : 1n;
1387
+ if (outflow < minOutflow) {
1388
+ throw fail(`the bridge moved only ${outflow} of the input token (${inputAsset}) out of the wallet, below the requested input (${requested}${inputIsNative ? ` minus fee/rent slack` : ''}); a bridge must spend its full input on the source chain.`);
1389
+ }
1390
+ } else if (outflow <= 0n) {
1391
+ throw fail(`the bridge moved no input token (${inputAsset}) out of the wallet; a bridge must spend its input on the source chain.`);
1392
+ }
1393
+ }
1255
1394
 
1256
1395
  // --- Assertion 2: output arrives at or above the minimum acceptable ---
1257
- const swapMode = request.swapMode ?? 'exactIn';
1258
- const outputIsNative = outputAsset === SOL_SENTINEL;
1259
- const outputDelta = deltas[outputAsset] || 0n;
1396
+ // The minimum-output computation below — and its quote-integrity checks (the
1397
+ // output amount must be present, an integer, and positive) — runs for EVERY
1398
+ // swap, bridges included: a bridge quote with a missing or zero output is
1399
+ // still malformed. Only the final delta comparison is bridge-skipped, because
1400
+ // the output settles on the destination chain and can never appear in this
1401
+ // source-chain simulation.
1260
1402
  let minOut;
1261
1403
  if (swapMode === 'exactOut') {
1262
1404
  if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
@@ -1290,26 +1432,30 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
1290
1432
  const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
1291
1433
  minOut = (quoted * (10000n - bps)) / 10000n;
1292
1434
  }
1293
- // Native-SOL output carries the same fee/rent noise as native input: the
1294
- // lamport delta is (SOL received − base/priority fee − net ATA rent), so a
1295
- // legitimate trade can land a few million lamports under the quoted amount at
1296
- // tight slippage or on a congested-network priority fee. Relax the floor by
1297
- // the same combined fee/rent slack used for native siblings (assertion 3) and
1298
- // native input (assertion 1) so fee noise never false-blocks; the slippage
1299
- // floor still bounds any real shortfall. SPL output has no such noise and
1300
- // keeps the exact floor.
1301
- const outputFloorSlack = outputIsNative
1302
- ? (siblingDustThreshold != null ? siblingDustThreshold : NATIVE_FEE_RENT_SLACK_LAMPORTS)
1303
- : 0n;
1304
- // minOut can be smaller than the dust tolerance for a dust-quoted swap; clamp
1305
- // the floor at 0 so the subtraction never goes negative and silently admits
1306
- // any non-negative outputDelta (including zero). The explicit outputDelta <= 0n
1307
- // check below then restores the invariant assertSwapOutcome (the EVM sibling)
1308
- // gets for free because its minOut can never collapse to <= 0: a swap must
1309
- // deliver SOME positive output, even when the dust-adjusted floor is 0.
1310
- const adjustedFloor = minOut > outputFloorSlack ? minOut - outputFloorSlack : 0n;
1311
- if (outputDelta <= 0n || outputDelta < adjustedFloor) {
1312
- throw fail(`the output token (${outputAsset}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1435
+ if (!isBridge) {
1436
+ const outputIsNative = outputAsset === SOL_SENTINEL;
1437
+ const outputDelta = deltas[outputAsset] || 0n;
1438
+ // Native-SOL output carries the same fee/rent noise as native input: the
1439
+ // lamport delta is (SOL received base/priority fee net ATA rent), so a
1440
+ // legitimate trade can land a few million lamports under the quoted amount at
1441
+ // tight slippage or on a congested-network priority fee. Relax the floor by
1442
+ // the same combined fee/rent slack used for native siblings (assertion 3) and
1443
+ // native input (assertion 1) so fee noise never false-blocks; the slippage
1444
+ // floor still bounds any real shortfall. SPL output has no such noise and
1445
+ // keeps the exact floor.
1446
+ const outputFloorSlack = outputIsNative
1447
+ ? (siblingDustThreshold != null ? siblingDustThreshold : NATIVE_FEE_RENT_SLACK_LAMPORTS)
1448
+ : 0n;
1449
+ // minOut can be smaller than the dust tolerance for a dust-quoted swap; clamp
1450
+ // the floor at 0 so the subtraction never goes negative and silently admits
1451
+ // any non-negative outputDelta (including zero). The explicit outputDelta <= 0n
1452
+ // check below then restores the invariant assertSwapOutcome (the EVM sibling)
1453
+ // gets for free because its minOut can never collapse to <= 0: a swap must
1454
+ // deliver SOME positive output, even when the dust-adjusted floor is 0.
1455
+ const adjustedFloor = minOut > outputFloorSlack ? minOut - outputFloorSlack : 0n;
1456
+ if (outputDelta <= 0n || outputDelta < adjustedFloor) {
1457
+ throw fail(`the output token (${outputAsset}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1458
+ }
1313
1459
  }
1314
1460
 
1315
1461
  // --- Assertion 3: no other tracked asset leaves the wallet ---
@@ -1326,7 +1472,9 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
1326
1472
  // inputAssertionSkipped tells the caller assertion 1 ran with the fee/rent
1327
1473
  // slack applied (native-SOL input, per the JSDoc above), so it can surface
1328
1474
  // that instead of implying the input spend was tightly delta-verified.
1329
- return { verified: true, inputAssertionSkipped: inputIsNative };
1475
+ // outputAssertionSkipped tells the caller assertion 2 did not run at all
1476
+ // (cross-chain bridge, per the JSDoc above).
1477
+ return { verified: true, inputAssertionSkipped: inputIsNative, outputAssertionSkipped: isBridge };
1330
1478
  }
1331
1479
 
1332
1480
  /**