nansen-cli 1.41.0 → 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 CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 1.41.0
4
14
 
5
15
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.41.0",
3
+ "version": "1.41.1",
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",
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": {
@@ -912,6 +912,18 @@ export function assertSwapCalldataNotBareTransfer(data) {
912
912
 
913
913
  // ============= Swap-outcome verification (balance-delta simulation) =============
914
914
 
915
+ /**
916
+ * A cross-chain bridge's output settles on the destination chain, invisible to
917
+ * a source-chain simulation, so the output-arrival assertion is meaningless
918
+ * for one and both assert...SwapOutcome functions skip it via this check.
919
+ * Derived from the immutable persisted request intent (not the loose
920
+ * quote/quoteData) so it can't drift between calls or across chains.
921
+ */
922
+ function isBridgeRequest(request) {
923
+ return request.toChain != null
924
+ && String(request.toChain).toLowerCase() !== String(request.chain).toLowerCase();
925
+ }
926
+
915
927
  /**
916
928
  * Assert that a SIMULATED swap's asset changes match the user's intent, failing
917
929
  * closed on any mismatch. This is a defence-in-depth outcome check that
@@ -929,6 +941,8 @@ export function assertSwapCalldataNotBareTransfer(data) {
929
941
  * log) is never counted.
930
942
  * 2. the output token arrives by AT LEAST minOut — exactOut: >= the requested
931
943
  * output; exactIn: the quoted output reduced by the slippage in effect.
944
+ * SKIPPED for a cross-chain bridge: the output settles on the destination
945
+ * chain and can never appear in a source-chain simulation.
932
946
  * 3. NO token other than the input leaves the wallet.
933
947
  * 4. the wallet grants no Approval to a spender outside `expectedSpenders`.
934
948
  *
@@ -944,6 +958,9 @@ export function assertSwapCalldataNotBareTransfer(data) {
944
958
  * router); anything else fails assertion 4. Compared case-insensitively.
945
959
  * @param {bigint} [ctx.siblingDustThreshold=0n] - non-input outflow tolerated
946
960
  * before assertion 3 fires (for fee-on-transfer / rounding). Strict 0 default.
961
+ * @returns {{verified: true, outputAssertionSkipped: boolean}} outputAssertionSkipped
962
+ * is true for a cross-chain bridge, meaning assertion 2 did not run — the
963
+ * caller should surface this.
947
964
  * @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
948
965
  */
949
966
  export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpenders, siblingDustThreshold = 0n } = {}) {
@@ -983,6 +1000,9 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
983
1000
  throw fail(`quote input and output tokens are the same (${inputToken}); refusing to verify.`);
984
1001
  }
985
1002
 
1003
+ // Bridges skip only assertion 2 (output arrival) below — see isBridgeRequest.
1004
+ const isBridge = isBridgeRequest(request);
1005
+
986
1006
  // --- Assertion 1: input outflow within the spend ceiling ---
987
1007
  // This bounds the outflow by maxInputAmount (the slippage-buffered ceiling),
988
1008
  // NOT the exact expected input: for exactOut the aggregator may legitimately
@@ -1005,9 +1025,50 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
1005
1025
  throw fail(`the input token (${inputToken}) left the wallet by ${outflow}, exceeding your maximum input (${cap}).`);
1006
1026
  }
1007
1027
 
1008
- // --- Assertion 2: output arrives at or above the minimum acceptable ---
1028
+ // Fail closed on an unrecognized swap mode. `?? 'exactIn'` only defaults a
1029
+ // missing mode; a persisted request with a garbage value (an edited/older
1030
+ // quote record) must not silently fall into the exactOut branch below, which
1031
+ // would drop the intent-relative input floor. The CLI validates --swap-mode
1032
+ // against this same enum, so a well-formed quote never reaches here invalid.
1009
1033
  const swapMode = request.swapMode ?? 'exactIn';
