nansen-cli 1.40.1 → 1.41.1

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.
@@ -6,9 +6,33 @@
6
6
 
7
7
  import { validateAddress } from './api.js';
8
8
  import { CHAIN_RPCS } from './rpc-urls.js';
9
+ import { parseTransactionMessage, resolveStaticAccount } from './solana-tx.js';
10
+ import { SOL_SENTINEL } from './solana-simulation.js';
9
11
 
10
12
  const SUPPORTED_CHAINS = ['solana', 'base'];
11
13
 
14
+ // SPL Token / Token-2022 instruction discriminators (first data byte) that can
15
+ // move control of a user's token account without moving its balance — the
16
+ // class of drain vector a balance-delta simulation can't see (see
17
+ // assertSolanaInstructionsSafe).
18
+ const SPL_TOKEN_PROGRAMS = new Set([
19
+ 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
20
+ 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb',
21
+ ]);
22
+ const SPL_APPROVE = 4;
23
+ const SPL_SET_AUTHORITY = 6;
24
+ const SPL_CLOSE_ACCOUNT = 9;
25
+ const SPL_APPROVE_CHECKED = 13;
26
+
27
+ const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111';
28
+ const COMPUTE_BUDGET_SET_UNIT_LIMIT = 2;
29
+ const COMPUTE_BUDGET_SET_UNIT_PRICE = 3;
30
+ const SOLANA_MAX_COMPUTE_UNITS = 1_400_000; // Solana's per-transaction compute-unit ceiling — the
31
+ // worst-case bound used when a price is set with no
32
+ // explicit limit instruction.
33
+ const MAX_PRIORITY_FEE_LAMPORTS = 10_000_000n; // 0.01 SOL sanity ceiling on the priority fee a
34
+ // single trade can be made to pay.
35
+
12
36
  /**
13
37
  * Validate quote inputs before any network call.
14
38
  * Throws on validation failure with an actionable error message.
@@ -578,7 +602,8 @@ export function needsAllowanceRevoke(existingAllowance, approveAmt) {
578
602
 
579
603
  /**
580
604
  * Compare two token addresses for equality (case-insensitive on EVM, exact on
581
- * Solana). Missing values never match.
605
+ * Solana except for the native-SOL sentinel aliasing above). Missing values
606
+ * never match.
582
607
  */
