nansen-cli 1.41.0 → 1.42.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/src/trading.js CHANGED
@@ -9,11 +9,12 @@ 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';
16
- import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, assertSolanaInstructionsSafe, assertSolanaSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER } from './trade-validation.js';
17
+ import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, assertSolanaInstructionsSafe, assertSolanaSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER, EVM_BRIDGE_NATIVE_FEE_SLACK, isBridgeRequest } from './trade-validation.js';
17
18
  import { readCompactU16 } from './solana-tx.js';
18
19
  export { readCompactU16 };
19
20
  import { CHAIN_RPCS } from './rpc-urls.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,21 @@ 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
+ // A cross-chain bridge may pay a fee in native ETH via msg.value on a
1012
+ // token-input route; that surfaces as a native sibling outflow which the
1013
+ // no-sibling-drain check (assertion 3) would otherwise reject. Tolerate it up
1014
+ // to the smaller of the tx's declared native value and the fixed cap — never
1015
+ // the full value, which a hostile quote could inflate to the whole balance.
1016
+ // assertSwapOutcome applies this only for bridges and only to native.
1017
+ let siblingDustThreshold = 0n;
1018
+ try {
1019
+ const declaredValue = BigInt(tx.value ?? 0);
1020
+ siblingDustThreshold = declaredValue < EVM_BRIDGE_NATIVE_FEE_SLACK ? declaredValue : EVM_BRIDGE_NATIVE_FEE_SLACK;
1021
+ } catch { /* non-integer value → leave 0n, assertion 3 stays strict */ }
1022
+ const outcome = assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders, siblingDustThreshold });
1023
+ if (outcome.outputAssertionSkipped) {
1024
+ log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
1025
+ }
905
1026
  log(` ✓ Swap outcome verified (via ${sim.method}).`);
906
1027
  return { proceed: true };
907
1028
  } catch (e) {
@@ -925,9 +1046,10 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
925
1046
  */
926
1047
  export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote, quoteData, log = () => {} }) {
927
1048
  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 };
1049
+ // Cross-chain (bridge): the output settles on the destination chain, so
1050
+ // the source-chain simulation still runs but assertSolanaSwapOutcome skips
1051
+ // only the output-arrival assertion internally (mirrors the EVM path above).
1052
+
931
1053
  if (!quoteData?.request) {
932
1054
  log(' ⚠ Swap-outcome verification skipped (no request intent — re-quote to enable it).');
933
1055
  return { proceed: true };
@@ -940,7 +1062,14 @@ export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64,
940
1062
  const sim = await simulateSolanaAssetChanges(chain, txBase64, { walletAddress });
941
1063
  const outcome = assertSolanaSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage });
942
1064
  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.');
1065
+ // On a native-SOL bridge the output assertion did NOT run (it settles on
1066
+ // the destination chain), so don't claim "output ... checks still ran" —
1067
+ // that would contradict the bridge line logged just below.
1068
+ const alsoRan = outcome.outputAssertionSkipped ? 'sibling checks still ran' : 'output and sibling checks still ran';
1069
+ log(` ℹ Native-SOL input spend is bounded with fee/rent slack, not exactly delta-verified; ${alsoRan}.`);
1070
+ }
1071
+ if (outcome.outputAssertionSkipped) {
1072
+ log(' ℹ Bridge: input-outflow and sibling checks ran; output arrives on the destination chain and is not simulated here.');
944
1073
  }
945
1074
  log(` ✓ Swap outcome verified (via ${sim.method}).`);
946
1075
  return { proceed: true };
@@ -1148,6 +1277,14 @@ export function assertCompleteEvmRequestIntent(request) {
1148
1277
  if (missing.length) {
1149
1278
  throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing an EVM swap. Refusing to sign.`);
1150
1279
  }
1280
+ // swapMode must be a recognized mode, not merely present. This runs
1281
+ // unconditionally before signing — unlike the swap-outcome verifier, which is
1282
+ // skipped by --no-verify-outcome or when the sim RPC degrades — so a corrupted
1283
+ // or edited quote record with a garbage swapMode fails closed regardless of
1284
+ // the outcome-verification path.
1285
+ if (request.swapMode !== 'exactIn' && request.swapMode !== 'exactOut') {
1286
+ 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.`);
1287
+ }
1151
1288
  }
1152
1289
 