1010
- const outputDelta = deltas[outputToken] || 0n;
1034
+ if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
1035
+ throw fail(`unrecognized swap mode "${swapMode}"; expected exactIn or exactOut.`);
1036
+ }
1037
+
1038
+ // A bridge drops assertion 2 (its output settles on the destination chain),
1039
+ // and for a normal swap that positive-output check is what implicitly proves
1040
+ // the input was actually consumed. Restore that with an intent-relative LOWER
1041
+ // bound on the source-chain outflow: a bare `outflow > 0` is too weak — a
1042
+ // fee-only or 1-unit no-op would still verify a bridge that never funded its
1043
+ // input. For exactIn the outflow must be ~the requested input (assertion 1
1044
+ // already caps it above); for exactOut the input is variable up to the cap, so
1045
+ // only a positive-outflow floor is meaningful. EVM native input delta is the
1046
+ // transferred value with gas excluded, so the outflow is exact — no fee slack.
1047
+ if (isBridge) {
1048
+ if (swapMode === 'exactIn') {
1049
+ if (request.amount == null) throw fail('bridge exactIn request is missing the requested input amount.');
1050
+ let requested;
1051
+ try {
1052
+ requested = BigInt(request.amount);
1053
+ } catch {
1054
+ throw fail(`requested input amount (${request.amount}) is not an integer.`);
1055
+ }
1056
+ if (requested <= 0n) throw fail(`bridge exactIn request has a non-positive input amount (${requested}).`);
1057
+ if (outflow < requested) {
1058
+ throw fail(`the bridge moved only ${outflow} of the input token (${inputToken}) out of the wallet, below the requested input (${requested}); a bridge must spend its full input on the source chain.`);
1059
+ }
1060
+ } else if (outflow <= 0n) {
1061
+ throw fail(`the bridge moved no input token (${inputToken}) out of the wallet; a bridge must spend its input on the source chain.`);
1062
+ }
1063
+ }
1064
+
1065
+ // --- Assertion 2: output arrives at or above the minimum acceptable ---
1066
+ // The minimum-output computation below — and its quote-integrity checks (the
1067
+ // output amount must be present, an integer, and positive) — runs for EVERY
1068
+ // swap, bridges included: a bridge quote with a missing or zero output is
1069
+ // still malformed. Only the final delta comparison is bridge-skipped, because
1070
+ // the output settles on the destination chain and can never appear in this
1071
+ // source-chain simulation.
1011
1072
  let minOut;
1012
1073
  if (swapMode === 'exactOut') {
1013
1074
  if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
@@ -1058,8 +1119,11 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
1058
1119
  const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
1059
1120
  minOut = (quoted * (10000n - bps)) / 10000n;
1060
1121
  }
1061
- if (outputDelta < minOut) {
1062
- throw fail(`the output token (${outputToken}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1122
+ if (!isBridge) {
1123
+ const outputDelta = deltas[outputToken] || 0n;
1124
+ if (outputDelta < minOut) {
1125
+ throw fail(`the output token (${outputToken}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1126
+ }
1063
1127
  }
1064
1128
 
1065
1129
  // --- Assertion 3: no token other than the input leaves the wallet ---
@@ -1117,7 +1181,7 @@ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpend
1117
1181
  }
1118
1182
  }
1119
1183
 
1120
- return { verified: true };
1184
+ return { verified: true, outputAssertionSkipped: isBridge };
1121
1185
  }
1122
1186
 
1123
1187
  // Native-SOL dust tolerated on a non-input sibling in assertSolanaSwapOutcome —
@@ -1170,6 +1234,8 @@ const NATIVE_FEE_RENT_SLACK_LAMPORTS = MAX_PRIORITY_FEE_LAMPORTS + NATIVE_SIBLIN
1170
1234
  * Native-SOL output relaxes this floor by NATIVE_FEE_RENT_SLACK_LAMPORTS
1171
1235
  * because its lamport delta also nets out the base fee, priority fee, and
1172
1236
  * ATA rent (same noise as native input); SPL output keeps the exact floor.
1237
+ * SKIPPED for a cross-chain bridge: the output settles on the destination
1238
+ * chain and can never appear in a source-chain simulation.
1173
1239
  * 3. no OTHER tracked asset leaves the wallet. SPL-token siblings get zero
1174
1240
  * tolerance; native SOL, when it's a sibling (not the input), tolerates
1175
1241
  * NATIVE_FEE_RENT_SLACK_LAMPORTS of fee/rent dust. All three assertions
@@ -1186,10 +1252,12 @@ const NATIVE_FEE_RENT_SLACK_LAMPORTS = MAX_PRIORITY_FEE_LAMPORTS + NATIVE_SIBLIN
1186
1252
  * @param {object} [ctx]
1187
1253
  * @param {number} [ctx.slippage] - slippage fraction in effect; defaults to 3%
1188
1254
  * @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.
