nansen-cli 1.39.0 → 1.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.40.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#516](https://github.com/nansen-ai/nansen-cli/pull/516) [`48722ef`](https://github.com/nansen-ai/nansen-cli/commit/48722ef0c7e6c0a7ce8c4c026245afb6e6f47e79) Thanks [@kome12](https://github.com/kome12)! - Fix cross-chain bridges into native SOL being refused at execute time. The quote/intent binding compared the wrapped-SOL mint (how `--to SOL` resolves) against the System Program address that aggregators use as the native-SOL sentinel and rejected them as different tokens. Both spellings are now treated as the same asset.
8
+
9
+ ## 1.40.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [#509](https://github.com/nansen-ai/nansen-cli/pull/509) [`430c300`](https://github.com/nansen-ai/nansen-cli/commit/430c3003d28a44bd1bfb123eef9290a7350a7e1e) Thanks [@kome12](https://github.com/kome12)! - `trade execute` now revokes an existing on-chain ERC-20 allowance before
14
+ re-approving when it is more than 10x the current trade's scoped amount, such
15
+ as a legacy unlimited approval or an allowance granted by another app. Most
16
+ trades are unaffected. Opt out with `--no-revoke-excessive-allowance`.
17
+
18
+ After each revoke or reapproval, the CLI reads the resulting allowance back
19
+ on-chain and fails closed (instead of proceeding to the swap) if it doesn't
20
+ match what was expected or can't be read.
21
+
22
+ ### Patch Changes
23
+
24
+ - [#498](https://github.com/nansen-ai/nansen-cli/pull/498) [`a964dd1`](https://github.com/nansen-ai/nansen-cli/commit/a964dd19c826f5231d2651547212d8169a74e7fe) Thanks [@crazywriter1](https://github.com/crazywriter1)! - Use `pending` nonce block tag for EVM sends: back-to-back transfers no longer risk reusing the same nonce when mempool transactions are queued.
25
+
26
+ - [#493](https://github.com/nansen-ai/nansen-cli/pull/493) [`bc89fef`](https://github.com/nansen-ai/nansen-cli/commit/bc89fef74489da3df32a12079effbdfa899373fd) Thanks [@crazywriter1](https://github.com/crazywriter1)! - Validate `--slippage-bps` on `limit-order create`: values outside 0-10000 now fail with a clear error before any auth/API call.
27
+
3
28
  ## 1.39.0
4
29
 
5
30
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.39.0",
3
+ "version": "1.40.1",
4
4
  "description": "AI-agent CLI for Nansen API analytics, DEX swaps, and cross-chain trading",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/cli.js CHANGED
@@ -190,7 +190,7 @@ export function parseArgs(args) {
190
190
  const key = arg.slice(2);
191
191
  const next = args[i + 1];
192
192
 
193
- if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream' || key === 'enrich' || key === 'full' || key === 'human' || key === 'enabled' || key === 'disabled' || key === 'expert' || key === 'json' || key === 'offline') {
193
+ if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream' || key === 'enrich' || key === 'full' || key === 'human' || key === 'enabled' || key === 'disabled' || key === 'expert' || key === 'json' || key === 'offline' || key === 'no-simulate' || key === 'no-verify-outcome' || key === 'no-revoke-excessive-allowance') {
194
194
  result.flags[key] = true;
195
195
  } else if (next && (!next.startsWith('-') || /^-\d/.test(next))) {
196
196
  // Try to parse as JSON first (for objects/arrays/booleans),
@@ -401,6 +401,17 @@ export function parseExpiry(expiryStr) {
401
401
  throw new Error(`Invalid expiry format: "${expiryStr}". Use "24h", "7d", "30d", or epoch ms.`);
402
402
  }
403
403
 
404
+ // Whole integer bps in [0, 10000], matching bridge parseSlippageBps.
405
+ // Number() would accept "1.5", "1e2", "0x10", and boolean true.
406
+ function parseSlippageBps(raw) {
407
+ const s = String(raw).trim();
408
+ const bad = 'Error: --slippage-bps must be a whole integer between 0 and 10000 basis points.';
409
+ if (!/^\d+$/.test(s)) throw new Error(bad);
410
+ const n = parseInt(s, 10);
411
+ if (!Number.isInteger(n) || n < 0 || n > 10000) throw new Error(bad);
412
+ return n;
413
+ }
414
+
404
415
  // ============= Order Formatting =============
405
416
 
406
417
  function formatOrderStatus(status) {
@@ -501,7 +512,7 @@ export function buildLimitOrderCommands(deps = {}) {
501
512
  const triggerPrice = options['trigger-price'];
502
513
  const triggerCondition = options['trigger-condition'];
503
514
  const triggerMintRaw = options['trigger-mint'];
504
- const slippageBps = options['slippage-bps'] != null ? Number(options['slippage-bps']) : undefined;
515
+ const slippageBpsRaw = options['slippage-bps'];
505
516
  const expiresStr = options.expires || '30d';
506
517
  const walletName = options.wallet;
507
518
 
@@ -516,7 +527,7 @@ OPTIONS:
516
527
  --trigger-mint <symbol|addr> Token whose price triggers the order (e.g. SOL)
517
528
  --trigger-condition <cond> "above" or "below"
518
529
  --trigger-price <usd> Trigger price in USD (must be a positive number)
519
- --slippage-bps <bps> Slippage in basis points (100 = 1%), omit for auto
530
+ --slippage-bps <bps> Whole integer bps, 0-10000 (100 = 1%), omit for auto
520
531
  --expires <duration> Expiry duration: "24h", "7d", "30d" (default: 30d)
521
532
  --wallet <name> Wallet name (or "walletconnect"/"wc")
522
533
 
@@ -581,6 +592,18 @@ EXAMPLES:
581
592
  return;
582
593
  }
583
594
 
595
+ // Same bounds as update / bridge: whole-integer bps in 0–10000.
596
+ let slippageBps;
597
+ if (slippageBpsRaw != null) {
598
+ try {
599
+ slippageBps = parseSlippageBps(slippageBpsRaw);
600
+ } catch (err) {
601
+ log(err.message);
602
+ exit(1);
603
+ return;
604
+ }
605
+ }
606
+
584
607
  let expiresAt;
585
608
  try {
586
609
  expiresAt = parseExpiry(expiresStr);
@@ -820,7 +843,7 @@ Usage: nansen trade limit-order update --order <orderId> [--trigger-price <usd>]
820
843
  OPTIONS:
821
844
  --order <id> Order ID to update
822
845
  --trigger-price <usd> New trigger price in USD
823
- --slippage-bps <bps> Slippage in basis points (100 = 1%)
846
+ --slippage-bps <bps> Whole integer bps, 0-10000 (100 = 1%)
824
847
  --wallet <name> Wallet name (or "walletconnect"/"wc")
825
848
 
826
849
  NOTE: Only provided fields are updated. Auto slippage can only be set at creation time
@@ -850,13 +873,13 @@ EXAMPLES:
850
873
  updateBody.triggerPriceUsd = price;
851
874
  }
852
875
  if (slippageBps != null) {
853
- const bps = Number(slippageBps);
854
- if (isNaN(bps) || bps < 0 || bps > 10000) {
855
- log('Error: --slippage-bps must be between 0 and 10000 basis points.');
876
+ try {
877
+ updateBody.slippageBps = parseSlippageBps(slippageBps);
878
+ } catch (err) {
879
+ log(err.message);
856
880
  exit(1);
857
881
  return;
858
882
  }
859
- updateBody.slippageBps = bps;
860
883
  }
861
884
 
862
885
  try {
package/src/schema.json CHANGED
@@ -1625,6 +1625,10 @@
1625
1625
  "no-verify-outcome": {
1626
1626
  "type": "boolean",
1627
1627
  "description": "Skip EVM swap-outcome verification. That check simulates the swap and confirms the wallet's balance changes match the quote (input spent within your max, expected output received, no other token moved) before broadcasting; it needs a simulation-capable endpoint (NANSEN_BASE_SIM_RPC) and degrades with a warning when none is available. No effect on Solana."
1628
+ },
1629
+ "no-revoke-excessive-allowance": {
1630
+ "type": "boolean",
1631
+ "description": "Skip revoking an existing on-chain ERC-20 allowance before re-approving when it exceeds 10x this trade's scoped amount. By default, an oversized or legacy allowance is revoked to zero and a fresh trade-scoped allowance is granted. WalletConnect users will see separate wallet prompts for the revoke and re-approval."
1628
1632
  }
1629
1633
  }
1630
1634
  },
@@ -1690,7 +1694,7 @@
1690
1694
  },
1691
1695
  "slippage-bps": {
1692
1696
  "type": "number",
1693
- "description": "Slippage tolerance in basis points (e.g. 50 = 0.5%)"
1697
+ "description": "Slippage as a whole integer in basis points, 0-10000 (50 = 0.5%), omit for auto"
1694
1698
  },
1695
1699
  "expires": {
1696
1700
  "type": "string",
@@ -1779,7 +1783,7 @@
1779
1783
  },
1780
1784
  "slippage-bps": {
1781
1785
  "type": "number",
1782
- "description": "New slippage in basis points (0-10000)"
1786
+ "description": "New slippage as a whole integer in basis points (0-10000)"
1783
1787
  },
1784
1788
  "wallet": {
1785
1789
  "type": "string",
@@ -122,6 +122,17 @@ const NATIVE_TOKEN_ADDRESSES = {
122
122
  base: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
123
123
  };
124
124
 
125
+ // Native SOL has two on-chain spellings that denote the same asset: the
126
+ // canonical wrapped-SOL mint (what the CLI resolves `SOL` to and persists as
127
+ // the request intent) and the System Program address that aggregators and
128
+ // bridges (e.g. Relay) use as the native-lamport sentinel in their quotes.
129
+ // tokensEqual treats them as equivalent so the intent-binding check doesn't
130
+ // false-reject a legitimate quote that names native SOL the other way.
131
+ const SOLANA_NATIVE_SOL_ALIASES = new Set([
132
+ 'So11111111111111111111111111111111111111112', // wrapped SOL mint
133
+ '11111111111111111111111111111111', // System Program — native SOL sentinel
134
+ ]);
135
+
125
136
  // USDC contract addresses per chain.
126
137
  const USDC_ADDRESSES = {
127
138
  solana: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v',
@@ -437,7 +448,8 @@ export function assertValidApprovalSpender(spender) {
437
448
  *
438
449
  * Guarantees on the returned string:
439
450
  * - spender is a valid 20-byte address (see assertValidApprovalSpender)
440
- * - amount is a positive integer strictly below MAX_UINT256 (never unlimited)
451
+ * - amount is a positive integer strictly below MAX_UINT256 (never unlimited),
452
+ * unless `allowZero` is explicitly set for a revoke-to-zero approval
441
453
  * - amount does not exceed `maxAllowance` when the caller supplies one
442
454
  * (the user's persisted request intent — see assertQuoteMatchesRequest)
443
455
  * - the encoded calldata is exactly 68 bytes (4-byte selector + two 32-byte
@@ -447,9 +459,10 @@ export function assertValidApprovalSpender(spender) {
447
459
  * @param {bigint|string|number} amount - Allowance in base units
448
460
  * @param {object} [opts]
449
461
  * @param {bigint|string|number} [opts.maxAllowance] - Hard cap from request intent
462
+ * @param {boolean} [opts.allowZero=false] - Allow encoding a zero-amount revoke approval
450
463
  * @returns {string} 0x-prefixed approve() calldata (exactly 68 bytes)
451
464
  */
452
- export function encodeApproveCalldata(spender, amount, { maxAllowance } = {}) {
465
+ export function encodeApproveCalldata(spender, amount, { maxAllowance, allowZero = false } = {}) {
453
466
  assertValidApprovalSpender(spender);
454
467
 
455
468
  let amt;
@@ -458,7 +471,7 @@ export function encodeApproveCalldata(spender, amount, { maxAllowance } = {}) {
458
471
  } catch {
459
472
  throw new Error(`Approval amount is not an integer (${amount}). Refusing to sign an approval.`);
460
473
  }
461
- if (amt <= 0n) {
474
+ if (amt < 0n || (amt === 0n && !allowZero)) {
462
475
  throw new Error(`Approval amount must be positive (got ${amt}). Refusing to sign an approval.`);
463
476
  }
464
477
  if (amt >= MAX_UINT256) {
@@ -544,6 +557,23 @@ export function approvalAmountForSwap({ inputAmount, swapMode, slippage }) {
544
557
  return amt;
545
558
  }
546
559
 
560
+ // Existing allowances above this multiple of the current trade's scoped amount
561
+ // are treated as stale/oversized rather than reusable dust from a prior swap.
562
+ export const OVERSIZED_ALLOWANCE_MULTIPLIER = 10n;
563
+
564
+ /**
565
+ * Decide whether an existing on-chain ERC-20 allowance should be revoked before
566
+ * granting the current trade's scoped approval.
567
+ *
568
+ * @param {bigint} existingAllowance - Current on-chain allowance
569
+ * @param {bigint} approveAmt - This trade's scoped approval amount
570
+ * @returns {boolean}
571
+ */
572
+ export function needsAllowanceRevoke(existingAllowance, approveAmt) {
573
+ if (approveAmt <= 0n) return false;
574
+ return existingAllowance > approveAmt * OVERSIZED_ALLOWANCE_MULTIPLIER;
575
+ }
576
+
547
577
  // ============= Quote vs. request-intent revalidation =============
548
578
 
549
579
  /**
@@ -552,7 +582,11 @@ export function approvalAmountForSwap({ inputAmount, swapMode, slippage }) {
552
582
  */
553
583
  function tokensEqual(a, b, chain) {
554
584
  if (!a || !b) return false;
555
- if (chain === 'solana') return a === b;
585
+ if (chain === 'solana') {
586
+ // Both sides naming native SOL (in either spelling) is a match.
587
+ if (SOLANA_NATIVE_SOL_ALIASES.has(a) && SOLANA_NATIVE_SOL_ALIASES.has(b)) return true;
588
+ return a === b;
589
+ }
556
590
  return a.toLowerCase() === b.toLowerCase();
557
591
  }
558
592
 
package/src/trading.js CHANGED
@@ -13,7 +13,7 @@ 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 } 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
18
  import { simulateAssetChanges, SwapSimulationError, hasSimulationRpc } from './swap-simulation.js';
19
19
  import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './api.js';
@@ -451,11 +451,10 @@ export function cleanupQuotes() {
451
451
 
452
452
  // ============= Transaction Signing =============
453
453
 
454
- // ----------------------------------------------------------------
455
- // TODO: SECURITY REVIEW REQUIRED
456
- // The signing functions below construct and sign raw transactions.
457
- // They MUST be audited before any production/mainnet use.
458
- // ----------------------------------------------------------------
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.
459
458
 
460
459
  /**
461
460
  * Sign a Solana transaction from quote data.
@@ -527,7 +526,8 @@ export function signSolanaTransaction(transactionBase64, privateKeyHex) {
527
526
  * @param {number} nonce - Account nonce
528
527
  * @returns {string} 0x-prefixed signed transaction hex
529
528
  */
530
- // ⚠️ 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.
531
531
  export function signEvmTransaction(txData, privateKeyHex, chain, nonce) {
532
532
  const chainConfig = CHAIN_MAP[chain];
533
533
  if (!chainConfig || chainConfig.type !== 'evm') {
@@ -802,26 +802,130 @@ export async function estimateEvmGas(chain, { from, to, data, value }) {
802
802
  }
803
803
  }
804
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
+
805
825
  /**
806
826
  * Check ERC-20 allowance for a given owner/spender pair.
807
827
  * Returns the allowance as a BigInt, or 0n on failure.
808
828
  */
809
829
  export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spenderAddress) {
810
- if (!CHAIN_RPCS[chain]) return 0n;
811
-
812
830
  try {
813
- // allowance(address,address) selector = 0xdd62ed3e
814
- const data = '0xdd62ed3e'
815
- + ownerAddress.slice(2).toLowerCase().padStart(64, '0')
816
- + spenderAddress.slice(2).toLowerCase().padStart(64, '0');
817
- const result = await evmRpcCall(chain, 'eth_call', [{ to: tokenAddress, data }, 'latest']);
818
- if (!result) return 0n;
819
- return BigInt(result);
820
- } 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`);
821
839
  return 0n;
822
840
  }
823
841
  }
824
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
+
825
929
  // approvalAmountForSwap now lives in trade-validation.js alongside the approval
826
930
  // encoder and the spend-ceiling check that both consume it, so the "how much can
827
931
  // leave the wallet" math has a single definition. Re-exported here because the
@@ -851,6 +955,21 @@ export function approvalCapForQuote(quoteData) {
851
955
  return quoteData?.swapMode === 'exactOut' ? undefined : quoteData?.request?.amount;
852
956
  }
853
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
+
854
973
  export function assertCompleteEvmRequestIntent(request) {
855
974
  if (!request) {
856
975
  throw new Error('Quote is missing request intent. Re-quote with this CLI version before executing an EVM swap. Refusing to sign.');
@@ -969,10 +1088,15 @@ export function assertUsableSpender(spenderAddress) {
969
1088
  * @param {string|number} gasPrice - Legacy gas price
970
1089
  * @param {bigint|string|number} amount - Allowance to grant, in base units (see approvalAmountForSwap)
971
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
972
1093
  * @returns {string} 0x-prefixed signed approval tx hex
973
1094
  */
974
- // ⚠️ SECURITY: ERC-20 approval signing - requires thorough review
975
- 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 } = {}) {
976
1100
  const chainConfig = CHAIN_MAP[chain];
977
1101
  if (!chainConfig) throw new Error(`Unsupported chain: ${chain}`);
978
1102
 
@@ -980,7 +1104,7 @@ export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKe
980
1104
  // can drain at most this one trade, never the wallet's full token balance.
981
1105
  // encodeApproveCalldata enforces a valid 20-byte spender, a bounded (< MAX)
982
1106
  // amount within the request cap, and exactly-68-byte calldata.
983
- const data = encodeApproveCalldata(spenderAddress, amount, { maxAllowance });
1107
+ const data = encodeApproveCalldata(spenderAddress, amount, { maxAllowance, allowZero });
984
1108
 
985
1109
  const tx = {
986
1110
  nonce,
@@ -996,7 +1120,8 @@ export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKe
996
1120
  }
997
1121
 
998
1122
  // ============= Legacy (Type 0) EVM Transaction Signing =============
999
- // ⚠️ 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.
1000
1125
 
1001
1126
  /**
1002
1127
  * Strip all leading zero bytes from a buffer.
@@ -1848,6 +1973,7 @@ CROSS-CHAIN NOTES (when using --to-chain):
1848
1973
  const quoteId = options.quote || options['quote-id'] || args[0];
1849
1974
  const walletName = options.wallet;
1850
1975
  const noSimulate = flags['no-simulate'];
1976
+ const noRevokeExcessiveAllowance = flags['no-revoke-excessive-allowance'];
1851
1977
  const noVerifyOutcome = flags['no-verify-outcome'];
1852
1978
  const gasless = Boolean(flags.gasless);
1853
1979
  // Read the API key for the swap-outcome sim endpoint. It's optional (the
@@ -1869,6 +1995,8 @@ OPTIONS:
1869
1995
  --wallet <name> Wallet name (default: default wallet)
1870
1996
  --no-simulate Skip pre-broadcast simulation (the eth_call revert check)
1871
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
1872
2000
  --gasless Relay-only: have Relay's solver pay gas (no WalletConnect)
1873
2001
 
1874
2002
  EXAMPLES:
@@ -2086,9 +2214,64 @@ EXAMPLES:
2086
2214
  chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
2087
2215
  );
2088
2216
 
2089
- 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
+ }
2090
2222
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
2091
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
+ }
2092
2275
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
2093
2276
  const approvalNonce = await getEvmNonce(chain, walletAddress);
2094
2277
  // Scope the approval to this trade's input (see approvalAmountForSwap).
@@ -2097,8 +2280,6 @@ EXAMPLES:
2097
2280
  const approvalData = encodeApproveCalldata(currentQuote.approvalAddress, approveAmt, {
2098
2281
  maxAllowance: approvalCapForQuote(quoteData),
2099
2282
  });
2100
- const approvalMaxFee = currentQuote.transaction?.maxFeePerGas || currentQuote.transaction?.gasPrice || '1000000';
2101
- const approvalPriorityFee = currentQuote.transaction?.maxPriorityFeePerGas || '1000000';
2102
2283
  const approvalSignResult = await privyClient.signEvmTransaction(evmWalletId, {
2103
2284
  to: currentQuote.inputMint,
2104
2285
  data: approvalData,
@@ -2110,9 +2291,21 @@ EXAMPLES:
2110
2291
  max_priority_fee_per_gas: toHex(approvalPriorityFee),
2111
2292
  });
2112
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
+ }
2113
2303
  const approvalResult = await executeTransaction({ signedTransaction: signedApproval, chain, simulate: !noSimulate });
2114
2304
  if (approvalResult.status !== 'Success') {
2115
- 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'}`);
2116
2309
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2117
2310
  lastQuoteError = `${quoteName} approval failed`;
2118
2311
  continue;
@@ -2122,12 +2315,20 @@ EXAMPLES:
2122
2315
  const receipt = await waitForReceipt(chain, approvalResult.txHash);
2123
2316
  log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
2124
2317
  } catch (receiptErr) {
2125
- 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}`);
2126
2319
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2127
2320
  lastQuoteError = `${quoteName} approval unconfirmed`;
2128
2321
  continue;
2129
2322
  }
2130
- 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();
2131
2332
  }
2132
2333
  }
2133
2334
 
@@ -2335,11 +2536,71 @@ EXAMPLES:
2335
2536
  chain, currentQuote.inputMint, wcAddress, currentQuote.approvalAddress
2336
2537
  );
2337
2538
 
2338
- 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
+ }
2339
2544
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
2340
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
+ }
2341
2601
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
2342
2602
  log(` Sending approval via WalletConnect...`);
2603
+ let approvalTxHash;
2343
2604
  try {
2344
2605
  const approvalResult = await sendApprovalViaWalletConnect(
2345
2606
  currentQuote.inputMint,
@@ -2348,7 +2609,7 @@ EXAMPLES:
2348
2609
  approveAmt,
2349
2610
  approvalCapForQuote(quoteData),
2350
2611
  );
2351
- let approvalTxHash = approvalResult.txHash;
2612
+ approvalTxHash = approvalResult.txHash;
2352
2613
  if (!approvalTxHash && approvalResult.signedTransaction) {
2353
2614
  // Wallet returned a signed tx instead of broadcasting — broadcast via Trading API
2354
2615
  log(` Broadcasting approval via Trading API...`);
@@ -2362,18 +2623,48 @@ EXAMPLES:
2362
2623
  }
2363
2624
  approvalTxHash = broadcastResult.txHash;
2364
2625
  }
2365
- if (approvalTxHash) {
2366
- log(` Waiting for approval confirmation...`);
2367
- const receipt = await waitForReceipt(chain, approvalTxHash);
2368
- 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');
2369
2633
  }
2370
2634
  } catch (approvalErr) {
2371
- 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}`);
2372
2639
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2373
2640
  lastQuoteError = `${quoteName} approval failed`;
2374
2641
  continue;
2375
2642
  }
2376
- 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();
2377
2668
  log('');
2378
2669
  }
2379
2670
  }
@@ -2561,14 +2852,68 @@ EXAMPLES:
2561
2852
  chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
2562
2853
  );
2563
2854
 
2564
- 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
+ }
2565
2860
  log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
2566
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
+ }
2567
2913
  log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
2568
2914
  log(` Sending approval tx...`);
2569
2915
  const approvalNonce = await getEvmNonce(chain, walletAddress);
2570
2916
 
2571
- const approvalGasPrice = currentQuote.transaction?.gasPrice || currentQuote.transaction?.maxFeePerGas || '1000000';
2572
2917
  const approvalTxHex = buildApprovalTransaction(
2573
2918
  currentQuote.inputMint,
2574
2919
  currentQuote.approvalAddress,
@@ -2587,7 +2932,10 @@ EXAMPLES:
2587
2932
  });
2588
2933
 
2589
2934
  if (approvalResult.status !== 'Success') {
2590
- 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'}`);
2591
2939
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2592
2940
  lastQuoteError = `${quoteName} approval failed`;
2593
2941
  continue;
@@ -2598,13 +2946,20 @@ EXAMPLES:
2598
2946
  const receipt = await waitForReceipt(chain, approvalResult.txHash);
2599
2947
  log(` ✓ Approval confirmed in block ${parseInt(receipt.blockNumber, 16)}: ${approvalResult.txHash}`);
2600
2948
  } catch (receiptErr) {
2601
- 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}`);
2602
2950
  if (qi + 1 < endIndex) log(` Trying next quote...`);
2603
2951
  lastQuoteError = `${quoteName} approval unconfirmed`;
2604
2952
  continue;
2605
2953
  }
2606
- // Wait for RPC state propagation after approval
2607
- 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();
2608
2963
  log('');
2609
2964
  }
2610
2965
  }
package/src/transfer.js CHANGED
@@ -120,9 +120,31 @@ async function buildEvmTransaction({ to, amount, token, privateKey, chain, max =
120
120
  const privBuf = Buffer.from(privateKey, 'hex');
121
121
  const from = deriveEvmAddress(privateKey);
122
122
 
123
- // Nonce
124
- const nonceHex = await rpcCall(rpcUrl, 'eth_getTransactionCount', [from, 'latest']);
125
- const nonce = BigInt(nonceHex);
123
+ // Fetch both pending and latest nonce counts. 'pending' is used so mempool-
124
+ // queued transactions are counted 'latest' alone would assign the same
125
+ // nonce to back-to-back sends, causing one to fail or silently replace the
126
+ // other. The gap check mirrors trading.js's getEvmNonce: if more than 2
127
+ // transactions are already queued, signing another would silently stack
128
+ // behind them and sit unexecutable until they clear or get replaced.
129
+ const MAX_PENDING_NONCE_GAP = 2;
130
+ const [pendingHex, latestHex] = await Promise.all([
131
+ rpcCall(rpcUrl, 'eth_getTransactionCount', [from, 'pending']),
132
+ rpcCall(rpcUrl, 'eth_getTransactionCount', [from, 'latest']),
133
+ ]);
134
+ const nonce = BigInt(pendingHex);
135
+ const latestNonce = BigInt(latestHex);
136
+ const gap = Number(nonce - latestNonce);
137
+ if (gap > MAX_PENDING_NONCE_GAP) {
138
+ // wallet send has no --nonce/--priority-fee, so don't tell the user to
139
+ // replace via this CLI. Also note the two-RPC race on load-balanced
140
+ // endpoints (same caveat as trading.js getEvmNonce).
141
+ throw new Error(
142
+ `${from} has ${gap} unmined transactions queued on ${chain} (next mined nonce ${latestNonce}, next pending ${nonce}). ` +
143
+ `Signing another would queue behind them and stay unexecutable until they clear. ` +
144
+ `Wait for them to clear (or replace them with a higher fee from where they were sent) before retrying. ` +
145
+ `Note that a load-balanced public RPC may report a transaction it isn't actually holding, so don't diagnose from a single endpoint.`,
146
+ );
147
+ }
126
148
 
127
149
  // Fees — dynamic priority fee
128
150
  const feeHistory = await rpcCall(rpcUrl, 'eth_feeHistory', [4, 'latest', [50]]);
@@ -114,13 +114,15 @@ export async function sendTransactionViaWalletConnect(txData, timeoutMs = 120000
114
114
  * @param {number} chainId - EIP-155 chain ID
115
115
  * @param {bigint|string|number} amount - Allowance to grant, in base units
116
116
  * @param {bigint|string|number} [maxAllowance] - Hard cap from persisted request intent
117
+ * @param {object} [opts]
118
+ * @param {boolean} [opts.allowZero=false] - Allow a zero-amount revoke approval
117
119
  * @returns {{ txHash?: string, signedTransaction?: string }}
118
120
  */
119
- export async function sendApprovalViaWalletConnect(tokenAddress, spenderAddress, chainId, amount, maxAllowance) {
121
+ export async function sendApprovalViaWalletConnect(tokenAddress, spenderAddress, chainId, amount, maxAllowance, { allowZero = false } = {}) {
120
122
  // encodeApproveCalldata enforces a valid 20-byte spender, a bounded (< MAX)
121
123
  // amount within the request cap, and exactly-68-byte calldata — so a
122
124
  // malformed or tampered spender/amount can't reshape the ABI word layout.
123
- const data = encodeApproveCalldata(spenderAddress, amount, { maxAllowance });
125
+ const data = encodeApproveCalldata(spenderAddress, amount, { maxAllowance, allowZero });
124
126
 
125
127
  return sendTransactionViaWalletConnect({
126
128
  to: tokenAddress,