1153
1290
  /**
@@ -1170,6 +1307,12 @@ export function assertCompleteSolanaRequestIntent(request) {
1170
1307
  if (missing.length) {
1171
1308
  throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing a Solana swap. Refusing to sign.`);
1172
1309
  }
1310
+ // swapMode must be a recognized mode, not merely present — see the EVM sibling.
1311
+ // Runs unconditionally before signing, so a garbage swapMode fails closed even
1312
+ // when the swap-outcome verifier is skipped or degraded.
1313
+ if (request.swapMode !== 'exactIn' && request.swapMode !== 'exactOut') {
1314
+ 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.`);
1315
+ }
1173
1316
  }
1174
1317
 
1175
1318
  /**
@@ -1718,6 +1861,12 @@ export function buildTradingCommands(deps = {}) {
1718
1861
  const autoSlippage = flags['auto-slippage'];
1719
1862
  const maxAutoSlippage = options['max-auto-slippage'];
1720
1863
  const swapMode = options['swap-mode'] || 'exactIn';
1864
+ if (swapMode !== 'exactIn' && swapMode !== 'exactOut') {
1865
+ throw new CommandError(
1866
+ `Invalid --swap-mode: "${swapMode}". Use one of: exactIn, exactOut.`,
1867
+ 'INVALID_INPUT',
1868
+ );
1869
+ }
1721
1870
  const amountUnit = options['amount-unit'];
1722
1871
  const aggregatorFilter = options.aggregator;
1723
1872
  if (aggregatorFilter && !['lifi', 'relay', 'jupiter', 'okx'].includes(aggregatorFilter)) {
@@ -2311,10 +2460,6 @@ EXAMPLES:
2311
2460
 
2312
2461
  if (chainType === 'solana' && isPrivy) {
2313
2462
  // 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
2463
  const solWalletId = quoteData.privyWalletIds?.solana;
2319
2464
  if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote');
2320
2465
  const walletResult = await privyClient.getWallet(solWalletId);
@@ -2328,6 +2473,11 @@ EXAMPLES:
2328
2473
  throw new Error('Could not resolve the Solana Privy wallet address; cannot confirm the quote was built for this wallet. Refusing to sign.');
2329
2474
  }
2330
2475
 
2476
+ // Solana: transaction is a base64 string (Jupiter), an object with a
2477
+ // base58-encoded `data` field (OKX), or raw uncompiled instructions
2478
+ // (Relay bridge quotes). Normalize to base64.
2479
+ const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => walletAddress);
2480
+
2331
2481
  // Validate the persisted request/quote metadata (token pair, amounts,
2332
2482
  // signer) before signing the aggregator's serialized transaction.
2333
2483
  assertCompleteSolanaRequestIntent(quoteData.request);
@@ -2398,7 +2548,16 @@ EXAMPLES:
2398
2548
  continue;
2399
2549
  }
2400
2550
  } else {
2401
- if (txValue > 0n) {
2551
+ // A token-input swap sends no native value — except a cross-chain
2552
+ // bridge may carry a bounded native fee via msg.value. Allow that up
2553
+ // to the same ceiling assertSwapOutcome tolerates as a native sibling
2554
+ // (verifySwapOutcome runs below and re-bounds the actual simulated
2555
+ // outflow to min(tx.value, cap)); reject any other non-zero value, and
2556
+ // any bridge fee above the ceiling.
2557
+ const bridgeFeeAllowed = quoteData?.request
2558
+ && isBridgeRequest(quoteData.request)
2559
+ && txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
2560
+ if (txValue > 0n && !bridgeFeeAllowed) {
2402
2561
  log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
2403
2562
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2404
2563
  lastQuoteError = `${quoteName} unexpected tx.value`;
@@ -2626,15 +2785,13 @@ EXAMPLES:
2626
2785
  // below binds the metadata (token pair, amounts, signer), and
2627
2786
  // assertSolanaInstructionsSafe statically inspects the tx's own
2628
2787
  // 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
- }
2788
+ // Solana: transaction is a base64 string (Jupiter), an object with a
2789
+ // base58-encoded `data` field (OKX), or raw uncompiled instructions
2790
+ // (Relay bridge quotes). Normalize to base64.
2635
2791
 
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.
2792
+ // Resolve the signer first both the Relay-shape compiler (which needs
2793
+ // an expected signer for its fee-payer check) and the intent-binding
2794
+ // check below use this exact same address.
2638
2795
  let solanaWalletAddress;
2639
2796
  if (isWalletConnect) {
2640
2797
  solanaWalletAddress = await getWalletConnectAddress(chainType);
@@ -2651,6 +2808,8 @@ EXAMPLES:
2651
2808
  }
2652
2809
  }
2653
2810
 
2811
+ const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => solanaWalletAddress);
2812
+
2654
2813
  // Validate the persisted request/quote metadata (token pair, amounts,
2655
2814
  // signer) before signing the opaque Solana transaction.
2656
2815
  assertCompleteSolanaRequestIntent(quoteData.request);
@@ -2765,7 +2924,16 @@ EXAMPLES:
2765
2924
  continue;
2766
2925
  }
2767
2926
  } else {
2768
- if (txValue > 0n) {
2927
+ // A token-input swap sends no native value — except a cross-chain
2928
+ // bridge may carry a bounded native fee via msg.value. Allow that up
2929
+ // to the same ceiling assertSwapOutcome tolerates as a native sibling
2930
+ // (verifySwapOutcome runs below and re-bounds the actual simulated
2931
+ // outflow to min(tx.value, cap)); reject any other non-zero value, and
2932
+ // any bridge fee above the ceiling.
2933
+ const bridgeFeeAllowed = quoteData?.request
2934
+ && isBridgeRequest(quoteData.request)
2935
+ && txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
2936
+ if (txValue > 0n && !bridgeFeeAllowed) {
2769
2937
  log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
2770
2938
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2771
2939
  lastQuoteError = `${quoteName} unexpected tx.value`;
@@ -3093,7 +3261,16 @@ EXAMPLES:
3093
3261
  continue;
3094
3262
  }
3095
3263
  } else {
3096
- if (txValue > 0n) {
3264
+ // A token-input swap sends no native value — except a cross-chain
3265
+ // bridge may carry a bounded native fee via msg.value. Allow that up
3266
+ // to the same ceiling assertSwapOutcome tolerates as a native sibling
3267
+ // (verifySwapOutcome runs below and re-bounds the actual simulated
3268
+ // outflow to min(tx.value, cap)); reject any other non-zero value, and
3269
+ // any bridge fee above the ceiling.
3270
+ const bridgeFeeAllowed = quoteData?.request
3271
+ && isBridgeRequest(quoteData.request)
3272
+ && txValue <= EVM_BRIDGE_NATIVE_FEE_SLACK;
3273
+ if (txValue > 0n && !bridgeFeeAllowed) {
3097
3274
  log(` ❌ ERC-20 swap has non-zero tx.value (${txValue}) for ${quoteName} — aborting`);
3098
3275
  if (qi + 1 < endIndex) log(` Trying next quote...`);
3099
3276
  lastQuoteError = `${quoteName} unexpected tx.value`;
package/src/wallet.js CHANGED
@@ -281,27 +281,32 @@ function hashPassword(password) {
281
281
 
282
282
  // ============= Prompt Helper =============
283
283
 
284
- async function promptPassword(question, deps = {}) {
284
+ // Exported for testing (mirrors the exported `prompt` in cli.js). The streams
285
+ // are injectable so the masking behavior can be exercised without a real TTY.
286
+ export async function promptPassword(question, deps = {}, { input: inStream = process.stdin, output: outStream = process.stderr } = {}) {
285
287
  const promptFn = deps.promptFn;
286
288
  if (promptFn) {
287
289
  return promptFn(question, true);
288
290
  }
289
291
  // Fallback to readline (only available in --human mode)
290
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
291
292
  return new Promise((resolve) => {
292
- if (process.stdout.isTTY) {
293
- process.stdout.write(question);
293
+ // Gate on stdin, not stdout: raw-mode masking disables the terminal's own
294
+ // echo, so a redirected stdout (e.g. `wallet export > backup.json`) can no
295
+ // longer fall through to readline and echo the password in cleartext. Prompt
296
+ // and mask characters go to stderr so they stay on the terminal and never
297
+ // pollute — or leak into — a redirected stdout.
298
+ if (inStream.isTTY) {
299
+ outStream.write(question);
294
300
  let input = '';
295
- process.stdin.setRawMode(true);
296
- process.stdin.resume();
297
- process.stdin.setEncoding('utf8');
301
+ inStream.setRawMode(true);
302
+ inStream.resume();
303
+ inStream.setEncoding('utf8');
298
304
  const onData = (char) => {
299
305
  if (char === '\n' || char === '\r') {
300
- process.stdin.setRawMode(false);
301
- process.stdin.pause();
302
- process.stdin.removeListener('data', onData);
303
- process.stdout.write('\n');
304
- rl.close();
306
+ inStream.setRawMode(false);
307
+ inStream.pause();
308
+ inStream.removeListener('data', onData);
309
+ outStream.write('\n');
305
310
  resolve(input);
306
311
  } else if (char === '\u0003') {
307
312
  process.exit();
@@ -309,11 +314,12 @@ async function promptPassword(question, deps = {}) {
309
314
  input = input.slice(0, -1);
310
315
  } else {
311
316
  input += char;
312
- process.stdout.write('*');
317
+ outStream.write('*');
313
318
  }
314
319
  };
315
- process.stdin.on('data', onData);
320
+ inStream.on('data', onData);
316
321
  } else {
322
+ const rl = readline.createInterface({ input: inStream, output: outStream });
317
323
  rl.question(question, (answer) => { rl.close(); resolve(answer); });
318
324
  }
319
325
  });
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,