nansen-cli 1.38.0 → 1.40.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,10 @@ 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, approvalAmountForSwap } from './trade-validation.js';
16
+ import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, approvalAmountForSwap, needsAllowanceRevoke, OVERSIZED_ALLOWANCE_MULTIPLIER } from './trade-validation.js';
17
17
  import { CHAIN_RPCS } from './rpc-urls.js';
18
- import { packageVersion, CommandError, telemetryHeaders } from './api.js';
18
+ import { simulateAssetChanges, SwapSimulationError, hasSimulationRpc } from './swap-simulation.js';
19
+ import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './api.js';
19
20
 
20
21
  // ============= Constants =============
21
22
 
@@ -450,11 +451,10 @@ export function cleanupQuotes() {
450
451
 
451
452
  // ============= Transaction Signing =============
452
453
 
453
- // ----------------------------------------------------------------
454
- // TODO: SECURITY REVIEW REQUIRED
455
- // The signing functions below construct and sign raw transactions.
456
- // They MUST be audited before any production/mainnet use.
457
- // ----------------------------------------------------------------
454
+ // The signing functions below construct and sign raw transactions from quote
455
+ // data. The authorization checks that make them safe to call live upstream:
456
+ // assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, scoped ERC-20
457
+ // approvals, and the approval target/amount validators in trade-validation.js.
458
458
 
459
459
  /**
460
460
  * Sign a Solana transaction from quote data.
@@ -526,7 +526,8 @@ export function signSolanaTransaction(transactionBase64, privateKeyHex) {
526
526
  * @param {number} nonce - Account nonce
527
527
  * @returns {string} 0x-prefixed signed transaction hex
528
528
  */
529
- // ⚠️ SECURITY: EVM transaction signing - requires thorough review before production use
529
+ // Pure EVM encode/sign primitive. Quote authorization and request-intent binding
530
+ // happen upstream before this function receives transaction calldata.
530
531
  export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
531
532
  const chainConfig = CHAIN_MAP[chain];
532
533
  if (!chainConfig || chainConfig.type !== 'evm') {
@@ -700,6 +701,92 @@ export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
700
701
  }
701
702
  }
702
703
 
704
+ /**
705
+ * Normalise an aggregator's transaction `value` to a 0x-hex string the RPC
706
+ * accepts. The field may be a decimal string ('1000000'), a 0x-hex string, a
707
+ * bare '0x' (no digits — `BigInt('0x')` throws), or absent. Anything unparseable
708
+ * becomes '0x0' rather than throwing, so a malformed value can't crash the
709
+ * degrade path or misfire as an outcome mismatch. Note: unlike swap-simulation's
710
+ * hexToBigInt, this keeps BigInt's decimal parsing (tx.value is often decimal).
711
+ */
712
+ function toRpcHexValue(value) {
713
+ if (!value || value === '0x') return '0x0';
714
+ try {
715
+ return '0x' + BigInt(value).toString(16);
716
+ } catch {
717
+ return '0x0';
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Verify — via balance-delta simulation — that a swap does to the wallet what
723
+ * the user asked and no more. Defence-in-depth on top of the static calldata
724
+ * guards: the cheap eth_call sim answers "will it revert", this answers "does the
725
+ * outcome match intent" (see assertSwapOutcome in trade-validation.js).
726
+ *
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
731
+ * outage never blocks trading. --no-verify-outcome skips it entirely.
732
+ *
733
+ * Returns { proceed, reason }. proceed=false means this quote failed
734
+ * verification: the caller should fall through to the next candidate WITHOUT
735
+ * signing or broadcasting the swap. proceed=true covers a clean pass AND a
736
+ * degrade (the warning is logged here).
737
+ *
738
+ * @param {object} args
739
+ * @param {string} args.chain
740
+ * @param {string} args.from - the wallet that will sign (the sender simulated)
741
+ * @param {object} args.quote - the quote about to be executed (currentQuote)
742
+ * @param {object} args.quoteData - the loaded quote record (.request, .slippage)
743
+ * @param {string|null} [args.apiKey] - Nansen API key for the hosted endpoint
744
+ * @param {function} [args.log]
745
+ */
746
+ export async function verifySwapOutcome({ chain, from, quote, quoteData, apiKey = null, log = () => {} }) {
747
+ 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 };
753
+ // No request intent recorded (a pre-intent quote): assertSwapOutcome has
754
+ // nothing to compare the simulated deltas against and would raise a misleading
755
+ // SWAP_OUTCOME_MISMATCH. Degrade cleanly — the static guards still ran, and a
756
+ // re-quote re-enables this check.
757
+ if (!quoteData?.request) {
758
+ log(' ⚠ Swap-outcome verification skipped (no request intent — re-quote to enable it).');
759
+ return { proceed: true };
760
+ }
761
+ if (!hasSimulationRpc(chain)) {
762
+ log(` ⚠ Swap-outcome verification unavailable (no simulation endpoint for ${chain}); proceeding without it.`);
763
+ return { proceed: true };
764
+ }
765
+ const tx = quote?.transaction || {};
766
+ // Spenders the wallet may legitimately (re)approve mid-swap: the approval
767
+ // target and the router it routes through. Anything else fails assertion 4.
768
+ const expectedSpenders = [quote?.approvalAddress, tx.to].filter(Boolean);
769
+ try {
770
+ const sim = await simulateAssetChanges(
771
+ chain,
772
+ { to: tx.to, data: tx.data, value: toRpcHexValue(tx.value) },
773
+ { from, apiKey },
774
+ );
775
+ assertSwapOutcome(quoteData.request, quote, sim, { slippage: quoteData.slippage, expectedSpenders });
776
+ log(` ✓ Swap outcome verified (via ${sim.method}).`);
777
+ return { proceed: true };
778
+ } catch (e) {
779
+ // Degrade (warn + proceed) when the simulation itself could not run; block
780
+ // (fall through to the next quote) when the outcome did not match or the
781
+ // swap reverts in simulation.
782
+ if (e instanceof SwapSimulationError && ['NO_SIM_RPC', 'NOT_SIM_CAPABLE', 'SIM_RPC_ERROR'].includes(e.code)) {
783
+ log(` ⚠ Swap-outcome verification could not run (${e.message}); proceeding without it.`);
784
+ return { proceed: true };
785
+ }
786
+ return { proceed: false, reason: e.message };
787
+ }
788
+ }
789
+
703
790
  /**
704
791
  * Estimate gas for an EVM transaction. Returns the gas estimate or null on failure.
705
792
  * Used to fix under-gassed quotes from aggregators.
@@ -715,26 +802,130 @@ export async function estimateEvmGas(chain, { from, to, data, value }) {
715
802
  }
716
803
  }
717
804
 
805
+ /**
806
+ * Read the current on-chain ERC-20 allowance, throwing on any RPC failure
807
+ * instead of masking it. checkErc20Allowance below wraps this with a
808
+ * catch-to-0 fallback for the pre-trade check (safe there, since a follow-up
809
+ * approve() overwrites whatever the prior value was); post-action
810
+ * verification needs the raw, fail-closed read instead.
811
+ */
812
+ async function readErc20AllowanceOrThrow(chain, tokenAddress, ownerAddress, spenderAddress) {
813
+ if (!CHAIN_RPCS[chain]) throw new Error(`no RPC configured for chain ${chain}`);
814
+ // allowance(address,address) selector = 0xdd62ed3e
815
+ const data = '0xdd62ed3e'
816
+ + ownerAddress.slice(2).toLowerCase().padStart(64, '0')
817
+ + spenderAddress.slice(2).toLowerCase().padStart(64, '0');
818
+ const result = await evmRpcCall(chain, 'eth_call', [{ to: tokenAddress, data }, 'latest']);
819
+ if (!/^0x[0-9a-fA-F]{64}$/.test(result || '')) {
820
+ throw new Error(`invalid allowance() return data: ${result || '<empty>'}`);
821
+ }
822
+ return BigInt(result);
823
+ }
824
+
718
825
  /**
719
826
  * Check ERC-20 allowance for a given owner/spender pair.
720
827
  * Returns the allowance as a BigInt, or 0n on failure.
721
828
  */
722
829
  export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spenderAddress) {
723
- if (!CHAIN_RPCS[chain]) return 0n;
724
-
725
830
  try {
726
- // allowance(address,address) selector = 0xdd62ed3e
727
- const data = '0xdd62ed3e'
728
- + ownerAddress.slice(2).toLowerCase().padStart(64, '0')
729
- + spenderAddress.slice(2).toLowerCase().padStart(64, '0');
730
- const result = await evmRpcCall(chain, 'eth_call', [{ to: tokenAddress, data }, 'latest']);
731
- if (!result) return 0n;
732
- return BigInt(result);
733
- } catch {
831
+ return await readErc20AllowanceOrThrow(chain, tokenAddress, ownerAddress, spenderAddress);
832
+ } catch (err) {
833
+ // Treat an unreadable allowance as 0 so the caller re-approves a fresh scoped
834
+ // amount (a normal approve() overwrites any real on-chain allowance) rather
835
+ // than trusting a value we couldn't verify. Surface it so a persistent RPC
836
+ // problem which would otherwise silently skip the excessive-allowance
837
+ // revoke — isn't invisible.
838
+ process.stderr.write(`⚠️ Could not read ERC-20 allowance on ${chain} (${err.message}); treating as 0.\n`);
734
839
  return 0n;
735
840
  }
736
841
  }
