nansen-cli 1.40.0 → 1.41.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.
@@ -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.
@@ -122,6 +146,17 @@ const NATIVE_TOKEN_ADDRESSES = {
122
146
  base: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
123
147
  };
124
148
 
149
+ // Native SOL has two on-chain spellings that denote the same asset: the
150
+ // canonical wrapped-SOL mint (what the CLI resolves `SOL` to and persists as
151
+ // the request intent) and the System Program address that aggregators and
152
+ // bridges (e.g. Relay) use as the native-lamport sentinel in their quotes.
153
+ // tokensEqual treats them as equivalent so the intent-binding check doesn't
154
+ // false-reject a legitimate quote that names native SOL the other way.
155
+ const SOLANA_NATIVE_SOL_ALIASES = new Set([
156
+ 'So11111111111111111111111111111111111111112', // wrapped SOL mint
157
+ '11111111111111111111111111111111', // System Program — native SOL sentinel
158
+ ]);
159
+
125
160
  // USDC contract addresses per chain.
126
161
  const USDC_ADDRESSES = {
127
162
  solana: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
@@ -567,11 +602,16 @@ export function needsAllowanceRevoke(existingAllowance, approveAmt) {
567
602
 
568
603
  /**
569
604
  * Compare two token addresses for equality (case-insensitive on EVM, exact on
570
- * Solana). Missing values never match.
605
+ * Solana except for the native-SOL sentinel aliasing above). Missing values
606
+ * never match.
571
607
  */
572
608
  function tokensEqual(a, b, chain) {
573
609
  if (!a || !b) return false;
574
- if (chain === 'solana') return a === b;
610
+ if (chain === 'solana') {
611
+ // Both sides naming native SOL (in either spelling) is a match.
612
+ if (SOLANA_NATIVE_SOL_ALIASES.has(a) && SOLANA_NATIVE_SOL_ALIASES.has(b)) return true;
613
+ return a === b;
614
+ }
575
615
  return a.toLowerCase() === b.toLowerCase();
576
616
  }
577
617
 
@@ -727,13 +767,15 @@ export function assertQuoteMatchesRequest(request, quote, { chain, walletAddress
727
767
  * exactOut gap where the API chooses the input and nothing capped it.
728
768
  *
729
769
  * The amount compared against the cap is the maximum that can actually leave the
730
- * wallet — for exactOut that is the slippage-buffered approval, NOT the raw quote
731
- * input. The approval encoder (encodeApproveCalldata) scopes the ERC-20 approval
732
- * to that same buffered amount and caps it at maxInputAmount, so validating the
733
- * raw input here would let a quote pass this check and then be refused at signing
734
- * (a 1,000,000 input at 3% slippage needs a 1,030,000 approval, which a 1,000,000
735
- * cap rejects). Comparing the same amount approvalAmountForSwap produces keeps
736
- * 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.
737
779
  *
738
780
  * Behaviour:
739
781
  * - exactOut with no persisted `maxInputAmount` → throws (fail closed). The
@@ -744,8 +786,9 @@ export function assertQuoteMatchesRequest(request, quote, { chain, walletAddress
744
786
  * more than the user approved leave the wallet.
745
787
  * - exactIn with no cap → no-op (request.amount already binds the input).
746
788
  *
747
- * Applies to native and ERC-20 swaps alike; the caller runs it before any
748
- * 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.
749
792
  *
750
793
  * @param {object} request - Persisted intent (quoteData.request)
751
794
  * @param {object} quote - The quote being executed
@@ -801,10 +844,18 @@ export function assertInputWithinMax(request, quote, slippage) {
801
844
  );
802
845
  }
803
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';
804
851
  throw new Error(
805
852
  swapMode === 'exactOut'
806
- ? `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.`
807
- : `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.`,
808
859
  );
809
860
  }
810
861
  }
@@ -1068,3 +1119,367 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
1068
1119
 
1069
1120
  return { verified: true };
1070
1121
  }
1122
+
1123
+ // Native-SOL dust tolerated on a non-input sibling in assertSolanaSwapOutcome —
1124
+ // covers the base tx fee plus one transient ATA's rent (e.g. a WSOL account
1125
+ // opened and closed within the swap). SPL-token siblings get no such tolerance
1126
+ // (dust threshold 0n); only native SOL legitimately moves as a byproduct of fees
1127
+ // and rent rather than the swap itself.
1128
+ const NATIVE_SIBLING_DUST_LAMPORTS = 3_000_000n; // ~0.003 SOL
1129
+
1130
+ // Full native-SOL fee/rent noise budget: the dust above PLUS the priority fee
1131
+ // a transaction may legitimately pay, up to the ceiling assertSolanaInstructionsSafe
1132
+ // enforces. NATIVE_SIBLING_DUST_LAMPORTS alone only covers the base fee + rent —
1133
+ // a real, legal priority fee (anywhere up to MAX_PRIORITY_FEE_LAMPORTS) also
1134
+ // leaves the wallet as native SOL regardless of whether SOL is the input,
1135
+ // output, or an uninvolved sibling of the swap, so all three assertions below
1136
+ // need the same combined slack or a legitimate high-priority-fee trade false-blocks.
1137
+ const NATIVE_FEE_RENT_SLACK_LAMPORTS = MAX_PRIORITY_FEE_LAMPORTS + NATIVE_SIBLING_DUST_LAMPORTS;
1138
+
1139
+ /**
1140
+ * The Solana sibling of assertSwapOutcome. Solana signs the aggregator's
1141
+ * serialized transaction verbatim and has no approval/calldata split to
1142
+ * validate, so this verifies the balance-delta simulation result (see
1143
+ * solana-simulation.js) against the persisted request intent directly.
1144
+ *
1145
+ * REQUIRES assertSolanaInstructionsSafe to have already run, RPC-free, on the
1146
+ * same transaction (both current signing paths in trading.js call it first):
1147
+ * an unexpected authority grant is rejected there (no assertion 4 sibling
1148
+ * needed here), and assertion 1's native-input slack below is only a safe
1149
+ * bound because that check has already enforced the priority-fee ceiling —
1150
+ * skip it on any future signing path and native-input drains widen from a
1151
+ * fixed slack to an unbounded priority fee.
1152
+ *
1153
+ * Three assertions:
1154
+ * 1. the input token leaves the wallet by no more than maxInputAmount.
1155
+ * Native-SOL input can't be bound at the exact cap the way an SPL input
1156
+ * can: its lamport delta also carries the base fee, priority fee, and net
1157
+ * ATA rent (opened minus reclaimed), which is too noisy for a tight
1158
+ * bound. It is still bounded, not skipped — the cap is relaxed by a
1159
+ * fee/rent slack (the priority-fee ceiling assertSolanaInstructionsSafe
1160
+ * enforces, plus one transient ATA's rent) so a real outflow beyond any
1161
+ * realistic transaction cost is still caught. Without this, a
1162
+ * transaction with an extra unaccounted native-SOL outflow (e.g. a plain
1163
+ * System-Program transfer, which assertSolanaInstructionsSafe does not
1164
+ * classify) would sail through as long as the declared output arrived —
1165
+ * neither assertQuoteMatchesRequest (checks the quote's declared
1166
+ * metadata, not the transaction's real effects) nor assertion 3 (which
1167
+ * exempts the input asset, assuming assertion 1 already bounded it)
1168
+ * would catch it.
1169
+ * 2. the output token arrives by at least the minimum acceptable amount.
1170
+ * Native-SOL output relaxes this floor by NATIVE_FEE_RENT_SLACK_LAMPORTS
1171
+ * because its lamport delta also nets out the base fee, priority fee, and
1172
+ * ATA rent (same noise as native input); SPL output keeps the exact floor.
1173
+ * 3. no OTHER tracked asset leaves the wallet. SPL-token siblings get zero
1174
+ * tolerance; native SOL, when it's a sibling (not the input), tolerates
1175
+ * NATIVE_FEE_RENT_SLACK_LAMPORTS of fee/rent dust. All three assertions
1176
+ * share this one slack value — splitting it (e.g. a smaller tolerance for
1177
+ * assertion 2/3 than assertion 1) would false-block a legitimate trade
1178
+ * paying close to the priority-fee ceiling on whichever assertion has the
1179
+ * smaller number, since the same fee leaves the wallet as native SOL
1180
+ * regardless of SOL's role in that particular swap.
1181
+ *
1182
+ * @param {object} request - persisted intent (quoteData.request); required
1183
+ * @param {object} quote - the quote being executed
1184
+ * @param {{deltas: Record<string, bigint|string|number>}} sim - the normalised
1185
+ * result from simulateSolanaAssetChanges()
1186
+ * @param {object} [ctx]
1187
+ * @param {number} [ctx.slippage] - slippage fraction in effect; defaults to 3%
1188
+ * @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.
1193
+ * @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
1194
+ */
1195
+ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, siblingDustThreshold } = {}) {
1196
+ const fail = (detail) => {
1197
+ const e = new Error(`Swap outcome mismatch (SWAP_OUTCOME_MISMATCH): ${detail} Refusing to sign.`);
1198
+ e.code = 'SWAP_OUTCOME_MISMATCH';
1199
+ return e;
1200
+ };
1201
+
1202
+ if (!request) throw fail('no request intent to verify the outcome against.');
1203
+ if (!sim || typeof sim !== 'object' || sim.deltas == null) {
1204
+ throw fail('simulation returned no asset changes to verify.');
1205
+ }
1206
+
1207
+ const deltas = {};
1208
+ for (const [k, v] of Object.entries(sim.deltas)) {
1209
+ let amt;
1210
+ try {
1211
+ amt = typeof v === 'bigint' ? v : BigInt(v);
1212
+ } catch {
1213
+ throw fail(`simulated delta for ${k} (${v}) is not an integer.`);
1214
+ }
1215
+ deltas[k] = amt;
1216
+ }
1217
+
1218
+ const foldNative = (mint) => (mint && SOLANA_NATIVE_SOL_ALIASES.has(mint) ? SOL_SENTINEL : mint);
1219
+ const inputAsset = quote?.inputMint ? foldNative(quote.inputMint) : null;
1220
+ const outputAsset = quote?.outputMint ? foldNative(quote.outputMint) : null;
1221
+ if (!inputAsset || !outputAsset) {
1222
+ throw fail('quote is missing the input or output token address.');
1223
+ }
1224
+ if (inputAsset === outputAsset) {
1225
+ throw fail(`quote input and output tokens are the same (${inputAsset}); refusing to verify.`);
1226
+ }
1227
+
1228
+ const inputIsNative = inputAsset === SOL_SENTINEL;
1229
+
1230
+ // --- Assertion 1: input outflow within the spend ceiling ---
1231
+ if (request.maxInputAmount == null) {
1232
+ throw fail('request has no maximum input to bound the outflow against.');
1233
+ }
1234
+ let cap;
1235
+ try {
1236
+ cap = BigInt(request.maxInputAmount);
1237
+ } catch {
1238
+ throw fail(`maximum input (${request.maxInputAmount}) is not an integer.`);
1239
+ }
1240
+ // Native-SOL input's lamport delta also carries the base fee, priority fee,
1241
+ // and net ATA rent (opened minus reclaimed), so it can't be bound at the
1242
+ // exact cap the way an SPL input can — but it must still be BOUNDED, not
1243
+ // skipped: without this, a transaction with an extra unaccounted native-SOL
1244
+ // outflow (e.g. a plain System-Program transfer, which assertSolanaInstructionsSafe
1245
+ // does not classify) sails through as long as the declared output still
1246
+ // arrives. The slack allows the worst realistic fee/rent noise — the same
1247
+ // priority-fee ceiling assertSolanaInstructionsSafe enforces, plus one
1248
+ // transient ATA's rent — without opening the cap back up to an unbounded drain.
1249
+ const effectiveCap = inputIsNative ? cap + NATIVE_FEE_RENT_SLACK_LAMPORTS : cap;
1250
+ const inputDelta = deltas[inputAsset] || 0n;
1251
+ const outflow = inputDelta < 0n ? -inputDelta : 0n;
1252
+ if (outflow > effectiveCap) {
1253
+ throw fail(`the input token (${inputAsset}) left the wallet by ${outflow}, exceeding your maximum input (${cap}${inputIsNative ? ` plus fee/rent slack` : ''}).`);
1254
+ }
1255
+
1256
+ // --- 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;
1260
+ let minOut;
1261
+ if (swapMode === 'exactOut') {
1262
+ if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
1263
+ try {
1264
+ minOut = BigInt(request.amount);
1265
+ } catch {
1266
+ throw fail(`requested output amount (${request.amount}) is not an integer.`);
1267
+ }
1268
+ if (minOut <= 0n) {
1269
+ throw fail(`exactOut request has a non-positive output amount (${minOut}); cannot compute a minimum acceptable output.`);
1270
+ }
1271
+ } else {
1272
+ const quotedRaw = quote?.outAmount ?? quote?.outputAmount;
1273
+ if (quotedRaw == null) {
1274
+ throw fail('quote is missing the quoted output amount; cannot compute the minimum acceptable output.');
1275
+ }
1276
+ let quoted;
1277
+ try {
1278
+ quoted = BigInt(quotedRaw);
1279
+ } catch {
1280
+ throw fail(`quoted output amount (${quotedRaw}) is not an integer.`);
1281
+ }
1282
+ if (quoted <= 0n) {
1283
+ throw fail(`quote has a non-positive output amount (${quoted}); cannot compute a minimum acceptable output.`);
1284
+ }
1285
+ // Floor of quoted × (1 − slippage), capped at 50% independent of what the
1286
+ // user set (mirrors assertSwapOutcome's rationale: a defence-in-depth
1287
+ // floor, not the user's execution tolerance).
1288
+ const rawSlip = Number.isFinite(slippage) && slippage >= 0 ? slippage : 0.03;
1289
+ const slip = Math.min(rawSlip, 0.5);
1290
+ const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
1291
+ minOut = (quoted * (10000n - bps)) / 10000n;
1292
+ }
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}).`);
1313
+ }
1314
+
1315
+ // --- Assertion 3: no other tracked asset leaves the wallet ---
1316
+ const nativeDust = siblingDustThreshold != null ? siblingDustThreshold : NATIVE_FEE_RENT_SLACK_LAMPORTS;
1317
+ for (const [token, delta] of Object.entries(deltas)) {
1318
+ if (token === inputAsset) continue; // bounded by assertion 1 (with fee/rent slack, for native input)
1319
+ if (delta >= 0n) continue;
1320
+ const dust = token === SOL_SENTINEL ? nativeDust : 0n;
1321
+ if (-delta > dust) {
1322
+ 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.`);
1323
+ }
1324
+ }
1325
+
1326
+ // inputAssertionSkipped tells the caller assertion 1 ran with the fee/rent
1327
+ // slack applied (native-SOL input, per the JSDoc above), so it can surface
1328
+ // that instead of implying the input spend was tightly delta-verified.
1329
+ return { verified: true, inputAssertionSkipped: inputIsNative };
1330
+ }
1331
+
1332
+ /**
1333
+ * Statically inspect a Solana transaction's instructions for drain vectors a
1334
+ * balance-delta simulation can't see — granting a token delegate, changing a
1335
+ * token account's authority, or closing an account to a stranger — and for an
1336
+ * excessive compute-budget priority fee. Runs before signing, on the raw
1337
+ * instructions rather than trusting the aggregator's intent.
1338
+ *
1339
+ * Scope: this inspects only the recognized top-level instructions of the
1340
+ * message — the SPL Token and ComputeBudget programs. It does not, and by
1341
+ * design cannot, see instructions a program issues via CPI at runtime, nor
1342
+ * does it classify calls to programs it doesn't recognize. It is one layer
1343
+ * (paired with the intent-binding metadata check), not a complete
1344
+ * authorization audit of the transaction.
1345
+ *
1346
+ * IMPORTANT — this static check does NOT classify SPL Transfer/TransferChecked:
1347
+ * a legitimate swap or vault deposit moves the input token (and WSOL) with
1348
+ * exactly those instructions, so they can't be blanket-rejected, and there is
1349
+ * nothing here to bound their destination or amount. On its own this leaves a
1350
+ * residual gap: a transaction that also transfers an unrelated ("sibling")
1351
+ * token the wallet holds, authorized by the wallet, would pass this check.
1352
+ * That gap is now closed, for swap execution, by outcome simulation —
1353
+ * assertSolanaSwapOutcome, run via verifySolanaSwapOutcome immediately after
1354
+ * this check on all Solana swap-execute signing paths (trading.js), simulates
1355
+ * the transaction and rejects any balance delta on a token other than the
1356
+ * declared input/output. That simulation degrades gracefully (warns and
1357
+ * proceeds) when no simulation RPC endpoint is configured, so this static
1358
+ * check plus the metadata binding remain the ONLY transaction-level guards
1359
+ * whenever a sim RPC is unavailable. Limit-order vault deposit/cancel
1360
+ * (limit-order.js) call only this static check, not verifySolanaSwapOutcome —
1361
+ * they have no swap quote (no declared input/output pair) to bind an outcome
1362
+ * check against, so the sibling-transfer gap described above is still open
1363
+ * there; tracked as a follow-up, not covered here.
1364
+ *
1365
+ * Within that scope, the SPL Token program requires the *authority* of
1366
+ * Approve/ApproveChecked/SetAuthority/CloseAccount to sign the transaction,
1367
+ * so checking "does our wallet authorize this instruction" catches the drain
1368
+ * without an RPC-based account-ownership lookup. For a single-owner authority
1369
+ * the wallet sits in the authority position itself; for a multisig authority
1370
+ * the authority account is the multisig and our wallet appears among the
1371
+ * signer accounts that follow it — so we treat the wallet signing *anywhere
1372
+ * from the authority position onward* as authorizing the instruction.
1373
+ * Address-lookup-table-resolved accounts can never be signers, so those
1374
+ * positions are always statically resolvable; only CloseAccount's destination
1375
+ * can legitimately be ALT-resolved, and an unresolvable destination is treated
1376
+ * the same as a stranger (fail closed). The instruction's own program ID must
1377
+ * also be statically resolvable — an ALT-resolved program ID can't be checked
1378
+ * against SPL_TOKEN_PROGRAMS/COMPUTE_BUDGET_PROGRAM, so it's rejected outright
1379
+ * rather than silently skipped.
1380
+ *
1381
+ * Throws on any of those patterns. Returns the parsed transaction otherwise.
1382
+ */
1383
+ export function assertSolanaInstructionsSafe(txBase64, { walletAddress } = {}) {
1384
+ // Fail closed on a missing wallet address: every authority check below
1385
+ // compares resolved accounts against `walletAddress`, so a null/undefined
1386
+ // address would make each comparison silently false and disable the drain
1387
+ // protection rather than over-reject. Refuse to run the check without knowing
1388
+ // whose signature we're guarding.
1389
+ if (!walletAddress) {
1390
+ throw new Error('Cannot verify Solana instruction safety without the signing wallet address. Refusing to sign.');
1391
+ }
1392
+ const parsed = parseTransactionMessage(txBase64);
1393
+ const accountAt = (ix, position) => resolveStaticAccount(parsed, ix.accountIndexes[position]);
1394
+
1395
+ // Does our wallet authorize this SPL instruction? For a single-owner
1396
+ // authority the wallet is at `authorityPos`; for a multisig authority the
1397
+ // authority account is the multisig and our wallet is one of the signer
1398
+ // accounts that follow it. Scanning from `authorityPos` to the end covers
1399
+ // both. Returns true on an unresolvable authority (null): the authority must
1400
+ // be a signer, so it can never legitimately be ALT-resolved — a null means an
1401
+ // out-of-bounds or ALT index there, i.e. a crafted/malformed transaction, and
1402
+ // we fail closed rather than let a silent misparse pass.
1403
+ const walletAuthorizes = (ix, authorityPos) => {
1404
+ if (accountAt(ix, authorityPos) === null) return true;
1405
+ for (let i = authorityPos; i < ix.accountIndexes.length; i++) {
1406
+ if (accountAt(ix, i) === walletAddress) return true;
1407
+ }
1408
+ return false;
1409
+ };
1410
+
1411
+ let computeUnitLimit = null;
1412
+ let computeUnitPriceMicroLamports = null;
1413
+
1414
+ for (const ix of parsed.instructions) {
1415
+ const programId = resolveStaticAccount(parsed, ix.programIdIndex);
1416
+ // A program ID that's only ALT-resolvable can't be checked against
1417
+ // SPL_TOKEN_PROGRAMS/COMPUTE_BUDGET_PROGRAM without an RPC call this
1418
+ // static check intentionally doesn't make — and skipping it here would
1419
+ // let an Approve/SetAuthority/CloseAccount instruction bypass the drain
1420
+ // protection above just by routing the program ID through an ALT entry.
1421
+ // Real swap/vault transactions reference these well-known programs as
1422
+ // static keys, so fail closed rather than silently skip classification.
1423
+ if (!programId) {
1424
+ throw new Error(
1425
+ 'Solana transaction invokes a program only resolvable via an address-lookup-table entry, which cannot be safety-classified. Refusing to sign.',
1426
+ );
1427
+ }
1428
+
1429
+ if (SPL_TOKEN_PROGRAMS.has(programId)) {
1430
+ // No discriminator byte — not a valid SPL Token instruction (the runtime
1431
+ // would reject it too). Skip explicitly so the fail-closed intent doesn't
1432
+ // rest on `undefined` never equalling a discriminant constant.
1433
+ if (ix.data.length === 0) continue;
1434
+ const discriminator = ix.data[0];
1435
+ if (discriminator === SPL_APPROVE || discriminator === SPL_APPROVE_CHECKED) {
1436
+ if (walletAuthorizes(ix, discriminator === SPL_APPROVE_CHECKED ? 3 : 2)) {
1437
+ throw new Error(
1438
+ 'Solana transaction grants a token delegate (Approve) authorized by your wallet. Refusing to sign.',
1439
+ );
1440
+ }
1441
+ } else if (discriminator === SPL_SET_AUTHORITY) {
1442
+ if (walletAuthorizes(ix, 1)) {
1443
+ throw new Error(
1444
+ "Solana transaction changes a token account's authority (SetAuthority) using your wallet's signature. Refusing to sign.",
1445
+ );
1446
+ }
1447
+ } else if (discriminator === SPL_CLOSE_ACCOUNT) {
1448
+ const authority = accountAt(ix, 2);
1449
+ const destination = accountAt(ix, 1);
1450
+ // Fail closed on an unresolvable authority regardless of destination;
1451
+ // otherwise reject only when our wallet authorizes the close and the
1452
+ // rent goes anywhere but back to us.
1453
+ if (authority === null || (walletAuthorizes(ix, 2) && destination !== walletAddress)) {
1454
+ throw new Error(
1455
+ `Solana transaction closes a token account and sends the reclaimed rent to ` +
1456
+ `${destination || 'an address only resolvable via an address lookup table'} instead of your wallet. Refusing to sign.`,
1457
+ );
1458
+ }
1459
+ }
1460
+ } else if (programId === COMPUTE_BUDGET_PROGRAM) {
1461
+ if (ix.data.length === 0) continue; // no discriminator — not a valid ComputeBudget instruction
1462
+ const discriminator = ix.data[0];
1463
+ if (discriminator === COMPUTE_BUDGET_SET_UNIT_LIMIT && ix.data.length >= 5) {
1464
+ computeUnitLimit = ix.data.readUInt32LE(1);
1465
+ } else if (discriminator === COMPUTE_BUDGET_SET_UNIT_PRICE) {
1466
+ if (ix.data.length < 9) {
1467
+ throw new Error('Solana transaction has a malformed compute-budget price instruction. Refusing to sign.');
1468
+ }
1469
+ computeUnitPriceMicroLamports = ix.data.readBigUInt64LE(1);
1470
+ }
1471
+ }
1472
+ }
1473
+
1474
+ if (computeUnitPriceMicroLamports != null) {
1475
+ const units = BigInt(computeUnitLimit ?? SOLANA_MAX_COMPUTE_UNITS);
1476
+ const feeLamports = (computeUnitPriceMicroLamports * units) / 1_000_000n;
1477
+ if (feeLamports > MAX_PRIORITY_FEE_LAMPORTS) {
1478
+ throw new Error(
1479
+ `Solana transaction sets an excessive priority fee (~${feeLamports} lamports, cap ${MAX_PRIORITY_FEE_LAMPORTS}). Refusing to sign.`,
1480
+ );
1481
+ }
1482
+ }
1483
+
1484
+ return parsed;
1485
+ }