1255
+ * @returns {{verified: true, inputAssertionSkipped: boolean, outputAssertionSkipped: boolean}}
1256
+ * inputAssertionSkipped is true when the input was native SOL, meaning
1257
+ * assertion 1 ran with the fee/rent slack applied instead of an exact bound
1258
+ * (see assertion 1's rationale above). outputAssertionSkipped is true for a
1259
+ * cross-chain bridge, meaning assertion 2 did not run. The caller should
1260
+ * surface both.
1193
1261
  * @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
1194
1262
  */
1195
1263
  export function assertSolanaSwapOutcome(request, quote, sim, { slippage, siblingDustThreshold } = {}) {
@@ -1227,6 +1295,9 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
1227
1295
 
1228
1296
  const inputIsNative = inputAsset === SOL_SENTINEL;
1229
1297
 
1298
+ // Bridges skip only assertion 2 (output arrival) below — see isBridgeRequest.
1299
+ const isBridge = isBridgeRequest(request);
1300
+
1230
1301
  // --- Assertion 1: input outflow within the spend ceiling ---
1231
1302
  if (request.maxInputAmount == null) {
1232
1303
  throw fail('request has no maximum input to bound the outflow against.');
@@ -1252,11 +1323,55 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
1252
1323
  if (outflow > effectiveCap) {
1253
1324
  throw fail(`the input token (${inputAsset}) left the wallet by ${outflow}, exceeding your maximum input (${cap}${inputIsNative ? ` plus fee/rent slack` : ''}).`);
1254
1325
  }
1326
+ // Fail closed on an unrecognized swap mode (mirrors assertSwapOutcome). A
1327
+ // persisted request with a garbage value must not fall into the exactOut
1328
+ // branch below and drop the intent-relative input floor.
1329
+ const swapMode = request.swapMode ?? 'exactIn';
1330
+ if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
1331
+ throw fail(`unrecognized swap mode "${swapMode}"; expected exactIn or exactOut.`);
1332
+ }
1333
+
1334
+ // A bridge drops assertion 2 (its output settles on the destination chain),
1335
+ // and for a normal swap that positive-output check is what implicitly proves
1336
+ // the input was actually consumed. Restore that with an intent-relative LOWER
1337
+ // bound on the source-chain outflow: a bare `outflow > 0` is too weak — a
1338
+ // native-SOL leg always burns a fee, so a fee-only no-op (and any partial SPL
1339
+ // outflow) would otherwise verify a bridge that never funded its input. For
1340
+ // exactIn the outflow must be ~the requested input (assertion 1 caps it
1341
+ // above); for exactOut the input is variable up to the cap, so only a
1342
+ // positive-outflow floor is meaningful. Native-SOL input carries fee/rent
1343
+ // noise (bridged amount + base/priority fee − reclaimed ATA rent), so relax
1344
+ // the floor by the same slack assertion 1 adds to the ceiling; SPL is exact.
1345
+ if (isBridge) {
1346
+ if (swapMode === 'exactIn') {
1347
+ if (request.amount == null) throw fail('bridge exactIn request is missing the requested input amount.');
1348
+ let requested;
1349
+ try {
1350
+ requested = BigInt(request.amount);
1351
+ } catch {
1352
+ throw fail(`requested input amount (${request.amount}) is not an integer.`);
1353
+ }
1354
+ if (requested <= 0n) throw fail(`bridge exactIn request has a non-positive input amount (${requested}).`);
1355
+ const floorSlack = inputIsNative ? NATIVE_FEE_RENT_SLACK_LAMPORTS : 0n;
1356
+ // Clamp the floor to a positive minimum: for a native bridge smaller than
1357
+ // the fee/rent slack a real leg is indistinguishable from a fee-only no-op,
1358
+ // so the tightest we can still require is a non-zero outflow.
1359
+ const minOutflow = requested > floorSlack ? requested - floorSlack : 1n;
1360
+ if (outflow < minOutflow) {
1361
+ throw fail(`the bridge moved only ${outflow} of the input token (${inputAsset}) out of the wallet, below the requested input (${requested}${inputIsNative ? ` minus fee/rent slack` : ''}); a bridge must spend its full input on the source chain.`);
1362
+ }
1363
+ } else if (outflow <= 0n) {
1364
+ throw fail(`the bridge moved no input token (${inputAsset}) out of the wallet; a bridge must spend its input on the source chain.`);
1365
+ }
1366
+ }
1255
1367
 
1256
1368
  // --- 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;
1369
+ // The minimum-output computation below — and its quote-integrity checks (the
1370
+ // output amount must be present, an integer, and positive) — runs for EVERY
1371
+ // swap, bridges included: a bridge quote with a missing or zero output is
1372
+ // still malformed. Only the final delta comparison is bridge-skipped, because
1373
+ // the output settles on the destination chain and can never appear in this
1374
+ // source-chain simulation.
1260
1375
  let minOut;
1261
1376
  if (swapMode === 'exactOut') {
1262
1377
  if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
@@ -1290,26 +1405,30 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
1290
1405
  const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
1291
1406
  minOut = (quoted * (10000n - bps)) / 10000n;
1292
1407
  }
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}).`);
1408
+ if (!isBridge) {
1409
+ const outputIsNative = outputAsset === SOL_SENTINEL;
1410
+ const outputDelta = deltas[outputAsset] || 0n;
1411
+ // Native-SOL output carries the same fee/rent noise as native input: the
1412
+ // lamport delta is (SOL received base/priority fee net ATA rent), so a
1413
+ // legitimate trade can land a few million lamports under the quoted amount at
1414
+ // tight slippage or on a congested-network priority fee. Relax the floor by
1415
+ // the same combined fee/rent slack used for native siblings (assertion 3) and
1416
+ // native input (assertion 1) so fee noise never false-blocks; the slippage
1417
+ // floor still bounds any real shortfall. SPL output has no such noise and
1418
+ // keeps the exact floor.
1419
+ const outputFloorSlack = outputIsNative
1420
+ ? (siblingDustThreshold != null ? siblingDustThreshold : NATIVE_FEE_RENT_SLACK_LAMPORTS)
1421
+ : 0n;
1422
+ // minOut can be smaller than the dust tolerance for a dust-quoted swap; clamp
1423
+ // the floor at 0 so the subtraction never goes negative and silently admits
1424
+ // any non-negative outputDelta (including zero). The explicit outputDelta <= 0n
1425
+ // check below then restores the invariant assertSwapOutcome (the EVM sibling)
1426
+ // gets for free because its minOut can never collapse to <= 0: a swap must
1427
+ // deliver SOME positive output, even when the dust-adjusted floor is 0.
1428
+ const adjustedFloor = minOut > outputFloorSlack ? minOut - outputFloorSlack : 0n;
1429
+ if (outputDelta <= 0n || outputDelta < adjustedFloor) {
1430
+ throw fail(`the output token (${outputAsset}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
1431
+ }
1313
1432
  }