737
842
 
843
+ /**
844
+ * A successful receipt only proves the revoke/approval call didn't revert —
845
+ * not that approve() actually produced the allowance we expect (a
846
+ * non-standard token or a race with another approval could still leave the
847
+ * wrong value on-chain). Poll the allowance a few times before failing
848
+ * closed: an `eth_call` at 'latest' immediately after a receipt can hit an
849
+ * RPC node that hasn't caught up with the just-mined block yet and read
850
+ * stale pre-transaction state — confirmed live (PR #509 review follow-up)
851
+ * against a real Base approval that read back as unset for several seconds
852
+ * after its receipt landed, then correctly as the approved amount once the
853
+ * node caught up.
854
+ */
855
+ const ALLOWANCE_VERIFY_ATTEMPTS = 5;
856
+ const DEFAULT_ALLOWANCE_VERIFY_DELAY_MS = 1500;
857
+ const DEFAULT_POST_ALLOWANCE_TX_PROPAGATION_MS = 2000;
858
+ let allowanceVerifyDelayMs = DEFAULT_ALLOWANCE_VERIFY_DELAY_MS;
859
+ let postAllowanceTxPropagationMs = DEFAULT_POST_ALLOWANCE_TX_PROPAGATION_MS;
860
+
861
+ export function __setAllowanceTimingForTests({
862
+ verifyDelayMs = DEFAULT_ALLOWANCE_VERIFY_DELAY_MS,
863
+ propagationDelayMs = DEFAULT_POST_ALLOWANCE_TX_PROPAGATION_MS,
864
+ } = {}) {
865
+ if (process.env.NODE_ENV !== 'test' && !process.env.VITEST) {
866
+ throw new Error('__setAllowanceTimingForTests is for tests only');
867
+ }
868
+ allowanceVerifyDelayMs = verifyDelayMs;
869
+ postAllowanceTxPropagationMs = propagationDelayMs;
870
+ }
871
+
872
+ async function waitForAllowanceTxPropagation() {
873
+ // The receipt + allowance poll verifies token state, but the following swap
874
+ // still goes through a broadcaster/load-balanced RPC path. Give that path a
875
+ // short propagation window before signing the next dependent transaction.
876
+ if (postAllowanceTxPropagationMs <= 0) return;
877
+ await new Promise(r => setTimeout(r, postAllowanceTxPropagationMs));
878
+ }
879
+
880
+ async function pollAllowanceUntil(chain, tokenAddress, ownerAddress, spenderAddress, isExpected) {
881
+ let allowance, lastErr;
882
+ for (let attempt = 0; attempt < ALLOWANCE_VERIFY_ATTEMPTS; attempt++) {
883
+ if (attempt > 0 && allowanceVerifyDelayMs > 0) {
884
+ await new Promise(r => setTimeout(r, allowanceVerifyDelayMs));
885
+ }
886
+ try {
887
+ allowance = await readErc20AllowanceOrThrow(chain, tokenAddress, ownerAddress, spenderAddress);
888
+ lastErr = undefined;
889
+ if (isExpected(allowance)) return allowance;
890
+ } catch (err) {
891
+ lastErr = err;
892
+ }
893
+ }
894
+ if (lastErr) throw lastErr;
895
+ throw new Error(
896
+ `allowance did not reach expected state after ${ALLOWANCE_VERIFY_ATTEMPTS} attempts (last read: ${allowance})`,
897
+ );
898
+ }
899
+
900
+ function allowanceRevokeRecoveryHint(txHash) {
901
+ const txHint = txHash ? ` Tx: ${txHash}.` : '';
902
+ return `${txHint} Check the transaction on-chain, then retry this execute command or re-quote if needed.`;
903
+ }
904
+
905
+ async function assertAllowanceRevoked(chain, tokenAddress, ownerAddress, spenderAddress) {
906
+ let allowance;
907
+ try {
908
+ allowance = await pollAllowanceUntil(chain, tokenAddress, ownerAddress, spenderAddress, a => a === 0n);
909
+ } catch (err) {
910
+ throw new Error(`could not verify the allowance was cleared (${err.message})`, { cause: err });
911
+ }
912
+ if (allowance !== 0n) {
913
+ throw new Error(`allowance is still ${allowance}, not 0`);
914
+ }
915
+ }
916
+
917
+ async function assertAllowanceAtLeast(chain, tokenAddress, ownerAddress, spenderAddress, minAmount) {
918
+ let allowance;
919
+ try {
920
+ allowance = await pollAllowanceUntil(chain, tokenAddress, ownerAddress, spenderAddress, a => a >= minAmount);
921
+ } catch (err) {
922
+ throw new Error(`could not verify the approval took effect (${err.message})`, { cause: err });
923
+ }
924
+ if (allowance < minAmount) {
925
+ throw new Error(`allowance is ${allowance}, below the ${minAmount} this trade requires`);
926
+ }
927
+ }
928
+
738
929
  // approvalAmountForSwap now lives in trade-validation.js alongside the approval
