nansen-cli 1.40.1 → 1.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/trading.js CHANGED
@@ -9,19 +9,25 @@ 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, 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 } from './trade-validation.js';
18
+ import { readCompactU16 } from './solana-tx.js';
19
+ export { readCompactU16 };
17
20
  import { CHAIN_RPCS } from './rpc-urls.js';
18
21
  import { simulateAssetChanges, SwapSimulationError, hasSimulationRpc } from './swap-simulation.js';
22
+ import { simulateSolanaAssetChanges, SolanaSimulationError, hasSolanaSimulationRpc } from './solana-simulation.js';
19
23
  import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './api.js';
20
24
 
21
25
  // ============= Constants =============
22
26
 
23
27
  const TRADING_API_URL = process.env.NANSEN_TRADING_API_URL || 'https://trading-api.nansen.ai';
24
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;
25
31
 
26
32
  const CHAIN_MAP = {
27
33
  solana: { index: '501', type: 'solana', chainId: 501, name: 'Solana', explorer: 'https://solscan.io/tx/', lifiChainId: '1151111081099710' },
@@ -109,7 +115,8 @@ export function getQuotesDir() {
109
115
  export function safeQuotesPath(filename) {
110
116
  const base = path.resolve(getQuotesDir());
111
117
  const target = path.resolve(base, filename);
112
- if (path.relative(base, target).startsWith('..')) return null;
118
+ const relative = path.relative(base, target);
119
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
113
120
  return target;
114
121
  }
115
122
 
@@ -505,6 +512,107 @@ export function signSolanaTransaction(transactionBase64, privateKeyHex) {
505
512
  return signedTx.toString('base64');
506
513
  }
507
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
+
508
616
  /**
509
617
  * Sign an EVM transaction from quote data.
510
618
  *
@@ -565,6 +673,30 @@ export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
565
673
  return signLegacyTransaction({ ...common, gasPrice: toHex(txData.gasPrice) }, privateKeyHex);
566
674
  }
567
675
 
676
+ /**
677
+ * Canonical EVM transaction hash: keccak256 over the raw signed tx bytes.
678
+ *
679
+ * Works for legacy (RLP) and typed (0x02-prefixed EIP-1559) transactions alike,
680
+ * because the tx hash is defined over exactly the bytes that get broadcast.
681
+ *
682
+ * NB: this is NOT the signing hash. signEvmTransaction/signLegacyTransaction hash
683
+ * the *unsigned* payload to produce the message that gets signed; this hashes the
684
+ * fully *signed* transaction to produce its on-chain identifier.
685
+ *
686
+ * @param {string} signedTxHex - 0x-prefixed (or bare) hex of the signed transaction
687
+ * @returns {string} 0x-prefixed transaction hash
688
+ */
689
+ export function evmTxHash(signedTxHex) {
690
+ if (typeof signedTxHex !== 'string') {
691
+ throw new Error('evmTxHash: signed transaction must be a hex string');
692
+ }
693
+ const hex = signedTxHex.startsWith('0x') ? signedTxHex.slice(2) : signedTxHex;
694
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) {
695
+ throw new Error('evmTxHash: signed transaction is not valid hex');
696
+ }
697
+ return '0x' + keccak256(Buffer.from(hex, 'hex')).toString('hex');
698
+ }
699
+
568
700
  // How many queued-but-unmined transactions we are willing to sign past.
569
701
  //
570
702
  // `pending` counts mempool-queued transactions as well as mined ones, and that is
@@ -658,7 +790,108 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 180000, pollMs =
658
790
  // Receipt not yet available — wait and retry
659
791
  await new Promise(r => setTimeout(r, pollMs));
660
792
  }