1314
1433
 
1315
1434
  // --- Assertion 3: no other tracked asset leaves the wallet ---
@@ -1326,7 +1445,9 @@ export function assertSolanaSwapOutcome(request, quote, sim, { slippage, sibling
1326
1445
  // inputAssertionSkipped tells the caller assertion 1 ran with the fee/rent
1327
1446
  // slack applied (native-SOL input, per the JSDoc above), so it can surface
1328
1447
  // that instead of implying the input spend was tightly delta-verified.
1329
- return { verified: true, inputAssertionSkipped: inputIsNative };
1448
+ // outputAssertionSkipped tells the caller assertion 2 did not run at all
1449
+ // (cross-chain bridge, per the JSDoc above).
1450
+ return { verified: true, inputAssertionSkipped: inputIsNative, outputAssertionSkipped: isBridge };
1330
1451
  }
1331
1452
 
1332
1453
  /**
package/src/trading.js CHANGED
@@ -9,7 +9,8 @@ import crypto from 'crypto';
9
9
  import fs from 'fs';
10
10
  import path from 'path';
11
11
  import { base58Encode, exportWallet, getWalletConfig, showWallet, listWallets } from './wallet.js';
12
- import { base58Decode } from './transfer.js';
12
+ import { base58Decode, encodeCompactU16 } from './transfer.js';
13
+ import { buildMessageV0, fetchRecentBlockhash } from './x402-svm.js';
13
14
  import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
14
15
  import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
15
16
  import { retrievePassword } from './keychain.js';
@@ -25,6 +26,8 @@ import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './ap
25
26
 
26
27
  const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai';
27
28
  const CLIENT_USER_AGENT = `nansen-cli/${packageVersion}`;
29
+ // Solana's max transaction wire size (IPv6 MTU minus headers).
30
+ const SOLANA_MAX_TX_SIZE = 1232;
28
31
 
29
32
  const CHAIN_MAP = {
30
33
  solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/', lifiChainId: '1151111081099710' },
@@ -509,6 +512,107 @@ export function signSolanaTransaction(transactionBase64, privateKeyHex) {
509
512
  return signedTx.toString('base64');
510
513
  }
511
514
 
515
+ // Any valid base58 32-byte value works here — recentBlockhash is fixed-size
516
+ // regardless of its actual value, so this is exact for a size-only preflight
517
+ // and lets the signer/signature-count checks below run before the real
518
+ // blockhash fetch (no wasted RPC round trip on a request we're going to reject).
519
+ const SIZE_CHECK_BLOCKHASH = '11111111111111111111111111111111';
520
+
521
+ function decodeInstructionData(hex) {
522
+ if (hex == null || hex === '') return Buffer.alloc(0); // some instructions legitimately carry no data
523
+ const body = hex.startsWith('0x') ? hex.slice(2) : hex;
524
+ // Buffer.from(str, 'hex') silently drops a trailing odd nibble and stops at
525
+ // the first non-hex character, so it would decode malformed data into a
526
+ // plausible-but-wrong instruction that then gets signed. Reject instead.
527
+ if (body.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(body)) {
528
+ throw new Error(`Cannot compile Solana transaction: instruction data is not valid hex ("${hex}")`);
529
+ }
530
+ return Buffer.from(body, 'hex');
531
+ }
532
+
533
+ /**
534
+ * Compile a raw, uncompiled Solana transaction — {instructions, addressLookupTableAddresses}
535
+ * — into a signable base64 VersionedTransaction. Some aggregators (Relay's Solana-source
536
+ * bridge quotes) return this shape instead of a ready-to-sign serialized transaction.
537
+ *
538
+ * Every account is kept static; the address-lookup-table hint is a size optimization,
539
+ * not a correctness requirement, so skipping it is valid as long as the compiled
540
+ * transaction still fits Solana's packet limit. Full lookup-table compilation is
541
+ * unimplemented — throws instead of silently building an oversized/invalid transaction.
542
+ *
543
+ * getExpectedSigner is an async thunk resolving to the address of the wallet that is
544
+ * about to sign. The transaction only ever gets a single signature written into slot 0
545
+ * (see signSolanaTransaction / the WalletConnect injection path), so the instructions'
546
+ * own declared signer must both be unambiguous (exactly one signer) and match that
547
+ * wallet — otherwise the transaction would silently sign the wrong account or leave a
548
+ * required signature slot empty, failing on-chain with an opaque error.
549
+ */
550
+ export async function compileRawSolanaTransaction(transaction, rpcUrl, getExpectedSigner) {
551
+ const instructions = transaction.instructions.map(ix => {
552
+ if (!Array.isArray(ix.keys)) {
553
+ throw new Error('Cannot compile Solana transaction: instruction is missing its "keys" accounts list');
554
+ }
555
+ return { programId: ix.programId, accounts: ix.keys, data: decodeInstructionData(ix.data) };
556
+ });
557
+
558
+ const feePayer = instructions.flatMap(ix => ix.accounts).find(a => a.isSigner)?.pubkey;
559
+ if (!feePayer) {
560
+ throw new Error('Cannot compile Solana transaction: no signer account found in instructions');
561
+ }
562
+
563
+ const expectedSigner = await getExpectedSigner();
564
+ if (!expectedSigner) {
565
+ throw new Error('Cannot compile Solana transaction: wallet address unavailable to verify the signer');
566
+ }
567
+ if (feePayer !== expectedSigner) {
568
+ throw new Error(
569
+ `Solana transaction signer (${feePayer}) doesn't match the wallet executing this trade ` +
570
+ `(${expectedSigner}). Refusing to sign — get a new quote.`
571
+ );
572
+ }
573
+
574
+ const preflight = buildMessageV0({ feePayer, instructions, recentBlockhash: SIZE_CHECK_BLOCKHASH });
575
+ if (preflight.numRequiredSignatures !== 1) {
576
+ throw new Error(
577
+ `Cannot compile Solana transaction: requires ${preflight.numRequiredSignatures} signatures, ` +
578
+ `but only the wallet's own signature can be provided.`
579
+ );
580
+ }
581
+ const unsignedSize = 1 + 64 + preflight.messageBytes.length; // compact-u16(1) + 1 signature slot
582
+ if (unsignedSize > SOLANA_MAX_TX_SIZE) {
583
+ throw new Error(
584
+ `Solana transaction too large to compile without address-lookup-table support ` +
585
+ `(${unsignedSize} bytes > ${SOLANA_MAX_TX_SIZE} limit). This route needs its ` +
586
+ `address lookup tables resolved, which isn't supported yet.`
587
+ );
588
+ }
589
+
590
+ const recentBlockhash = await fetchRecentBlockhash(rpcUrl);
591
+ const { messageBytes } = buildMessageV0({ feePayer, instructions, recentBlockhash });
592
+ const unsignedTx = Buffer.concat([encodeCompactU16(1), Buffer.alloc(64), messageBytes]);
593
+ return unsignedTx.toString('base64');
594
+ }
595
+
596
+ /**
597
+ * Normalize a Solana quote's `transaction` field to a base64-encoded, ready-to-sign
598
+ * VersionedTransaction. Three shapes seen across aggregators: Jupiter (already base64),
599
+ * OKX ({data: base58}), and Relay bridge quotes (raw uncompiled
600
+ * {instructions, addressLookupTableAddresses} — compiled client-side).
601
+ *
602
+ * getExpectedSigner (only consulted for the Relay shape) is an async thunk resolving to
603
+ * the signing wallet's address — see compileRawSolanaTransaction.
604
+ */
605
+ export async function normalizeSolanaTransaction(transaction, rpcUrl, getExpectedSigner) {
606
+ if (typeof transaction === 'string') return transaction; // Jupiter: already base64
607
+ // Dispatch most-specific shape first. Only Relay carries `instructions` and
608
+ // only OKX carries `data`; checking `instructions` ahead of the bare
609
+ // `data` truthiness test keeps a future Relay shape that also had a `data`
610
+ // field from being mis-routed into the OKX base58 decode.
611
+ if (Array.isArray(transaction.instructions)) return compileRawSolanaTransaction(transaction, rpcUrl, getExpectedSigner);
612
+ if (transaction.data) return base58Decode(transaction.data).toString('base64'); // OKX: base58 serialized tx
613
+ throw new Error('Unrecognized Solana transaction format in quote');
614
+ }
615
+
512
616
  /**
513
617
  * Sign an EVM transaction from quote data.
514
618
  *
@@ -853,10 +957,12 @@ function toRpcHexValue(value) {
853
957
  * guards: the cheap eth_call sim answers "will it revert", this answers "does the
854
958
  * outcome match intent" (see assertSwapOutcome in trade-validation.js).
855
959
  *
856
- * EVM-only, and on its own gate independent of --no-simulate/gasless. Skipped
857
- * for cross-chain bridges (the output lands on the destination chain, so a
858
- * source-chain simulation can't observe it). When no simulation-capable endpoint
859
- * is configured it DEGRADES logs a warning, then proceeds — so a simulation
960
+ * EVM-only, and on its own gate independent of --no-simulate/gasless. Runs for
961
+ * cross-chain bridges too assertSwapOutcome skips only the output-arrival
962
+ * assertion internally, since the output lands on the destination chain and a
963
+ * source-chain simulation can't observe it; the input-outflow and no-sibling-
964
+ * drain assertions still bound the source-chain leg. When no simulation-capable
965
+ * endpoint is configured it DEGRADES — logs a warning, then proceeds — so a simulation
860
966
  * outage never blocks trading. --no-verify-outcome skips it entirely.
861
967
  *
862
968
  * Returns { proceed, reason }. proceed=false means this quote failed
@@ -874,11 +980,12 @@ function toRpcHexValue(value) {
874
980
  */
875
981
  export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey = null, log = () => {} }) {
876
982
  if (CHAIN_MAP[chain?.toLowerCase()]?.type !== 'evm') return { proceed: true }; // EVM-only
877
- // Cross-chain: the output token settles on the destination chain, so it can
878
- // never appear in a source-chain simulation and the output-received assertion
879
- // would always fail. The source-chain leg only spends/locks the input here;
880
- // skip outcome verification for bridges (mirrors the bridge branch below).
881
- if (quoteData?.toChain && quoteData.toChain !== quoteData.chain) return { proceed: true };
983
+ // Cross-chain (bridge): the output token settles on the destination chain,
984
+ // so the source-chain simulation still runs but assertSwapOutcome skips
985
+ // only the output-arrival assertion internally (isBridge, derived from
986
+ // quoteData.request). The input-outflow cap and no-sibling-drain checks
987
+ // still bound the source-chain leg.
988
+
882
989
  // No request intent recorded (a pre-intent quote): assertSwapOutcome has
883
990
  // nothing to compare the simulated deltas against and would raise a misleading
884
991
  // SWAP_OUTCOME_MISMATCH. Degrade cleanly — the static guards still ran, and a
@@ -901,7 +1008,10 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
901
1008
  { to: tx.to, data: tx.data, value: toRpcHexValue(tx.value) },
902
1009
  { from, apiKey },
903
1010
  );
