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.
- package/CHANGELOG.md +36 -0
- package/package.json +1 -1
- package/src/limit-order.js +20 -0
- package/src/perp.js +6 -0
- package/src/rpc-urls.js +9 -0
- package/src/schema.json +3 -2
- package/src/solana-simulation.js +345 -0
- package/src/solana-tx.js +153 -0
- package/src/trade-validation.js +538 -17
- package/src/trading.js +521 -74
- package/src/x402-svm.js +9 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.41.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#527](https://github.com/nansen-ai/nansen-cli/pull/527) [`02efb6d`](https://github.com/nansen-ai/nansen-cli/commit/02efb6d1f40453c03135eb68cf493486a5b6133a) Thanks [@kome12](https://github.com/kome12)! - Cross-chain (bridge) swaps now run swap-outcome verification instead of skipping it entirely. The output-arrival check is still skipped (the output settles on the destination chain), but the input-outflow cap and no-sibling-drain checks now run on the source-chain leg, closing a gap where a compromised quote's bridge instructions could move more than the declared input. Bridges also now enforce an intent-relative lower bound on the source-chain input outflow (an exactIn bridge must spend ~the requested input, so a large fee-only or partial no-op no longer verifies) and still validate the quote's output-amount integrity, and the native-SOL bridge log no longer contradicts itself about whether the output check ran. Note the lower bound relaxes by a native-SOL fee/rent allowance (~0.013 SOL), so on a small native-SOL leg at or below that allowance the floor effectively collapses to a bare "outflow > 0" — the tightest bound possible for a native leg whose fees are indistinguishable from the transfer. `--swap-mode` is now validated against `exactIn`/`exactOut` at the CLI, and both the swap-outcome verifier and the pre-signing request-intent completeness checks fail closed on an unrecognized mode in a persisted quote so a garbage value cannot bypass the exactIn input floor — even when outcome verification is skipped or degraded.
|
|
8
|
+
|
|
9
|
+
Because bridges now go through the simulation, a bridge quote that **reverts in simulation** returns `proceed: false` and is dropped (the signing loop falls through to the next quote); only a simulation that cannot run at all (`NO_SIM_RPC` / `SIM_RPC_ERROR` / `NOT_SIM_CAPABLE`) degrades to proceed-without-verification, matching same-chain swaps. This is a new, fail-closed outcome for bridges specifically.
|
|
10
|
+
|
|
11
|
+
- [#513](https://github.com/nansen-ai/nansen-cli/pull/513) [`55eb953`](https://github.com/nansen-ai/nansen-cli/commit/55eb953cc15fe21aa441d1700e05ef053c643a58) Thanks [@kome12](https://github.com/kome12)! - Fix `trade execute` crashing on Solana-source bridge quotes from the Relay aggregator, which return raw uncompiled instructions instead of a ready-to-sign transaction. These are now compiled client-side before signing.
|
|
12
|
+
|
|
13
|
+
## 1.41.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- [#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.
|
|
18
|
+
|
|
19
|
+
- [#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.
|
|
20
|
+
|
|
21
|
+
- [#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`.
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- [#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
|
|
26
|
+
the signed bytes instead of trusting the broadcaster's reported hash, and fail
|
|
27
|
+
closed if they disagree. Once a transaction has been broadcast, every uncertain
|
|
28
|
+
outcome now aborts the whole execute instead of silently trying the next quote
|
|
29
|
+
(which could broadcast a second transaction): a hash mismatch, a signed
|
|
30
|
+
transaction we cannot re-derive a hash for, and a receipt-confirmation timeout
|
|
31
|
+
(distinguished from a genuine on-chain revert) are all fatal across the swap,
|
|
32
|
+
approval, and revoke paths. Broadcaster hashes are also compared
|
|
33
|
+
prefix-insensitively, so a bare (0x-less) hash is no longer a false mismatch.
|
|
34
|
+
|
|
35
|
+
- [#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).
|
|
36
|
+
|
|
37
|
+
- [#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.
|
|
38
|
+
|
|
3
39
|
## 1.40.1
|
|
4
40
|
|
|
5
41
|
### Patch Changes
|
package/package.json
CHANGED
package/src/limit-order.js
CHANGED
|
@@ -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
|
@@ -1570,6 +1570,7 @@
|
|
|
1570
1570
|
"swap-mode": {
|
|
1571
1571
|
"type": "string",
|
|
1572
1572
|
"default": "exactIn",
|
|
1573
|
+
"enum": ["exactIn", "exactOut"],
|
|
1573
1574
|
"description": "\"exactIn\" (default) to spend exactly --amount of the sell token, or \"exactOut\" to receive exactly --amount of the buy token. Not supported together with --amount-unit percent."
|
|
1574
1575
|
},
|
|
1575
1576
|
"slippage": {
|
|
@@ -1586,7 +1587,7 @@
|
|
|
1586
1587
|
},
|
|
1587
1588
|
"max-input": {
|
|
1588
1589
|
"type": "string",
|
|
1589
|
-
"description": "exactOut only: hard ceiling on the sell-token spend, in base units. Required
|
|
1590
|
+
"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
1591
|
}
|
|
1591
1592
|
},
|
|
1592
1593
|
"prerequisites": [
|
|
@@ -1624,7 +1625,7 @@
|
|
|
1624
1625
|
},
|
|
1625
1626
|
"no-verify-outcome": {
|
|
1626
1627
|
"type": "boolean",
|
|
1627
|
-
"description": "Skip
|
|
1628
|
+
"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
1629
|
},
|
|
1629
1630
|
"no-revoke-excessive-allowance": {
|
|
1630
1631
|
"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
|
+
}
|
package/src/solana-tx.js
ADDED
|
@@ -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
|
+
}
|