661
- throw new Error(`Transaction receipt not found after ${timeoutMs}ms. Tx: ${txHash}`);
793
+ // A timeout is NOT a confirmed revert: the tx may still be pending under our
794
+ // nonce. Tag it so callers can distinguish "reverted" (safe to try the next
795
+ // quote) from "unconfirmed" (retrying may broadcast a second tx that races
796
+ // the first for the same nonce). See the swap-path receipt catch.
797
+ const timeoutErr = new Error(`Transaction receipt not found after ${timeoutMs}ms. Tx: ${txHash}`);
798
+ timeoutErr.code = 'RECEIPT_TIMEOUT';
799
+ throw timeoutErr;
800
+ }
801
+
802
+ /**
803
+ * Post-broadcast failures that must abort the whole `execute` rather than fall
804
+ * through to the next quote. Once a transaction is broadcast we hold no evidence
805
+ * about what landed on-chain, so "try the next quote" would sign and broadcast a
806
+ * second transaction — the one thing we must not do. Covers every path (swap,
807
+ * approval, revoke; Privy/WalletConnect/local-key). Each code is thrown with a
808
+ * rationale at its throw site:
809
+ * - TXHASH_MISMATCH — broadcaster reported a tx we did not sign
810
+ * - INVALID_SIGNED_TX — we cannot even derive a hash for what we broadcast
811
+ * - RECEIPT_TIMEOUT — receipt never landed; the tx may still be pending, so
812
+ * retrying would race a second tx against the same nonce
813
+ * (a confirmed on-chain revert is NOT this — it may retry)
814
+ *
815
+ * @param {Error} err
816
+ * @returns {boolean}
817
+ */
818
+ function isFatalBroadcastError(err) {
819
+ return err?.code === 'TXHASH_MISMATCH'
820
+ || err?.code === 'INVALID_SIGNED_TX'
821
+ || err?.code === 'RECEIPT_TIMEOUT';
822
+ }
823
+
824
+ /**
825
+ * Assert the broadcaster reported the transaction we actually signed, and return
826
+ * our locally-derived hash.
827
+ *
828
+ * Fails closed (TXHASH_MISMATCH) when the broadcaster's returned hash differs
829
+ * from keccak256 of our signed bytes: a mismatch means its receipt would confirm
830
+ * a transaction we never signed, so nothing has been verified. When the
831
+ * broadcaster returns no hash we cannot compare, so the returned local hash is
832
+ * what callers must poll for a receipt — a substituted transaction then times
833
+ * out rather than falsely confirming.
834
+ *
835
+ * @param {string} signedTxHex - the raw signed tx we sent to /execute
836
+ * @param {string} broadcasterTxHash - the txHash /execute returned (may be empty)
837
+ * @param {string} [label] - describes the tx for the error, e.g. "allowance-revoke"
838
+ * @returns {string} our locally-derived transaction hash
839
+ */
840
+ function assertTxHashMatch(signedTxHex, broadcasterTxHash, label = '') {
841
+ const what = label ? `the ${label} transaction this CLI signed` : 'the transaction this CLI signed';
842
+ // A derivation failure here happens AFTER the tx was broadcast, so it must be
843
+ // fatal (INVALID_SIGNED_TX), never swallowed into "try the next quote": we
844
+ // hold no hash for the transaction we just sent.
845
+ let localHash;
846
+ try {
847
+ localHash = evmTxHash(signedTxHex);
848
+ } catch (hashErr) {
849
+ throw new CommandError(
850
+ `Aborting: cannot derive a local hash for ${what}: ${hashErr.message}. `
851
+ + `The transaction may already have been broadcast, so nothing further will run — `
852
+ + `check your wallet before retrying.`,
853
+ 'INVALID_SIGNED_TX',
854
+ );
855
+ }
856
+ // Normalize both sides through the same bare-hex form before comparing.
857
+ // evmTxHash always emits 0x-prefixed, but a broadcaster may report bare hex;
858
+ // comparing 0x-prefixed against bare would be a false mismatch on the prefix
859
+ // alone — and TXHASH_MISMATCH is fatal, so that would wrongly abort.
860
+ if (broadcasterTxHash) {
861
+ const norm = h => h.toLowerCase().replace(/^0x/, '');
862
+ if (norm(localHash) !== norm(broadcasterTxHash)) {
863
+ throw new CommandError(
864
+ `Aborting: the broadcaster reported transaction ${broadcasterTxHash}, but ${what} `
865
+ + `hashes to ${localHash}. These must match — a mismatch means the receipt would confirm `
866
+ + `a transaction you did not sign, so nothing has been verified and no further steps will `
867
+ + `run. Check both hashes on a block explorer to see what was actually broadcast before retrying.`,
868
+ 'TXHASH_MISMATCH',
869
+ );
870
+ }
871
+ }
872
+ return localHash;
873
+ }
874
+
875
+ /**
876
+ * Confirm a broadcast EVM transaction against the hash we derived locally from
877
+ * the signed bytes — not the hash the broadcaster reported. See
878
+ * {@link assertTxHashMatch} for the two guarantees (fail closed on mismatch;
879
+ * poll our own hash so a silent substitution times out rather than confirms).
880
+ *
881
+ * @param {string} chain
882
+ * @param {string} signedTxHex - the raw signed tx we sent to /execute
883
+ * @param {string} broadcasterTxHash - the txHash /execute returned
884
+ * @param {string} [label] - describes the tx for a mismatch error, e.g.
885
+ * "allowance-revoke" — the least useful moment to lose context is a revoke
886
+ * mismatch with the allowance sitting at 0, so callers should pass it
887
+ * @returns {Promise<{receipt: object, hash: string}>} the receipt and the
888
+ * locally-derived hash it was confirmed against (log THIS, not the
889
+ * broadcaster's hash — it is the transaction we actually verified landed)
890
+ */
891
+ export async function confirmEvmBroadcast(chain, signedTxHex, broadcasterTxHash, label = '') {
892
+ const hash = assertTxHashMatch(signedTxHex, broadcasterTxHash, label);
893
+ const receipt = await waitForReceipt(chain, hash);
894
+ return { receipt, hash };
662
895
  }
663
896
 