904
- assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders });
1011
+ const outcome = assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders });
1012
+ if (outcome.outputAssertionSkipped) {
1013
+ log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
1014
+ }
905
1015
  log(` ✓ Swap outcome verified (via ${sim.method}).`);
906
1016
  return { proceed: true };
907
1017
  } catch (e) {
@@ -925,9 +1035,10 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
925
1035
  */
926
1036
  export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote, quoteData, log = () => {} }) {
927
1037
  if (chain !== 'solana') return { proceed: true };
928
- // Cross-chain: the output settles on the destination chain and can never
929
- // appear in a source-chain simulation (mirrors the EVM bridge skip above).
930
- if (quoteData?.toChain && quoteData.toChain !== quoteData.chain) return { proceed: true };
1038
+ // Cross-chain (bridge): the output settles on the destination chain, so
1039
+ // the source-chain simulation still runs but assertSolanaSwapOutcome skips
1040
+ // only the output-arrival assertion internally (mirrors the EVM path above).
1041
+
931
1042
  if (!quoteData?.request) {
932
1043
  log(' ⚠ Swap-outcome verification skipped (no request intent — re-quote to enable it).');
933
1044
  return { proceed: true };
@@ -940,7 +1051,14 @@ export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64,
940
1051
  const sim = await simulateSolanaAssetChanges(chain, txBase64, { walletAddress });
941
1052
  const outcome = assertSolanaSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage });
942
1053
  if (outcome.inputAssertionSkipped) {
943
- log(' ℹ Native-SOL input spend is bounded with fee/rent slack, not exactly delta-verified; output and sibling checks still ran.');
1054
+ // On a native-SOL bridge the output assertion did NOT run (it settles on
1055
+ // the destination chain), so don't claim "output ... checks still ran" —
1056
+ // that would contradict the bridge line logged just below.
1057
+ const alsoRan = outcome.outputAssertionSkipped ? 'sibling checks still ran' : 'output and sibling checks still ran';
1058
+ log(` ℹ Native-SOL input spend is bounded with fee/rent slack, not exactly delta-verified; ${alsoRan}.`);
1059
+ }
1060
+ if (outcome.outputAssertionSkipped) {
1061
+ log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
944
1062
  }
945
1063
  log(` ✓ Swap outcome verified (via ${sim.method}).`);
946
1064
  return { proceed: true };
@@ -1148,6 +1266,14 @@ export function assertCompleteEvmRequestIntent(request) {
1148
1266
  if (missing.length) {
1149
1267
  throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing an EVM swap. Refusing to sign.`);
1150
1268
  }
1269
+ // swapMode must be a recognized mode, not merely present. This runs
1270
+ // unconditionally before signing — unlike the swap-outcome verifier, which is
1271
+ // skipped by --no-verify-outcome or when the sim RPC degrades — so a corrupted
1272
+ // or edited quote record with a garbage swapMode fails closed regardless of
1273
+ // the outcome-verification path.
1274
+ if (request.swapMode !== 'exactIn' && request.swapMode !== 'exactOut') {
1275
+ throw new Error(`Quote request intent has an unrecognized swap mode ("${request.swapMode}"); expected exactIn or exactOut. Re-quote before executing an EVM swap. Refusing to sign.`);
1276
+ }
1151
1277
  }
1152
1278
 
1153
1279
  /**
@@ -1170,6 +1296,12 @@ export function assertCompleteSolanaRequestIntent(request) {
1170
1296
  if (missing.length) {
1171
1297
  throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing a Solana swap. Refusing to sign.`);
1172
1298
  }
