nansen-cli 1.40.0 → 1.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/trading.js CHANGED
@@ -13,9 +13,12 @@ import { base58Decode } from './transfer.js';
13
13
  import { keccak256, signSecp256k1, rlpEncode } from './crypto.js';
14
14
  import { getWalletConnectAddress, sendTransactionViaWalletConnect, sendSolanaTransactionViaWalletConnect, sendApprovalViaWalletConnect } from './walletconnect-trading.js';
15
15
  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';
16
+ import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, assertSolanaInstructionsSafe, assertSolanaSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER } from './trade-validation.js';
17
+ import { readCompactU16 } from './solana-tx.js';
18
+ export { readCompactU16 };
17
19
  import { CHAIN_RPCS } from './rpc-urls.js';
18
20
  import { simulateAssetChanges, SwapSimulationError, hasSimulationRpc } from './swap-simulation.js';
21
+ import { simulateSolanaAssetChanges, SolanaSimulationError, hasSolanaSimulationRpc } from './solana-simulation.js';
19
22
  import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './api.js';
20
23
 
21
24
  // ============= Constants =============
@@ -109,7 +112,8 @@ export function getQuotesDir() {
109
112
  export function safeQuotesPath(filename) {
110
113
  const base = path.resolve(getQuotesDir());
111
114
  const target = path.resolve(base, filename);
112
- if (path.relative(base, target).startsWith('..')) return null;
115
+ const relative = path.relative(base, target);
116
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
113
117
  return target;
114
118
  }
115
119
 
@@ -565,6 +569,30 @@ export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
565
569
  return signLegacyTransaction({ ...common, gasPrice: toHex(txData.gasPrice) }, privateKeyHex);
566
570
  }
567
571
 
572
+ /**
573
+ * Canonical EVM transaction hash: keccak256 over the raw signed tx bytes.
574
+ *
575
+ * Works for legacy (RLP) and typed (0x02-prefixed EIP-1559) transactions alike,
576
+ * because the tx hash is defined over exactly the bytes that get broadcast.
577
+ *
578
+ * NB: this is NOT the signing hash. signEvmTransaction/signLegacyTransaction hash
579
+ * the *unsigned* payload to produce the message that gets signed; this hashes the
580
+ * fully *signed* transaction to produce its on-chain identifier.
581
+ *
582
+ * @param {string} signedTxHex - 0x-prefixed (or bare) hex of the signed transaction
583
+ * @returns {string} 0x-prefixed transaction hash
584
+ */
585
+ export function evmTxHash(signedTxHex) {
586
+ if (typeof signedTxHex !== 'string') {
587
+ throw new Error('evmTxHash: signed transaction must be a hex string');
588
+ }
589
+ const hex = signedTxHex.startsWith('0x') ? signedTxHex.slice(2) : signedTxHex;
590
+ if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) {
591
+ throw new Error('evmTxHash: signed transaction is not valid hex');
592
+ }
593
+ return '0x' + keccak256(Buffer.from(hex, 'hex')).toString('hex');
594
+ }
595
+
568
596
  // How many queued-but-unmined transactions we are willing to sign past.
569
597
  //
570
598
  // `pending` counts mempool-queued transactions as well as mined ones, and that is
@@ -658,7 +686,108 @@ export async function waitForReceipt(chain, txHash, timeoutMs = 180000, pollMs =
658
686
  // Receipt not yet available — wait and retry
659
687
  await new Promise(r => setTimeout(r, pollMs));
660
688
  }
