nansen-cli 1.37.0 → 1.39.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 +36 -0
- package/README.md +58 -2
- package/package.json +1 -1
- package/skills/nansen-wallet-batch/SKILL.md +1 -1
- package/skills/nansen-wallet-keychain-migration/SKILL.md +14 -12
- package/skills/nansen-wallet-profiler/SKILL.md +1 -1
- package/src/api.js +4 -3
- package/src/cli.js +55 -13
- package/src/doctor.js +480 -0
- package/src/keychain.js +46 -0
- package/src/response-meta.js +2 -2
- package/src/rpc-urls.js +67 -0
- package/src/schema.json +68 -1
- package/src/swap-simulation.js +477 -0
- package/src/telemetry.js +9 -2
- package/src/trade-validation.js +653 -0
- package/src/trading.js +530 -20
- package/src/update-check.js +2 -1
- package/src/walletconnect-trading.js +11 -7
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 } from './trade-validation.js';
|
|
16
|
+
import { validateQuoteInput, validateBalance, resolvePercentAmount, validateGasBalance, encodeApproveCalldata, assertValidApprovalSpender, assertQuoteMatchesRequest, assertSwapCalldataNotBareTransfer, assertSwapOutcome, approvalAmountForSwap } 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
|
|
|
@@ -380,7 +381,7 @@ export function loadTxRecord(txHash) {
|
|
|
380
381
|
* Save a quote response to disk for later execution.
|
|
381
382
|
* @returns {string} Quote ID
|
|
382
383
|
*/
|
|
383
|
-
export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null, toChain = null) {
|
|
384
|
+
export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalletIds = null, toChain = null, meta = {}) {
|
|
384
385
|
const dir = getQuotesDir();
|
|
385
386
|
if (!fs.existsSync(dir)) {
|
|
386
387
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -393,6 +394,15 @@ export function saveQuote(quoteResponse, chain, signerType = 'local', privyWalle
|
|
|
393
394
|
const data = { quoteId, type: 'swap', chain, timestamp, signerType, response: quoteResponse };
|
|
394
395
|
if (toChain) data.toChain = toChain;
|
|
395
396
|
if (privyWalletIds) data.privyWalletIds = privyWalletIds;
|
|
397
|
+
// Persisted so the execute path can scope ERC-20 approvals to the trade
|
|
398
|
+
// (exactOut is buffered by the slippage that was actually used).
|
|
399
|
+
if (meta.swapMode) data.swapMode = meta.swapMode;
|
|
400
|
+
if (meta.slippage != null) data.slippage = meta.slippage;
|
|
401
|
+
// Immutable request intent — the chain, wallet, token pair, mode, and amount
|
|
402
|
+
// the user actually asked for. The execute path revalidates the API's quote
|
|
403
|
+
// against this (see assertQuoteMatchesRequest) so a compromised or buggy quote
|
|
404
|
+
// can't inflate the input, approval, or native value past the user's intent.
|
|
405
|
+
if (meta.request) data.request = meta.request;
|
|
396
406
|
|
|
397
407
|
fs.writeFileSync(path.join(dir, `${quoteId}.json`), JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
398
408
|
cleanupQuotes();
|
|
@@ -691,6 +701,92 @@ export async function simulateEvmCall(chain, { from, to, data, value, gas }) {
|
|
|
691
701
|
}
|
|
692
702
|
}
|
|
693
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
|
+
|
|
694
790
|
/**
|
|
695
791
|
* Estimate gas for an EVM transaction. Returns the gas estimate or null on failure.
|
|
696
792
|
* Used to fix under-gassed quotes from aggregators.
|
|
@@ -726,6 +822,141 @@ export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spe
|
|
|
726
822
|
}
|
|
727
823
|
}
|
|
728
824
|
|
|
825
|
+
// approvalAmountForSwap now lives in trade-validation.js alongside the approval
|
|
826
|
+
// encoder and the spend-ceiling check that both consume it, so the "how much can
|
|
827
|
+
// leave the wallet" math has a single definition. Re-exported here because the
|
|
828
|
+
// execute paths below (and tests) import it from this module.
|
|
829
|
+
export { approvalAmountForSwap };
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* The maximum allowance (spend ceiling, in the SELL token's base units) to hand
|
|
833
|
+
* the approval encoder for a saved quote. Centralised so every signing path
|
|
834
|
+
* shares one definition and a refactor can't reintroduce a wrong-unit cap.
|
|
835
|
+
*
|
|
836
|
+
* Returns the persisted `maxInputAmount` when present. Otherwise:
|
|
837
|
+
* - exactIn: falls back to `request.amount`, which for exactIn IS the input
|
|
838
|
+
* bound (covers quotes saved before maxInputAmount existed).
|
|
839
|
+
* - exactOut: returns undefined — there is NO safe fallback, because
|
|
840
|
+
* `request.amount` is the OUTPUT amount (a different token). The encoder
|
|
841
|
+
* still bounds the amount below MAX_UINT256, and assertInputWithinMax fails
|
|
842
|
+
* closed on a missing exactOut cap before any approval is built, so exactOut
|
|
843
|
+
* never legitimately reaches here without a cap.
|
|
844
|
+
*
|
|
845
|
+
* @param {object} quoteData - The loaded quote record (with .swapMode, .request)
|
|
846
|
+
* @returns {string|number|undefined} allowance cap, or undefined for no cap
|
|
847
|
+
*/
|
|
848
|
+
export function approvalCapForQuote(quoteData) {
|
|
849
|
+
const cap = quoteData?.request?.maxInputAmount;
|
|
850
|
+
if (cap != null) return cap;
|
|
851
|
+
return quoteData?.swapMode === 'exactOut' ? undefined : quoteData?.request?.amount;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export function assertCompleteEvmRequestIntent(request) {
|
|
855
|
+
if (!request) {
|
|
856
|
+
throw new Error('Quote is missing request intent. Re-quote with this CLI version before executing an EVM swap. Refusing to sign.');
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const missing = [];
|
|
860
|
+
for (const field of ['chain', 'walletAddress', 'fromToken', 'toToken', 'swapMode', 'amount', 'maxInputAmount']) {
|
|
861
|
+
if (request[field] == null || request[field] === '') missing.push(field);
|
|
862
|
+
}
|
|
863
|
+
if (missing.length) {
|
|
864
|
+
throw new Error(`Quote request intent is incomplete (${missing.join(', ')} missing). Re-quote before executing an EVM swap. Refusing to sign.`);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Sanity-check the target of a swap transaction before signing it.
|
|
870
|
+
*
|
|
871
|
+
* This is a defensive gate, not a router allowlist. It rejects the crude cases
|
|
872
|
+
* where the transaction clearly isn't a swap routed through an aggregator: a
|
|
873
|
+
* null/zero target, or a call straight at the token being sold (which would
|
|
874
|
+
* encode a transfer/approve of that token rather than a swap — the one
|
|
875
|
+
* full-balance drain that needs no prior approval). It also confirms the target
|
|
876
|
+
* carries contract code. The code check fails closed: it retries a few times
|
|
877
|
+
* and, if it still can't confirm the target is a contract, throws rather than
|
|
878
|
+
* signing against an unverified target — a flaky or hostile RPC must not be
|
|
879
|
+
* able to silently disable the guard. A missing RPC config throws immediately.
|
|
880
|
+
*
|
|
881
|
+
* Throws on a definitive rejection; returns nothing on pass. Callers run this
|
|
882
|
+
* inside the per-quote try so a rejected quote falls through to the next one.
|
|
883
|
+
*
|
|
884
|
+
* @param {string} chain - Chain name
|
|
885
|
+
* @param {string} to - Transaction target (quote.transaction.to)
|
|
886
|
+
* @param {string} inputMint - The token being sold (quote.inputMint)
|
|
887
|
+
*/
|
|
888
|
+
export async function validateSwapTarget(chain, to, inputMint, { verifiedTargets } = {}) {
|
|
889
|
+
if (!to || /^0x0+$/i.test(to)) {
|
|
890
|
+
throw new Error(`Swap target address is empty or zero (${to ?? 'undefined'}). Refusing to sign.`);
|
|
891
|
+
}
|
|
892
|
+
// A legit swap — same-chain OR cross-chain bridge — routes through an
|
|
893
|
+
// aggregator/router, never the sold token itself. This gate is intentionally
|
|
894
|
+
// NOT same-chain-scoped: a bare ERC-20 `transfer`/`approve` necessarily
|
|
895
|
+
// targets the token contract, so `to === inputMint` is the drain shape in both
|
|
896
|
+
// cases, and the bridge routes this CLI uses (Relay/Li.Fi) route deposits
|
|
897
|
+
// through a router (to != token), so this never fires on a legitimate bridge.
|
|
898
|
+
// Loosening it for cross-chain would let a compromised bridge quote encode a
|
|
899
|
+
// bare transfer to an attacker (the cross-chain path does not parse the
|
|
900
|
+
// calldata recipient/amount), so it fails closed here. (A WETH-style direct
|
|
901
|
+
// unwrap can trip this; re-quote or use the native sentinel 0xeee…eee if so.)
|
|
902
|
+
if (inputMint && to.toLowerCase() === inputMint.toLowerCase()) {
|
|
903
|
+
throw new Error(
|
|
904
|
+
`Swap target equals the token being sold (${to}). A swap routes through an aggregator, not the token itself. Refusing to sign.`,
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
// Skip the RPC round-trip (and its retries) for a target already confirmed to
|
|
908
|
+
// carry contract code earlier in this same execute run. Quote lists commonly
|
|
909
|
+
// share one router across all quotes, so this avoids re-verifying — and, on a
|
|
910
|
+
// flaky RPC, re-retrying — the same target N times. Only SUCCESSFUL checks are
|
|
911
|
+
// cached, so a transient failure still gets a fresh attempt on the next quote.
|
|
912
|
+
const targetKey = `${chain}:${to.toLowerCase()}`;
|
|
913
|
+
if (verifiedTargets?.has(targetKey)) return;
|
|
914
|
+
|
|
915
|
+
// Fail CLOSED on an unverifiable target: retry a few times, then refuse rather
|
|
916
|
+
// than sign against a target we couldn't confirm carries contract code. A
|
|
917
|
+
// flaky — or hostile — RPC must not be able to silently disable this guard.
|
|
918
|
+
let code;
|
|
919
|
+
let lastErr = null;
|
|
920
|
+
const MAX_ATTEMPTS = 3;
|
|
921
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
922
|
+
try {
|
|
923
|
+
code = await evmRpcCall(chain, 'eth_getCode', [to, 'latest']);
|
|
924
|
+
lastErr = null;
|
|
925
|
+
break;
|
|
926
|
+
} catch (err) {
|
|
927
|
+
// A missing RPC config is a deterministic setup error, not a flaky
|
|
928
|
+
// network — surface it immediately rather than burn retries on it.
|
|
929
|
+
if (err?.message?.startsWith('No RPC URL')) throw err;
|
|
930
|
+
lastErr = err;
|
|
931
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
932
|
+
process.stderr.write(` ⚠ Swap target check attempt ${attempt}/${MAX_ATTEMPTS} failed (${err.message}); retrying...\n`);
|
|
933
|
+
await new Promise(r => setTimeout(r, 300));
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (lastErr) {
|
|
938
|
+
throw new Error(
|
|
939
|
+
`Could not verify swap target ${to} is a contract after ${MAX_ATTEMPTS} attempts (${lastErr.message}). Refusing to sign — check RPC connectivity or configure a reliable RPC URL.`,
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
if (!code || code === '0x' || code === '0x0') {
|
|
943
|
+
throw new Error(`Swap target ${to} is not a contract (no code). Refusing to sign.`);
|
|
944
|
+
}
|
|
945
|
+
verifiedTargets?.add(targetKey);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Reject an approval whose spender is not a well-formed, non-zero 20-byte EVM
|
|
950
|
+
* address. A real aggregator spender is always a 20-byte contract address; an
|
|
951
|
+
* empty, zero, or over-length value means the quote is malformed or tampered.
|
|
952
|
+
* An over-length spender is especially dangerous — concatenated into approval
|
|
953
|
+
* calldata it shifts the ABI word layout — so we refuse before signing.
|
|
954
|
+
* Delegates to the shared strict validator used by the calldata encoder.
|
|
955
|
+
*/
|
|
956
|
+
export function assertUsableSpender(spenderAddress) {
|
|
957
|
+
assertValidApprovalSpender(spenderAddress);
|
|
958
|
+
}
|
|
959
|
+
|
|
729
960
|
/**
|
|
730
961
|
* Send an ERC-20 approval transaction.
|
|
731
962
|
* Required before swapping non-native EVM tokens.
|
|
@@ -735,19 +966,21 @@ export async function checkErc20Allowance(chain, tokenAddress, ownerAddress, spe
|
|
|
735
966
|
* @param {string} privateKeyHex - Wallet private key
|
|
736
967
|
* @param {string} chain - Chain name
|
|
737
968
|
* @param {number} nonce - Account nonce
|
|
969
|
+
* @param {string|number} gasPrice - Legacy gas price
|
|
970
|
+
* @param {bigint|string|number} amount - Allowance to grant, in base units (see approvalAmountForSwap)
|
|
971
|
+
* @param {bigint|string|number} [maxAllowance] - Hard cap from persisted request intent
|
|
738
972
|
* @returns {string} 0x-prefixed signed approval tx hex
|
|
739
973
|
*/
|
|
740
974
|
// ⚠️ SECURITY: ERC-20 approval signing - requires thorough review
|
|
741
|
-
export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKeyHex, chain, nonce, gasPrice) {
|
|
975
|
+
export function buildApprovalTransaction(tokenAddress, spenderAddress, privateKeyHex, chain, nonce, gasPrice, amount, maxAllowance) {
|
|
742
976
|
const chainConfig = CHAIN_MAP[chain];
|
|
743
977
|
if (!chainConfig) throw new Error(`Unsupported chain: ${chain}`);
|
|
744
978
|
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
+ MAX_UINT256_HEX;
|
|
979
|
+
// Scope the approval to the swap's input amount so a malicious or buggy quote
|
|
980
|
+
// can drain at most this one trade, never the wallet's full token balance.
|
|
981
|
+
// encodeApproveCalldata enforces a valid 20-byte spender, a bounded (< MAX)
|
|
982
|
+
// amount within the request cap, and exactly-68-byte calldata.
|
|
983
|
+
const data = encodeApproveCalldata(spenderAddress, amount, { maxAllowance });
|
|
751
984
|
|
|
752
985
|
const tx = {
|
|
753
986
|
nonce,
|
|
@@ -1233,6 +1466,11 @@ OPTIONS:
|
|
|
1233
1466
|
--auto-slippage Enable auto slippage calculation
|
|
1234
1467
|
--max-auto-slippage <pct> Max auto slippage when auto-slippage enabled
|
|
1235
1468
|
--swap-mode <mode> exactIn (default) or exactOut
|
|
1469
|
+
--max-input <baseUnits> exactOut only: hard ceiling on the sell-token spend
|
|
1470
|
+
(base units), measured against the slippage-buffered
|
|
1471
|
+
approval (input + slippage), not the bare quote input.
|
|
1472
|
+
Required for EVM (Base) exactOut and enforced before
|
|
1473
|
+
signing; optional on Solana (no ERC-20 approval to scope).
|
|
1236
1474
|
--aggregator <name> Force a specific aggregator (lifi, relay, jupiter, okx).
|
|
1237
1475
|
Filters the quote list client-side; errors if none match.
|
|
1238
1476
|
|
|
@@ -1267,6 +1505,49 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1267
1505
|
throw new CommandError('Error: --amount-unit percent is not supported with --swap-mode exactOut. Percentage is relative to your sell-token balance.', 'INVALID_INPUT');
|
|
1268
1506
|
}
|
|
1269
1507
|
|
|
1508
|
+
// The exactOut spend-ceiling requirements below only guard the EVM signing
|
|
1509
|
+
// path: the ERC-20 approval scoping, request-intent binding, and
|
|
1510
|
+
// assertInputWithinMax checks are wired into the EVM execute paths only.
|
|
1511
|
+
// Solana signs the API transaction verbatim (no approval to scope), so
|
|
1512
|
+
// requiring --max-input there would break existing Solana exactOut users
|
|
1513
|
+
// without buying any of that path a security guarantee. Gate on EVM source.
|
|
1514
|
+
const isEvmSource = CHAIN_MAP[chain?.toLowerCase()]?.type === 'evm';
|
|
1515
|
+
|
|
1516
|
+
// exactOut scopes the ERC-20 approval to a slippage-buffered max input. With
|
|
1517
|
+
// uncapped auto-slippage the actual bound is server-side and unknown, so the
|
|
1518
|
+
// buffer could be under-sized and the swap would revert on allowance. Require
|
|
1519
|
+
// an explicit cap so the approval is always bounded by a value we know.
|
|
1520
|
+
if (isEvmSource && swapMode === 'exactOut' && autoSlippage && maxAutoSlippage == null) {
|
|
1521
|
+
throw new CommandError('Error: --swap-mode exactOut with --auto-slippage requires --max-auto-slippage so the approval can be scoped to a bounded input (e.g. --max-auto-slippage 0.05).', 'INVALID_INPUT');
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// --max-input: an explicit ceiling (base units of the sell token) on how
|
|
1525
|
+
// much may leave the wallet for an exactOut swap, persisted as intent and
|
|
1526
|
+
// enforced before signing. exactIn is already capped at --amount (the
|
|
1527
|
+
// input the user names), so the flag is exactOut-only.
|
|
1528
|
+
const maxInputRaw = options['max-input'];
|
|
1529
|
+
let maxInputOverride = null;
|
|
1530
|
+
if (maxInputRaw != null) {
|
|
1531
|
+
if (swapMode !== 'exactOut') {
|
|
1532
|
+
throw new CommandError('Error: --max-input only applies to --swap-mode exactOut (exactIn already caps spend at --amount).', 'INVALID_INPUT');
|
|
1533
|
+
}
|
|
1534
|
+
const maxInputError = validateBaseUnitAmount(maxInputRaw);
|
|
1535
|
+
if (maxInputError) {
|
|
1536
|
+
throw new CommandError(`Error: invalid --max-input: ${maxInputError} (--max-input is in base units of the sell token).`, 'INVALID_INPUT');
|
|
1537
|
+
}
|
|
1538
|
+
// validateBaseUnitAmount catches negatives/decimals but not non-numeric
|
|
1539
|
+
// input (e.g. "abc"); guard the BigInt so it surfaces cleanly, not as a
|
|
1540
|
+
// raw "Cannot convert … to a BigInt".
|
|
1541
|
+
try {
|
|
1542
|
+
maxInputOverride = BigInt(maxInputRaw).toString();
|
|
1543
|
+
} catch {
|
|
1544
|
+
throw new CommandError(`Error: invalid --max-input "${maxInputRaw}": must be an integer in base units of the sell token.`, 'INVALID_INPUT');
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
if (isEvmSource && swapMode === 'exactOut' && maxInputOverride == null) {
|
|
1548
|
+
throw new CommandError('Error: --swap-mode exactOut requires --max-input (base units of the sell token) so the input is independently capped before signing.', 'INVALID_INPUT');
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1270
1551
|
// Static input validation — catches common agent errors (wrong addresses,
|
|
1271
1552
|
// same-token swaps, bad amounts) before any network or wallet call.
|
|
1272
1553
|
try {
|
|
@@ -1463,6 +1744,46 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1463
1744
|
response.quotes = matching;
|
|
1464
1745
|
}
|
|
1465
1746
|
|
|
1747
|
+
// Slippage actually in effect. Computed here (not just at save time) so
|
|
1748
|
+
// the --max-input filter below measures the same buffered approval the
|
|
1749
|
+
// execute path will build, keeping quote-time and execute-time in lockstep.
|
|
1750
|
+
const effectiveSlippage = slippage != null ? Number(slippage)
|
|
1751
|
+
: autoSlippage ? (maxAutoSlippage != null ? Number(maxAutoSlippage) : 0.05)
|
|
1752
|
+
: 0.03;
|
|
1753
|
+
|
|
1754
|
+
// Explicit --max-input: drop quotes whose *buffered* input exceeds the cap
|
|
1755
|
+
// so we never print a Quote ID the execute path would refuse. For exactOut
|
|
1756
|
+
// the approval is slippage-buffered (approvalAmountForSwap), so a raw input
|
|
1757
|
+
// at the cap still overflows it once buffered (1,000,000 @ 3% → 1,030,000);
|
|
1758
|
+
// filtering on the raw input would save a quote the approval encoder later
|
|
1759
|
+
// rejects for exceeding the cap. (max-input is exactOut-only. The derived
|
|
1760
|
+
// default is computed from the max quote input below, so it can never
|
|
1761
|
+
// exclude a quote — only an explicit cap can.)
|
|
1762
|
+
if (maxInputOverride != null) {
|
|
1763
|
+
const cap = BigInt(maxInputOverride);
|
|
1764
|
+
// Max sell-token base units that can leave the wallet for this quote.
|
|
1765
|
+
const spendFor = (q) => approvalAmountForSwap({
|
|
1766
|
+
inputAmount: q.inputAmount ?? q.inAmount ?? '0',
|
|
1767
|
+
swapMode,
|
|
1768
|
+
slippage: effectiveSlippage,
|
|
1769
|
+
});
|
|
1770
|
+
const withinCap = response.quotes.filter((q) => {
|
|
1771
|
+
const spend = spendFor(q);
|
|
1772
|
+
return spend > 0n && spend <= cap;
|
|
1773
|
+
});
|
|
1774
|
+
if (!withinCap.length) {
|
|
1775
|
+
const cheapest = response.quotes.reduce((min, q) => {
|
|
1776
|
+
const spend = spendFor(q);
|
|
1777
|
+
return spend > 0n && (min == null || spend < min) ? spend : min;
|
|
1778
|
+
}, null);
|
|
1779
|
+
throw new CommandError(
|
|
1780
|
+
`No quote fits --max-input ${cap}. The cheapest fits within ${cheapest ?? 'unknown'} base units (input + ${effectiveSlippage} slippage buffer). Raise --max-input or lower the requested output.`,
|
|
1781
|
+
'MAX_INPUT_EXCEEDED'
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1784
|
+
response.quotes = withinCap;
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1466
1787
|
log('');
|
|
1467
1788
|
response.quotes.forEach((q, i) => log(formatQuote(q, i)));
|
|
1468
1789
|
|
|
@@ -1476,7 +1797,26 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1476
1797
|
}
|
|
1477
1798
|
|
|
1478
1799
|
const signerType = isWalletConnect ? 'walletconnect' : walletProvider;
|
|
1479
|
-
const
|
|
1800
|
+
const maxInputAmount = swapMode === 'exactOut' ? maxInputOverride : String(resolvedAmount);
|
|
1801
|
+
const quoteId = saveQuote(response, chain, signerType, privyWalletIds, isCrossChain ? toChainRaw : null, {
|
|
1802
|
+
swapMode,
|
|
1803
|
+
slippage: effectiveSlippage,
|
|
1804
|
+
// Immutable record of what the user asked for; revalidated at execute
|
|
1805
|
+
// time so the API's quote can't drift beyond it. For exactIn `amount`
|
|
1806
|
+
// is the input; for exactOut it is the requested output. `maxInputAmount`
|
|
1807
|
+
// is the spend ceiling enforced in both modes before signing.
|
|
1808
|
+
request: {
|
|
1809
|
+
chain,
|
|
1810
|
+
toChain: isCrossChain ? toChainRaw : null,
|
|
1811
|
+
walletAddress,
|
|
1812
|
+
recipient: params.toWalletAddress ?? null,
|
|
1813
|
+
fromToken: from,
|
|
1814
|
+
toToken: to,
|
|
1815
|
+
swapMode,
|
|
1816
|
+
amount: resolvedAmount,
|
|
1817
|
+
maxInputAmount,
|
|
1818
|
+
},
|
|
1819
|
+
});
|
|
1480
1820
|
log(`\n Quote ID: ${quoteId}`);
|
|
1481
1821
|
log(` Execute: nansen trade execute --quote ${quoteId}`);
|
|
1482
1822
|
if (response.quotes.length > 1) {
|
|
@@ -1508,7 +1848,18 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1508
1848
|
const quoteId = options.quote || options['quote-id'] || args[0];
|
|
1509
1849
|
const walletName = options.wallet;
|
|
1510
1850
|
const noSimulate = flags['no-simulate'];
|
|
1851
|
+
const noVerifyOutcome = flags['no-verify-outcome'];
|
|
1511
1852
|
const gasless = Boolean(flags.gasless);
|
|
1853
|
+
// Read the API key for the swap-outcome sim endpoint. It's optional (the
|
|
1854
|
+
// check degrades to a warning if the endpoint can't authenticate), so a
|
|
1855
|
+
// malformed config must not crash an in-progress trade — fall back to null.
|
|
1856
|
+
const apiKey = (() => {
|
|
1857
|
+
try {
|
|
1858
|
+
return loadConfig().apiKey;
|
|
1859
|
+
} catch {
|
|
1860
|
+
return null;
|
|
1861
|
+
}
|
|
1862
|
+
})();
|
|
1512
1863
|
|
|
1513
1864
|
if (!quoteId) {
|
|
1514
1865
|
throw new CommandError(`Usage: nansen trade execute --quote <quoteId> [options]
|
|
@@ -1516,7 +1867,8 @@ CROSS-CHAIN NOTES (when using --to-chain):
|
|
|
1516
1867
|
OPTIONS:
|
|
1517
1868
|
--quote <id> Quote ID from 'nansen quote'
|
|
1518
1869
|
--wallet <name> Wallet name (default: default wallet)
|
|
1519
|
-
--no-simulate Skip pre-broadcast simulation
|
|
1870
|
+
--no-simulate Skip pre-broadcast simulation (the eth_call revert check)
|
|
1871
|
+
--no-verify-outcome Skip EVM swap-outcome verification (balance-delta check)
|
|
1520
1872
|
--gasless Relay-only: have Relay's solver pay gas (no WalletConnect)
|
|
1521
1873
|
|
|
1522
1874
|
EXAMPLES:
|
|
@@ -1610,6 +1962,10 @@ EXAMPLES:
|
|
|
1610
1962
|
}
|
|
1611
1963
|
|
|
1612
1964
|
let lastQuoteError = null;
|
|
1965
|
+
// Swap targets confirmed to carry contract code in this execute run, so a
|
|
1966
|
+
// router shared across quotes is verified once, not per quote (see
|
|
1967
|
+
// validateSwapTarget). Scoped to this run — never cached across processes.
|
|
1968
|
+
const verifiedTargets = new Set();
|
|
1613
1969
|
|
|
1614
1970
|
for (let qi = startIndex; qi < endIndex; qi++) {
|
|
1615
1971
|
const currentQuote = allQuotes[qi];
|
|
@@ -1672,6 +2028,26 @@ EXAMPLES:
|
|
|
1672
2028
|
const walletResult = await privyClient.getWallet(evmWalletId);
|
|
1673
2029
|
const walletAddress = walletResult.address;
|
|
1674
2030
|
|
|
2031
|
+
// Guard the swap target before any RPC call, approval, or signing —
|
|
2032
|
+
// whatever `to`/`data` the quote supplied gets signed verbatim.
|
|
2033
|
+
await validateSwapTarget(chain, currentQuote.transaction.to, currentQuote.inputMint, { verifiedTargets });
|
|
2034
|
+
|
|
2035
|
+
// Bind the quote to the immutable request intent persisted at quote
|
|
2036
|
+
// time, so a compromised API can't inflate the input (and therefore
|
|
2037
|
+
// the scoped approval and native value) past what the user asked to spend.
|
|
2038
|
+
assertCompleteEvmRequestIntent(quoteData.request);
|
|
2039
|
+
assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
|
|
2040
|
+
|
|
2041
|
+
// Reject a bare ERC-20 transfer/approve/transferFrom as the outer
|
|
2042
|
+
// call: a real swap or bridge routes through an aggregator/router,
|
|
2043
|
+
// never a direct token method. Runs on cross-chain too — the
|
|
2044
|
+
// validateSwapTarget gate above only refuses `to === inputMint`, so
|
|
2045
|
+
// a bare transfer to a SIBLING token the wallet holds would
|
|
2046
|
+
// otherwise slip through the bridge path (which doesn't parse the
|
|
2047
|
+
// calldata recipient) and drain it. Legitimate bridges route through
|
|
2048
|
+
// a router selector, so this never fires on a real cross-chain quote.
|
|
2049
|
+
assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
|
|
2050
|
+
|
|
1675
2051
|
// Validate transaction.value (same checks as local wallet)
|
|
1676
2052
|
const isNative = isNativeToken(currentQuote.inputMint);
|
|
1677
2053
|
const txValue = BigInt(currentQuote.transaction.value || '0');
|
|
@@ -1695,20 +2071,32 @@ EXAMPLES:
|
|
|
1695
2071
|
// Handle approval if needed
|
|
1696
2072
|
// Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
|
|
1697
2073
|
if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
|
|
2074
|
+
assertUsableSpender(currentQuote.approvalAddress);
|
|
1698
2075
|
const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
|
|
2076
|
+
const approveAmt = approvalAmountForSwap({ inputAmount, swapMode: quoteData.swapMode, slippage: quoteData.slippage });
|
|
2077
|
+
if (approveAmt <= 0n) {
|
|
2078
|
+
// Malformed quote (no/invalid input amount): a zero-scoped approval
|
|
2079
|
+
// would waste gas and the swap would revert on insufficient allowance.
|
|
2080
|
+
log(` ❌ ${quoteName} has a zero input amount — cannot scope approval, skipping.`);
|
|
2081
|
+
lastQuoteError = `${quoteName} has a zero input amount`;
|
|
2082
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
1699
2085
|
const existingAllowance = await checkErc20Allowance(
|
|
1700
2086
|
chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
|
|
1701
2087
|
);
|
|
1702
2088
|
|
|
1703
|
-
if (existingAllowance >=
|
|
2089
|
+
if (existingAllowance >= approveAmt && existingAllowance > 0n) {
|
|
1704
2090
|
log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
|
|
1705
2091
|
} else {
|
|
1706
2092
|
log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
|
|
1707
2093
|
const approvalNonce = await getEvmNonce(chain, walletAddress);
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
2094
|
+
// Scope the approval to this trade's input (see approvalAmountForSwap).
|
|
2095
|
+
// encodeApproveCalldata enforces a valid 20-byte spender, a
|
|
2096
|
+
// bounded (< MAX) amount within the request cap, and 68-byte calldata.
|
|
2097
|
+
const approvalData = encodeApproveCalldata(currentQuote.approvalAddress, approveAmt, {
|
|
2098
|
+
maxAllowance: approvalCapForQuote(quoteData),
|
|
2099
|
+
});
|
|
1712
2100
|
const approvalMaxFee = currentQuote.transaction?.maxFeePerGas || currentQuote.transaction?.gasPrice || '1000000';
|
|
1713
2101
|
const approvalPriorityFee = currentQuote.transaction?.maxPriorityFeePerGas || '1000000';
|
|
1714
2102
|
const approvalSignResult = await privyClient.signEvmTransaction(evmWalletId, {
|
|
@@ -1759,6 +2147,20 @@ EXAMPLES:
|
|
|
1759
2147
|
}
|
|
1760
2148
|
}
|
|
1761
2149
|
|
|
2150
|
+
// Verify the swap's simulated on-chain outcome matches intent.
|
|
2151
|
+
// Its own gate (runs even when --no-simulate/gasless skip the
|
|
2152
|
+
// cheap revert check above); degrades with a warning if no
|
|
2153
|
+
// simulation endpoint is available.
|
|
2154
|
+
if (!noVerifyOutcome) {
|
|
2155
|
+
const outcome = await verifySwapOutcome({ chain, from: walletAddress, quote: currentQuote, quoteData, apiKey, log });
|
|
2156
|
+
if (!outcome.proceed) {
|
|
2157
|
+
log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
|
|
2158
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2159
|
+
lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
1762
2164
|
// Gas resolution — fall back to eth_estimateGas if quote has no gas
|
|
1763
2165
|
const txData = currentQuote.transaction;
|
|
1764
2166
|
const apiGas = parseInt(currentQuote.gas || '0');
|
|
@@ -1805,6 +2207,12 @@ EXAMPLES:
|
|
|
1805
2207
|
signedTransaction = signResult.data?.signed_transaction || signResult.signed_transaction;
|
|
1806
2208
|
|
|
1807
2209
|
} else if (chainType === 'solana') {
|
|
2210
|
+
// NB: validateSwapTarget (the EVM `to`/`data` guard) intentionally does
|
|
2211
|
+
// not apply here — Solana quotes are a pre-built serialized
|
|
2212
|
+
// VersionedTransaction with no `to`/`data`/approval split to validate,
|
|
2213
|
+
// and this path (including the WalletConnect sub-branch below) signs it
|
|
2214
|
+
// as supplied. Deeper Solana inspection (e.g. checking instruction
|
|
2215
|
+
// program IDs) is tracked as a follow-up, not an oversight.
|
|
1808
2216
|
// Solana: transaction is either a base64 string (Jupiter) or an object
|
|
1809
2217
|
// with a base58-encoded `data` field (OKX). Normalize to base64.
|
|
1810
2218
|
let txBase64 = currentQuote.transaction;
|
|
@@ -1860,8 +2268,36 @@ EXAMPLES:
|
|
|
1860
2268
|
} else if (isWalletConnect) {
|
|
1861
2269
|
// EVM via WalletConnect: wallet signs and may broadcast
|
|
1862
2270
|
const wcAddress = await getWalletConnectAddress(chainType);
|
|
2271
|
+
// A session dropped mid-execute returns null here. Without this
|
|
2272
|
+
// guard a null address would fall through to assertQuoteMatchesRequest,
|
|
2273
|
+
// whose `request.walletAddress && walletAddress` condition would
|
|
2274
|
+
// silently skip the signer-binding check. Fail closed instead.
|
|
2275
|
+
if (!wcAddress) {
|
|
2276
|
+
throw new CommandError('WalletConnect session lost during execute. Reconnect with `walletconnect connect` and retry.', 'NO_WALLET');
|
|
2277
|
+
}
|
|
1863
2278
|
const isNative = isNativeToken(currentQuote.inputMint);
|
|
1864
2279
|
|
|
2280
|
+
// Guard the swap target before any RPC call, approval, or signing —
|
|
2281
|
+
// whatever `to`/`data` the quote supplied gets signed verbatim.
|
|
2282
|
+
await validateSwapTarget(chain, currentQuote.transaction.to, currentQuote.inputMint, { verifiedTargets });
|
|
2283
|
+
|
|
2284
|
+
// Bind the quote to the immutable request intent persisted at quote
|
|
2285
|
+
// time, so a compromised API can't inflate the input (and therefore
|
|
2286
|
+
// the scoped approval and native value) past what the user asked to spend.
|
|
2287
|
+
// The connected WC address is the signer here.
|
|
2288
|
+
assertCompleteEvmRequestIntent(quoteData.request);
|
|
2289
|
+
assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress: wcAddress, slippage: quoteData.slippage });
|
|
2290
|
+
|
|
2291
|
+
// Reject a bare ERC-20 transfer/approve/transferFrom as the outer
|
|
2292
|
+
// call: a real swap or bridge routes through an aggregator/router,
|
|
2293
|
+
// never a direct token method. Runs on cross-chain too — the
|
|
2294
|
+
// validateSwapTarget gate above only refuses `to === inputMint`, so
|
|
2295
|
+
// a bare transfer to a SIBLING token the wallet holds would
|
|
2296
|
+
// otherwise slip through the bridge path (which doesn't parse the
|
|
2297
|
+
// calldata recipient) and drain it. Legitimate bridges route through
|
|
2298
|
+
// a router selector, so this never fires on a real cross-chain quote.
|
|
2299
|
+
assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
|
|
2300
|
+
|
|
1865
2301
|
// Validate transaction.value (same checks as local wallet)
|
|
1866
2302
|
const txValue = BigInt(currentQuote.transaction.value || '0');
|
|
1867
2303
|
if (isNative) {
|
|
@@ -1884,12 +2320,22 @@ EXAMPLES:
|
|
|
1884
2320
|
// Handle approval via WalletConnect if needed
|
|
1885
2321
|
// Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
|
|
1886
2322
|
if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
|
|
2323
|
+
assertUsableSpender(currentQuote.approvalAddress);
|
|
1887
2324
|
const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
|
|
2325
|
+
const approveAmt = approvalAmountForSwap({ inputAmount, swapMode: quoteData.swapMode, slippage: quoteData.slippage });
|
|
2326
|
+
if (approveAmt <= 0n) {
|
|
2327
|
+
// Malformed quote (no/invalid input amount): a zero-scoped approval
|
|
2328
|
+
// would waste gas and the swap would revert on insufficient allowance.
|
|
2329
|
+
log(` ❌ ${quoteName} has a zero input amount — cannot scope approval, skipping.`);
|
|
2330
|
+
lastQuoteError = `${quoteName} has a zero input amount`;
|
|
2331
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2332
|
+
continue;
|
|
2333
|
+
}
|
|
1888
2334
|
const existingAllowance = await checkErc20Allowance(
|
|
1889
2335
|
chain, currentQuote.inputMint, wcAddress, currentQuote.approvalAddress
|
|
1890
2336
|
);
|
|
1891
2337
|
|
|
1892
|
-
if (existingAllowance >=
|
|
2338
|
+
if (existingAllowance >= approveAmt && existingAllowance > 0n) {
|
|
1893
2339
|
log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
|
|
1894
2340
|
} else {
|
|
1895
2341
|
log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
|
|
@@ -1899,6 +2345,8 @@ EXAMPLES:
|
|
|
1899
2345
|
currentQuote.inputMint,
|
|
1900
2346
|
currentQuote.approvalAddress,
|
|
1901
2347
|
chainConfig.chainId,
|
|
2348
|
+
approveAmt,
|
|
2349
|
+
approvalCapForQuote(quoteData),
|
|
1902
2350
|
);
|
|
1903
2351
|
let approvalTxHash = approvalResult.txHash;
|
|
1904
2352
|
if (!approvalTxHash && approvalResult.signedTransaction) {
|
|
@@ -1947,6 +2395,20 @@ EXAMPLES:
|
|
|
1947
2395
|
}
|
|
1948
2396
|
}
|
|
1949
2397
|
|
|
2398
|
+
// Verify the swap's simulated on-chain outcome matches intent. Its
|
|
2399
|
+
// own gate: runs even when --no-simulate/gasless skip the cheap
|
|
2400
|
+
// eth_call revert check above; degrades with a warning when no
|
|
2401
|
+
// simulation endpoint is set.
|
|
2402
|
+
if (!noVerifyOutcome) {
|
|
2403
|
+
const outcome = await verifySwapOutcome({ chain, from: wcAddress, quote: currentQuote, quoteData, apiKey, log });
|
|
2404
|
+
if (!outcome.proceed) {
|
|
2405
|
+
log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
|
|
2406
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2407
|
+
lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
|
|
1950
2412
|
// Resolve gas
|
|
1951
2413
|
const txData = currentQuote.transaction;
|
|
1952
2414
|
const apiGas = parseInt(currentQuote.gas || "0");
|
|
@@ -2032,6 +2494,28 @@ EXAMPLES:
|
|
|
2032
2494
|
// EVM: quote.transaction is { to, data, value, gas, gasPrice }
|
|
2033
2495
|
const walletAddress = exported.evm.address;
|
|
2034
2496
|
|
|
2497
|
+
// Guard the swap target before any RPC call, approval, or signing —
|
|
2498
|
+
// whatever `to`/`data` the quote supplied gets signed verbatim, so
|
|
2499
|
+
// reject an implausible target (zero, EOA, or the sold token itself)
|
|
2500
|
+
// before spending gas on an approval.
|
|
2501
|
+
await validateSwapTarget(chain, currentQuote.transaction.to, currentQuote.inputMint, { verifiedTargets });
|
|
2502
|
+
|
|
2503
|
+
// Bind the quote to the immutable request intent persisted at quote
|
|
2504
|
+
// time, so a compromised API can't inflate the input (and therefore
|
|
2505
|
+
// the scoped approval and native value) past what the user asked to spend.
|
|
2506
|
+
assertCompleteEvmRequestIntent(quoteData.request);
|
|
2507
|
+
assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
|
|
2508
|
+
|
|
2509
|
+
// Reject a bare ERC-20 transfer/approve/transferFrom as the outer
|
|
2510
|
+
// call: a real swap or bridge routes through an aggregator/router,
|
|
2511
|
+
// never a direct token method. Runs on cross-chain too — the
|
|
2512
|
+
// validateSwapTarget gate above only refuses `to === inputMint`, so
|
|
2513
|
+
// a bare transfer to a SIBLING token the wallet holds would
|
|
2514
|
+
// otherwise slip through the bridge path (which doesn't parse the
|
|
2515
|
+
// calldata recipient) and drain it. Legitimate bridges route through
|
|
2516
|
+
// a router selector, so this never fires on a real cross-chain quote.
|
|
2517
|
+
assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
|
|
2518
|
+
|
|
2035
2519
|
// Handle approval if needed — skip for native ETH
|
|
2036
2520
|
// Check existing allowance first to avoid unnecessary approve txs
|
|
2037
2521
|
// (industry standard: LiFi SDK checkAllowance, 1inch Permit2)
|
|
@@ -2061,13 +2545,23 @@ EXAMPLES:
|
|
|
2061
2545
|
|
|
2062
2546
|
// Empty-string approvalAddress is Relay's "no approval needed" sentinel — skip.
|
|
2063
2547
|
if (currentQuote.approvalAddress && currentQuote.approvalAddress !== '' && !isNative) {
|
|
2548
|
+
assertUsableSpender(currentQuote.approvalAddress);
|
|
2064
2549
|
// Check if sufficient allowance already exists
|
|
2065
|
-
const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount ||
|
|
2550
|
+
const inputAmount = BigInt(currentQuote.inputAmount || currentQuote.inAmount || '0');
|
|
2551
|
+
const approveAmt = approvalAmountForSwap({ inputAmount, swapMode: quoteData.swapMode, slippage: quoteData.slippage });
|
|
2552
|
+
if (approveAmt <= 0n) {
|
|
2553
|
+
// Malformed quote (no/invalid input amount): a zero-scoped approval
|
|
2554
|
+
// would waste gas and the swap would revert on insufficient allowance.
|
|
2555
|
+
log(` ❌ ${quoteName} has a zero input amount — cannot scope approval, skipping.`);
|
|
2556
|
+
lastQuoteError = `${quoteName} has a zero input amount`;
|
|
2557
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2558
|
+
continue;
|
|
2559
|
+
}
|
|
2066
2560
|
const existingAllowance = await checkErc20Allowance(
|
|
2067
2561
|
chain, currentQuote.inputMint, walletAddress, currentQuote.approvalAddress
|
|
2068
2562
|
);
|
|
2069
2563
|
|
|
2070
|
-
if (existingAllowance >=
|
|
2564
|
+
if (existingAllowance >= approveAmt && existingAllowance > 0n) {
|
|
2071
2565
|
log(` ✓ Sufficient allowance exists for ${quoteName}, skipping approval`);
|
|
2072
2566
|
} else {
|
|
2073
2567
|
log(` ⚠ Approval required → ${currentQuote.approvalAddress}`);
|
|
@@ -2082,6 +2576,8 @@ EXAMPLES:
|
|
|
2082
2576
|
chain,
|
|
2083
2577
|
approvalNonce,
|
|
2084
2578
|
approvalGasPrice,
|
|
2579
|
+
approveAmt,
|
|
2580
|
+
approvalCapForQuote(quoteData),
|
|
2085
2581
|
);
|
|
2086
2582
|
|
|
2087
2583
|
const approvalResult = await executeTransaction({
|
|
@@ -2132,6 +2628,20 @@ EXAMPLES:
|
|
|
2132
2628
|
}
|
|
2133
2629
|
}
|
|
2134
2630
|
|
|
2631
|
+
// Verify the swap's simulated on-chain outcome matches intent. Its
|
|
2632
|
+
// own gate: runs even when --no-simulate/gasless skip the cheap
|
|
2633
|
+
// eth_call revert check above; degrades with a warning when no
|
|
2634
|
+
// simulation endpoint is set.
|
|
2635
|
+
if (!noVerifyOutcome) {
|
|
2636
|
+
const outcome = await verifySwapOutcome({ chain, from: walletAddress, quote: currentQuote, quoteData, apiKey, log });
|
|
2637
|
+
if (!outcome.proceed) {
|
|
2638
|
+
log(` ❌ ${quoteName} failed swap-outcome verification: ${outcome.reason}`);
|
|
2639
|
+
if (qi + 1 < endIndex) log(` Trying next quote...`);
|
|
2640
|
+
lastQuoteError = `${quoteName} outcome verification failed: ${outcome.reason}`;
|
|
2641
|
+
continue;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2135
2645
|
// Use the Trading API's gas estimation (quote.gas) directly.
|
|
2136
2646
|
// The API already applies a 1.5x buffer over eth_estimateGas.
|
|
2137
2647
|
// Skip client-side re-estimation — it adds latency and the API value is reliable.
|