739
930
  // encoder and the spend-ceiling check that both consume it, so the "how much can
740
931
  // leave the wallet" math has a single definition. Re-exported here because the
@@ -764,6 +955,21 @@ export function approvalCapForQuote(quoteData) {
764
955
  return quoteData?.swapMode === 'exactOut' ? undefined : quoteData?.request?.amount;
765
956
  }
766
957
 
958
+ // Decide what to do with a pre-existing on-chain allowance before a swap.
959
+ // `shouldRevoke` describes the allowance ("it's oversized"), NOT the action taken:
960
+ // callers use it both to gate the actual revoke (in the !reuseAllowance branch)
961
+ // and to warn when reuse is forced by --no-revoke-excessive-allowance (in the
962
+ // reuseAllowance branch). Note shouldRevoke ⟹ existingAllowance > approveAmt*10 ⟹
963
+ // existingAllowance >= approveAmt, so with the flag set reuseAllowance is always
964
+ // true and the revoke/"after revoking (now 0)" paths (all in the else branch) are
965
+ // never reached spuriously — keep that invariant if you add branches here.
966
+ function resolveAllowanceAction(existingAllowance, approveAmt, noRevokeExcessiveAllowance) {
967
+ const shouldRevoke = existingAllowance > 0n && needsAllowanceRevoke(existingAllowance, approveAmt);
968
+ const reuseAllowance = existingAllowance >= approveAmt && existingAllowance > 0n
969
+ && (noRevokeExcessiveAllowance || !shouldRevoke);
970
+ return { shouldRevoke, reuseAllowance };
971
+ }
972
+
767
973
  export function assertCompleteEvmRequestIntent(request) {
768
974
  if (!request) {
769
975
  throw new Error('Quote is missing request intent. Re-quote with this CLI version before executing an EVM swap. Refusing to sign.');
@@ -882,10 +1088,15 @@ export function assertUsableSpender(spenderAddress) {
882
1088
  * @param {string|number} gasPrice - Legacy gas price
883
1089
  * @param {bigint|string|number} amount - Allowance to grant, in base units (see approvalAmountForSwap)
884
1090
  * @param {bigint|string|number} [maxAllowance] - Hard cap from persisted request intent
1091
+ * @param {object} [opts]
1092
+ * @param {boolean} [opts.allowZero=false] - Allow a zero-amount revoke approval
885
1093
  * @returns {string} 0x-prefixed signed approval tx hex
886
1094
  */
887
- // ⚠️ SECURITY: ERC-20 approval signing - requires thorough review
888
- export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKeyHex, chain, nonce, gasPrice, amount, maxAllowance) {
1095
+ // Approval signing is intentionally narrow: callers pass either the scoped swap
1096
+ // amount from approvalAmountForSwap or, for excessive-allowance cleanup, an
1097
+ // explicit allowZero revoke. encodeApproveCalldata validates the spender,
1098
+ // amount, optional request cap, and final ABI width before signing.
1099
+ export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKeyHex, chain, nonce, gasPrice, amount, maxAllowance, { allowZero = false } = {}) {
889
1100
  const chainConfig = CHAIN_MAP[chain];
890
1101
  if (!chainConfig) throw new Error(`Unsupported chain: ${chain}`);
891
1102
 
@@ -893,7 +1104,7 @@ export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKe
893
1104
  // can drain at most this one trade, never the wallet's full token balance.
894
1105
  // encodeApproveCalldata enforces a valid 20-byte spender, a bounded (< MAX)
895
1106
  // amount within the request cap, and exactly-68-byte calldata.
896
- const data = encodeApproveCalldata(spenderAddress, amount, { maxAllowance });
1107
+ const data = encodeApproveCalldata(spenderAddress, amount, { maxAllowance, allowZero });
897
1108
 
898
1109
  const tx = {
899
1110
  nonce,
@@ -909,7 +1120,8 @@ export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKe
909
1120
  }
910
1121
 
911
1122
  // ============= Legacy (Type 0) EVM Transaction Signing =============
912
- // ⚠️ SECURITY: Legacy EVM transaction signing - requires thorough review before production use
1123
+ // Low-level RLP/secp256k1 signing primitive used after upstream quote and
1124
+ // allowance validation has already bounded what the transaction can authorize.
913
1125
 
914
1126
  /**
915
1127
  * Strip all leading zero bytes from a buffer.
@@ -1761,7 +1973,19 @@ CROSS-CHAIN NOTES (when using --to-chain):
1761
1973
  const quoteId = options.quote || options['quote-id'] || args[0];
1762
1974
  const walletName = options.wallet;
1763
1975
  const noSimulate = flags['no-simulate'];
1976
+ const noRevokeExcessiveAllowance = flags['no-revoke-excessive-allowance'];
1977
+ const noVerifyOutcome = flags['no-verify-outcome'];
1764
1978
  const gasless = Boolean(flags.gasless);
1979
+ // Read the API key for the swap-outcome sim endpoint. It's optional (the
1980
+ // check degrades to a warning if the endpoint can't authenticate), so a
1981
+ // malformed config must not crash an in-progress trade — fall back to null.
1982
+ const apiKey = (() => {
1983
+ try {
1984
+ return loadConfig().apiKey;
1985
+ } catch {
1986
+ return null;
1987
+ }
1988
+ })();
1765
1989
 
1766
1990
  if (!quoteId) {
1767
1991
  throw new CommandError(`Usage: nansen trade execute --quote <quoteId> [options]
@@ -1769,7 +1993,10 @@ CROSS-CHAIN NOTES (when using --to-chain):
1769
1993
  OPTIONS:
1770
1994
  --quote <id> Quote ID from 'nansen quote'
1771
1995
  --wallet <name> Wallet name (default: default wallet)
1772
- --no-simulate Skip pre-broadcast simulation
1996
+ --no-simulate Skip pre-broadcast simulation (the eth_call revert check)
1997
+ --no-verify-outcome Skip EVM swap-outcome verification (balance-delta check)
1998
+ --no-revoke-excessive-allowance
1999
+ Skip auto-revoking an oversized/legacy allowance before re-approving
1773
2000
  --gasless Relay-only: have Relay's solver pay gas (no WalletConnect)
1774
2001
 
1775
2002
  EXAMPLES:
@@ -1939,15 +2166,15 @@ EXAMPLES:
1939
2166
  assertCompleteEvmRequestIntent(quoteData.request);
1940
2167
  assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
1941
2168
 
1942
- // Same-chain only: a legit swap's outer call is a router method,
1943
- // never a bare ERC-20 transfer/approve. Reject that drain shape.
1944
- // Cross-chain routes skip THIS selector check, but a bare transfer
1945
- // still targets the token contract, so validateSwapTarget's
1946
- // `to === inputMint` gate above already refuses it on both paths —
1947
- // cross-chain bare transfers are not actually waved through here.
1948
- if (!quoteData.toChain) {
1949
- assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
1950
- }
2169
+ // Reject a bare ERC-20 transfer/approve/transferFrom as the outer
2170
+ // call: a real swap or bridge routes through an aggregator/router,
2171
+ // never a direct token method. Runs on cross-chain too — the
2172
+ // validateSwapTarget gate above only refuses `to === inputMint`, so
2173
+ // a bare transfer to a SIBLING token the wallet holds would
2174
+ // otherwise slip through the bridge path (which doesn't parse the
2175
+ // calldata recipient) and drain it. Legitimate bridges route through
2176
+ // a router selector, so this never fires on a real cross-chain quote.
2177
+ assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
1951
2178
 
1952
2179
  // Validate transaction.value (same checks as local wallet)
1953
2180
  const isNative = isNativeToken(currentQuote.inputMint);
@@ -1987,9 +2214,64 @@ EXAMPLES:
1987
2214
  chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
1988
2215
  );
1989
2216
 
1990
- if (existingAllowance >= approveAmt && existingAllowance > 0n) {
2217
+ const { shouldRevoke, reuseAllowance } = resolveAllowanceAction(existingAllowance, approveAmt, noRevokeExcessiveAllowance);
2218
+ if (reuseAllowance) {
2219
+ if (noRevokeExcessiveAllowance && shouldRevoke) {
2220
+ log(` ⚠ Existing allowance (${existingAllowance}) for ${quoteName} is excessive (>${OVERSIZED_ALLOWANCE_MULTIPLIER}x this trade), but --no-revoke-excessive-allowance was set`);
2221
+ }
1991
2222
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
1992
2223
  } else {
2224
+ const approvalMaxFee = currentQuote.transaction?.maxFeePerGas || currentQuote.transaction?.gasPrice || '1000000';
2225
+ const approvalPriorityFee = currentQuote.transaction?.maxPriorityFeePerGas || '1000000';
2226
+
2227
+ if (shouldRevoke) {
2228
+ log(` ⚠ Existing allowance (${existingAllowance}) for ${quoteName} is excessive (>${OVERSIZED_ALLOWANCE_MULTIPLIER}x this trade) — revoking before re-approving`);
2229
+ const revokeNonce = await getEvmNonce(chain, walletAddress);
2230
+ const revokeData = encodeApproveCalldata(currentQuote.approvalAddress, 0n, { allowZero: true });
2231
+ const revokeSignResult = await privyClient.signEvmTransaction(evmWalletId, {
2232
+ to: currentQuote.inputMint,
2233
+ data: revokeData,
2234
+ value: '0x0',
2235
+ chain_id: chainConfig.chainId,
2236
+ nonce: toHex(revokeNonce),
2237
+ gas_limit: toHex(100000),
2238
+ max_fee_per_gas: toHex(approvalMaxFee),
2239
+ max_priority_fee_per_gas: toHex(approvalPriorityFee),
2240
+ });
2241
+ const signedRevoke = revokeSignResult.data?.signed_transaction || revokeSignResult.signed_transaction;
2242
+ if (!signedRevoke) {
2243
+ log(` ❌ Allowance revoke failed for ${quoteName}: Privy returned no signed transaction`);
2244
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2245
+ lastQuoteError = `${quoteName} allowance revoke failed`;
2246
+ continue;
2247
+ }
2248
+ const revokeResult = await executeTransaction({ signedTransaction: signedRevoke, chain, simulate: !noSimulate });
2249
+ if (revokeResult.status !== 'Success') {
2250
+ log(` ❌ Allowance revoke failed for ${quoteName}: ${revokeResult.error || 'unknown'}`);
2251
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2252
+ lastQuoteError = `${quoteName} allowance revoke failed`;
2253
+ continue;
2254
+ }
2255
+ log(` Waiting for allowance revoke confirmation...`);
2256
+ try {
2257
+ const receipt = await waitForReceipt(chain, revokeResult.txHash);
2258
+ log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeResult.txHash}`);
2259
+ } catch (receiptErr) {
2260
+ log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
2261
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2262
+ lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
2263
+ continue;
2264
+ }
2265
+ try {
2266
+ await assertAllowanceRevoked(chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress);
2267
+ } catch (pollErr) {
2268
+ log(` ❌ Revoke tx confirmed but allowance was not cleared for ${quoteName}: ${pollErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
2269
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2270
+ lastQuoteError = `${quoteName} allowance revoke verification failed`;
2271
+ continue;
2272
+ }
2273
+ await waitForAllowanceTxPropagation();
2274
+ }
1993
2275
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
1994
2276
  const approvalNonce = await getEvmNonce(chain, walletAddress);
1995
2277
  // Scope the approval to this trade's input (see approvalAmountForSwap).
@@ -1998,8 +2280,6 @@ EXAMPLES:
1998
2280
  const approvalData = encodeApproveCalldata(currentQuote.approvalAddress, approveAmt, {
1999
2281
  maxAllowance: approvalCapForQuote(quoteData),
2000
2282
  });
2001
- const approvalMaxFee = currentQuote.transaction?.maxFeePerGas || currentQuote.transaction?.gasPrice || '1000000';
2002
- const approvalPriorityFee = currentQuote.transaction?.maxPriorityFeePerGas || '1000000';
2003
2283
  const approvalSignResult = await privyClient.signEvmTransaction(evmWalletId, {
2004
2284
  to: currentQuote.inputMint,
2005
2285
  data: approvalData,
@@ -2011,9 +2291,21 @@ EXAMPLES:
2011
2291
  max_priority_fee_per_gas: toHex(approvalPriorityFee),
2012
2292
  });
2013
2293
  const signedApproval = approvalSignResult.data?.signed_transaction || approvalSignResult.signed_transaction;
2294
+ if (!signedApproval) {
2295
+ const revokedMsg = shouldRevoke
2296
+ ? ' after revoking the prior allowance (now 0)'
2297
+ : '';
2298
+ log(` ❌ Approval failed for ${quoteName}${revokedMsg}: Privy returned no signed transaction`);
2299
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2300
+ lastQuoteError = `${quoteName} approval failed`;
2301
+ continue;
2302
+ }
2014
2303
  const approvalResult = await executeTransaction({ signedTransaction: signedApproval, chain, simulate: !noSimulate });
2015
2304
  if (approvalResult.status !== 'Success') {
2016
- log(` ❌ Approval failed for ${quoteName}: ${approvalResult.error || 'unknown'}`);
2305
+ const revokedMsg = shouldRevoke
2306
+ ? ' after revoking the prior allowance (now 0)'
2307
+ : '';
2308
+ log(` ❌ Approval failed for ${quoteName}${revokedMsg}: ${approvalResult.error || 'unknown'}`);
2017
2309
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2018
2310
  lastQuoteError = `${quoteName} approval failed`;
2019
2311
  continue;
@@ -2023,12 +2315,20 @@ EXAMPLES:
2023
2315
  const receipt = await waitForReceipt(chain, approvalResult.txHash);
2024
2316
  log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
2025
2317
  } catch (receiptErr) {
2026
- log(` ❌ Approval may not have confirmed: ${receiptErr.message}`);
2318
+ log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`);
2027
2319
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2028
2320
  lastQuoteError = `${quoteName} approval unconfirmed`;
2029
2321
  continue;
2030
2322
  }
2031
- await new Promise(r => setTimeout(r, 2000));
2323
+ try {
2324
+ await assertAllowanceAtLeast(chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress, approveAmt);
2325
+ } catch (pollErr) {
2326
+ log(` ❌ Approval tx confirmed but allowance did not reach the required amount for ${quoteName}${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${pollErr.message}`);
2327
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2328
+ lastQuoteError = `${quoteName} approval verification failed`;
2329
+ continue;
2330
+ }
2331
+ await waitForAllowanceTxPropagation();
2032
2332
  }
2033
2333
  }
2034
2334
 
@@ -2048,6 +2348,20 @@ EXAMPLES:
2048
2348
  }
2049
2349
  }
2050
2350
 
2351
+ // Verify the swap's simulated on-chain outcome matches intent.
2352
+ // Its own gate (runs even when --no-simulate/gasless skip the
2353
+ // cheap revert check above); degrades with a warning if no
2354
+ // simulation endpoint is available.
2355
+ if (!noVerifyOutcome) {
2356
+ const outcome = await verifySwapOutcome({ chain, from: walletAddress, quote: currentQuote, quoteData, apiKey, log });
2357
+ if (!outcome.proceed) {
2358
+ log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
2359
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2360
+ lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
2361
+ continue;
2362
+ }
2363
+ }
2364
+
2051
2365
  // Gas resolution — fall back to eth_estimateGas if quote has no gas
2052
2366
  const txData = currentQuote.transaction;
2053
2367
  const apiGas = parseInt(currentQuote.gas || '0');
@@ -2175,15 +2489,15 @@ EXAMPLES:
2175
2489
  assertCompleteEvmRequestIntent(quoteData.request);
2176
2490
  assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress: wcAddress, slippage: quoteData.slippage });
2177
2491
 
2178
- // Same-chain only: a legit swap's outer call is a router method,
2179
- // never a bare ERC-20 transfer/approve. Reject that drain shape.
2180
- // Cross-chain routes skip THIS selector check, but a bare transfer
2181
- // still targets the token contract, so validateSwapTarget's
2182
- // `to === inputMint` gate above already refuses it on both paths —
2183
- // cross-chain bare transfers are not actually waved through here.
2184
- if (!quoteData.toChain) {
2185
- assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2186
- }
2492
+ // Reject a bare ERC-20 transfer/approve/transferFrom as the outer
2493
+ // call: a real swap or bridge routes through an aggregator/router,
2494
+ // never a direct token method. Runs on cross-chain too — the
2495
+ // validateSwapTarget gate above only refuses `to === inputMint`, so
2496
+ // a bare transfer to a SIBLING token the wallet holds would
2497
+ // otherwise slip through the bridge path (which doesn't parse the
2498
+ // calldata recipient) and drain it. Legitimate bridges route through
2499
+ // a router selector, so this never fires on a real cross-chain quote.
2500
+ assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2187
2501
 
2188
2502
  // Validate transaction.value (same checks as local wallet)
2189
2503
  const txValue = BigInt(currentQuote.transaction.value || '0');
@@ -2222,11 +2536,71 @@ EXAMPLES:
2222
2536
  chain, currentQuote.inputMint, wcAddress, currentQuote.approvalAddress
2223
2537
  );
2224
2538
 
2225
- if (existingAllowance >= approveAmt && existingAllowance > 0n) {
2539
+ const { shouldRevoke, reuseAllowance } = resolveAllowanceAction(existingAllowance, approveAmt, noRevokeExcessiveAllowance);
2540
+ if (reuseAllowance) {
2541
+ if (noRevokeExcessiveAllowance && shouldRevoke) {
2542
+ log(` ⚠ Existing allowance (${existingAllowance}) for ${quoteName} is excessive (>${OVERSIZED_ALLOWANCE_MULTIPLIER}x this trade), but --no-revoke-excessive-allowance was set`);
2543
+ }
2226
2544
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
2227
2545
  } else {
2546
+ if (shouldRevoke) {
2547
+ log(` ⚠ Existing allowance (${existingAllowance}) for ${quoteName} is excessive (>${OVERSIZED_ALLOWANCE_MULTIPLIER}x this trade) — revoking before re-approving`);
2548
+ log(` Sending allowance revocation via WalletConnect (you'll be asked to approve this separately)...`);
2549
+ let revokeTxHash;
2550
+ try {
2551
+ const revokeResult = await sendApprovalViaWalletConnect(
2552
+ currentQuote.inputMint,
2553
+ currentQuote.approvalAddress,
2554
+ chainConfig.chainId,
2555
+ 0n,
2556
+ undefined,
2557
+ { allowZero: true },
2558
+ );
2559
+ revokeTxHash = revokeResult.txHash;
2560
+ if (!revokeTxHash && revokeResult.signedTransaction) {
2561
+ log(` Broadcasting allowance revocation via Trading API...`);
2562
+ const broadcastResult = await executeTransaction({
2563
+ signedTransaction: revokeResult.signedTransaction,
2564
+ chain,
2565
+ simulate: !noSimulate,
2566
+ });
2567
+ if (broadcastResult.status !== 'Success') {
2568
+ throw new Error(broadcastResult.error || 'broadcast failed');
2569
+ }
2570
+ revokeTxHash = broadcastResult.txHash;
2571
+ }
2572
+ if (!revokeTxHash) {
2573
+ throw new Error('Allowance revoke returned no transaction hash and no signed transaction; cannot confirm allowance was cleared');
2574
+ }
2575
+ } catch (revokeErr) {
2576
+ log(` ❌ Allowance revoke failed for ${quoteName}: ${revokeErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
2577
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2578
+ lastQuoteError = `${quoteName} allowance revoke failed`;
2579
+ continue;
2580
+ }
2581
+ log(` Waiting for allowance revoke confirmation...`);
2582
+ try {
2583
+ const receipt = await waitForReceipt(chain, revokeTxHash);
2584
+ log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeTxHash}`);
2585
+ } catch (receiptErr) {
2586
+ log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
2587
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2588
+ lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
2589
+ continue;
2590
+ }
2591
+ try {
2592
+ await assertAllowanceRevoked(chain, currentQuote.inputMint, wcAddress, currentQuote.approvalAddress);
2593
+ } catch (pollErr) {
2594
+ log(` ❌ Revoke tx confirmed but allowance was not cleared for ${quoteName}: ${pollErr.message}.${allowanceRevokeRecoveryHint(revokeTxHash)}`);
2595
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2596
+ lastQuoteError = `${quoteName} allowance revoke verification failed`;
2597
+ continue;
2598
+ }
2599
+ await waitForAllowanceTxPropagation();
2600
+ }
2228
2601
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
2229
2602
  log(` Sending approval via WalletConnect...`);
2603
+ let approvalTxHash;
2230
2604
  try {
2231
2605
  const approvalResult = await sendApprovalViaWalletConnect(
2232
2606
  currentQuote.inputMint,
@@ -2235,7 +2609,7 @@ EXAMPLES:
2235
2609
  approveAmt,
2236
2610
  approvalCapForQuote(quoteData),
2237
2611
  );
2238
- let approvalTxHash = approvalResult.txHash;
2612
+ approvalTxHash = approvalResult.txHash;
2239
2613
  if (!approvalTxHash && approvalResult.signedTransaction) {
2240
2614
  // Wallet returned a signed tx instead of broadcasting — broadcast via Trading API
2241
2615
  log(` Broadcasting approval via Trading API...`);
@@ -2249,18 +2623,48 @@ EXAMPLES:
2249
2623
  }
2250
2624
  approvalTxHash = broadcastResult.txHash;
2251
2625
  }
2252
- if (approvalTxHash) {
2253
- log(` Waiting for approval confirmation...`);
2254
- const receipt = await waitForReceipt(chain, approvalTxHash);
2255
- log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalTxHash}`);
2626
+ if (!approvalTxHash) {
2627
+ // Fail closed: the wallet returned neither a hash nor a
2628
+ // signed tx, so we can't confirm the approval landed —
2629
+ // never fall through to the swap (esp. after a revoke has
2630
+ // already zeroed the allowance). The catch adds the
2631
+ // "after revoking (now 0)" context.
2632
+ throw new Error('returned no transaction hash and no signed transaction; cannot confirm approval landed');
2256
2633
  }
2257
2634
  } catch (approvalErr) {
2258
- log(` ❌ Approval failed for ${quoteName}: ${approvalErr.message}`);
2635
+ const revokedMsg = shouldRevoke
2636
+ ? ' after revoking the prior allowance (now 0)'
2637
+ : '';
2638
+ log(` ❌ Approval failed for ${quoteName}${revokedMsg}: ${approvalErr.message}`);
2259
2639
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2260
2640
  lastQuoteError = `${quoteName} approval failed`;
2261
2641
  continue;
2262
2642
  }
2263
- await new Promise(r => setTimeout(r, 2000));
2643
+ log(` Waiting for approval confirmation...`);
2644
+ try {
2645
+ const receipt = await waitForReceipt(chain, approvalTxHash);
2646
+ log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalTxHash}`);
2647
+ } catch (receiptErr) {
2648
+ const revokedMsg = shouldRevoke
2649
+ ? ' after revoking the prior allowance (now 0)'
2650
+ : '';
2651
+ log(` ❌ Approval may not have confirmed${revokedMsg}: ${receiptErr.message}`);
2652
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2653
+ lastQuoteError = `${quoteName} approval unconfirmed`;
2654
+ continue;
2655
+ }
2656
+ try {
2657
+ await assertAllowanceAtLeast(chain, currentQuote.inputMint, wcAddress, currentQuote.approvalAddress, approveAmt);
2658
+ } catch (pollErr) {
2659
+ const revokedMsg = shouldRevoke
2660
+ ? ' after revoking the prior allowance (now 0)'
2661
+ : '';
2662
+ log(` ❌ Approval tx confirmed but allowance did not reach the required amount for ${quoteName}${revokedMsg}: ${pollErr.message}`);
2663
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2664
+ lastQuoteError = `${quoteName} approval verification failed`;
2665
+ continue;
2666
+ }
2667
+ await waitForAllowanceTxPropagation();
2264
2668
  log('');
2265
2669
  }
2266
2670
  }
@@ -2282,6 +2686,20 @@ EXAMPLES:
2282
2686
  }
2283
2687
  }
2284
2688
 
2689
+ // Verify the swap's simulated on-chain outcome matches intent. Its
2690
+ // own gate: runs even when --no-simulate/gasless skip the cheap
2691
+ // eth_call revert check above; degrades with a warning when no
2692
+ // simulation endpoint is set.
2693
+ if (!noVerifyOutcome) {
2694
+ const outcome = await verifySwapOutcome({ chain, from: wcAddress, quote: currentQuote, quoteData, apiKey, log });
2695
+ if (!outcome.proceed) {
2696
+ log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
2697
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2698
+ lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
2699
+ continue;
2700
+ }
2701
+ }
2702
+
2285
2703
  // Resolve gas
2286
2704
  const txData = currentQuote.transaction;
2287
2705
  const apiGas = parseInt(currentQuote.gas || "0");
@@ -2379,15 +2797,15 @@ EXAMPLES:
2379
2797
  assertCompleteEvmRequestIntent(quoteData.request);
2380
2798
  assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
2381
2799
 
2382
- // Same-chain only: a legit swap's outer call is a router method,
2383
- // never a bare ERC-20 transfer/approve. Reject that drain shape.
2384
- // Cross-chain routes skip THIS selector check, but a bare transfer
2385
- // still targets the token contract, so validateSwapTarget's
2386
- // `to === inputMint` gate above already refuses it on both paths —
2387
- // cross-chain bare transfers are not actually waved through here.
2388
- if (!quoteData.toChain) {
2389
- assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2390
- }
2800
+ // Reject a bare ERC-20 transfer/approve/transferFrom as the outer
2801
+ // call: a real swap or bridge routes through an aggregator/router,
2802
+ // never a direct token method. Runs on cross-chain too — the
2803
+ // validateSwapTarget gate above only refuses `to === inputMint`, so
2804
+ // a bare transfer to a SIBLING token the wallet holds would
2805
+ // otherwise slip through the bridge path (which doesn't parse the
2806
+ // calldata recipient) and drain it. Legitimate bridges route through
2807
+ // a router selector, so this never fires on a real cross-chain quote.
2808
+ assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2391
2809
 
2392
2810
  // Handle approval if needed — skip for native ETH
2393
2811
  // Check existing allowance first to avoid unnecessary approve txs
@@ -2434,14 +2852,68 @@ EXAMPLES:
2434
2852
  chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
2435
2853
  );
2436
2854
 
2437
- if (existingAllowance >= approveAmt && existingAllowance > 0n) {
2855
+ const { shouldRevoke, reuseAllowance } = resolveAllowanceAction(existingAllowance, approveAmt, noRevokeExcessiveAllowance);
2856
+ if (reuseAllowance) {
2857
+ if (noRevokeExcessiveAllowance && shouldRevoke) {
2858
+ log(` ⚠ Existing allowance (${existingAllowance}) for ${quoteName} is excessive (>${OVERSIZED_ALLOWANCE_MULTIPLIER}x this trade), but --no-revoke-excessive-allowance was set`);
2859
+ }
2438
2860
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
2439
2861
  } else {
2862
+ const approvalGasPrice = currentQuote.transaction?.gasPrice || currentQuote.transaction?.maxFeePerGas || '1000000';
2863
+
2864
+ if (shouldRevoke) {
2865
+ log(` ⚠ Existing allowance (${existingAllowance}) for ${quoteName} is excessive (>${OVERSIZED_ALLOWANCE_MULTIPLIER}x this trade) — revoking before re-approving`);
2866
+ log(` Sending allowance revocation tx...`);
2867
+ const revokeNonce = await getEvmNonce(chain, walletAddress);
2868
+ const revokeTxHex = buildApprovalTransaction(
2869
+ currentQuote.inputMint,
2870
+ currentQuote.approvalAddress,
2871
+ exported.evm.privateKey,
2872
+ chain,
2873
+ revokeNonce,
2874
+ approvalGasPrice,
2875
+ 0n,
2876
+ undefined,
2877
+ { allowZero: true },
2878
+ );
2879
+
2880
+ const revokeResult = await executeTransaction({
2881
+ signedTransaction: revokeTxHex,
2882
+ chain,
2883
+ simulate: !noSimulate,
2884
+ });
2885
+
2886
+ if (revokeResult.status !== 'Success') {
2887
+ log(` ❌ Allowance revoke failed for ${quoteName}: ${revokeResult.error || 'unknown error'}`);
2888
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2889
+ lastQuoteError = `${quoteName} allowance revoke failed`;
2890
+ continue;
2891
+ }
2892
+
2893
+ log(` Waiting for allowance revoke confirmation...`);
2894
+ try {
2895
+ const receipt = await waitForReceipt(chain, revokeResult.txHash);
2896
+ log(` ✓ Allowance revoked in block ${parseInt(receipt.blockNumber, 16)}: ${revokeResult.txHash}`);
2897
+ } catch (receiptErr) {
2898
+ log(` ❌ Allowance revoke may not have confirmed for ${quoteName}: ${receiptErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
2899
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2900
+ lastQuoteError = `${quoteName} allowance revoke unconfirmed`;
2901
+ continue;
2902
+ }
2903
+ try {
2904
+ await assertAllowanceRevoked(chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress);
2905
+ } catch (pollErr) {
2906
+ log(` ❌ Revoke tx confirmed but allowance was not cleared for ${quoteName}: ${pollErr.message}.${allowanceRevokeRecoveryHint(revokeResult.txHash)}`);
2907
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2908
+ lastQuoteError = `${quoteName} allowance revoke verification failed`;
2909
+ continue;
2910
+ }
2911
+ await waitForAllowanceTxPropagation();
2912
+ }
2440
2913
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
2441
2914
  log(` Sending approval tx...`);
2442
2915
  const approvalNonce = await getEvmNonce(chain, walletAddress);
2443
2916
 
2444
- const approvalGasPrice = currentQuote.transaction?.gasPrice || currentQuote.transaction?.maxFeePerGas || '1000000';
2445
2917
  const approvalTxHex = buildApprovalTransaction(
2446
2918
  currentQuote.inputMint,
2447
2919
  currentQuote.approvalAddress,
@@ -2460,7 +2932,10 @@ EXAMPLES:
2460
2932
  });
2461
2933
 
2462
2934
  if (approvalResult.status !== 'Success') {
2463
- log(` ❌ Approval failed for ${quoteName}: ${approvalResult.error || 'unknown error'}`);
2935
+ const revokedMsg = shouldRevoke
2936
+ ? ' after revoking the prior allowance (now 0)'
2937
+ : '';
2938
+ log(` ❌ Approval failed for ${quoteName}${revokedMsg}: ${approvalResult.error || 'unknown error'}`);
2464
2939
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2465
2940
  lastQuoteError = `${quoteName} approval failed`;
2466
2941
  continue;
@@ -2471,13 +2946,20 @@ EXAMPLES:
2471
2946
  const receipt = await waitForReceipt(chain, approvalResult.txHash);
2472
2947
  log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
2473
2948
  } catch (receiptErr) {
2474
- log(` ❌ Approval may not have confirmed: ${receiptErr.message}`);
2949
+ log(` ❌ Approval may not have confirmed${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${receiptErr.message}`);
2475
2950
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2476
2951
  lastQuoteError = `${quoteName} approval unconfirmed`;
2477
2952
  continue;
2478
2953
  }
2479
- // Wait for RPC state propagation after approval
2480
- await new Promise(r => setTimeout(r, 2000));
2954
+ try {
2955
+ await assertAllowanceAtLeast(chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress, approveAmt);
2956
+ } catch (pollErr) {
2957
+ log(` ❌ Approval tx confirmed but allowance did not reach the required amount for ${quoteName}${shouldRevoke ? ' after revoking the prior allowance (now 0)' : ''}: ${pollErr.message}`);
2958
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2959
+ lastQuoteError = `${quoteName} approval verification failed`;
2960
+ continue;
2961
+ }
2962
+ await waitForAllowanceTxPropagation();
2481
2963
  log('');
2482
2964
  }
2483
2965
  }
@@ -2501,6 +2983,20 @@ EXAMPLES:
2501
2983
  }
2502
2984
  }
2503
2985
 
2986
+ // Verify the swap's simulated on-chain outcome matches intent. Its
2987
+ // own gate: runs even when --no-simulate/gasless skip the cheap
2988
+ // eth_call revert check above; degrades with a warning when no
2989
+ // simulation endpoint is set.
2990
+ if (!noVerifyOutcome) {
2991
+ const outcome = await verifySwapOutcome({ chain, from: walletAddress, quote: currentQuote, quoteData, apiKey, log });
2992
+ if (!outcome.proceed) {
2993
+ log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
2994
+ if (qi + 1 < endIndex) log(` Trying next quote...`);
2995
+ lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
2996
+ continue;
2997
+ }
2998
+ }
2999
+
2504
3000
  // Use the Trading API's gas estimation (quote.gas) directly.
2505
3001
  // The API already applies a 1.5x buffer over eth_estimateGas.
2506
3002
  // Skip client-side re-estimation — it adds latency and the API value is reliable.