661
- throw new Error(`Transaction receipt not found after ${timeoutMs}ms. Tx: ${txHash}`);
689
+ // A timeout is NOT a confirmed revert: the tx may still be pending under our
690
+ // nonce. Tag it so callers can distinguish "reverted" (safe to try the next
691
+ // quote) from "unconfirmed" (retrying may broadcast a second tx that races
692
+ // the first for the same nonce). See the swap-path receipt catch.
693
+ const timeoutErr = new Error(`Transaction receipt not found after ${timeoutMs}ms. Tx: ${txHash}`);
694
+ timeoutErr.code = 'RECEIPT_TIMEOUT';
695
+ throw timeoutErr;
696
+ }
697
+
698
+ /**
699
+ * Post-broadcast failures that must abort the whole `execute` rather than fall
700
+ * through to the next quote. Once a transaction is broadcast we hold no evidence
701
+ * about what landed on-chain, so "try the next quote" would sign and broadcast a
702
+ * second transaction — the one thing we must not do. Covers every path (swap,
703
+ * approval, revoke; Privy/WalletConnect/local-key). Each code is thrown with a
704
+ * rationale at its throw site:
705
+ * - TXHASH_MISMATCH — broadcaster reported a tx we did not sign
706
+ * - INVALID_SIGNED_TX — we cannot even derive a hash for what we broadcast
707
+ * - RECEIPT_TIMEOUT — receipt never landed; the tx may still be pending, so
708
+ * retrying would race a second tx against the same nonce
709
+ * (a confirmed on-chain revert is NOT this — it may retry)
710
+ *
711
+ * @param {Error} err
712
+ * @returns {boolean}
713
+ */
714
+ function isFatalBroadcastError(err) {
715
+ return err?.code === 'TXHASH_MISMATCH'
716
+ || err?.code === 'INVALID_SIGNED_TX'
717
+ || err?.code === 'RECEIPT_TIMEOUT';
718
+ }
719
+
720
+ /**
721
+ * Assert the broadcaster reported the transaction we actually signed, and return
722
+ * our locally-derived hash.
723
+ *
724
+ * Fails closed (TXHASH_MISMATCH) when the broadcaster's returned hash differs
725
+ * from keccak256 of our signed bytes: a mismatch means its receipt would confirm
726
+ * a transaction we never signed, so nothing has been verified. When the
727
+ * broadcaster returns no hash we cannot compare, so the returned local hash is
728
+ * what callers must poll for a receipt — a substituted transaction then times
729
+ * out rather than falsely confirming.
730
+ *
731
+ * @param {string} signedTxHex - the raw signed tx we sent to /execute
732
+ * @param {string} broadcasterTxHash - the txHash /execute returned (may be empty)
733
+ * @param {string} [label] - describes the tx for the error, e.g. "allowance-revoke"
734
+ * @returns {string} our locally-derived transaction hash
735
+ */
736
+ function assertTxHashMatch(signedTxHex, broadcasterTxHash, label = '') {
737
+ const what = label ? `the ${label} transaction this CLI signed` : 'the transaction this CLI signed';
738
+ // A derivation failure here happens AFTER the tx was broadcast, so it must be
739
+ // fatal (INVALID_SIGNED_TX), never swallowed into "try the next quote": we
740
+ // hold no hash for the transaction we just sent.
741
+ let localHash;
742
+ try {
743
+ localHash = evmTxHash(signedTxHex);
744
+ } catch (hashErr) {
745
+ throw new CommandError(
746
+ `Aborting: cannot derive a local hash for ${what}: ${hashErr.message}. `
747
+ + `The transaction may already have been broadcast, so nothing further will run — `
748
+ + `check your wallet before retrying.`,
749
+ 'INVALID_SIGNED_TX',
750
+ );
751
+ }
752
+ // Normalize both sides through the same bare-hex form before comparing.
753
+ // evmTxHash always emits 0x-prefixed, but a broadcaster may report bare hex;
754
+ // comparing 0x-prefixed against bare would be a false mismatch on the prefix
755
+ // alone — and TXHASH_MISMATCH is fatal, so that would wrongly abort.
756
+ if (broadcasterTxHash) {
757
+ const norm = h => h.toLowerCase().replace(/^0x/, '');
758
+ if (norm(localHash) !== norm(broadcasterTxHash)) {
759
+ throw new CommandError(
760
+ `Aborting: the broadcaster reported transaction ${broadcasterTxHash}, but ${what} `
761
+ + `hashes to ${localHash}. These must match — a mismatch means the receipt would confirm `
762
+ + `a transaction you did not sign, so nothing has been verified and no further steps will `
763
+ + `run. Check both hashes on a block explorer to see what was actually broadcast before retrying.`,
764
+ 'TXHASH_MISMATCH',
765
+ );
766
+ }
767
+ }
768
+ return localHash;
769
+ }
770
+
771
+ /**
772
+ * Confirm a broadcast EVM transaction against the hash we derived locally from
773
+ * the signed bytes — not the hash the broadcaster reported. See
774
+ * {@link assertTxHashMatch} for the two guarantees (fail closed on mismatch;
775
+ * poll our own hash so a silent substitution times out rather than confirms).
776
+ *
777
+ * @param {string} chain
778
+ * @param {string} signedTxHex - the raw signed tx we sent to /execute
779
+ * @param {string} broadcasterTxHash - the txHash /execute returned
780
+ * @param {string} [label] - describes the tx for a mismatch error, e.g.
781
+ * "allowance-revoke" — the least useful moment to lose context is a revoke
782
+ * mismatch with the allowance sitting at 0, so callers should pass it
783
+ * @returns {Promise<{receipt: object, hash: string}>} the receipt and the
784
+ * locally-derived hash it was confirmed against (log THIS, not the
785
+ * broadcaster's hash — it is the transaction we actually verified landed)
786
+ */
787
+ export async function confirmEvmBroadcast(chain, signedTxHex, broadcasterTxHash, label = '') {
788
+ const hash = assertTxHashMatch(signedTxHex, broadcasterTxHash, label);
789
+ const receipt = await waitForReceipt(chain, hash);
790
+ return { receipt, hash };
662
791
  }