664
897
  /**
@@ -724,10 +957,12 @@ function toRpcHexValue(value) {
724
957
  * guards: the cheap eth_call sim answers "will it revert", this answers "does the
725
958
  * outcome match intent" (see assertSwapOutcome in trade-validation.js).
726
959
  *
727
- * EVM-only, and on its own gate independent of --no-simulate/gasless. Skipped
728
- * for cross-chain bridges (the output lands on the destination chain, so a
729
- * source-chain simulation can't observe it). When no simulation-capable endpoint
730
- * 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
731
966
  * outage never blocks trading. --no-verify-outcome skips it entirely.
732
967
  *
733
968
  * Returns { proceed, reason }. proceed=false means this quote failed
@@ -745,11 +980,12 @@ function toRpcHexValue(value) {
745
980
  */
746
981
  export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey = null, log = () => {} }) {
747
982
  if (CHAIN_MAP[chain?.toLowerCase()]?.type !== 'evm') return { proceed: true }; // EVM-only
748
- // Cross-chain: the output token settles on the destination chain, so it can
749
- // never appear in a source-chain simulation and the output-received assertion
750
- // would always fail. The source-chain leg only spends/locks the input here;
751
- // skip outcome verification for bridges (mirrors the bridge branch below).
752
- 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
+
753
989
  // No request intent recorded (a pre-intent quote): assertSwapOutcome has
754
990
  // nothing to compare the simulated deltas against and would raise a misleading
755
991
  // SWAP_OUTCOME_MISMATCH. Degrade cleanly — the static guards still ran, and a
@@ -772,7 +1008,10 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
772
1008
  { to: tx.to, data: tx.data, value: toRpcHexValue(tx.value) },
773
1009
  { from, apiKey },
774
1010
  );
775
- 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
+ }
776
1015
  log(` ✓ Swap outcome verified (via ${sim.method}).`);
777
1016
  return { proceed: true };
778
1017
  } catch (e) {
@@ -787,6 +1026,51 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
787
1026
  }
788
1027
  }
789
1028
 
