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 +32 -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 +2 -2
- package/src/solana-simulation.js +345 -0
- package/src/solana-tx.js +153 -0
- package/src/trade-validation.js +428 -13
- package/src/trading.js +362 -54
package/src/trade-validation.js
CHANGED
|
@@ -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
|
|
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')
|
|
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
|
|
731
|
-
* input.
|
|
732
|
-
* to that same buffered amount and caps it at maxInputAmount, so
|
|
733
|
-
* raw input here would let a quote pass this check and then be
|
|
734
|
-
* (a 1,000,000 input at 3% slippage needs a 1,030,000
|
|
735
|
-
* cap rejects).
|
|
736
|
-
*
|
|
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
|
|
748
|
-
*
|
|
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
|
-
?
|
|
807
|
-
|
|
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
|
+
}
|