663
792
 
664
793
  /**
@@ -787,6 +916,43 @@ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey
787
916
  }
788
917
  }
789
918
 
919
+ /**
920
+ * The Solana sibling of verifySwapOutcome: simulates the swap transaction via
921
+ * simulateTransaction and checks the resulting balance deltas against the
922
+ * persisted request intent, degrading (warn + proceed) on any RPC/sim outage
923
+ * so an outage never blocks a trade — only a real outcome mismatch or an
924
+ * in-simulation revert blocks (falls through to the next quote).
925
+ */
926
+ export async function verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote, quoteData, log = () => {} }) {
927
+ 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 };
931
+ if (!quoteData?.request) {
932
+ log(' ⚠ Swap-outcome verification skipped (no request intent — re-quote to enable it).');
933
+ return { proceed: true };
934
+ }
935
+ if (!hasSolanaSimulationRpc(chain)) {
936
+ log(` ⚠ Swap-outcome verification unavailable (no simulation endpoint for ${chain}); proceeding without it.`);
937
+ return { proceed: true };
938
+ }
939
+ try {
940
+ const sim = await simulateSolanaAssetChanges(chain, txBase64, { walletAddress });
941
+ const outcome = assertSolanaSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage });
942
+ 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.');
944
+ }
945
+ log(` ✓ Swap outcome verified (via ${sim.method}).`);
946
+ return { proceed: true };
947
+ } catch (e) {
948
+ if (e instanceof SolanaSimulationError && ['NO_SIM_RPC', 'SIM_RPC_ERROR'].includes(e.code)) {
949
+ log(` ⚠ Swap-outcome verification could not run (${e.message}); proceeding without it.`);
950
+ return { proceed: true };
951
+ }
952
+ return { proceed: false, reason: e.message };
953
+ }
954
+ }
955
+
790
956
  /**
791
957
  * Estimate gas for an EVM transaction. Returns the gas estimate or null on failure.
792
958
  * Used to fix under-gassed quotes from aggregators.
@@ -984,6 +1150,28 @@ export function assertCompleteEvmRequestIntent(request) {
984
1150
  }
985
1151
  }
986
1152
 
1153
+ /**
1154
+ * The Solana sibling of assertCompleteEvmRequestIntent. Solana signs the
1155
+ * aggregator's serialized VersionedTransaction verbatim — there is no
1156
+ * approval/calldata split to independently validate — so assertQuoteMatchesRequest
1157
+ * is the only guard between a compromised quote and a signed drain. That check's
1158
+ * per-field `if (request.x)` comparisons silently skip a missing field, so this
1159
+ * closes the gap by failing closed on any incomplete request intent up front.
1160
+ */
1161
+ export function assertCompleteSolanaRequestIntent(request) {
1162
+ if (!request) {
1163
+ throw new Error('Quote is missing request intent. Re-quote with this CLI version before executing a Solana swap. Refusing to sign.');
1164
+ }
1165
+
1166
+ const missing = [];
1167
+ for (const field of ['chain', 'walletAddress', 'fromToken', 'toToken', 'swapMode', 'amount', 'maxInputAmount']) {
1168
+ if (request[field] == null || request[field] === '') missing.push(field);
1169
+ }
1170
+ if (missing.length) {
1171
+ throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing a Solana swap. Refusing to sign.`);
1172
+ }
1173
+ }
1174
+
987
1175
  /**
988
1176
  * Sanity-check the target of a swap transaction before signing it.
989
1177
  *
@@ -1257,23 +1445,6 @@ function rlpNormalize(val) {
1257
1445
  return toBuffer(val);
1258
1446
  }
1259
1447
 
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
1448
  // ============= Chain Utilities =============
1278
1449
 
1279
1450
  /**
@@ -1593,9 +1764,9 @@ OPTIONS:
1593
1764
  --swap-mode <mode> exactIn (default) or exactOut
1594
1765
  --max-input <baseUnits> exactOut only: hard ceiling on the sell-token spend
1595
1766
  (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).
1767
+ spend (input + slippage), not the bare quote input.
1768
+ Required for exactOut on every chain and enforced
1769
+ before signing.
1599
1770
  --aggregator <name> Force a specific aggregator (lifi, relay, jupiter, okx).
1600
1771
  Filters the quote list client-side; errors if none match.
1601
1772
 
@@ -1630,12 +1801,9 @@ CROSS-CHAIN NOTES (when using --to-chain):
1630
1801
  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
1802
  }
1632
1803
 
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.
1804
+ // isEvmSource gates the ERC-20-approval-specific check just below (auto-slippage
1805
+ // sizing an approval has no Solana equivalent). The --max-input requirement
1806
+ // itself is NOT gated on it — see the check after maxInputOverride is parsed.
1639
1807
  const isEvmSource = CHAIN_MAP[chain?.toLowerCase()]?.type === 'evm';
1640
1808
 
1641
1809
  // exactOut scopes the ERC-20 approval to a slippage-buffered max input. With
@@ -1669,7 +1837,10 @@ CROSS-CHAIN NOTES (when using --to-chain):
1669
1837
  throw new CommandError(`Error: invalid --max-input "${maxInputRaw}": must be an integer in base units of the sell token.`, 'INVALID_INPUT');
1670
1838
  }
1671
1839
  }
1672
- if (isEvmSource && swapMode === 'exactOut' && maxInputOverride == null) {
1840
+ // Required on every chain: an exactOut cap derived from the API's own quote
1841
+ // response would just check that quote against itself and could never reject
1842
+ // anything (there is no independent signal to catch an inflated input).
1843
+ if (swapMode === 'exactOut' && maxInputOverride == null) {
1673
1844
  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
1845
  }
1675
1846
 
@@ -1922,6 +2093,9 @@ CROSS-CHAIN NOTES (when using --to-chain):
1922
2093
  }
1923
2094
 
1924
2095
  const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
2096
+ // exactOut has no request.amount input bound (amount is the OUTPUT), so
2097
+ // maxInputAmount is the only spend ceiling assertInputWithinMax can enforce.
2098
+ // Required explicitly via --max-input on every chain (checked above).
1925
2099
  const maxInputAmount = swapMode === 'exactOut' ? maxInputOverride : String(resolvedAmount);
1926
2100
  const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null, {
1927
2101
  swapMode,
@@ -1994,7 +2168,7 @@ OPTIONS:
1994
2168
  --quote <id> Quote ID from 'nansen quote'
1995
2169
  --wallet <name> Wallet name (default: default wallet)
1996
2170
  --no-simulate Skip pre-broadcast simulation (the eth_call revert check)
1997
- --no-verify-outcome Skip EVM swap-outcome verification (balance-delta check)
2171
+ --no-verify-outcome Skip swap-outcome verification (balance-delta check)
1998
2172
  --no-revoke-excessive-allowance
1999
2173
  Skip auto-revoking an oversized/legacy allowance before re-approving
2000
2174
  --gasless Relay-only: have Relay's solver pay gas (no WalletConnect)
@@ -2141,9 +2315,45 @@ EXAMPLES:
2141
2315
  if (typeof txBase64 === 'object' && txBase64.data) {
2142
2316
  txBase64 = base58Decode(txBase64.data).toString('base64');
2143
2317
  }
2144
- log(' Signing Solana transaction via Privy...');
2145
2318
  const solWalletId = quoteData.privyWalletIds?.solana;
2146
2319
  if (!solWalletId) throw new Error('No Solana Privy wallet ID in quote');
2320
+ const walletResult = await privyClient.getWallet(solWalletId);
2321
+ const walletAddress = walletResult.address;
2322
+ // Fail closed if the signer address doesn't resolve: without it the
2323
+ // wallet-binding comparison below would silently skip, leaving the
2324
+ // quote unbound to the wallet that will sign it. This is resolved
2325
+ // independently of the persisted request so assertQuoteMatchesRequest
2326
+ // is a real check, not a comparison of the request against itself.
2327
+ if (!walletAddress) {
2328
+ throw new Error('Could not resolve the Solana Privy wallet address; cannot confirm the quote was built for this wallet. Refusing to sign.');
2329
+ }
2330
+
2331
+ // Validate the persisted request/quote metadata (token pair, amounts,
2332
+ // signer) before signing the aggregator's serialized transaction.
2333
+ assertCompleteSolanaRequestIntent(quoteData.request);
2334
+ assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
2335
+
2336
+ // Then statically inspect the serialized transaction's own
2337
+ // instructions ahead of signing — catches a delegate grant, authority
2338
+ // change, close-to-stranger, or excessive fee that the metadata check
2339
+ // alone wouldn't see. The residual sibling-transfer gap is closed by
2340
+ // verifySolanaSwapOutcome below (degrades gracefully when no sim RPC
2341
+ // is available, so this static check remains a guard when sim is off).
2342
+ assertSolanaInstructionsSafe(txBase64, { walletAddress });
2343
+
2344
+ // Verify the swap's simulated on-chain outcome matches intent.
2345
+ // Degrades with a warning if no simulation endpoint is available.
2346
+ if (!noVerifyOutcome) {
2347
+ const outcome = await verifySolanaSwapOutcome({ chain, walletAddress, txBase64, quote: currentQuote, quoteData, log });
2348
+ if (!outcome.proceed) {
2349
+ log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
2350
+ if (qi + 1 < endIndex) log(' Trying next quote...');
2351
+ lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
2352
+ continue;
2353
+ }
2354
+ }
2355
+
2356
+ log(' Signing Solana transaction via Privy...');
2147
2357
  const signResult = await privyClient.signSolanaTransaction(solWalletId, txBase64);
2148
2358
  signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
2149
2359
  requestId = currentQuote.metadata?.requestId;
@@ -2254,9 +2464,10 @@ EXAMPLES:
2254
2464
  }
2255
2465
  log(` Waiting for allowance revoke confirmation...`);
2256
2466
  try {
2257
- const receipt = await waitForReceipt(chain, revokeResult.txHash);
2258
- log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeResult.txHash}`);
2467
+ const { receipt, hash: revokeHash } = await confirmEvmBroadcast(chain, signedRevoke, revokeResult.txHash, 'allowance-revoke');
2468
+ log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeHash}`);
2259
2469
  } catch (receiptErr) {
2470
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2260
2471
  log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
2261
2472
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2262
2473
  lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
@@ -2312,9 +2523,10 @@ EXAMPLES:
2312
2523
  }
2313
2524
  log(` Waiting for approval confirmation...`);
2314
2525
  try {
2315
- const receipt = await waitForReceipt(chain, approvalResult.txHash);
2316
- log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
2526
+ const { receipt, hash: approvalHash } = await confirmEvmBroadcast(chain, signedApproval, approvalResult.txHash, 'allowance-approval');
2527
+ log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalHash}`);
2317
2528
  } catch (receiptErr) {
2529
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2318
2530
  log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`);
2319
2531
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2320
2532
  lastQuoteError = `${quoteName} approval unconfirmed`;
@@ -2408,12 +2620,12 @@ EXAMPLES:
2408
2620
  signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
2409
2621
 
2410
2622
  } 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.
2623
+ // NB: validateSwapTarget (the EVM `to`/`data` guard) does not apply
2624
+ // here — Solana quotes are a pre-built serialized VersionedTransaction
2625
+ // with no `to`/`data`/approval split to validate. assertQuoteMatchesRequest
2626
+ // below binds the metadata (token pair, amounts, signer), and
2627
+ // assertSolanaInstructionsSafe statically inspects the tx's own
2628
+ // instructions before signing.
2417
2629
  // Solana: transaction is either a base64 string (Jupiter) or an object
2418
2630
  // with a base58-encoded `data` field (OKX). Normalize to base64.
2419
2631
  let txBase64 = currentQuote.transaction;
@@ -2421,6 +2633,49 @@ EXAMPLES:
2421
2633
  txBase64 = base58Decode(txBase64.data).toString('base64');
2422
2634
  }
2423
2635
 
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.
2638
+ let solanaWalletAddress;
2639
+ if (isWalletConnect) {
2640
+ solanaWalletAddress = await getWalletConnectAddress(chainType);
2641
+ if (!solanaWalletAddress) {
2642
+ throw new CommandError('WalletConnect session lost during execute. Reconnect with `walletconnect connect` and retry.', 'NO_WALLET');
2643
+ }
2644
+ } else {
2645
+ solanaWalletAddress = exported.solana.address;
2646
+ // Fail closed if the signer address doesn't resolve: without it the
2647
+ // wallet-binding comparison below would silently skip, leaving the
2648
+ // quote unbound to the wallet that will sign it.
2649
+ if (!solanaWalletAddress) {
2650
+ throw new Error("Could not resolve the local wallet's Solana address; cannot confirm the quote was built for this wallet. Refusing to sign.");
2651
+ }
2652
+ }
2653
+
2654
+ // Validate the persisted request/quote metadata (token pair, amounts,
2655
+ // signer) before signing the opaque Solana transaction.
2656
+ assertCompleteSolanaRequestIntent(quoteData.request);
2657
+ assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress: solanaWalletAddress, slippage: quoteData.slippage });
2658
+
2659
+ // Then statically inspect the serialized transaction's own
2660
+ // instructions ahead of signing — catches a delegate grant, authority
2661
+ // change, close-to-stranger, or excessive fee that the metadata check
2662
+ // alone wouldn't see. The residual sibling-transfer gap is closed by
2663
+ // verifySolanaSwapOutcome below (degrades gracefully when no sim RPC
2664
+ // is available, so this static check remains a guard when sim is off).
2665
+ assertSolanaInstructionsSafe(txBase64, { walletAddress: solanaWalletAddress });
2666
+
2667
+ // Verify the swap's simulated on-chain outcome matches intent.
2668
+ // Degrades with a warning if no simulation endpoint is available.
2669
+ if (!noVerifyOutcome) {
2670
+ const outcome = await verifySolanaSwapOutcome({ chain, walletAddress: solanaWalletAddress, txBase64, quote: currentQuote, quoteData, log });
2671
+ if (!outcome.proceed) {
2672
+ log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
2673
+ if (qi + 1 < endIndex) log(' Trying next quote...');
2674
+ lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
2675
+ continue;
2676
+ }
2677
+ }
2678
+
2424
2679
  if (isWalletConnect) {
2425
2680
  // Solana via WalletConnect: convert base64 → base58 for WC protocol
2426
2681
  log(' Signing Solana transaction via WalletConnect...');
@@ -2567,12 +2822,13 @@ EXAMPLES:
2567
2822
  if (broadcastResult.status !== 'Success') {
2568
2823
  throw new Error(broadcastResult.error || 'broadcast failed');
2569
2824
  }
2570
- revokeTxHash = broadcastResult.txHash;
2825
+ revokeTxHash = assertTxHashMatch(revokeResult.signedTransaction, broadcastResult.txHash, 'allowance-revoke');
2571
2826
  }
2572
2827
  if (!revokeTxHash) {
2573
2828
  throw new Error('Allowance revoke returned no transaction hash and no signed transaction; cannot confirm allowance was cleared');
2574
2829
  }
2575
2830
  } catch (revokeErr) {
2831
+ if (isFatalBroadcastError(revokeErr)) throw revokeErr;
2576
2832
  log(` ❌ Allowance revoke failed for ${quoteName}: ${revokeErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
2577
2833
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2578
2834
  lastQuoteError = `${quoteName} allowance revoke failed`;
@@ -2583,6 +2839,7 @@ EXAMPLES:
2583
2839
  const receipt = await waitForReceipt(chain, revokeTxHash);
2584
2840
  log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeTxHash}`);
2585
2841
  } catch (receiptErr) {
2842
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2586
2843
  log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
2587
2844
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2588
2845
  lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
@@ -2621,7 +2878,7 @@ EXAMPLES:
2621
2878
  if (broadcastResult.status !== 'Success') {
2622
2879
  throw new Error(broadcastResult.error || 'broadcast failed');
2623
2880
  }
2624
- approvalTxHash = broadcastResult.txHash;
2881
+ approvalTxHash = assertTxHashMatch(approvalResult.signedTransaction, broadcastResult.txHash, 'allowance-approval');
2625
2882
  }
2626
2883
  if (!approvalTxHash) {
2627
2884
  // Fail closed: the wallet returned neither a hash nor a
@@ -2632,6 +2889,7 @@ EXAMPLES:
2632
2889
  throw new Error('returned no transaction hash and no signed transaction; cannot confirm approval landed');
2633
2890
  }
2634
2891
  } catch (approvalErr) {
2892
+ if (isFatalBroadcastError(approvalErr)) throw approvalErr;
2635
2893
  const revokedMsg = shouldRevoke
2636
2894
  ? ' after revoking the prior allowance (now 0)'
2637
2895
  : '';
@@ -2645,6 +2903,7 @@ EXAMPLES:
2645
2903
  const receipt = await waitForReceipt(chain, approvalTxHash);
2646
2904
  log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalTxHash}`);