1029
+ /**
1030
+ * The Solana sibling of verifySwapOutcome: simulates the swap transaction via
1031
+ * simulateTransaction and checks the resulting balance deltas against the
1032
+ * persisted request intent, degrading (warn + proceed) on any RPC/sim outage
1033
+ * so an outage never blocks a trade — only a real outcome mismatch or an
1034
+ * in-simulation revert blocks (falls through to the next quote).
1035
+ */
1036
+ export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote, quoteData, log = () => {} }) {
1037
+ if (chain !== 'solana') 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
+
1042
+ if (!quoteData?.request) {
1043
+ log(' ⚠ Swap-outcome verification skipped (no request intent — re-quote to enable it).');
1044
+ return { proceed: true };
1045
+ }
1046
+ if (!hasSolanaSimulationRpc(chain)) {
1047
+ log(` ⚠ Swap-outcome verification unavailable (no simulation endpoint for ${chain}); proceeding without it.`);
1048
+ return { proceed: true };
1049
+ }
1050
+ try {
1051
+ const sim = await simulateSolanaAssetChanges(chain, txBase64, { walletAddress });
1052
+ const outcome = assertSolanaSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage });
1053
+ if (outcome.inputAssertionSkipped) {
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.');
1062
+ }
1063
+ log(` ✓ Swap outcome verified (via ${sim.method}).`);
1064
+ return { proceed: true };
1065
+ } catch (e) {
1066
+ if (e instanceof SolanaSimulationError && ['NO_SIM_RPC', 'SIM_RPC_ERROR'].includes(e.code)) {
1067
+ log(` ⚠ Swap-outcome verification could not run (${e.message}); proceeding without it.`);
1068
+ return { proceed: true };
1069
+ }
1070
+ return { proceed: false, reason: e.message };
1071
+ }
1072
+ }
1073
+
790
1074
  /**
791
1075
  * Estimate gas for an EVM transaction. Returns the gas estimate or null on failure.
792
1076
  * Used to fix under-gassed quotes from aggregators.
@@ -982,6 +1266,42 @@ export function assertCompleteEvmRequestIntent(request) {
982
1266
  if (missing.length) {
983
1267
  throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing an EVM swap. Refusing to sign.`);
984
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
+ }
1277
+ }
1278
+
1279
+ /**
1280
+ * The Solana sibling of assertCompleteEvmRequestIntent. Solana signs the
1281
+ * aggregator's serialized VersionedTransaction verbatim — there is no
1282
+ * approval/calldata split to independently validate — so assertQuoteMatchesRequest
1283
+ * is the only guard between a compromised quote and a signed drain. That check's
1284
+ * per-field `if (request.x)` comparisons silently skip a missing field, so this
1285
+ * closes the gap by failing closed on any incomplete request intent up front.
1286
+ */
1287
+ export function assertCompleteSolanaRequestIntent(request) {
1288
+ if (!request) {
1289
+ throw new Error('Quote is missing request intent. Re-quote with this CLI version before executing a Solana swap. Refusing to sign.');
1290
+ }
1291
+
1292
+ const missing = [];
1293
+ for (const field of ['chain', 'walletAddress', 'fromToken', 'toToken', 'swapMode', 'amount', 'maxInputAmount']) {
1294
+ if (request[field] == null || request[field] === '') missing.push(field);
1295
+ }
1296
+ if (missing.length) {
1297
+ throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing a Solana swap. Refusing to sign.`);
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
+ }
985
1305
  }
986
1306
 
987
1307
  /**
@@ -1257,23 +1577,6 @@ function rlpNormalize(val) {
1257
1577
  return toBuffer(val);
1258
1578
  }
1259
1579
 
1260
- // ============= Compact-u16 (Solana) =============
1261
-
1262
- /**
1263
- * Read a compact-u16 from a buffer (Solana transaction format).
1264
- */
1265
- export function readCompactU16(buf, offset) {
1266
- let value = 0;
1267
- let size = 0;
1268
- for (let i = 0; i < 3; i++) {
1269
- const byte = buf[offset + i];
1270
- value |= (byte & 0x7f) << (7 * i);
1271
- size++;
1272
- if ((byte & 0x80) === 0) break;
1273
- }
1274
- return { value, size };
1275
- }
1276
-
1277
1580
  // ============= Chain Utilities =============
1278
1581
 
1279
1582
  /**
@@ -1547,6 +1850,12 @@ export function buildTradingCommands(deps = {}) {
1547
1850
  const autoSlippage = flags['auto-slippage'];
1548
1851
  const maxAutoSlippage = options['max-auto-slippage'];
1549
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
+ }
1550
1859
  const amountUnit = options['amount-unit'];
1551
1860
  const aggregatorFilter = options.aggregator;
1552
1861
  if (aggregatorFilter && !['lifi', 'relay', 'jupiter', 'okx'].includes(aggregatorFilter)) {
@@ -1593,9 +1902,9 @@ OPTIONS:
1593
1902
  --swap-mode <mode> exactIn (default) or exactOut
1594
1903
  --max-input <baseUnits> exactOut only: hard ceiling on the sell-token spend
1595
1904
  (base units), measured against the slippage-buffered
1596
- approval (input + slippage), not the bare quote input.
1597
- Required for EVM (Base) exactOut and enforced before
1598
- signing; optional on Solana (no ERC-20 approval to scope).
1905
+ spend (input + slippage), not the bare quote input.
1906
+ Required for exactOut on every chain and enforced
1907
+ before signing.
1599
1908
  --aggregator <name> Force a specific aggregator (lifi, relay, jupiter, okx).
1600
1909
  Filters the quote list client-side; errors if none match.
1601
1910
 
@@ -1630,12 +1939,9 @@ CROSS-CHAIN NOTES (when using --to-chain):
1630
1939
  throw new CommandError('Error: --amount-unit percent is not supported with --swap-mode exactOut. Percentage is relative to your sell-token balance.', 'INVALID_INPUT');
1631
1940
  }
1632
1941
 
1633
- // The exactOut spend-ceiling requirements below only guard the EVM signing
1634
- // path: the ERC-20 approval scoping, request-intent binding, and
1635
- // assertInputWithinMax checks are wired into the EVM execute paths only.
1636
- // Solana signs the API transaction verbatim (no approval to scope), so
1637
- // requiring --max-input there would break existing Solana exactOut users
1638
- // without buying any of that path a security guarantee. Gate on EVM source.
1942
+ // isEvmSource gates the ERC-20-approval-specific check just below (auto-slippage
1943
+ // sizing an approval has no Solana equivalent). The --max-input requirement
1944
+ // itself is NOT gated on it — see the check after maxInputOverride is parsed.
1639
1945
  const isEvmSource = CHAIN_MAP[chain?.toLowerCase()]?.type === 'evm';
1640
1946
 
1641
1947
  // exactOut scopes the ERC-20 approval to a slippage-buffered max input. With
@@ -1669,7 +1975,10 @@ CROSS-CHAIN NOTES (when using --to-chain):
1669
1975
  throw new CommandError(`Error: invalid --max-input "${maxInputRaw}": must be an integer in base units of the sell token.`, 'INVALID_INPUT');
1670
1976
  }
1671
1977
  }
1672
- if (isEvmSource && swapMode === 'exactOut' && maxInputOverride == null) {
1978
+ // Required on every chain: an exactOut cap derived from the API's own quote
1979
+ // response would just check that quote against itself and could never reject
1980
+ // anything (there is no independent signal to catch an inflated input).
1981
+ if (swapMode === 'exactOut' && maxInputOverride == null) {
1673
1982
  throw new CommandError('Error: --swap-mode exactOut requires --max-input (base units of the sell token) so the input is independently capped before signing.', 'INVALID_INPUT');
1674
1983
  }
1675
1984
 
@@ -1922,6 +2231,9 @@ CROSS-CHAIN NOTES (when using --to-chain):
1922
2231
  }
1923
2232
 
1924
2233
  const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
2234
+ // exactOut has no request.amount input bound (amount is the OUTPUT), so
2235
+ // maxInputAmount is the only spend ceiling assertInputWithinMax can enforce.
2236
+ // Required explicitly via --max-input on every chain (checked above).
1925
2237
  const maxInputAmount = swapMode === 'exactOut' ? maxInputOverride : String(resolvedAmount);
1926
2238
  const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null, {
1927
2239
  swapMode,
@@ -1994,7 +2306,7 @@ OPTIONS:
1994
2306
  --quote <id> Quote ID from 'nansen quote'
1995
2307
  --wallet <name> Wallet name (default: default wallet)
1996
2308
  --no-simulate Skip pre-broadcast simulation (the eth_call revert check)
1997
- --no-verify-outcome Skip EVM swap-outcome verification (balance-delta check)
2309
+ --no-verify-outcome Skip swap-outcome verification (balance-delta check)
1998
2310
  --no-revoke-excessive-allowance
1999
2311
  Skip auto-revoking an oversized/legacy allowance before re-approving
2000
2312
  --gasless Relay-only: have Relay's solver pay gas (no WalletConnect)
@@ -2137,13 +2449,50 @@ EXAMPLES:
2137
2449
 
2138
2450
  if (chainType === 'solana' && isPrivy) {
2139
2451
  // Solana via Privy: sign the serialized transaction
2140
- let txBase64 = currentQuote.transaction;
2141
- if (typeof txBase64 === 'object' && txBase64.data) {
2142
- txBase64 = base58Decode(txBase64.data).toString('base64');
2143
- }
2144
- log(' Signing Solana transaction via Privy...');
2145
2452
  const solWalletId = quoteData.privyWalletIds?.solana;
2146
2453
  if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote');
2454
+ const walletResult = await privyClient.getWallet(solWalletId);
2455
+ const walletAddress = walletResult.address;
2456
+ // Fail closed if the signer address doesn't resolve: without it the
2457
+ // wallet-binding comparison below would silently skip, leaving the
2458
+ // quote unbound to the wallet that will sign it. This is resolved
2459
+ // independently of the persisted request so assertQuoteMatchesRequest
2460
+ // is a real check, not a comparison of the request against itself.
2461
+ if (!walletAddress) {
2462
+ throw new Error('Could not resolve the Solana Privy wallet address; cannot confirm the quote was built for this wallet. Refusing to sign.');
2463
+ }
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
+
2470
+ // Validate the persisted request/quote metadata (token pair, amounts,
2471
+ // signer) before signing the aggregator's serialized transaction.
2472
+ assertCompleteSolanaRequestIntent(quoteData.request);
2473
+ assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
2474
+
2475
+ // Then statically inspect the serialized transaction's own
2476
+ // instructions ahead of signing — catches a delegate grant, authority
2477
+ // change, close-to-stranger, or excessive fee that the metadata check
2478
+ // alone wouldn't see. The residual sibling-transfer gap is closed by
2479
+ // verifySolanaSwapOutcome below (degrades gracefully when no sim RPC
2480
+ // is available, so this static check remains a guard when sim is off).
2481
+ assertSolanaInstructionsSafe(txBase64, { walletAddress });
2482
+
2483
+ // Verify the swap's simulated on-chain outcome matches intent.
2484
+ // Degrades with a warning if no simulation endpoint is available.
2485
+ if (!noVerifyOutcome) {
2486
+ const outcome = await verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote: currentQuote, quoteData, log });
2487
+ if (!outcome.proceed) {
2488
+ log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
2489
+ if (qi + 1 < endIndex) log(' Trying next quote...');
2490
+ lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
2491
+ continue;
2492
+ }
2493
+ }
2494
+
2495
+ log(' Signing Solana transaction via Privy...');
2147
2496
  const signResult = await privyClient.signSolanaTransaction(solWalletId, txBase64);
2148
2497
  signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
2149
2498
  requestId = currentQuote.metadata?.requestId;
@@ -2254,9 +2603,10 @@ EXAMPLES:
2254
2603
  }
2255
2604
  log(` Waiting for allowance revoke confirmation...`);
2256
2605
  try {
2257
- const receipt = await waitForReceipt(chain, revokeResult.txHash);
2258
- log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeResult.txHash}`);
2606
+ const { receipt, hash: revokeHash } = await confirmEvmBroadcast(chain, signedRevoke, revokeResult.txHash, 'allowance-revoke');
2607
+ log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeHash}`);
2259
2608
  } catch (receiptErr) {
2609
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2260
2610
  log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
2261
2611
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2262
2612
  lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
@@ -2312,9 +2662,10 @@ EXAMPLES:
2312
2662
  }
2313
2663
  log(` Waiting for approval confirmation...`);
2314
2664
  try {
2315
- const receipt = await waitForReceipt(chain, approvalResult.txHash);
2316
- log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
2665
+ const { receipt, hash: approvalHash } = await confirmEvmBroadcast(chain, signedApproval, approvalResult.txHash, 'allowance-approval');
2666
+ log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalHash}`);
2317
2667
  } catch (receiptErr) {
2668
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2318
2669
  log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`);
2319
2670
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2320
2671
  lastQuoteError = `${quoteName} approval unconfirmed`;
@@ -2408,17 +2759,60 @@ EXAMPLES:
2408
2759
  signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
2409
2760
 
2410
2761
  } else if (chainType === 'solana') {
2411
- // NB: validateSwapTarget (the EVM `to`/`data` guard) intentionally does
2412
- // not apply here — Solana quotes are a pre-built serialized
2413
- // VersionedTransaction with no `to`/`data`/approval split to validate,
2414
- // and this path (including the WalletConnect sub-branch below) signs it
2415
- // as supplied. Deeper Solana inspection (e.g. checking instruction
2416
- // program IDs) is tracked as a follow-up, not an oversight.
2417
- // Solana: transaction is either a base64 string (Jupiter) or an object
2418
- // with a base58-encoded `data` field (OKX). Normalize to base64.
2419
- let txBase64 = currentQuote.transaction;
2420
- if (typeof txBase64 === 'object' && txBase64.data) {
2421
- txBase64 = base58Decode(txBase64.data).toString('base64');
2762
+ // NB: validateSwapTarget (the EVM `to`/`data` guard) does not apply
2763
+ // here — Solana quotes are a pre-built serialized VersionedTransaction
2764
+ // with no `to`/`data`/approval split to validate. assertQuoteMatchesRequest
2765
+ // below binds the metadata (token pair, amounts, signer), and
2766
+ // assertSolanaInstructionsSafe statically inspects the tx's own
2767
+ // instructions before signing.
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.
2771
+
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.
2775
+ let solanaWalletAddress;
2776
+ if (isWalletConnect) {
2777
+ solanaWalletAddress = await getWalletConnectAddress(chainType);
2778
+ if (!solanaWalletAddress) {
2779
+ throw new CommandError('WalletConnect session lost during execute. Reconnect with `walletconnect connect` and retry.', 'NO_WALLET');
2780
+ }
2781
+ } else {
2782
+ solanaWalletAddress = exported.solana.address;
2783
+ // Fail closed if the signer address doesn't resolve: without it the
2784
+ // wallet-binding comparison below would silently skip, leaving the
2785
+ // quote unbound to the wallet that will sign it.
2786
+ if (!solanaWalletAddress) {
2787
+ throw new Error("Could not resolve the local wallet's Solana address; cannot confirm the quote was built for this wallet. Refusing to sign.");
2788
+ }
2789
+ }
2790
+
2791
+ const txBase64 = await normalizeSolanaTransaction(currentQuote.transaction, CHAIN_RPCS.solana, async () => solanaWalletAddress);
2792
+
2793
+ // Validate the persisted request/quote metadata (token pair, amounts,
2794
+ // signer) before signing the opaque Solana transaction.
2795
+ assertCompleteSolanaRequestIntent(quoteData.request);
2796
+ assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress: solanaWalletAddress, slippage: quoteData.slippage });
2797
+
2798
+ // Then statically inspect the serialized transaction's own
2799
+ // instructions ahead of signing — catches a delegate grant, authority
2800
+ // change, close-to-stranger, or excessive fee that the metadata check
2801
+ // alone wouldn't see. The residual sibling-transfer gap is closed by
2802
+ // verifySolanaSwapOutcome below (degrades gracefully when no sim RPC
2803
+ // is available, so this static check remains a guard when sim is off).
2804
+ assertSolanaInstructionsSafe(txBase64, { walletAddress: solanaWalletAddress });
2805
+
2806
+ // Verify the swap's simulated on-chain outcome matches intent.
2807
+ // Degrades with a warning if no simulation endpoint is available.
2808
+ if (!noVerifyOutcome) {
2809
+ const outcome = await verifySolanaSwapOutcome({ chain, walletAddress: solanaWalletAddress, txBase64, quote: currentQuote, quoteData, log });
2810
+ if (!outcome.proceed) {
2811
+ log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
2812
+ if (qi + 1 < endIndex) log(' Trying next quote...');
2813
+ lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
2814
+ continue;
2815
+ }
2422
2816
  }
2423
2817
 
2424
2818
  if (isWalletConnect) {
@@ -2567,12 +2961,13 @@ EXAMPLES:
2567
2961
  if (broadcastResult.status !== 'Success') {
2568
2962
  throw new Error(broadcastResult.error || 'broadcast failed');
2569
2963
  }
2570
- revokeTxHash = broadcastResult.txHash;
2964
+ revokeTxHash = assertTxHashMatch(revokeResult.signedTransaction, broadcastResult.txHash, 'allowance-revoke');
2571
2965
  }
2572
2966
  if (!revokeTxHash) {
2573
2967
  throw new Error('Allowance revoke returned no transaction hash and no signed transaction; cannot confirm allowance was cleared');
2574
2968
  }
2575
2969
  } catch (revokeErr) {
2970
+ if (isFatalBroadcastError(revokeErr)) throw revokeErr;
2576
2971
  log(` ❌ Allowance revoke failed for ${quoteName}: ${revokeErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
2577
2972
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2578
2973
  lastQuoteError = `${quoteName} allowance revoke failed`;
@@ -2583,6 +2978,7 @@ EXAMPLES:
2583
2978
  const receipt = await waitForReceipt(chain, revokeTxHash);
2584
2979
  log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeTxHash}`);
2585
2980
  } catch (receiptErr) {
2981
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2586
2982
  log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
2587
2983
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2588
2984
  lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
@@ -2621,7 +3017,7 @@ EXAMPLES:
2621
3017
  if (broadcastResult.status !== 'Success') {
2622
3018
  throw new Error(broadcastResult.error || 'broadcast failed');
2623
3019
  }
2624
- approvalTxHash = broadcastResult.txHash;
3020
+ approvalTxHash = assertTxHashMatch(approvalResult.signedTransaction, broadcastResult.txHash, 'allowance-approval');
2625
3021
  }
2626
3022
  if (!approvalTxHash) {
2627
3023
  // Fail closed: the wallet returned neither a hash nor a
@@ -2632,6 +3028,7 @@ EXAMPLES:
2632
3028
  throw new Error('returned no transaction hash and no signed transaction; cannot confirm approval landed');
2633
3029
  }
2634
3030
  } catch (approvalErr) {
3031
+ if (isFatalBroadcastError(approvalErr)) throw approvalErr;
2635
3032
  const revokedMsg = shouldRevoke
2636
3033
  ? ' after revoking the prior allowance (now 0)'
2637
3034
  : '';
@@ -2645,6 +3042,7 @@ EXAMPLES:
2645
3042
  const receipt = await waitForReceipt(chain, approvalTxHash);
2646
3043
  log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalTxHash}`);
