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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.41.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#512](https://github.com/nansen-ai/nansen-cli/pull/512) [`ba42a9c`](https://github.com/nansen-ai/nansen-cli/commit/ba42a9c51d75b0cf909c1f14a8e509d7a4ca77ad) Thanks [@kome12](https://github.com/kome12)! - Validate Solana swap quotes against the original request before signing (local, Privy, and WalletConnect wallets). The CLI now checks that a quote's chain, token pair, amounts, and target wallet match what was requested at quote time and refuses to sign when they don't, bringing Solana in line with the existing EVM checks. `--swap-mode exactOut` now also requires `--max-input` on Solana (previously EVM-only), so the maximum spend is bounded by a value you supply rather than one taken from the quote itself.
8
+
9
+ - [#514](https://github.com/nansen-ai/nansen-cli/pull/514) [`1bc7337`](https://github.com/nansen-ai/nansen-cli/commit/1bc73378db87fed5c362e04b52ae8da8c69fbff7) Thanks [@kome12](https://github.com/kome12)! - `trade execute` and `trade limit-order` on Solana now statically check the aggregator's compiled instructions before signing, and reject a transaction that grants a token delegate, changes a token account's authority, closes an account with its rent redirected to a stranger, or sets an excessive compute-budget priority fee — closing a class of drain vector a balance-delta simulation alone can't see.
10
+
11
+ - [#522](https://github.com/nansen-ai/nansen-cli/pull/522) [`820bf05`](https://github.com/nansen-ai/nansen-cli/commit/820bf058de615a818fa099c8bf527a94339c8f9e) Thanks [@kome12](https://github.com/kome12)! - Verify a Solana swap's simulated on-chain outcome before signing (local, Privy, and WalletConnect wallets), mirroring the existing EVM balance-delta check. The CLI simulates the aggregator's transaction and confirms the wallet's balance changes match the quote — input spent within your max, expected output received, no other asset drained — refusing to sign on a mismatch or an in-simulation revert. Covered by the existing `--no-verify-outcome` flag; degrades with a warning (and still signs) when no simulation-capable RPC is available, so an RPC outage never blocks a trade. New env var: `NANSEN_SOLANA_SIM_RPC`.
12
+
13
+ ### Patch Changes
14
+
15
+ - [#519](https://github.com/nansen-ai/nansen-cli/pull/519) [`55ab7db`](https://github.com/nansen-ai/nansen-cli/commit/55ab7dbdc46ff7181b303d421146691b6eb7c9a7) Thanks [@kome12](https://github.com/kome12)! - trade execute: confirm EVM transactions against the hash derived locally from
16
+ the signed bytes instead of trusting the broadcaster's reported hash, and fail
17
+ closed if they disagree. Once a transaction has been broadcast, every uncertain
18
+ outcome now aborts the whole execute instead of silently trying the next quote
19
+ (which could broadcast a second transaction): a hash mismatch, a signed
20
+ transaction we cannot re-derive a hash for, and a receipt-confirmation timeout
21
+ (distinguished from a genuine on-chain revert) are all fatal across the swap,
22
+ approval, and revoke paths. Broadcaster hashes are also compared
23
+ prefix-insensitively, so a bare (0x-less) hash is no longer a false mismatch.
24
+
25
+ - [#521](https://github.com/nansen-ai/nansen-cli/pull/521) [`977e326`](https://github.com/nansen-ai/nansen-cli/commit/977e3269246cc1c25d03e837c9ce4ac03c5d70b9) Thanks [@aikido-autofix](https://github.com/apps/aikido-autofix)! - Fix potential path traversal in safeQuotesPath by rejecting absolute relative paths (Windows cross-drive escape).
26
+
27
+ - [#497](https://github.com/nansen-ai/nansen-cli/pull/497) [`223a9d5`](https://github.com/nansen-ai/nansen-cli/commit/223a9d501c0e624f3986a547ace3adf621c00896) Thanks [@crazywriter1](https://github.com/crazywriter1)! - Reject `--oid` values above 2^53-1 on `perp cancel`: large Hyperliquid uint64 order IDs would be silently rounded by JS Number, potentially cancelling the wrong order.
28
+
29
+ ## 1.40.1
30
+
31
+ ### Patch Changes
32
+
33
+ - [#516](https://github.com/nansen-ai/nansen-cli/pull/516) [`48722ef`](https://github.com/nansen-ai/nansen-cli/commit/48722ef0c7e6c0a7ce8c4c026245afb6e6f47e79) Thanks [@kome12](https://github.com/kome12)! - Fix cross-chain bridges into native SOL being refused at execute time. The quote/intent binding compared the wrapped-SOL mint (how `--to SOL` resolves) against the System Program address that aggregators use as the native-SOL sentinel and rejected them as different tokens. Both spellings are now treated as the same asset.
34
+
3
35
  ## 1.40.0
4
36
 
5
37
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.40.0",
3
+ "version": "1.41.0",
4
4
  "description": "AI-agent CLI for Nansen API analytics, DEX swaps, and cross-chain trading",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -11,6 +11,7 @@ import path from 'path';
11
11
  import { base58Encode, exportWallet, getWalletConfig, showWallet } from './wallet.js';
12
12
  import { signEd25519, base58Decode, parseAmount, getTokenInfo } from './transfer.js';
13
13
  import { signSolanaTransaction, resolveTokenAddress } from './trading.js';
14
+ import { assertSolanaInstructionsSafe } from './trade-validation.js';
14
15
  import { validateTokenAddress, telemetryHeaders, packageVersion } from './api.js';
15
16
  import { getWalletConnectAddress, sendSolanaTransactionViaWalletConnect, signSolanaMessageViaWalletConnect } from './walletconnect-trading.js';
16
17
  import { retrievePassword } from './keychain.js';
@@ -673,6 +674,17 @@ EXAMPLES:
673
674
  });
674
675
 
675
676
  // 5. Sign deposit transaction
677
+ // Same pre-signing drain gate the swap path uses. The legitimate deposit
678
+ // moves the input token into the already-registered vault (step 3) with an
679
+ // SPL Transfer/TransferChecked — which this gate does not classify — plus,
680
+ // when selling native SOL, a temp-WSOL CloseAccount whose rent returns to
681
+ // the wallet (permitted). Neither trips the delegate/authority/
682
+ // close-to-stranger checks, so this does not reject a well-formed deposit;
683
+ // it only fires if the crafted tx additionally grants a delegate, reassigns
684
+ // authority, or closes to a stranger. Verified live: a real SOL→USDC create
685
+ // round-trip clears this gate (the API-crafted deposit is a SOL-wrap
686
+ // transfer into the vault plus a temp-WSOL close-to-self).
687
+ assertSolanaInstructionsSafe(deposit.transaction, { walletAddress: pubkey });
676
688
  log(' Signing deposit transaction...');
677
689
  const signedDepositTx = await signTransaction(deposit.transaction, walletType, walletInfo);
678
690
 
@@ -807,6 +819,14 @@ EXAMPLES:
807
819
  const cancelResult = await cancelOrderRequest(token, orderId);
808
820
 
809
821
  // 3. Sign the withdrawal transaction
822
+ // Withdrawal moves the deposited token back out of the vault; that transfer
823
+ // is authorized by the vault program's PDA, not our wallet, so it isn't a
824
+ // wallet-authorized drain even if it were classified. Any temp-WSOL close
825
+ // returns rent to the wallet. This gate is defense-in-depth against a
826
+ // crafted withdrawal that instead grants a delegate, reassigns authority, or
827
+ // closes to a stranger. Verified live alongside the deposit path via a real
828
+ // cancel round-trip.
829
+ assertSolanaInstructionsSafe(cancelResult.transaction, { walletAddress: pubkey });
810
830
  log(' Signing withdrawal transaction...');
811
831
  const signedTx = await signTransaction(cancelResult.transaction, walletType, walletInfo);
812
832
 
package/src/perp.js CHANGED
@@ -466,6 +466,12 @@ function parsePositiveInt(raw, name) {
466
466
  if (!Number.isInteger(n) || n <= 0) {
467
467
  throw invalid(`Invalid --${name} "${raw}". Must be a positive integer.`);
468
468
  }
469
+ // Hyperliquid order IDs are uint64. JS Number loses precision above 2^53-1,
470
+ // so parseInt would silently round a large oid and cancel the wrong order.
471
+ // Refuse here for the same reason the response path withholds unsafe oids.
472
+ if (!Number.isSafeInteger(n)) {
473
+ throw invalid(`Invalid --${name} "${raw}". Value exceeds safe integer precision (2^53-1); copy the exact order ID from "nansen perp positions".`);
474
+ }
469
475
  return n;
470
476
  }
471
477
 
package/src/rpc-urls.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * NANSEN_XLAYER_RPC Custom X Layer RPC
13
13
  * NANSEN_SOLANA_RPC Custom Solana RPC
14
14
  * NANSEN_BASE_SIM_RPC Custom Base simulation RPC (see SIMULATION_RPCS below)
15
+ * NANSEN_SOLANA_SIM_RPC Custom Solana simulation RPC (see SIMULATION_RPCS below)
15
16
  *
16
17
  * Simulation RPCs (SIMULATION_RPCS) are a SEPARATE registry from the cheap
17
18
  * defaults above. Swap-outcome verification (src/swap-simulation.js) needs an
@@ -76,8 +77,16 @@ const DEFAULT_BASE_SIM_RPC = 'https://api.nansen.ai/api/v1/trade/simulate-swap';
76
77
  // Intentionally a mutable export: unit tests override an entry in-place (e.g.
77
78
  // `SIMULATION_RPCS.base = ...`) to point at a mock or to null out the endpoint,
78
79
  // restoring it in afterEach. Runtime code only ever reads it.
80
+ //
81
+ // Solana's `simulateTransaction` (with `accounts` + `replaceRecentBlockhash`) is
82
+ // a standard public-RPC method, unlike the EVM entry above — no trace RPC or
83
+ // Nansen-hosted proxy is needed, so this defaults to the same public endpoint as
84
+ // CHAIN_RPCS.solana. It is called anonymously (see isNansenHostedUrl); a public
85
+ // node may throttle simulation-with-accounts, in which case the check degrades
86
+ // (warns and proceeds) rather than blocking a trade.
79
87
  export const SIMULATION_RPCS = {
80
88
  base: process.env.NANSEN_BASE_SIM_RPC || DEFAULT_BASE_SIM_RPC,
89
+ solana: process.env.NANSEN_SOLANA_SIM_RPC || CHAIN_RPCS.solana,
81
90
  };
82
91
 
83
92
  // Nansen hosts the API key may be forwarded to. Kept to an explicit allowlist
package/src/schema.json CHANGED
@@ -1586,7 +1586,7 @@
1586
1586
  },
1587
1587
  "max-input": {
1588
1588
  "type": "string",
1589
- "description": "exactOut only: hard ceiling on the sell-token spend, in base units. Required for EVM (Base) exactOut and optional on Solana (which has no ERC-20 approval to scope). Measured against the slippage-buffered approval (input + slippage), not the bare quote input, so it matches the amount that can actually leave the wallet. Persisted with the quote and enforced before any approval or signing — a quote whose buffered approval exceeds it is refused."
1589
+ "description": "exactOut only: hard ceiling on the sell-token spend, in base units. Required on every chain for --swap-mode exactOut. Measured against the slippage-buffered spend (input + slippage), not the bare quote input, so it matches the amount that can actually leave the wallet. Persisted with the quote and enforced before signing — a quote whose buffered spend exceeds it is refused."
1590
1590
  }
1591
1591
  },
1592
1592
  "prerequisites": [
@@ -1624,7 +1624,7 @@
1624
1624
  },
1625
1625
  "no-verify-outcome": {
1626
1626
  "type": "boolean",
1627
- "description": "Skip EVM swap-outcome verification. That check simulates the swap and confirms the wallet's balance changes match the quote (input spent within your max, expected output received, no other token moved) before broadcasting; it needs a simulation-capable endpoint (NANSEN_BASE_SIM_RPC) and degrades with a warning when none is available. No effect on Solana."
1627
+ "description": "Skip swap-outcome verification. That check simulates the swap and confirms the wallet's balance changes match the quote (input spent within your max, expected output received, no other asset moved) before broadcasting; it needs a simulation-capable endpoint (NANSEN_BASE_SIM_RPC on EVM, NANSEN_SOLANA_SIM_RPC on Solana) and degrades with a warning when none is available."
1628
1628
  },
1629
1629
  "no-revoke-excessive-allowance": {
1630
1630
  "type": "boolean",
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Solana swap-outcome simulation: run a swap transaction through
3
+ * `simulateTransaction` and report the resulting balance changes to the
4
+ * signer's wallet.
5
+ *
6
+ * This is the Solana sibling of swap-simulation.js. Solana signs the
7
+ * aggregator's serialized transaction verbatim, so this is the only check that
8
+ * confirms the wallet actually comes out the way the quote promised — the
9
+ * static checks in trade-validation.js inspect the instructions themselves,
10
+ * not their effect. All delta math runs here, client-side, from the raw
11
+ * `accounts` snapshot the RPC returns, so the verification stays independent
12
+ * of the service that built the quote.
13
+ *
14
+ * Unlike the EVM simulation endpoint, `simulateTransaction` (with `accounts` +
15
+ * `replaceRecentBlockhash`) is a standard public-RPC method — no trace RPC, no
16
+ * Nansen-hosted proxy. It is always called anonymously (see SIMULATION_RPCS.solana
17
+ * in rpc-urls.js); the Nansen API key is never attached here.
18
+ */
19
+
20
+ import { SIMULATION_RPCS } from './rpc-urls.js';
21
+ import { parseTransactionMessage } from './solana-tx.js';
22
+ import { base58Encode } from './wallet.js';
23
+
24
+ // Mirrors NATIVE_SOL_SYSTEM_MINT in trading.js / SOLANA_NATIVE_SOL_ALIASES in
25
+ // trade-validation.js (duplicated, not imported, to avoid a circular import —
26
+ // both of those modules will import from this one).
27
+ const WSOL_MINT = 'So11111111111111111111111111111111111111112';
28
+ export const SOL_SENTINEL = '11111111111111111111111111111111';
29
+
30
+ function isSolanaNativeMint(mint) {
31
+ return mint === WSOL_MINT || mint === SOL_SENTINEL;
32
+ }
33
+
34
+ // getMultipleAccounts caps at 100 keys per call; batch defensively even though
35
+ // a real swap's writable-account set is well under this.
36
+ const MAX_ACCOUNTS_PER_CALL = 100;
37
+
38
+ /**
39
+ * A simulation error the caller can distinguish from a genuine outcome mismatch.
40
+ * `code` is one of:
41
+ * NO_SIM_RPC - no simulation endpoint configured for the chain
42
+ * SIM_RPC_ERROR - transport/parse failure talking to the endpoint,
43
+ * OR a 200 OK sim that omitted the `accounts`
44
+ * result we asked for (an RPC capability gap, not
45
+ * a corrupt result — see the throw site below)
46
+ * SIM_REVERTED - the transaction itself reverted in simulation
47
+ * SIM_RESULT_UNPARSEABLE - the sim ran AND returned the accounts we asked
48
+ * for, but a tracked balance or lookup table
49
+ * couldn't be resolved/parsed
50
+ * NO_SIM_RPC and SIM_RPC_ERROR are degrade conditions (warn, proceed per
51
+ * policy); the caller decides. SIM_REVERTED and SIM_RESULT_UNPARSEABLE are
52
+ * outcome/parse problems and must not be silently ignored — fail closed.
53
+ */
54
+ export class SolanaSimulationError extends Error {
55
+ constructor(code, message) {
56
+ super(message);
57
+ this.name = 'SolanaSimulationError';
58
+ this.code = code;
59
+ }
60
+ }
61
+
62
+ /** Whether a sim-capable endpoint is configured for this chain. */
63
+ export function hasSolanaSimulationRpc(chain) {
64
+ return chain === 'solana' && Boolean(SIMULATION_RPCS.solana);
65
+ }
66
+
67
+ async function rpcCall(rpcUrl, method, params, timeoutMs) {
68
+ const controller = new AbortController();
69
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
70
+ try {
71
+ const res = await fetch(rpcUrl, {
72
+ method: 'POST',
73
+ headers: { 'Content-Type': 'application/json' },
74
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
75
+ signal: controller.signal,
76
+ });
77
+ const text = await res.text();
78
+ let body;
79
+ try {
80
+ body = JSON.parse(text);
81
+ } catch {
82
+ throw new SolanaSimulationError(
83
+ 'SIM_RPC_ERROR',
84
+ `Simulation RPC returned non-JSON (HTTP ${res.status}) for ${method}: ${text.slice(0, 120)}`,
85
+ );
86
+ }
87
+ const ok = res.ok ?? (res.status >= 200 && res.status < 300);
88
+ if (!ok) {
89
+ const detail = body?.error?.message || text.slice(0, 120);
90
+ throw new SolanaSimulationError('SIM_RPC_ERROR', `Simulation RPC HTTP ${res.status} for ${method}: ${detail}`);
91
+ }
92
+ if (body.error) {
93
+ throw new SolanaSimulationError('SIM_RPC_ERROR', `${method} error: ${body.error.message || JSON.stringify(body.error)}`);
94
+ }
95
+ return body.result;
96
+ } catch (e) {
97
+ if (e instanceof SolanaSimulationError) throw e;
98
+ if (e.name === 'AbortError') {
99
+ throw new SolanaSimulationError('SIM_RPC_ERROR', `Simulation RPC timed out after ${timeoutMs}ms (${method})`);
100
+ }
101
+ throw new SolanaSimulationError('SIM_RPC_ERROR', `Simulation RPC request failed (${method}): ${e.message}`);
102
+ } finally {
103
+ clearTimeout(timer);
104
+ }
105
+ }
106
+
107
+ async function getMultipleAccountsChunked(rpcUrl, pubkeys, encoding, timeoutMs) {
108
+ if (!pubkeys.length) return [];
109
+ const out = [];
110
+ for (let i = 0; i < pubkeys.length; i += MAX_ACCOUNTS_PER_CALL) {
111
+ const chunk = pubkeys.slice(i, i + MAX_ACCOUNTS_PER_CALL);
112
+ const result = await rpcCall(rpcUrl, 'getMultipleAccounts', [chunk, { encoding, commitment: 'confirmed' }], timeoutMs);
113
+ if (!Array.isArray(result?.value)) {
114
+ throw new SolanaSimulationError(
115
+ 'SIM_RPC_ERROR',
116
+ `getMultipleAccounts returned no account array (value: ${JSON.stringify(result?.value)}); RPC may be rate-limiting or malfunctioning.`,
117
+ );
118
+ }
119
+ // The RPC spec guarantees value.length === chunk.length (null entries for
120
+ // missing accounts), and every downstream read indexes into this array
121
+ // positionally against writableKeys. A short array would silently shift
122
+ // every later account's pre-state by one slot rather than fail loudly —
123
+ // treat a non-conforming length the same as a malformed response.
124
+ if (result.value.length !== chunk.length) {
125
+ throw new SolanaSimulationError(
126
+ 'SIM_RPC_ERROR',
127
+ `getMultipleAccounts returned ${result.value.length} accounts for a ${chunk.length}-key request; RPC may be rate-limiting or malfunctioning.`,
128
+ );
129
+ }
130
+ out.push(...result.value);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * A static account index is writable unless it's a readonly signer or one of
137
+ * the trailing readonly non-signers. Mirrors the header layout parsed by
138
+ * parseTransactionMessage (solana-tx.js).
139
+ */
140
+ function isStaticWritable(parsed, index) {
141
+ const { numRequiredSignatures, numReadonlySignedAccounts, numReadonlyUnsignedAccounts } = parsed.header;
142
+ const numStatic = parsed.staticAccountKeys.length;
143
+ if (index < numRequiredSignatures) {
144
+ return index < numRequiredSignatures - numReadonlySignedAccounts;
145
+ }
146
+ return index < numStatic - numReadonlyUnsignedAccounts;
147
+ }
148
+
149
+ /**
150
+ * Resolve only the WRITABLE accounts a v0 transaction references through
151
+ * address-lookup-tables. A readonly ALT account can never change balance, so
152
+ * it's irrelevant to the drain-surface check below — resolving only the
153
+ * writable indexes saves decoding entries this check will never use.
154
+ */
155
+ async function resolveAltWritableAccounts(rpcUrl, parsed, timeoutMs) {
156
+ if (!parsed.addressTableLookups.length) return [];
157
+ const tableAddresses = parsed.addressTableLookups.map((l) => l.lookupTableAddress);
158
+ const tableInfos = await getMultipleAccountsChunked(rpcUrl, tableAddresses, 'base64', timeoutMs);
159
+
160
+ const resolved = [];
161
+ parsed.addressTableLookups.forEach((lookup, i) => {
162
+ const info = tableInfos[i];
163
+ if (!info) {
164
+ throw new SolanaSimulationError(
165
+ 'SIM_RESULT_UNPARSEABLE',
166
+ `Address lookup table ${lookup.lookupTableAddress} not found; cannot resolve its accounts.`,
167
+ );
168
+ }
169
+ // LookupTableMeta: typeIndex u32, deactivationSlot u64, lastExtendedSlot u64,
170
+ // lastExtendedSlotStartIndex u8, authority Option<Pubkey>, padding — 32-byte
171
+ // pubkeys begin at byte offset 56.
172
+ const raw = Array.isArray(info.data) ? info.data[0] : info.data;
173
+ const buf = Buffer.from(raw, 'base64');
174
+ for (const idx of lookup.writableIndexes) {
175
+ const offset = 56 + idx * 32;
176
+ if (offset + 32 > buf.length) {
177
+ throw new SolanaSimulationError(
178
+ 'SIM_RESULT_UNPARSEABLE',
179
+ `Address lookup table ${lookup.lookupTableAddress} has no entry at index ${idx}.`,
180
+ );
181
+ }
182
+ resolved.push(base58Encode(buf.subarray(offset, offset + 32)));
183
+ }
184
+ });
185
+ return resolved;
186
+ }
187
+
188
+ /** Lamports (native) or SPL token base-unit balance from a jsonParsed account. */
189
+ function extractBalance(kind, info) {
190
+ if (!info) return 0n;
191
+ try {
192
+ if (kind === 'native') return BigInt(info.lamports ?? 0);
193
+ const amt = info.data?.parsed?.info?.tokenAmount?.amount;
194
+ return amt != null ? BigInt(amt) : 0n;
195
+ } catch {
196
+ throw new SolanaSimulationError('SIM_RESULT_UNPARSEABLE', `Could not parse a simulated ${kind} balance.`);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Simulate a Solana swap transaction and return the normalised asset changes
202
+ * it causes to `walletAddress`'s wallet.
203
+ *
204
+ * @param {string} chain - chain key (only 'solana' is wired today)
205
+ * @param {string} txBase64 - the serialized transaction to simulate, unsigned
206
+ * @param {{ walletAddress: string, timeoutMs?: number }} opts
207
+ * @returns {Promise<{ deltas: Record<string, bigint>, method: string }>}
208
+ * deltas is keyed by mint address, with native SOL (and any WSOL leg of the
209
+ * same swap) folded under SOL_SENTINEL.
210
+ * @throws {SolanaSimulationError} on any degrade condition, an in-sim revert,
211
+ * or an unparseable result.
212
+ */
213
+ export async function simulateSolanaAssetChanges(chain, txBase64, { walletAddress, timeoutMs = 20000 } = {}) {
214
+ const rpcUrl = SIMULATION_RPCS[chain];
215
+ if (!rpcUrl) {
216
+ throw new SolanaSimulationError('NO_SIM_RPC', `No simulation RPC configured for chain '${chain}'.`);
217
+ }
218
+ if (!walletAddress) {
219
+ throw new SolanaSimulationError('SIM_RPC_ERROR', 'simulateSolanaAssetChanges requires a `walletAddress`.');
220
+ }
221
+
222
+ const parsed = parseTransactionMessage(txBase64);
223
+
224
+ // Deduplicated: the same pubkey could in principle appear more than once
225
+ // across the static keys and ALT-resolved writable indexes (or across two
226
+ // different lookup tables). Without dedup, tracking + summing its delta
227
+ // twice would double (or further inflate) a real balance change, letting
228
+ // assertSolanaSwapOutcome's minimum-output check pass on an inflated delta
229
+ // that doesn't reflect what the wallet actually received.
230
+ const writableKeys = [];
231
+ const seenWritableKeys = new Set();
232
+ const pushWritable = (key) => {
233
+ if (seenWritableKeys.has(key)) return;
234
+ seenWritableKeys.add(key);
235
+ writableKeys.push(key);
236
+ };
237
+ for (let i = 0; i < parsed.staticAccountKeys.length; i++) {
238
+ if (isStaticWritable(parsed, i)) pushWritable(parsed.staticAccountKeys[i]);
239
+ }
240
+ for (const key of await resolveAltWritableAccounts(rpcUrl, parsed, timeoutMs)) {
241
+ pushWritable(key);
242
+ }
243
+
244
+ const preAccountInfos = await getMultipleAccountsChunked(rpcUrl, writableKeys, 'jsonParsed', timeoutMs);
245
+
246
+ // Track the wallet's own accounts among the writable set — that's the
247
+ // complete drain surface assertSolanaSwapOutcome needs (input/output/sibling).
248
+ // Accounts that don't exist pre-swap are tracked as *candidates*: a first-ever
249
+ // purchase of the output token has the tx create its ATA, so it has no
250
+ // pre-state to read ownership from. We snapshot it post-simulation anyway and
251
+ // classify it from the created account's owner (below); a zero pre-balance
252
+ // makes its whole post-balance the delta. Skipping these would report a 0n
253
+ // output delta and false-block every first-time buy.
254
+ const tracked = []; // { pubkey, preInfo, classified, kind?, mint? }
255
+ let sawWalletAccount = false;
256
+ writableKeys.forEach((pubkey, i) => {
257
+ const info = preAccountInfos[i];
258
+ if (!info) {
259
+ tracked.push({ pubkey, preInfo: null, classified: false });
260
+ return;
261
+ }
262
+ if (pubkey === walletAddress) {
263
+ tracked.push({ pubkey, preInfo: info, classified: true, kind: 'native' });
264
+ sawWalletAccount = true;
265
+ return;
266
+ }
267
+ const parsedInfo = info.data?.parsed;
268
+ if (parsedInfo?.type === 'account' && parsedInfo.info?.owner === walletAddress) {
269
+ tracked.push({ pubkey, preInfo: info, classified: true, kind: 'token', mint: parsedInfo.info.mint });
270
+ sawWalletAccount = true;
271
+ }
272
+ });
273
+
274
+ // The wallet's native account is the fee payer, so it always pre-exists and is
275
+ // writable; not seeing it means we couldn't locate the signer's own accounts
276
+ // and can't meaningfully verify the outcome. (Unclassified candidates alone
277
+ // don't count — they may all belong to other parties.)
278
+ if (!sawWalletAccount) {
279
+ throw new SolanaSimulationError(
280
+ 'SIM_RESULT_UNPARSEABLE',
281
+ 'Could not resolve the signer wallet as a writable account in this transaction; cannot verify the outcome.',
282
+ );
283
+ }
284
+
285
+ const simResult = await rpcCall(
286
+ rpcUrl,
287
+ 'simulateTransaction',
288
+ [
289
+ txBase64,
290
+ {
291
+ sigVerify: false,
292
+ replaceRecentBlockhash: true,
293
+ commitment: 'confirmed',
294
+ encoding: 'base64',
295
+ accounts: { addresses: tracked.map((t) => t.pubkey), encoding: 'jsonParsed' },
296
+ },
297
+ ],
298
+ timeoutMs,
299
+ );
300
+
301
+ if (simResult?.value?.err != null) {
302
+ throw new SolanaSimulationError('SIM_REVERTED', `Swap reverts in simulation: ${JSON.stringify(simResult.value.err)}`);
303
+ }
304
+ const postAccountInfos = simResult?.value?.accounts;
305
+ if (!Array.isArray(postAccountInfos) || postAccountInfos.length !== tracked.length) {
306
+ // A 200 OK with `err: null` but no (or short) `accounts` array means the
307
+ // node accepted the request without honoring the `accounts` param — the
308
+ // same "public node doesn't fully support this" gap rpc-urls.js documents
309
+ // as a degrade condition, not a corrupt result. SIM_RPC_ERROR here (rather
310
+ // than SIM_RESULT_UNPARSEABLE) keeps this in verifySolanaSwapOutcome's
311
+ // degrade allow-list so a capability gap on the default public RPC warns
312
+ // and proceeds instead of blocking every trade.
313
+ throw new SolanaSimulationError('SIM_RPC_ERROR', 'Simulation did not return balance data for the tracked accounts (RPC may not support the `accounts` parameter).');
314
+ }
315
+
316
+ const deltas = {};
317
+ tracked.forEach((t, i) => {
318
+ const postInfo = postAccountInfos[i];
319
+ let { kind, mint } = t;
320
+ if (!t.classified) {
321
+ // Newly-created account: classify from its post-simulation state and
322
+ // count it only if the tx created it as one of the wallet's own accounts
323
+ // (e.g. the output-token ATA). Anything else is another party's account.
324
+ if (!postInfo) return;
325
+ const parsedInfo = postInfo.data?.parsed;
326
+ if (parsedInfo?.type === 'account' && parsedInfo.info?.owner === walletAddress) {
327
+ kind = 'token';
328
+ mint = parsedInfo.info.mint;
329
+ } else {
330
+ return;
331
+ }
332
+ }
333
+ const pre = extractBalance(kind, t.preInfo); // null preInfo (new account) → 0n
334
+ const post = extractBalance(kind, postInfo);
335
+ const delta = post - pre;
336
+ if (delta === 0n) return;
337
+ const key = kind === 'native' ? SOL_SENTINEL : (isSolanaNativeMint(mint) ? SOL_SENTINEL : mint);
338
+ deltas[key] = (deltas[key] || 0n) + delta;
339
+ });
340
+ for (const k of Object.keys(deltas)) {
341
+ if (deltas[k] === 0n) delete deltas[k];
342
+ }
343
+
344
+ return { deltas, method: 'simulateTransaction' };
345
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Nansen CLI - Solana Transaction Deserialization
3
+ * Parses a serialized (legacy or v0) Solana VersionedTransaction so its
4
+ * instructions can be statically inspected before signing.
5
+ */
6
+
7
+ import { base58Encode } from './wallet.js';
8
+
9
+ export function readCompactU16(buf, offset) {
10
+ let value = 0;
11
+ let shift = 0;
12
+ let size = 0;
13
+ for (let i = 0; i < 3; i++) {
14
+ if (offset + i >= buf.length) {
15
+ throw new Error('Malformed Solana transaction: compact-u16 length runs past end of buffer');
16
+ }
17
+ const byte = buf[offset + i];
18
+ value |= (byte & 0x7f) << shift;
19
+ size++;
20
+ if ((byte & 0x80) === 0) break;
21
+ shift += 7;
22
+ }
23
+ return { value, size };
24
+ }
25
+
26
+ /**
27
+ * Parse a base64-encoded Solana transaction (wire format:
28
+ * [compact-u16 sigCount][sigCount * 64-byte signatures][message]) into its
29
+ * header, static account keys, instructions, and (v0 only) address-table
30
+ * lookups. `data` on instructions is returned undecoded (raw Buffer).
31
+ */
32
+ export function parseTransactionMessage(base64) {
33
+ const bytes = Buffer.from(base64, 'base64');
34
+ // Fail closed on any read past the end of the buffer: a truncated or crafted
35
+ // transaction must throw here rather than silently misparse into a wrong
36
+ // (possibly drain-hiding) instruction list before signing.
37
+ const requireBytes = (off, need) => {
38
+ if (off + need > bytes.length) {
39
+ throw new Error('Malformed Solana transaction: read past end of buffer');
40
+ }
41
+ };
42
+ const { value: numSignatures, size: sigCountSize } = readCompactU16(bytes, 0);
43
+ let offset = sigCountSize + numSignatures * 64;
44
+
45
+ requireBytes(offset, 1);
46
+ const first = bytes[offset];
47
+ const isVersioned = (first & 0x80) !== 0;
48
+ if (isVersioned) {
49
+ // The low 7 bits are the version number. Only v0 exists today, and the rest
50
+ // of this parser assumes the v0 message layout (static keys, instructions,
51
+ // then address-table lookups). A future/unknown version could lay out its
52
+ // bytes differently, so as a pre-signing safety gate we fail closed rather
53
+ // than skip the prefix and misparse an unsupported format into a
54
+ // wrong-and-possibly-drain-hiding instruction list.
55
+ const version = first & 0x7f;
56
+ if (version !== 0) {
57
+ throw new Error(`Unsupported Solana transaction version ${version}. Refusing to sign.`);
58
+ }
59
+ offset += 1; // skip the version-prefix byte; header follows
60
+ }
61
+
62
+ requireBytes(offset, 3);
63
+ const numRequiredSignatures = bytes[offset];
64
+ const numReadonlySignedAccounts = bytes[offset + 1];
65
+ const numReadonlyUnsignedAccounts = bytes[offset + 2];
66
+ offset += 3;
67
+
68
+ const { value: numAccountKeys, size: keysCountSize } = readCompactU16(bytes, offset);
69
+ offset += keysCountSize;
70
+ const staticAccountKeys = [];
71
+ for (let i = 0; i < numAccountKeys; i++) {
72
+ requireBytes(offset, 32);
73
+ staticAccountKeys.push(base58Encode(bytes.subarray(offset, offset + 32)));
74
+ offset += 32;
75
+ }
76
+
77
+ requireBytes(offset, 32);
78
+ const recentBlockhash = base58Encode(bytes.subarray(offset, offset + 32));
79
+ offset += 32;
80
+
81
+ const { value: numInstructions, size: ixCountSize } = readCompactU16(bytes, offset);
82
+ offset += ixCountSize;
83
+ const instructions = [];
84
+ for (let i = 0; i < numInstructions; i++) {
85
+ requireBytes(offset, 1);
86
+ const programIdIndex = bytes[offset];
87
+ offset += 1;
88
+ const { value: numAccounts, size: accCountSize } = readCompactU16(bytes, offset);
89
+ offset += accCountSize;
90
+ requireBytes(offset, numAccounts);
91
+ const accountIndexes = [];
92
+ for (let j = 0; j < numAccounts; j++) {
93
+ accountIndexes.push(bytes[offset]);
94
+ offset += 1;
95
+ }
96
+ const { value: dataLen, size: dataLenSize } = readCompactU16(bytes, offset);
97
+ offset += dataLenSize;
98
+ requireBytes(offset, dataLen);
99
+ const data = bytes.subarray(offset, offset + dataLen);
100
+ offset += dataLen;
101
+ instructions.push({ programIdIndex, accountIndexes, data });
102
+ }
103
+
104
+ const addressTableLookups = [];
105
+ if (isVersioned) {
106
+ const { value: numLookups, size: lookupCountSize } = readCompactU16(bytes, offset);
107
+ offset += lookupCountSize;
108
+ for (let i = 0; i < numLookups; i++) {
109
+ requireBytes(offset, 32);
110
+ const lookupTableAddress = base58Encode(bytes.subarray(offset, offset + 32));
111
+ offset += 32;
112
+ const { value: numWritable, size: writableCountSize } = readCompactU16(bytes, offset);
113
+ offset += writableCountSize;
114
+ requireBytes(offset, numWritable);
115
+ const writableIndexes = [];
116
+ for (let j = 0; j < numWritable; j++) {
117
+ writableIndexes.push(bytes[offset]);
118
+ offset += 1;
119
+ }
120
+ const { value: numReadonly, size: readonlyCountSize } = readCompactU16(bytes, offset);
121
+ offset += readonlyCountSize;
122
+ requireBytes(offset, numReadonly);
123
+ const readonlyIndexes = [];
124
+ for (let j = 0; j < numReadonly; j++) {
125
+ readonlyIndexes.push(bytes[offset]);
126
+ offset += 1;
127
+ }
128
+ addressTableLookups.push({ lookupTableAddress, writableIndexes, readonlyIndexes });
129
+ }
130
+ }
131
+
132
+ return {
133
+ isVersioned,
134
+ header: { numRequiredSignatures, numReadonlySignedAccounts, numReadonlyUnsignedAccounts },
135
+ staticAccountKeys,
136
+ recentBlockhash,
137
+ instructions,
138
+ addressTableLookups,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Resolve an account index to its base58 pubkey, or null if it's only
144
+ * resolvable via an address-lookup-table entry (requires an RPC fetch this
145
+ * module intentionally doesn't make). Lookup-table entries can never be
146
+ * signers — Solana's message format requires every signer to be a static
147
+ * account key — so a null result here only ever means "not a signer, and
148
+ * this specific pubkey can't be verified without a network call."
149
+ */
150
+ export function resolveStaticAccount(parsed, index) {
151
+ if (index < parsed.staticAccountKeys.length) return parsed.staticAccountKeys[index];
152
+ return null;
153
+ }