1299
+ // swapMode must be a recognized mode, not merely present — see the EVM sibling.
1300
+ // Runs unconditionally before signing, so a garbage swapMode fails closed even
1301
+ // when the swap-outcome verifier is skipped or degraded.
1302
+ if (request.swapMode !== 'exactIn' && request.swapMode !== 'exactOut') {
1303
+ throw new Error(`Quote request intent has an unrecognized swap mode ("${request.swapMode}"); expected exactIn or exactOut. Re-quote before executing a Solana swap. Refusing to sign.`);
1304
+ }
1173
1305
  }
1174
1306
 
1175
1307
  /**
@@ -1718,6 +1850,12 @@ export function buildTradingCommands(deps = {}) {
1718
1850
  const autoSlippage = flags['auto-slippage'];
1719
1851
  const maxAutoSlippage = options['max-auto-slippage'];
1720
1852
  const swapMode = options['swap-mode'] || 'exactIn';
1853
+ if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
1854
+ throw new CommandError(
1855
+ `Invalid --swap-mode: "${swapMode}". Use one of: exactIn, exactOut.`,
1856
+ 'INVALID_INPUT',
1857
+ );
1858
+ }
1721
1859
  const amountUnit = options['amount-unit'];
1722
1860
  const aggregatorFilter = options.aggregator;
1723
1861
  if (aggregatorFilter && !['lifi', 'relay', 'jupiter', 'okx'].includes(aggregatorFilter)) {
@@ -2311,10 +2449,6 @@ EXAMPLES:
2311
2449
 
2312
2450
  if (chainType === 'solana' && isPrivy) {
2313
2451
  // Solana via Privy: sign the serialized transaction
2314
- let txBase64 = currentQuote.transaction;
2315
- if (typeof txBase64 === 'object' && txBase64.data) {
2316
- txBase64 = base58Decode(txBase64.data).toString('base64');
2317
- }
2318
2452
  const solWalletId = quoteData.privyWalletIds?.solana;
2319
2453
  if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote');
2320
2454
  const walletResult = await privyClient.getWallet(solWalletId);
@@ -2328,6 +2462,11 @@ EXAMPLES:
2328
2462
  throw new Error('Could not resolve the Solana Privy wallet address; cannot confirm the quote was built for this wallet. Refusing to sign.');
2329
2463
  }
2330
2464
 
2465
+ // Solana: transaction is a base64 string (Jupiter), an object with a
2466
+ // base58-encoded `data` field (OKX), or raw uncompiled instructions
2467
+ // (Relay bridge quotes). Normalize to base64.
2468
+ const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => walletAddress);
2469
+
2331
2470
  // Validate the persisted request/quote metadata (token pair, amounts,
2332
2471
  // signer) before signing the aggregator's serialized transaction.
2333
2472
  assertCompleteSolanaRequestIntent(quoteData.request);
@@ -2626,15 +2765,13 @@ EXAMPLES:
2626
2765
  // below binds the metadata (token pair, amounts, signer), and
2627
2766
  // assertSolanaInstructionsSafe statically inspects the tx's own
2628
2767
  // instructions before signing.
2629
- // Solana: transaction is either a base64 string (Jupiter) or an object
2630
- // with a base58-encoded `data` field (OKX). Normalize to base64.
2631
- let txBase64 = currentQuote.transaction;
2632
- if (typeof txBase64 === 'object' && txBase64.data) {
2633
- txBase64 = base58Decode(txBase64.data).toString('base64');
2634
- }
2768
+ // Solana: transaction is a base64 string (Jupiter), an object with a
2769
+ // base58-encoded `data` field (OKX), or raw uncompiled instructions
2770
+ // (Relay bridge quotes). Normalize to base64.
2635
2771
 
2636
- // Resolve the signer for this sub-path so the intent-binding check
2637
- // below can confirm the quote was built for this exact wallet.
2772
+ // Resolve the signer first both the Relay-shape compiler (which needs
2773
+ // an expected signer for its fee-payer check) and the intent-binding
2774
+ // check below use this exact same address.
2638
2775
  let solanaWalletAddress;
2639
2776
  if (isWalletConnect) {
2640
2777
  solanaWalletAddress = await getWalletConnectAddress(chainType);
@@ -2651,6 +2788,8 @@ EXAMPLES:
2651
2788
  }
2652
2789
  }
2653
2790
 
2791
+ const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => solanaWalletAddress);
2792
+
2654
2793
  // Validate the persisted request/quote metadata (token pair, amounts,
2655
2794
  // signer) before signing the opaque Solana transaction.
2656
2795
  assertCompleteSolanaRequestIntent(quoteData.request);
package/src/x402-svm.js CHANGED
@@ -32,9 +32,13 @@ export function deriveATA(ownerBase58, mintBase58, tokenProgramBase58 = TOKEN_PR
32
32
 
33
33
  /**
34
34
  * Build a Solana MessageV0 from accounts and instructions.
35
- * Simplified builder for x402 payment transactions.
35
+ * feePayer is always placed at account index 0, forced signer+writable,
36
+ * regardless of whether an instruction references it directly.
37
+ * Returns numRequiredSignatures alongside the bytes since it's read back out
38
+ * of the header to size the signature-placeholder slots of the wrapping
39
+ * unsigned transaction (see callers).
36
40
  */
37
- function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts: _accounts }) {
41
+ export function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts: _accounts }) {
38
42
  // All unique accounts in order: feePayer first, then signers, then rest
39
43
  const accountMap = new Map();
40
44
  const feePayerKey = feePayer;
@@ -129,10 +133,10 @@ function buildMessageV0({ feePayer, instructions, recentBlockhash, accounts: _ac
129
133
  parts.push(ix.data);
130
134
  }
131
135
 
132
- // Address table lookups (empty for our use case)
136
+ // Address table lookups (empty all accounts referenced statically above)
133
137
  parts.push(encodeCompactU16(0));
134
138
 
135
- return Buffer.concat(parts);
139
+ return { messageBytes: Buffer.concat(parts), numRequiredSignatures };
136
140
  }
137
141
 
138
142
  // ============= Ed25519 Signing =============
@@ -231,7 +235,7 @@ export function buildUnsignedSvmTransaction(
231
235
  },
232
236
  ];
233
237
 
234
- const messageBytes = buildMessageV0({
238
+ const { messageBytes } = buildMessageV0({
235
239
  feePayer: feePayerStr,
236
240
  instructions,
237
241
  recentBlockhash,