2647
3044
  } catch (receiptErr) {
3045
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2648
3046
  const revokedMsg = shouldRevoke
2649
3047
  ? ' after revoking the prior allowance (now 0)'
2650
3048
  : '';
@@ -2730,6 +3128,14 @@ EXAMPLES:
2730
3128
  try {
2731
3129
  await waitForReceipt(chain, wcResult.txHash);
2732
3130
  } catch (receiptErr) {
3131
+ // A timeout here is uncertain post-broadcast state, not a
3132
+ // confirmed revert — fail closed rather than retry (which would
3133
+ // broadcast a second swap). Applies even though this path has no
3134
+ // locally-derived hash to bind to.
3135
+ if (receiptErr.code === 'RECEIPT_TIMEOUT') {
3136
+ throw new CommandError(`\n ⚠ Transaction was broadcast but NOT confirmed within the wait window.\n Tx Hash: ${wcResult.txHash}\n Explorer: ${chainConfig.explorer}${wcResult.txHash}\n ${receiptErr.message}\n\n The transaction may still be pending — do NOT assume it failed. Check the\n explorer before retrying; retrying may broadcast a second swap.`, 'RECEIPT_TIMEOUT');
3137
+ }
3138
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2733
3139
  log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`);
2734
3140
  log(` Tx Hash: ${wcResult.txHash}`);
2735
3141
  log(` Explorer: ${chainConfig.explorer}${wcResult.txHash}`);
@@ -2892,9 +3298,10 @@ EXAMPLES:
2892
3298
 
2893
3299
  log(` Waiting for allowance revoke confirmation...`);
2894
3300
  try {
2895
- const receipt = await waitForReceipt(chain, revokeResult.txHash);
2896
- log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeResult.txHash}`);
3301
+ const { receipt, hash: revokeHash } = await confirmEvmBroadcast(chain, revokeTxHex, revokeResult.txHash, 'allowance-revoke');
3302
+ log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeHash}`);
2897
3303
  } catch (receiptErr) {
3304
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2898
3305
  log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
2899
3306
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2900
3307
  lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
@@ -2943,9 +3350,10 @@ EXAMPLES:
2943
3350
 
2944
3351
  log(` Waiting for approval confirmation...`);
2945
3352
  try {
2946
- const receipt = await waitForReceipt(chain, approvalResult.txHash);
2947
- log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
3353
+ const { receipt, hash: approvalHash } = await confirmEvmBroadcast(chain, approvalTxHex, approvalResult.txHash, 'allowance-approval');
3354
+ log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalHash}`);
2948
3355
  } catch (receiptErr) {
3356
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2949
3357
  log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`);
2950
3358
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2951
3359
  lastQuoteError = `${quoteName} approval unconfirmed`;
@@ -3065,17 +3473,51 @@ EXAMPLES:
3065
3473
  const result = await executeTransaction(execParams);
3066
3474
 
3067
3475
  if (result.status === 'Success') {
3068
- const txId = result.signature || result.txHash;
3069
- const explorerUrl = chainConfig.explorer + txId;
3476
+ let txId = result.signature || result.txHash;
3477
+ let explorerUrl = chainConfig.explorer + txId;
3070
3478
 
3071
3479
  // For EVM: verify the tx actually succeeded on-chain
3072
- if (chainType === 'evm' && result.txHash) {
3480
+ if (chainType === 'evm') {
3073
3481
  log(' Verifying on-chain status...');
3482
+ // Non-gasless: derive our local hash up front, OUTSIDE the receipt-poll
3483
+ // try below. A hex-validation failure here means no poll ever ran, so it
3484
+ // must surface as itself — not as the "REVERTED on-chain" diagnostic that
3485
+ // catch is reserved for. (Gasless has no local hash to bind to: the Relay
3486
+ // solver wraps and broadcasts its own tx, so result.txHash legitimately is
3487
+ // not the hash of the bytes we signed.)
3488
+ if (!gasless) {
3489
+ try {
3490
+ txId = evmTxHash(signedTransaction);
3491
+ } catch (hashErr) {
3492
+ throw new CommandError(`Cannot derive local tx hash for ${quoteName}: ${hashErr.message}`, 'INVALID_SIGNED_TX');
3493
+ }
3494
+ explorerUrl = chainConfig.explorer + txId;
3495
+ }
3074
3496
  try {
3075
- await waitForReceipt(chain, result.txHash);
3497
+ if (gasless) {
3498
+ // If the solver reported no hash there is nothing to poll — skip
3499
+ // rather than block on eth_getTransactionReceipt(undefined).
3500
+ if (result.txHash) await waitForReceipt(chain, result.txHash);
3501
+ } else {
3502
+ const { hash } = await confirmEvmBroadcast(chain, signedTransaction, result.txHash);
3503
+ txId = hash;
3504
+ explorerUrl = chainConfig.explorer + txId;
3505
+ }
3076
3506
  } catch (receiptErr) {
3507
+ // A receipt TIMEOUT is not a confirmed revert: the tx was
3508
+ // broadcast and may still be pending under our nonce. Retrying
3509
+ // the next quote would sign and broadcast a SECOND swap racing
3510
+ // the first for that nonce — the duplicate-broadcast this PR
3511
+ // exists to prevent. (It's also exactly how guarantee #2's
3512
+ // silent-substitution case surfaces: a 180s timeout polling our
3513
+ // own hash.) Fail closed with a clearer banner than the generic
3514
+ // rethrow, then let isFatalBroadcastError handle the rest.
3515
+ if (receiptErr.code === 'RECEIPT_TIMEOUT') {
3516
+ throw new CommandError(`\n ⚠ Transaction was broadcast but NOT confirmed within the wait window.\n Tx Hash: ${txId || result.txHash}\n Explorer: ${explorerUrl}\n ${receiptErr.message}\n\n The transaction may still be pending — do NOT assume it failed. Check the\n explorer before retrying; retrying may broadcast a second swap against the\n same nonce.`, 'RECEIPT_TIMEOUT');
3517
+ }
3518
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
3077
3519
  log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`);