2647
2905
  } catch (receiptErr) {
2906
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2648
2907
  const revokedMsg = shouldRevoke
2649
2908
  ? ' after revoking the prior allowance (now 0)'
2650
2909
  : '';
@@ -2730,6 +2989,14 @@ EXAMPLES:
2730
2989
  try {
2731
2990
  await waitForReceipt(chain, wcResult.txHash);
2732
2991
  } catch (receiptErr) {
2992
+ // A timeout here is uncertain post-broadcast state, not a
2993
+ // confirmed revert — fail closed rather than retry (which would
2994
+ // broadcast a second swap). Applies even though this path has no
2995
+ // locally-derived hash to bind to.
2996
+ if (receiptErr.code === 'RECEIPT_TIMEOUT') {
2997
+ 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');
2998
+ }
2999
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2733
3000
  log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`);
2734
3001
  log(` Tx Hash: ${wcResult.txHash}`);
2735
3002
  log(` Explorer: ${chainConfig.explorer}${wcResult.txHash}`);
@@ -2892,9 +3159,10 @@ EXAMPLES:
2892
3159
 
2893
3160
  log(` Waiting for allowance revoke confirmation...`);
2894
3161
  try {
2895
- const receipt = await waitForReceipt(chain, revokeResult.txHash);
2896
- log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeResult.txHash}`);
3162
+ const { receipt, hash: revokeHash } = await confirmEvmBroadcast(chain, revokeTxHex, revokeResult.txHash, 'allowance-revoke');
3163
+ log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeHash}`);
2897
3164
  } catch (receiptErr) {
3165
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2898
3166
  log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
2899
3167
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2900
3168
  lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
@@ -2943,9 +3211,10 @@ EXAMPLES:
2943
3211
 
2944
3212
  log(` Waiting for approval confirmation...`);
2945
3213
  try {
2946
- const receipt = await waitForReceipt(chain, approvalResult.txHash);
2947
- log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
3214
+ const { receipt, hash: approvalHash } = await confirmEvmBroadcast(chain, approvalTxHex, approvalResult.txHash, 'allowance-approval');
3215
+ log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalHash}`);
2948
3216
  } catch (receiptErr) {
3217
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
2949
3218
  log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`);
2950
3219
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2951
3220
  lastQuoteError = `${quoteName} approval unconfirmed`;
@@ -3065,17 +3334,51 @@ EXAMPLES:
3065
3334
  const result = await executeTransaction(execParams);
3066
3335
 
3067
3336
  if (result.status === 'Success') {
3068
- const txId = result.signature || result.txHash;
3069
- const explorerUrl = chainConfig.explorer + txId;
3337
+ let txId = result.signature || result.txHash;
3338
+ let explorerUrl = chainConfig.explorer + txId;
3070
3339
 
3071
3340
  // For EVM: verify the tx actually succeeded on-chain
3072
- if (chainType === 'evm' && result.txHash) {
3341
+ if (chainType === 'evm') {
3073
3342
  log(' Verifying on-chain status...');
3343
+ // Non-gasless: derive our local hash up front, OUTSIDE the receipt-poll
3344
+ // try below. A hex-validation failure here means no poll ever ran, so it
3345
+ // must surface as itself — not as the "REVERTED on-chain" diagnostic that
3346
+ // catch is reserved for. (Gasless has no local hash to bind to: the Relay
3347
+ // solver wraps and broadcasts its own tx, so result.txHash legitimately is
3348
+ // not the hash of the bytes we signed.)
3349
+ if (!gasless) {
3350
+ try {
3351
+ txId = evmTxHash(signedTransaction);
3352
+ } catch (hashErr) {
3353
+ throw new CommandError(`Cannot derive local tx hash for ${quoteName}: ${hashErr.message}`, 'INVALID_SIGNED_TX');
3354
+ }
3355
+ explorerUrl = chainConfig.explorer + txId;
3356
+ }
3074
3357
  try {
3075
- await waitForReceipt(chain, result.txHash);
3358
+ if (gasless) {
3359
+ // If the solver reported no hash there is nothing to poll — skip
3360
+ // rather than block on eth_getTransactionReceipt(undefined).
3361
+ if (result.txHash) await waitForReceipt(chain, result.txHash);
3362
+ } else {
3363
+ const { hash } = await confirmEvmBroadcast(chain, signedTransaction, result.txHash);
3364
+ txId = hash;
3365
+ explorerUrl = chainConfig.explorer + txId;
3366
+ }
3076
3367
  } catch (receiptErr) {
3368
+ // A receipt TIMEOUT is not a confirmed revert: the tx was
3369
+ // broadcast and may still be pending under our nonce. Retrying
3370
+ // the next quote would sign and broadcast a SECOND swap racing
3371
+ // the first for that nonce — the duplicate-broadcast this PR
3372
+ // exists to prevent. (It's also exactly how guarantee #2's
3373
+ // silent-substitution case surfaces: a 180s timeout polling our
3374
+ // own hash.) Fail closed with a clearer banner than the generic
3375
+ // rethrow, then let isFatalBroadcastError handle the rest.
3376
+ if (receiptErr.code === 'RECEIPT_TIMEOUT') {
3377
+ 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');
3378
+ }
3379
+ if (isFatalBroadcastError(receiptErr)) throw receiptErr;
3077
3380
  log(`\n ⚠ Transaction was broadcast but REVERTED on-chain!`);
3078
- log(` Tx Hash: ${result.txHash}`);
3381
+ log(` Tx Hash: ${txId || result.txHash}`);
3079
3382
  log(` Explorer: ${explorerUrl}`);
3080
3383
  log(` Error: ${receiptErr.message}`);
3081
3384
  if (qi + 1 < endIndex) {
@@ -3083,7 +3386,7 @@ EXAMPLES:
3083
3386
  lastQuoteError = `${quoteName} reverted on-chain`;
3084
3387
  continue;
3085
3388
  }
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');
3389
+ 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
3390
  }
3088
3391
  }
3089
3392
 
@@ -3141,6 +3444,11 @@ EXAMPLES:
3141
3444
  }
3142
3445
 
3143
3446
  } catch (quoteErr) {
3447
+ // Post-broadcast failures abort the whole execute — never retry the
3448
+ // next quote once a transaction is already out and its outcome is
3449
+ // unknown (mismatch, underivable local hash, or an unconfirmed
3450
+ // receipt timeout). See isFatalBroadcastError.
3451
+ if (isFatalBroadcastError(quoteErr)) throw quoteErr;
3144
3452
  const msg = quoteErr.message || '';
3145
3453
  log(` ❌ Quote ${quoteName} failed: ${msg}`);
3146
3454
  if (msg.includes('AccountNotFound') && chainType === 'solana') {