583
608
  function tokensEqual(a, b, chain) {
584
609
  if (!a || !b) return false;
@@ -742,13 +767,15 @@ export function assertQuoteMatchesRequest(request, quote, { chain, walletAddress
742
767
  * exactOut gap where the API chooses the input and nothing capped it.
743
768
  *
744
769
  * The amount compared against the cap is the maximum that can actually leave the
745
- * wallet — for exactOut that is the slippage-buffered approval, NOT the raw quote
746
- * input. The approval encoder (encodeApproveCalldata) scopes the ERC-20 approval
747
- * to that same buffered amount and caps it at maxInputAmount, so validating the
748
- * raw input here would let a quote pass this check and then be refused at signing
749
- * (a 1,000,000 input at 3% slippage needs a 1,030,000 approval, which a 1,000,000
750
- * cap rejects). Comparing the same amount approvalAmountForSwap produces keeps
751
- * this check and the encoder in lockstep.
770
+ * wallet — for exactOut that is the slippage-buffered spend, NOT the raw quote
771
+ * input. On EVM the approval encoder (encodeApproveCalldata) scopes the ERC-20
772
+ * approval to that same buffered amount and caps it at maxInputAmount, so
773
+ * validating the raw input here would let a quote pass this check and then be
774
+ * refused at signing (a 1,000,000 input at 3% slippage needs a 1,030,000
775
+ * approval, which a 1,000,000 cap rejects). On Solana there is no approval step,
776
+ * but the swap can still consume up to that buffered amount, so the same ceiling
777
+ * applies. Comparing the amount approvalAmountForSwap produces keeps this check
778
+ * consistent with what the execute path can actually spend.
752
779
  *
753
780
  * Behaviour:
754
781
  * - exactOut with no persisted `maxInputAmount` → throws (fail closed). The
@@ -759,8 +786,9 @@ export function assertQuoteMatchesRequest(request, quote, { chain, walletAddress
759
786
  * more than the user approved leave the wallet.
760
787
  * - exactIn with no cap → no-op (request.amount already binds the input).
761
788
  *
762
- * Applies to native and ERC-20 swaps alike; the caller runs it before any
763
- * approval, transaction signing, or WalletConnect call.
789
+ * Applies to native, ERC-20, and Solana swaps alike (Solana has no approval step,
790
+ * so the "buffered spend" ceiling is just the spend itself); the caller runs it
791
+ * before any approval, transaction signing, or WalletConnect call.
764
792
  *
765
793
  * @param {object} request - Persisted intent (quoteData.request)
766
794
  * @param {object} quote - The quote being executed
@@ -816,10 +844,18 @@ export function assertInputWithinMax(request, quote, slippage) {
816
844
  );
817
845
  }
818
846
  if (spend > cap) {
847
+ // Normalize case: request.chain is persisted verbatim from the user's
848
+ // --chain input (e.g. `--chain Solana`), so an exact === would mislabel a
849
+ // Solana swap with the EVM-worded (approval/native-value) message.
850
+ const isSolana = String(request.chain).toLowerCase() === 'solana';
819
851
  throw new Error(
820
852
  swapMode === 'exactOut'
821
- ? `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.`
822
- : `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.`,
853
+ ? isSolana
854
+ ? `Quote needs ${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.`
855
+ : `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.`
856
+ : isSolana
857
+ ? `Quote input amount (${input}) exceeds your maximum input (${cap}). Refusing to sign.`
858
+ : `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.`,
823
859
  );
824
860
  }
825
861
  }
@@ -876,6 +912,18 @@ export function assertSwapCalldataNotBareTransfer(data) {
876
912
 
877
913
  // ============= Swap-outcome verification (balance-delta simulation) =============
878
914
 
915
+ /**
916
+ * A cross-chain bridge's output settles on the destination chain, invisible to
917
+ * a source-chain simulation, so the output-arrival assertion is meaningless
918
+ * for one and both assert...SwapOutcome functions skip it via this check.
919
+ * Derived from the immutable persisted request intent (not the loose
920
+ * quote/quoteData) so it can't drift between calls or across chains.
921
+ */
922
+ function isBridgeRequest(request) {
923
+ return request.toChain != null
924
+ && String(request.toChain).toLowerCase() !== String(request.chain).toLowerCase();
925
+ }
926
+
879
927
  /**
880
928
  * Assert that a SIMULATED swap's asset changes match the user's intent, failing
881
929
  * closed on any mismatch. This is a defence-in-depth outcome check that
@@ -893,6 +941,8 @@ export function assertSwapCalldataNotBareTransfer(data) {
893
941
  * log) is never counted.
894
942
  * 2. the output token arrives by AT LEAST minOut — exactOut: >= the requested
895
943
  * output; exactIn: the quoted output reduced by the slippage in effect.
944
+ * SKIPPED for a cross-chain bridge: the output settles on the destination
945
+ * chain and can never appear in a source-chain simulation.
896
946
  * 3. NO token other than the input leaves the wallet.
897
947
  * 4. the wallet grants no Approval to a spender outside `expectedSpenders`.
898
948
  *
@@ -908,6 +958,9 @@ export function assertSwapCalldataNotBareTransfer(data) {
908
958
  * router); anything else fails assertion 4. Compared case-insensitively.
909
959
  * @param {bigint} [ctx.siblingDustThreshold=0n] - non-input outflow tolerated
910
960
  * before assertion 3 fires (for fee-on-transfer / rounding). Strict 0 default.
961
+ * @returns {{verified: true, outputAssertionSkipped: boolean}} outputAssertionSkipped
962
+ * is true for a cross-chain bridge, meaning assertion 2 did not run — the
963
+ * caller should surface this.
911
964
  * @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
912
965
  */
913
966
  export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpenders, siblingDustThreshold = 0n } = {}) {
@@ -947,6 +1000,9 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
947
1000
  throw fail(`quote input and output tokens are the same (${inputToken}); refusing to verify.`);
948
1001
  }
949
1002
 
1003
+ // Bridges skip only assertion 2 (output arrival) below — see isBridgeRequest.
1004
+ const isBridge = isBridgeRequest(request);
1005
+
950
1006
  // --- Assertion 1: input outflow within the spend ceiling ---
951
1007
  // This bounds the outflow by maxInputAmount (the slippage-buffered ceiling),
952
1008
  // NOT the exact expected input: for exactOut the aggregator may legitimately
@@ -969,9 +1025,50 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
969
1025
  throw fail(`the input token (${inputToken}) left the wallet by ${outflow}, exceeding your maximum input (${cap}).`);
970
1026
  }
971
1027
 
972
- // --- Assertion 2: output arrives at or above the minimum acceptable ---
1028
+ // Fail closed on an unrecognized swap mode. `?? 'exactIn'` only defaults a
1029
+ // missing mode; a persisted request with a garbage value (an edited/older
1030
+ // quote record) must not silently fall into the exactOut branch below, which
1031
+ // would drop the intent-relative input floor. The CLI validates --swap-mode
1032
+ // against this same enum, so a well-formed quote never reaches here invalid.
973
1033
  const swapMode = request.swapMode ?? 'exactIn';
974
- const outputDelta = deltas[outputToken] || 0n;
1034
+ if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
1035
+ throw fail(`unrecognized swap mode "${swapMode}"; expected exactIn or exactOut.`);
1036
+ }
1037
+
1038
+ // A bridge drops assertion 2 (its output settles on the destination chain),
1039
+ // and for a normal swap that positive-output check is what implicitly proves
1040
+ // the input was actually consumed. Restore that with an intent-relative LOWER
1041
+ // bound on the source-chain outflow: a bare `outflow > 0` is too weak — a
1042
+ // fee-only or 1-unit no-op would still verify a bridge that never funded its
1043
+ // input. For exactIn the outflow must be ~the requested input (assertion 1
1044
+ // already caps it above); for exactOut the input is variable up to the cap, so
1045
+ // only a positive-outflow floor is meaningful. EVM native input delta is the
1046
+ // transferred value with gas excluded, so the outflow is exact — no fee slack.
1047
+ if (isBridge) {
1048
+ if (swapMode === 'exactIn') {
1049
+ if (request.amount == null) throw fail('bridge exactIn request is missing the requested input amount.');
1050
+ let requested;
1051
+ try {
1052
+ requested = BigInt(request.amount);
1053
+ } catch {
1054
+ throw fail(`requested input amount (${request.amount}) is not an integer.`);
1055
+ }
1056
+ if (requested <= 0n) throw fail(`bridge exactIn request has a non-positive input amount (${requested}).`);
1057
+ if (outflow < requested) {
1058
+ 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.`);
1059
+ }
1060
+ } else if (outflow <= 0n) {
1061
+ throw fail(`the bridge moved no input token (${inputToken}) out of the wallet; a bridge must spend its input on the source chain.`);
1062
+ }
1063
+ }
1064
+
1065
+ // --- Assertion 2: output arrives at or above the minimum acceptable ---
1066
+ // The minimum-output computation below — and its quote-integrity checks (the
1067
+ // output amount must be present, an integer, and positive) — runs for EVERY
1068
+ // swap, bridges included: a bridge quote with a missing or zero output is
1069
+ // still malformed. Only the final delta comparison is bridge-skipped, because
1070
+ // the output settles on the destination chain and can never appear in this
1071
+ // source-chain simulation.
975
1072
  let minOut;
976
1073
  if (swapMode === 'exactOut') {
977
1074
  if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
@@ -1022,8 +1119,11 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
1022
1119
  const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
1023
1120
  minOut = (quoted * (10000n - bps)) / 10000n;
1024
1121
  }
1025
- if (outputDelta < minOut) {
1026
- throw fail(`the output token (${outputToken}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1122
+ if (!isBridge) {
1123
+ const outputDelta = deltas[outputToken] || 0n;
1124
+ if (outputDelta < minOut) {
1125
+ throw fail(`the output token (${outputToken}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1126
+ }
1027
1127
  }
1028
1128
 
1029
1129
  // --- Assertion 3: no token other than the input leaves the wallet ---
@@ -1081,5 +1181,426 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
1081
1181
  }
1082
1182
  }
1083
1183
 
1084
- return { verified: true };
1184
+ return { verified: true, outputAssertionSkipped: isBridge };
1185
+ }
1186
+
1187
+ // Native-SOL dust tolerated on a non-input sibling in assertSolanaSwapOutcome —
1188
+ // covers the base tx fee plus one transient ATA's rent (e.g. a WSOL account
1189
+ // opened and closed within the swap). SPL-token siblings get no such tolerance
1190
+ // (dust threshold 0n); only native SOL legitimately moves as a byproduct of fees
1191
+ // and rent rather than the swap itself.
1192
+ const NATIVE_SIBLING_DUST_LAMPORTS = 3_000_000n; // ~0.003 SOL
1193
+
1194
+ // Full native-SOL fee/rent noise budget: the dust above PLUS the priority fee
1195
+ // a transaction may legitimately pay, up to the ceiling assertSolanaInstructionsSafe
1196
+ // enforces. NATIVE_SIBLING_DUST_LAMPORTS alone only covers the base fee + rent —
1197
+ // a real, legal priority fee (anywhere up to MAX_PRIORITY_FEE_LAMPORTS) also
1198
+ // leaves the wallet as native SOL regardless of whether SOL is the input,
1199
+ // output, or an uninvolved sibling of the swap, so all three assertions below
1200
+ // need the same combined slack or a legitimate high-priority-fee trade false-blocks.
1201
+ const NATIVE_FEE_RENT_SLACK_LAMPORTS = MAX_PRIORITY_FEE_LAMPORTS + NATIVE_SIBLING_DUST_LAMPORTS;
1202
+
1203
+ /**
1204
+ * The Solana sibling of assertSwapOutcome. Solana signs the aggregator's
1205
+ * serialized transaction verbatim and has no approval/calldata split to
1206
+ * validate, so this verifies the balance-delta simulation result (see
1207
+ * solana-simulation.js) against the persisted request intent directly.
1208
+ *
1209
+ * REQUIRES assertSolanaInstructionsSafe to have already run, RPC-free, on the
1210
+ * same transaction (both current signing paths in trading.js call it first):
1211
+ * an unexpected authority grant is rejected there (no assertion 4 sibling
1212
+ * needed here), and assertion 1's native-input slack below is only a safe
1213
+ * bound because that check has already enforced the priority-fee ceiling —
1214
+ * skip it on any future signing path and native-input drains widen from a
1215
+ * fixed slack to an unbounded priority fee.
1216
+ *
1217
+ * Three assertions:
1218
+ * 1. the input token leaves the wallet by no more than maxInputAmount.
1219
+ * Native-SOL input can't be bound at the exact cap the way an SPL input
1220
+ * can: its lamport delta also carries the base fee, priority fee, and net
1221
+ * ATA rent (opened minus reclaimed), which is too noisy for a tight
1222
+ * bound. It is still bounded, not skipped — the cap is relaxed by a
1223
+ * fee/rent slack (the priority-fee ceiling assertSolanaInstructionsSafe
1224
+ * enforces, plus one transient ATA's rent) so a real outflow beyond any
1225
+ * realistic transaction cost is still caught. Without this, a
1226
+ * transaction with an extra unaccounted native-SOL outflow (e.g. a plain
1227
+ * System-Program transfer, which assertSolanaInstructionsSafe does not
1228
+ * classify) would sail through as long as the declared output arrived —
1229
+ * neither assertQuoteMatchesRequest (checks the quote's declared
1230
+ * metadata, not the transaction's real effects) nor assertion 3 (which
1231
+ * exempts the input asset, assuming assertion 1 already bounded it)
1232
+ * would catch it.
1233
+ * 2. the output token arrives by at least the minimum acceptable amount.
1234
+ * Native-SOL output relaxes this floor by NATIVE_FEE_RENT_SLACK_LAMPORTS
1235
+ * because its lamport delta also nets out the base fee, priority fee, and
1236
+ * ATA rent (same noise as native input); SPL output keeps the exact floor.
1237
+ * SKIPPED for a cross-chain bridge: the output settles on the destination
1238
+ * chain and can never appear in a source-chain simulation.
1239
+ * 3. no OTHER tracked asset leaves the wallet. SPL-token siblings get zero
1240
+ * tolerance; native SOL, when it's a sibling (not the input), tolerates
1241
+ * NATIVE_FEE_RENT_SLACK_LAMPORTS of fee/rent dust. All three assertions
1242
+ * share this one slack value — splitting it (e.g. a smaller tolerance for
1243
+ * assertion 2/3 than assertion 1) would false-block a legitimate trade
1244
+ * paying close to the priority-fee ceiling on whichever assertion has the
1245
+ * smaller number, since the same fee leaves the wallet as native SOL
1246
+ * regardless of SOL's role in that particular swap.
1247
+ *
1248
+ * @param {object} request - persisted intent (quoteData.request); required
1249
+ * @param {object} quote - the quote being executed
1250
+ * @param {{deltas: Record<string, bigint|string|number>}} sim - the normalised
1251
+ * result from simulateSolanaAssetChanges()
1252
+ * @param {object} [ctx]
1253
+ * @param {number} [ctx.slippage] - slippage fraction in effect; defaults to 3%
1254
+ * @param {bigint} [ctx.siblingDustThreshold] - overrides NATIVE_FEE_RENT_SLACK_LAMPORTS
1255
+ * @returns {{verified: true, inputAssertionSkipped: boolean, outputAssertionSkipped: boolean}}
1256
+ * inputAssertionSkipped is true when the input was native SOL, meaning
1257
+ * assertion 1 ran with the fee/rent slack applied instead of an exact bound
1258
+ * (see assertion 1's rationale above). outputAssertionSkipped is true for a
1259
+ * cross-chain bridge, meaning assertion 2 did not run. The caller should
1260
+ * surface both.
1261
+ * @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
1262
+ */
1263
+ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, siblingDustThreshold } = {}) {
1264
+ const fail = (detail) => {
1265
+ const e = new Error(`Swap outcome mismatch (SWAP_OUTCOME_MISMATCH): ${detail} Refusing to sign.`);
1266
+ e.code = 'SWAP_OUTCOME_MISMATCH';
1267
+ return e;
1268
+ };
1269
+
1270
+ if (!request) throw fail('no request intent to verify the outcome against.');
1271
+ if (!sim || typeof sim !== 'object' || sim.deltas == null) {
1272
+ throw fail('simulation returned no asset changes to verify.');
1273
+ }
1274
+
1275
+ const deltas = {};
1276
+ for (const [k, v] of Object.entries(sim.deltas)) {
1277
+ let amt;
1278
+ try {
1279
+ amt = typeof v === 'bigint' ? v : BigInt(v);
1280
+ } catch {
1281
+ throw fail(`simulated delta for ${k} (${v}) is not an integer.`);
1282
+ }
1283
+ deltas[k] = amt;
1284
+ }
1285
+
1286
+ const foldNative = (mint) => (mint && SOLANA_NATIVE_SOL_ALIASES.has(mint) ? SOL_SENTINEL : mint);
1287
+ const inputAsset = quote?.inputMint ? foldNative(quote.inputMint) : null;
1288
+ const outputAsset = quote?.outputMint ? foldNative(quote.outputMint) : null;
1289
+ if (!inputAsset || !outputAsset) {
1290
+ throw fail('quote is missing the input or output token address.');
1291
+ }
1292
+ if (inputAsset === outputAsset) {
1293
+ throw fail(`quote input and output tokens are the same (${inputAsset}); refusing to verify.`);
1294
+ }
1295
+
1296
+ const inputIsNative = inputAsset === SOL_SENTINEL;
1297
+
1298
+ // Bridges skip only assertion 2 (output arrival) below — see isBridgeRequest.
1299
+ const isBridge = isBridgeRequest(request);
1300
+
1301
+ // --- Assertion 1: input outflow within the spend ceiling ---
1302
+ if (request.maxInputAmount == null) {
1303
+ throw fail('request has no maximum input to bound the outflow against.');
1304
+ }
1305
+ let cap;
1306
+ try {
1307
+ cap = BigInt(request.maxInputAmount);
1308
+ } catch {
1309
+ throw fail(`maximum input (${request.maxInputAmount}) is not an integer.`);
1310
+ }
1311
+ // Native-SOL input's lamport delta also carries the base fee, priority fee,
1312
+ // and net ATA rent (opened minus reclaimed), so it can't be bound at the
1313
+ // exact cap the way an SPL input can — but it must still be BOUNDED, not
1314
+ // skipped: without this, a transaction with an extra unaccounted native-SOL
1315
+ // outflow (e.g. a plain System-Program transfer, which assertSolanaInstructionsSafe
1316
+ // does not classify) sails through as long as the declared output still
1317
+ // arrives. The slack allows the worst realistic fee/rent noise — the same
1318
+ // priority-fee ceiling assertSolanaInstructionsSafe enforces, plus one
1319
+ // transient ATA's rent — without opening the cap back up to an unbounded drain.
1320
+ const effectiveCap = inputIsNative ? cap + NATIVE_FEE_RENT_SLACK_LAMPORTS : cap;
1321
+ const inputDelta = deltas[inputAsset] || 0n;
1322
+ const outflow = inputDelta < 0n ? -inputDelta : 0n;
1323
+ if (outflow > effectiveCap) {
1324
+ throw fail(`the input token (${inputAsset}) left the wallet by ${outflow}, exceeding your maximum input (${cap}${inputIsNative ? ` plus fee/rent slack` : ''}).`);
1325
+ }
1326
+ // Fail closed on an unrecognized swap mode (mirrors assertSwapOutcome). A
1327
+ // persisted request with a garbage value must not fall into the exactOut
1328
+ // branch below and drop the intent-relative input floor.
1329
+ const swapMode = request.swapMode ?? 'exactIn';
1330
+ if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
1331
+ throw fail(`unrecognized swap mode "${swapMode}"; expected exactIn or exactOut.`);
1332
+ }
1333
+
1334
+ // A bridge drops assertion 2 (its output settles on the destination chain),
1335
+ // and for a normal swap that positive-output check is what implicitly proves
1336
+ // the input was actually consumed. Restore that with an intent-relative LOWER
1337
+ // bound on the source-chain outflow: a bare `outflow > 0` is too weak — a
1338
+ // native-SOL leg always burns a fee, so a fee-only no-op (and any partial SPL
1339
+ // outflow) would otherwise verify a bridge that never funded its input. For
1340
+ // exactIn the outflow must be ~the requested input (assertion 1 caps it
1341
+ // above); for exactOut the input is variable up to the cap, so only a
1342
+ // positive-outflow floor is meaningful. Native-SOL input carries fee/rent
1343
+ // noise (bridged amount + base/priority fee − reclaimed ATA rent), so relax
1344
+ // the floor by the same slack assertion 1 adds to the ceiling; SPL is exact.
1345
+ if (isBridge) {
1346
+ if (swapMode === 'exactIn') {
1347
+ if (request.amount == null) throw fail('bridge exactIn request is missing the requested input amount.');
1348
+ let requested;
1349
+ try {
1350
+ requested = BigInt(request.amount);
1351
+ } catch {
1352
+ throw fail(`requested input amount (${request.amount}) is not an integer.`);
1353
+ }
1354
+ if (requested <= 0n) throw fail(`bridge exactIn request has a non-positive input amount (${requested}).`);
1355
+ const floorSlack = inputIsNative ? NATIVE_FEE_RENT_SLACK_LAMPORTS : 0n;
1356
+ // Clamp the floor to a positive minimum: for a native bridge smaller than
1357
+ // the fee/rent slack a real leg is indistinguishable from a fee-only no-op,
1358
+ // so the tightest we can still require is a non-zero outflow.
1359
+ const minOutflow = requested > floorSlack ? requested - floorSlack : 1n;
1360
+ if (outflow < minOutflow) {
1361
+ 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.`);
1362
+ }
1363
+ } else if (outflow <= 0n) {
1364
+ throw fail(`the bridge moved no input token (${inputAsset}) out of the wallet; a bridge must spend its input on the source chain.`);
1365
+ }
1366
+ }
1367
+
1368
+ // --- Assertion 2: output arrives at or above the minimum acceptable ---
1369
+ // The minimum-output computation below — and its quote-integrity checks (the
1370
+ // output amount must be present, an integer, and positive) — runs for EVERY
1371
+ // swap, bridges included: a bridge quote with a missing or zero output is
1372
+ // still malformed. Only the final delta comparison is bridge-skipped, because
1373
+ // the output settles on the destination chain and can never appear in this
1374
+ // source-chain simulation.
1375
+ let minOut;
1376
+ if (swapMode === 'exactOut') {
1377
+ if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
1378
+ try {
1379
+ minOut = BigInt(request.amount);
1380
+ } catch {
1381
+ throw fail(`requested output amount (${request.amount}) is not an integer.`);
1382
+ }
1383
+ if (minOut <= 0n) {
1384
+ throw fail(`exactOut request has a non-positive output amount (${minOut}); cannot compute a minimum acceptable output.`);
1385
+ }
1386
+ } else {
1387
+ const quotedRaw = quote?.outAmount ?? quote?.outputAmount;
1388
+ if (quotedRaw == null) {
1389
+ throw fail('quote is missing the quoted output amount; cannot compute the minimum acceptable output.');
1390
+ }
1391
+ let quoted;
1392
+ try {
1393
+ quoted = BigInt(quotedRaw);
1394
+ } catch {
1395
+ throw fail(`quoted output amount (${quotedRaw}) is not an integer.`);
1396
+ }
1397
+ if (quoted <= 0n) {
1398
+ throw fail(`quote has a non-positive output amount (${quoted}); cannot compute a minimum acceptable output.`);
1399
+ }
1400
+ // Floor of quoted × (1 − slippage), capped at 50% independent of what the
1401
+ // user set (mirrors assertSwapOutcome's rationale: a defence-in-depth
1402
+ // floor, not the user's execution tolerance).
1403
+ const rawSlip = Number.isFinite(slippage) && slippage >= 0 ? slippage : 0.03;
1404
+ const slip = Math.min(rawSlip, 0.5);
1405
+ const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
1406
+ minOut = (quoted * (10000n - bps)) / 10000n;
1407
+ }
1408
+ if (!isBridge) {
1409
+ const outputIsNative = outputAsset === SOL_SENTINEL;
1410
+ const outputDelta = deltas[outputAsset] || 0n;
1411
+ // Native-SOL output carries the same fee/rent noise as native input: the
1412
+ // lamport delta is (SOL received − base/priority fee − net ATA rent), so a
1413
+ // legitimate trade can land a few million lamports under the quoted amount at
1414
+ // tight slippage or on a congested-network priority fee. Relax the floor by
1415
+ // the same combined fee/rent slack used for native siblings (assertion 3) and
1416
+ // native input (assertion 1) so fee noise never false-blocks; the slippage
1417
+ // floor still bounds any real shortfall. SPL output has no such noise and
1418
+ // keeps the exact floor.
1419
+ const outputFloorSlack = outputIsNative
1420
+ ? (siblingDustThreshold != null ? siblingDustThreshold : NATIVE_FEE_RENT_SLACK_LAMPORTS)
1421
+ : 0n;
1422
+ // minOut can be smaller than the dust tolerance for a dust-quoted swap; clamp
1423
+ // the floor at 0 so the subtraction never goes negative and silently admits
1424
+ // any non-negative outputDelta (including zero). The explicit outputDelta <= 0n
1425
+ // check below then restores the invariant assertSwapOutcome (the EVM sibling)
1426
+ // gets for free because its minOut can never collapse to <= 0: a swap must
1427
+ // deliver SOME positive output, even when the dust-adjusted floor is 0.
1428
+ const adjustedFloor = minOut > outputFloorSlack ? minOut - outputFloorSlack : 0n;
1429
+ if (outputDelta <= 0n || outputDelta < adjustedFloor) {
1430
+ throw fail(`the output token (${outputAsset}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1431
+ }
1432
+ }
1433
+
1434
+ // --- Assertion 3: no other tracked asset leaves the wallet ---
1435
+ const nativeDust = siblingDustThreshold != null ? siblingDustThreshold : NATIVE_FEE_RENT_SLACK_LAMPORTS;
1436
+ for (const [token, delta] of Object.entries(deltas)) {
1437
+ if (token === inputAsset) continue; // bounded by assertion 1 (with fee/rent slack, for native input)
1438
+ if (delta >= 0n) continue;
1439
+ const dust = token === SOL_SENTINEL ? nativeDust : 0n;
1440
+ if (-delta > dust) {
1441
+ 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.`);
1442
+ }
1443
+ }
1444
+
1445
+ // inputAssertionSkipped tells the caller assertion 1 ran with the fee/rent
1446
+ // slack applied (native-SOL input, per the JSDoc above), so it can surface
1447
+ // that instead of implying the input spend was tightly delta-verified.
1448
+ // outputAssertionSkipped tells the caller assertion 2 did not run at all
1449
+ // (cross-chain bridge, per the JSDoc above).
1450
+ return { verified: true, inputAssertionSkipped: inputIsNative, outputAssertionSkipped: isBridge };
1451
+ }
1452
+
1453
+ /**
1454
+ * Statically inspect a Solana transaction's instructions for drain vectors a
1455
+ * balance-delta simulation can't see — granting a token delegate, changing a
1456
+ * token account's authority, or closing an account to a stranger — and for an
1457
+ * excessive compute-budget priority fee. Runs before signing, on the raw
1458
+ * instructions rather than trusting the aggregator's intent.
1459
+ *
1460
+ * Scope: this inspects only the recognized top-level instructions of the
1461
+ * message — the SPL Token and ComputeBudget programs. It does not, and by
1462
+ * design cannot, see instructions a program issues via CPI at runtime, nor
1463
+ * does it classify calls to programs it doesn't recognize. It is one layer
1464
+ * (paired with the intent-binding metadata check), not a complete
1465
+ * authorization audit of the transaction.
1466
+ *
1467
+ * IMPORTANT — this static check does NOT classify SPL Transfer/TransferChecked:
1468
+ * a legitimate swap or vault deposit moves the input token (and WSOL) with
1469
+ * exactly those instructions, so they can't be blanket-rejected, and there is
1470
+ * nothing here to bound their destination or amount. On its own this leaves a
1471
+ * residual gap: a transaction that also transfers an unrelated ("sibling")
1472
+ * token the wallet holds, authorized by the wallet, would pass this check.
1473
+ * That gap is now closed, for swap execution, by outcome simulation —
1474
+ * assertSolanaSwapOutcome, run via verifySolanaSwapOutcome immediately after
1475
+ * this check on all Solana swap-execute signing paths (trading.js), simulates
1476
+ * the transaction and rejects any balance delta on a token other than the
1477
+ * declared input/output. That simulation degrades gracefully (warns and
1478
+ * proceeds) when no simulation RPC endpoint is configured, so this static
1479
+ * check plus the metadata binding remain the ONLY transaction-level guards
1480
+ * whenever a sim RPC is unavailable. Limit-order vault deposit/cancel
1481
+ * (limit-order.js) call only this static check, not verifySolanaSwapOutcome —
1482
+ * they have no swap quote (no declared input/output pair) to bind an outcome
1483
+ * check against, so the sibling-transfer gap described above is still open
1484
+ * there; tracked as a follow-up, not covered here.
1485
+ *
1486
+ * Within that scope, the SPL Token program requires the *authority* of
1487
+ * Approve/ApproveChecked/SetAuthority/CloseAccount to sign the transaction,
1488
+ * so checking "does our wallet authorize this instruction" catches the drain
1489
+ * without an RPC-based account-ownership lookup. For a single-owner authority
1490
+ * the wallet sits in the authority position itself; for a multisig authority
1491
+ * the authority account is the multisig and our wallet appears among the
1492
+ * signer accounts that follow it — so we treat the wallet signing *anywhere
1493
+ * from the authority position onward* as authorizing the instruction.
1494
+ * Address-lookup-table-resolved accounts can never be signers, so those
1495
+ * positions are always statically resolvable; only CloseAccount's destination
1496
+ * can legitimately be ALT-resolved, and an unresolvable destination is treated
1497
+ * the same as a stranger (fail closed). The instruction's own program ID must
1498
+ * also be statically resolvable — an ALT-resolved program ID can't be checked
1499
+ * against SPL_TOKEN_PROGRAMS/COMPUTE_BUDGET_PROGRAM, so it's rejected outright
1500
+ * rather than silently skipped.
1501
+ *
1502
+ * Throws on any of those patterns. Returns the parsed transaction otherwise.
1503
+ */
1504
+ export function assertSolanaInstructionsSafe(txBase64, { walletAddress } = {}) {
1505
+ // Fail closed on a missing wallet address: every authority check below
1506
+ // compares resolved accounts against `walletAddress`, so a null/undefined
1507
+ // address would make each comparison silently false and disable the drain
1508
+ // protection rather than over-reject. Refuse to run the check without knowing
1509
+ // whose signature we're guarding.
1510
+ if (!walletAddress) {
1511
+ throw new Error('Cannot verify Solana instruction safety without the signing wallet address. Refusing to sign.');
1512
+ }
1513
+ const parsed = parseTransactionMessage(txBase64);
1514
+ const accountAt = (ix, position) => resolveStaticAccount(parsed, ix.accountIndexes[position]);
1515
+
1516
+ // Does our wallet authorize this SPL instruction? For a single-owner
1517
+ // authority the wallet is at `authorityPos`; for a multisig authority the
1518
+ // authority account is the multisig and our wallet is one of the signer
1519
+ // accounts that follow it. Scanning from `authorityPos` to the end covers
1520
+ // both. Returns true on an unresolvable authority (null): the authority must
1521
+ // be a signer, so it can never legitimately be ALT-resolved — a null means an
1522
+ // out-of-bounds or ALT index there, i.e. a crafted/malformed transaction, and
1523
+ // we fail closed rather than let a silent misparse pass.
1524
+ const walletAuthorizes = (ix, authorityPos) => {
1525
+ if (accountAt(ix, authorityPos) === null) return true;
1526
+ for (let i = authorityPos; i < ix.accountIndexes.length; i++) {
1527
+ if (accountAt(ix, i) === walletAddress) return true;
1528
+ }
1529
+ return false;
1530
+ };
1531
+
1532
+ let computeUnitLimit = null;
1533
+ let computeUnitPriceMicroLamports = null;
1534
+
1535
+ for (const ix of parsed.instructions) {
1536
+ const programId = resolveStaticAccount(parsed, ix.programIdIndex);
1537
+ // A program ID that's only ALT-resolvable can't be checked against
1538
+ // SPL_TOKEN_PROGRAMS/COMPUTE_BUDGET_PROGRAM without an RPC call this
1539
+ // static check intentionally doesn't make — and skipping it here would
1540
+ // let an Approve/SetAuthority/CloseAccount instruction bypass the drain
1541
+ // protection above just by routing the program ID through an ALT entry.
1542
+ // Real swap/vault transactions reference these well-known programs as
1543
+ // static keys, so fail closed rather than silently skip classification.
1544
+ if (!programId) {
1545
+ throw new Error(
1546
+ 'Solana transaction invokes a program only resolvable via an address-lookup-table entry, which cannot be safety-classified. Refusing to sign.',
1547
+ );
1548
+ }
1549
+
1550
+ if (SPL_TOKEN_PROGRAMS.has(programId)) {
1551
+ // No discriminator byte — not a valid SPL Token instruction (the runtime
1552
+ // would reject it too). Skip explicitly so the fail-closed intent doesn't
1553
+ // rest on `undefined` never equalling a discriminant constant.
1554
+ if (ix.data.length === 0) continue;
1555
+ const discriminator = ix.data[0];
1556
+ if (discriminator === SPL_APPROVE || discriminator === SPL_APPROVE_CHECKED) {
1557
+ if (walletAuthorizes(ix, discriminator === SPL_APPROVE_CHECKED ? 3 : 2)) {
1558
+ throw new Error(
1559
+ 'Solana transaction grants a token delegate (Approve) authorized by your wallet. Refusing to sign.',
1560
+ );
1561
+ }
1562
+ } else if (discriminator === SPL_SET_AUTHORITY) {
1563
+ if (walletAuthorizes(ix, 1)) {
1564
+ throw new Error(
1565
+ "Solana transaction changes a token account's authority (SetAuthority) using your wallet's signature. Refusing to sign.",
1566
+ );
1567
+ }
1568
+ } else if (discriminator === SPL_CLOSE_ACCOUNT) {
1569
+ const authority = accountAt(ix, 2);
1570
+ const destination = accountAt(ix, 1);
1571
+ // Fail closed on an unresolvable authority regardless of destination;
1572
+ // otherwise reject only when our wallet authorizes the close and the
1573
+ // rent goes anywhere but back to us.
1574
+ if (authority === null || (walletAuthorizes(ix, 2) && destination !== walletAddress)) {
1575
+ throw new Error(
1576
+ `Solana transaction closes a token account and sends the reclaimed rent to ` +
1577
+ `${destination || 'an address only resolvable via an address lookup table'} instead of your wallet. Refusing to sign.`,
1578
+ );
1579
+ }
1580
+ }
1581
+ } else if (programId === COMPUTE_BUDGET_PROGRAM) {
1582
+ if (ix.data.length === 0) continue; // no discriminator — not a valid ComputeBudget instruction
1583
+ const discriminator = ix.data[0];
1584
+ if (discriminator === COMPUTE_BUDGET_SET_UNIT_LIMIT && ix.data.length >= 5) {
1585
+ computeUnitLimit = ix.data.readUInt32LE(1);
1586
+ } else if (discriminator === COMPUTE_BUDGET_SET_UNIT_PRICE) {
1587
+ if (ix.data.length < 9) {
1588
+ throw new Error('Solana transaction has a malformed compute-budget price instruction. Refusing to sign.');
1589
+ }
1590
+ computeUnitPriceMicroLamports = ix.data.readBigUInt64LE(1);
1591
+ }
1592
+ }
1593
+ }
1594
+
1595
+ if (computeUnitPriceMicroLamports != null) {
1596
+ const units = BigInt(computeUnitLimit ?? SOLANA_MAX_COMPUTE_UNITS);
1597
+ const feeLamports = (computeUnitPriceMicroLamports * units) / 1_000_000n;
1598
+ if (feeLamports > MAX_PRIORITY_FEE_LAMPORTS) {
1599
+ throw new Error(
1600
+ `Solana transaction sets an excessive priority fee (~${feeLamports} lamports, cap ${MAX_PRIORITY_FEE_LAMPORTS}). Refusing to sign.`,
1601
+ );
1602
+ }
1603
+ }
1604
+
1605
+ return parsed;
1085
1606
  }