3078
- log(` Tx Hash: ${result.txHash}`);
3520
+ log(` Tx Hash: ${txId || result.txHash}`);
3079
3521
  log(` Explorer: ${explorerUrl}`);
3080
3522
  log(` Error: ${receiptErr.message}`);
3081
3523
  if (qi + 1 < endIndex) {
@@ -3083,7 +3525,7 @@ EXAMPLES:
3083
3525
  lastQuoteError = `${quoteName} reverted on-chain`;
3084
3526
  continue;
3085
3527
  }
3086
- throw new CommandError(`\n ⚠ Transaction was broadcast but REVERTED on-chain!\n Tx Hash: ${result.txHash}\n Explorer: ${explorerUrl}\n Error: ${receiptErr.message}\n\n The trading API reported success, but the contract execution failed.\n This can happen due to: stale quotes, insufficient gas, or liquidity changes.`, 'TX_REVERTED');
3528
+ throw new CommandError(`\n ⚠ Transaction was broadcast but REVERTED on-chain!\n Tx Hash: ${txId || result.txHash}\n Explorer: ${explorerUrl}\n Error: ${receiptErr.message}\n\n The trading API reported success, but the contract execution failed.\n This can happen due to: stale quotes, insufficient gas, or liquidity changes.`, 'TX_REVERTED');
3087
3529
  }
3088
3530
  }
3089
3531
 
@@ -3141,6 +3583,11 @@ EXAMPLES:
3141
3583
  }
3142
3584
 
3143
3585
  } catch (quoteErr) {
3586
+ // Post-broadcast failures abort the whole execute — never retry the
3587
+ // next quote once a transaction is already out and its outcome is
3588
+ // unknown (mismatch, underivable local hash, or an unconfirmed
3589
+ // receipt timeout). See isFatalBroadcastError.
3590
+ if (isFatalBroadcastError(quoteErr)) throw quoteErr;
3144
3591
  const msg = quoteErr.message || '';
3145
3592
  log(` ❌ Quote ${quoteName} failed: ${msg}`);
3146
3593
  if (msg.includes('AccountNotFound') && chainType === 'solana') {