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