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 +25 -0
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/limit-order.js +30 -7
- package/src/schema.json +6 -2
- package/src/trade-validation.js +38 -4
- package/src/trading.js +396 -41
- package/src/transfer.js +25 -3
- package/src/walletconnect-trading.js +4 -2
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
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),
|
package/src/limit-order.js
CHANGED
|
@@ -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
|
|
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>
|
|
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>
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
|
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",
|
package/src/trade-validation.js
CHANGED
|
@@ -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
|
|
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')
|
|
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
|
-
//
|
|
456
|
-
//
|
|
457
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
}
|
|
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
|
-
//
|
|
975
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2607
|
-
|
|
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
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
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,
|