nansen-cli 1.38.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.39.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#495](https://github.com/nansen-ai/nansen-cli/pull/495) [`3306897`](https://github.com/nansen-ai/nansen-cli/commit/3306897c1aaae594f4401fd4656b2451ab375d78) Thanks [@kome12](https://github.com/kome12)! - Add EVM swap-outcome verification to `trade execute`. Before broadcasting a swap on an EVM chain (Base), the CLI now simulates the transaction and confirms the wallet's balance changes match the quote — the input is spent within your maximum, at least the expected output is received, and no other token or NFT leaves the wallet — refusing to sign when they don't. This runs on top of the existing pre-broadcast checks and needs a simulation-capable RPC (`NANSEN_BASE_SIM_RPC`); when none is available it degrades with a warning rather than blocking the trade. Skip it with `--no-verify-outcome`. Solana is unaffected.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#495](https://github.com/nansen-ai/nansen-cli/pull/495) [`e8cf217`](https://github.com/nansen-ai/nansen-cli/commit/e8cf217feaa9e7c8f68f4f3c3c2a49adcda07101) Thanks [@kome12](https://github.com/kome12)! - Harden swap-outcome verification error handling: a revert reported by the simulation endpoint as a top-level JSON-RPC error (rather than a per-call status) now fails closed (blocks the swap) instead of degrading, and a non-2xx simulation response (e.g. HTTP 401 "Invalid API key") now degrades with the real status and message instead of a misleading "returned no call result" warning.
12
+
13
+ - [#499](https://github.com/nansen-ai/nansen-cli/pull/499) [`de0bcc5`](https://github.com/nansen-ai/nansen-cli/commit/de0bcc562bcd20a80edd3ab2f486870b80629c83) Thanks [@gulshngill](https://github.com/gulshngill)! - Fix `profiler labels`: call `/api/v1/profiler/address/labels` with its v1 request body — the beta endpoint previously used was removed from the Nansen API. `profiler batch --include labels` now returns the label array itself instead of the raw `{pagination, data}` envelope.
14
+
15
+ - [#506](https://github.com/nansen-ai/nansen-cli/pull/506) [`f407edb`](https://github.com/nansen-ai/nansen-cli/commit/f407edb19444d6b5a1a631d29b4c4fb9bd280708) Thanks [@gulshngill](https://github.com/gulshngill)! - Add a canonical MCP setup section to the README — endpoint `https://mcp.nansen.ai/ra/mcp`, `NANSEN-API-KEY` auth, per-client setup paths for Claude Code, Claude Tag, and generic or stdio-only clients, plus a pointer to the connection docs for Claude Desktop and Cursor — and point the out-of-credits and low-credit warnings at the credits tab of the billing page, `app.nansen.ai/api?tab=api`, instead of the bare `app.nansen.ai/api`.
16
+
17
+ - [#500](https://github.com/nansen-ai/nansen-cli/pull/500) [`9ccf8a2`](https://github.com/nansen-ai/nansen-cli/commit/9ccf8a20841a9ca01a2627ccf5de2575bf016a46) Thanks [@gulshngill](https://github.com/gulshngill)! - Document global pagination options in `nansen schema`.
18
+
3
19
  ## 1.38.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -51,6 +51,61 @@ nansen schema [command] [--pretty] # full command reference (no API key neede
51
51
 
52
52
  Run `nansen schema --pretty` for the full subcommand and field reference.
53
53
 
54
+ ## MCP
55
+
56
+ Connect any MCP client to Nansen's streamable HTTP server:
57
+
58
+ - **Endpoint:** `https://mcp.nansen.ai/ra/mcp`
59
+ - **Authentication:** `NANSEN-API-KEY` header
60
+ - **API key:** [app.nansen.ai/auth/agent-setup](https://app.nansen.ai/auth/agent-setup)
61
+
62
+ **Claude Desktop and Cursor:** setup instructions for both — the Claude Desktop `.dxt` bundle and the Cursor install deep link — are in the connection docs: [docs.nansen.ai/mcp/connecting](https://docs.nansen.ai/mcp/connecting).
63
+
64
+ **One-command (Claude Code):**
65
+
66
+ ```bash
67
+ claude mcp add --transport http nansen https://mcp.nansen.ai/ra/mcp --header "NANSEN-API-KEY: <your-key>"
68
+ ```
69
+
70
+ **Manual (any streamable-HTTP client):** for example, add this to Cursor's `~/.cursor/mcp.json`:
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "nansen": {
76
+ "url": "https://mcp.nansen.ai/ra/mcp",
77
+ "headers": {
78
+ "NANSEN-API-KEY": "<your-key>"
79
+ }
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ **Manual (stdio-only clients):** use `mcp-remote` as a bridge. Keep the header as one argument with no space after the colon:
86
+
87
+ ```json
88
+ {
89
+ "mcpServers": {
90
+ "nansen": {
91
+ "command": "npx",
92
+ "args": [
93
+ "-y",
94
+ "mcp-remote@latest",
95
+ "https://mcp.nansen.ai/ra/mcp",
96
+ "--header",
97
+ "NANSEN-API-KEY:${NANSEN_API_KEY}"
98
+ ],
99
+ "env": {
100
+ "NANSEN_API_KEY": "<your-key>"
101
+ }
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ **Claude Tag (Claude in Slack):** an admin must attach a plugin whose `.mcp.json` points at `https://mcp.nansen.ai/ra/mcp` and add a custom credential allowing the host `mcp.nansen.ai`. See the [Claude Tag custom-connections documentation](https://claude.com/docs/claude-tag/admins/connections/custom). Per-user fallback: use Claude Code or Claude Desktop.
108
+
54
109
  ## Trading
55
110
 
56
111
  DEX swaps on `solana` and `base`. Two-step: quote then execute.
@@ -216,7 +271,7 @@ nansen research smart-money netflow --chain solana --fields token_symbol,net_flo
216
271
 
217
272
  | Code | Action |
218
273
  |------|--------|
219
- | `CREDITS_EXHAUSTED` | Stop all API calls immediately. `details.credits.remaining` is your actual balance. Top up at [app.nansen.ai/api](https://app.nansen.ai/api). |
274
+ | `CREDITS_EXHAUSTED` | Stop all API calls immediately. `details.credits.remaining` is your actual balance. Top up at [app.nansen.ai/api?tab=api](https://app.nansen.ai/api?tab=api). |
220
275
  | `UNAUTHORIZED` | Wrong or missing key. Re-auth. |
221
276
  | `RATE_LIMITED` | Auto-retried by CLI. `details.rateLimit.resetSeconds` is how long the window needs to drain. |
222
277
  | `UNSUPPORTED_FILTER` | Remove the filter and retry. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.38.0",
3
+ "version": "1.39.0",
4
4
  "description": "AI-agent CLI for Nansen API analytics, DEX swaps, and cross-chain trading",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -19,7 +19,7 @@ allowed-tools: Bash(nansen:*)
19
19
  ADDRESSES="0xaddr1,0xaddr2,0xaddr3,..." CHAIN=ethereum
20
20
  nansen research profiler batch --addresses "$ADDRESSES" --chain $CHAIN --include labels,balance
21
21
  # → .data.{total, completed, results[]: {address, chain, labels[], balance, error}}
22
- # labels[]: {label, category ("smart_money","fund","social","behavioral","others"), fullname}
22
+ # labels[]: {label, category ("smart_money","fund","social","behavioral","others"), kind[]}
23
23
  # balance: {data[]: {token_symbol, token_amount, price_usd, value_usd}}
24
24
  ```
25
25
  Check .error per result — invalid addresses return an error message, not a crash. Skip those.
@@ -92,7 +92,7 @@ nansen research profiler compare --addresses "0xabc,0xdef" --chain ethereum
92
92
 
93
93
  - `pnl-summary` has no pagination support (returns aggregate stats, not a list).
94
94
  - `perp-positions` has no pagination support.
95
- - `labels` has no pagination support the API ignores `per_page` and always returns all labels for the address. `--limit` is not available for this sub-command.
95
+ - `labels` supports pagination — `--limit`/`--page` are honoured and the response is `{pagination: {page, per_page, is_last_page}, data[]: {label, category, kind[]}}`.
96
96
  - `transactions` caps at per_page=100 (API limit).
97
97
  - `trace` makes many API calls — use `--width` conservatively.
98
98
  - `batch` accepts `--file <path>` with one address per line as alternative to `--addresses`.
package/src/api.js CHANGED
@@ -393,7 +393,7 @@ function requireValidToken(tokenAddress, chain) {
393
393
  if (!v.valid) throw new NansenError(v.error, v.code);
394
394
  }
395
395
 
396
- function loadConfig() {
396
+ export function loadConfig() {
397
397
  // Base config from files, then env vars override individual fields
398
398
  let config = null;
399
399
 
@@ -908,8 +908,9 @@ export class NansenAPI {
908
908
  async addressLabels(params = {}) {
909
909
  const { address, chain = 'ethereum', pagination = { page: 1, per_page: 100 } } = params;
910
910
  if (address) requireValidAddress(address, chain);
911
- return this.request('/api/beta/profiler/address/labels', {
912
- parameters: { address, chain },
911
+ return this.request('/api/v1/profiler/address/labels', {
912
+ address,
913
+ chain,
913
914
  pagination
914
915
  });
915
916
  }
package/src/cli.js CHANGED
@@ -489,7 +489,9 @@ async function enrichTransfers(result, apiInstance, chain) {
489
489
  for (const addr of addrs) {
490
490
  try {
491
491
  const labelsResult = await apiInstance.addressLabels({ address: addr, chain });
492
- labelMap[addr] = labelsResult?.labels || labelsResult?.data?.results || [];
492
+ labelMap[addr] = Array.isArray(labelsResult?.data)
493
+ ? labelsResult.data.map(item => item.label)
494
+ : labelsResult?.labels || [];
493
495
  } catch {
494
496
  labelMap[addr] = [];
495
497
  }
@@ -568,7 +570,10 @@ export async function batchProfile(api, params = {}) {
568
570
  }
569
571
  try {
570
572
  if (include.includes('labels')) {
571
- entry.labels = await api.addressLabels({ address, chain });
573
+ const labelsResult = await api.addressLabels({ address, chain });
574
+ entry.labels = Array.isArray(labelsResult?.data)
575
+ ? labelsResult.data
576
+ : labelsResult?.labels || [];
572
577
  }
573
578
  if (include.includes('balance')) {
574
579
  entry.balance = await api.addressBalance({ address, chain });
@@ -118,12 +118,12 @@ export function creditWarning(meta) {
118
118
  const { used, remaining, cost } = credits;
119
119
  if (remaining === null) return null;
120
120
  if (remaining === 0) {
121
- return '⚠️ Out of API credits. Top up at https://app.nansen.ai/api';
121
+ return '⚠️ Out of API credits. Top up at https://app.nansen.ai/api?tab=api';
122
122
  }
123
123
  // The cost header is the authoritative charge; used is the fallback.
124
124
  const charged = cost ?? used;
125
125
  if (charged !== null && charged > 0 && remaining < charged) {
126
- return `⚠️ ${remaining} API credit${remaining === 1 ? '' : 's'} left — less than this call cost (${charged}). Top up at https://app.nansen.ai/api`;
126
+ return `⚠️ ${remaining} API credit${remaining === 1 ? '' : 's'} left — less than this call cost (${charged}). Top up at https://app.nansen.ai/api?tab=api`;
127
127
  }
128
128
  return null;
129
129
  }
package/src/rpc-urls.js CHANGED
@@ -11,6 +11,23 @@
11
11
  * NANSEN_BSC_RPC Custom BNB Smart Chain RPC
12
12
  * NANSEN_XLAYER_RPC Custom X Layer RPC
13
13
  * NANSEN_SOLANA_RPC Custom Solana RPC
14
+ * NANSEN_BASE_SIM_RPC Custom Base simulation RPC (see SIMULATION_RPCS below)
15
+ *
16
+ * Simulation RPCs (SIMULATION_RPCS) are a SEPARATE registry from the cheap
17
+ * defaults above. Swap-outcome verification (src/swap-simulation.js) needs an
18
+ * endpoint that supports state-changing simulation with asset-transfer tracing
19
+ * (`eth_simulateV1` / `debug_traceCall`), which the free public defaults in
20
+ * CHAIN_RPCS deliberately DISABLE. Keeping the two registries apart means only
21
+ * the (pricey) simulation calls hit the trace-capable endpoint; ordinary reads
22
+ * (nonce, balance, allowance, eth_call revert check) stay on the cheap default.
23
+ *
24
+ * The shipped simulation endpoint is a Nansen-hosted service authenticated with
25
+ * the user's existing Nansen API key (no secret in this public package): the
26
+ * trace-capable upstream is reached server-side, so the baked default carries no
27
+ * credential. With no NANSEN_BASE_SIM_RPC override, swap-outcome verification
28
+ * uses this default; if the service is ever unreachable it degrades with a
29
+ * warning rather than blocking the trade. To use your own endpoint (or for local
30
+ * dev/e2e), point NANSEN_BASE_SIM_RPC at any trace-capable RPC in a gitignored .env.
14
31
  *
15
32
  * Backward-compat aliases (deprecated — prefer the forms above):
16
33
  * NANSEN_RPC_BASE Old name for NANSEN_BASE_RPC; trading.js previously read this
@@ -40,3 +57,53 @@ export const CHAIN_RPCS = {
40
57
  polygon: process.env.NANSEN_POLYGON_RPC || DEFAULT_POLYGON_RPC,
41
58
  bnb: process.env.NANSEN_BNB_RPC || DEFAULT_BNB_RPC,
42
59
  };
60
+
61
+ // Zero-config default for the shipped Nansen-hosted simulation endpoint. It
62
+ // authenticates with the user's existing Nansen API key (attached automatically
63
+ // by swap-simulation.js), and the trace-capable upstream is reached server-side —
64
+ // so this URL carries no secret and is safe to bake into a public package. Never
65
+ // embed an RPC URL that carries an inline token here; any embedded secret would
66
+ // leak on publish.
67
+ const DEFAULT_BASE_SIM_RPC = 'https://api.nansen.ai/api/v1/trade/simulate-swap';
68
+
69
+ // Separate registry for swap-outcome simulation (src/swap-simulation.js). These
70
+ // endpoints must support state-changing simulation with asset-transfer tracing
71
+ // (`eth_simulateV1` / `debug_traceCall`), which the CHAIN_RPCS public defaults
72
+ // disable. Only outcome verification reads this registry; every other RPC call
73
+ // stays on the cheap CHAIN_RPCS default. A null entry (no baked default and no
74
+ // override) signals "no sim-capable endpoint" to the caller, which degrades.
75
+ //
76
+ // Intentionally a mutable export: unit tests override an entry in-place (e.g.
77
+ // `SIMULATION_RPCS.base = ...`) to point at a mock or to null out the endpoint,
78
+ // restoring it in afterEach. Runtime code only ever reads it.
79
+ export const SIMULATION_RPCS = {
80
+ base: process.env.NANSEN_BASE_SIM_RPC || DEFAULT_BASE_SIM_RPC,
81
+ };
82
+
83
+ // Nansen hosts the API key may be forwarded to. Kept to an explicit allowlist
84
+ // (not a `*.nansen.ai` wildcard): the key only ever authenticates the sim proxy
85
+ // on api.nansen.ai, and a wildcard would forward it to any subdomain that
86
+ // resolves — including a misconfigured or compromised one. Add new sim hosts
87
+ // here deliberately if one is ever introduced.
88
+ const NANSEN_HOSTED_SIM_HOSTS = new Set(['api.nansen.ai']);
89
+
90
+ /**
91
+ * Whether a simulation URL is a Nansen-hosted endpoint that may receive the
92
+ * user's Nansen API key. The key authenticates the shipped default proxy
93
+ * (DEFAULT_BASE_SIM_RPC); a NANSEN_BASE_SIM_RPC override can point at ANY host
94
+ * (dev node, third-party trace RPC), and forwarding the credential there would
95
+ * leak it. So the key is attached ONLY when this returns true — every other
96
+ * endpoint is called anonymously.
97
+ *
98
+ * Trust is: https + hostname is one of NANSEN_HOSTED_SIM_HOSTS. Anything else
99
+ * (http, other host, unparseable) is untrusted and gets no key.
100
+ */
101
+ export function isNansenHostedUrl(url) {
102
+ try {
103
+ const u = new URL(url);
104
+ if (u.protocol !== 'https:') return false;
105
+ return NANSEN_HOSTED_SIM_HOSTS.has(u.hostname.toLowerCase());
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
package/src/schema.json CHANGED
@@ -1620,7 +1620,11 @@
1620
1620
  },
1621
1621
  "no-simulate": {
1622
1622
  "type": "boolean",
1623
- "description": "Skip the pre-broadcast simulation."
1623
+ "description": "Skip the pre-broadcast simulation (the eth_call revert check)."
1624
+ },
1625
+ "no-verify-outcome": {
1626
+ "type": "boolean",
1627
+ "description": "Skip EVM swap-outcome verification. That check simulates the swap and confirms the wallet's balance changes match the quote (input spent within your max, expected output received, no other token moved) before broadcasting; it needs a simulation-capable endpoint (NANSEN_BASE_SIM_RPC) and degrades with a warning when none is available. No effect on Solana."
1624
1628
  }
1625
1629
  }
1626
1630
  },
@@ -1968,6 +1972,15 @@
1968
1972
  "type": "string",
1969
1973
  "description": "Comma-separated list of fields to include in output"
1970
1974
  },
1975
+ "limit": {
1976
+ "type": "number",
1977
+ "description": "Maximum results per page for list-returning research commands; maps to pagination.per_page. General endpoints default to 10 (max 1000), while profiler address endpoints default to 20 (max 100). Supported by smart-money, profiler, token, perp, points, prediction-market, and supported research historical-* commands. Commands that declare their own limit option (search, token top-tokens, trade limit-order list) use those command-specific semantics instead; token ohlcv, profiler perp-positions, and historical-token-flow-summary do not support pagination. profiler labels defaults to 100 when omitted."
1978
+ },
1979
+ "page": {
1980
+ "type": "number",
1981
+ "default": 1,
1982
+ "description": "1-based page number for list-returning research commands; maps to pagination.page. Supported by smart-money, profiler, token, perp, points, prediction-market, and supported research historical-* commands. Trade, wallet, and operational commands ignore it; token ohlcv, profiler perp-positions, and historical-token-flow-summary do not support pagination. profiler labels defaults to page 1 when omitted."
1983
+ },
1971
1984
  "no-retry": {
1972
1985
  "type": "boolean",
1973
1986
  "description": "Disable automatic retry on rate limits/errors"
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Swap-outcome simulation: run a swap transaction through a trace-capable RPC
3
+ * and report the asset changes it would cause to the sender's wallet.
4
+ *
5
+ * This is a defence-in-depth check that complements the static checks in
6
+ * trade-validation.js: instead of only inspecting the swap calldata, it
7
+ * simulates the call so assertSwapOutcome can confirm the resulting balance
8
+ * changes match what the user asked for, failing closed on any mismatch.
9
+ *
10
+ * The endpoint returns the RAW simulation result and all delta math runs here,
11
+ * client-side, so the verification stays independent of the service that built
12
+ * the quote. See src/rpc-urls.js SIMULATION_RPCS for why this needs a separate,
13
+ * trace-capable endpoint.
14
+ *
15
+ * EVM-only: Solana signs the aggregator transaction verbatim and is out of scope.
16
+ */
17
+
18
+ import { SIMULATION_RPCS, isNansenHostedUrl } from './rpc-urls.js';
19
+
20
+ // keccak256("Transfer(address,address,uint256)") — shared by ERC-20 and ERC-721.
21
+ // ERC-20 indexes (from, to) and carries value in `data` (3 topics); ERC-721 also
22
+ // indexes the tokenId (4 topics). We distinguish them by topic count below.
23
+ const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
24
+ // keccak256("Approval(address,address,uint256)") — shared by ERC-20 and ERC-721.
25
+ // ERC-20 indexes (owner, spender) with the value in `data` (3 topics); ERC-721
26
+ // also indexes the tokenId (4 topics), granting control of one specific NFT.
27
+ const APPROVAL_TOPIC = '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925';
28
+ // keccak256("ApprovalForAll(address,address,bool)") — ERC-721 AND ERC-1155. Grants
29
+ // an operator control of the owner's ENTIRE collection; `data` is the bool flag.
30
+ const APPROVAL_FOR_ALL_TOPIC = '0x17307eab39ab6107e8899845ad3d59bd9653f200f220920489ca2b5937696c31';
31
+ // keccak256("TransferSingle(address,address,address,uint256,uint256)") — ERC-1155
32
+ const ERC1155_SINGLE_TOPIC = '0xc3d58168c5ae7397731d063d5bbf3d657854427343f4c083240f7aacaa2d0f62';
33
+ // keccak256("TransferBatch(address,address,address,uint256[],uint256[])") — ERC-1155
34
+ const ERC1155_BATCH_TOPIC = '0x4a39dc06d4c0dbc64b70af90fd698a233a518aa5d07e595d983b8c0526c8f7fb';
35
+
36
+ // The CLI's EVM native-asset sentinel (mirrors EVM_NATIVE in trading.js and
37
+ // NATIVE_TOKEN_ADDRESSES in trade-validation.js). Native ETH movements surface
38
+ // in traces either as synthetic Transfer logs from the zero address
39
+ // (eth_simulateV1 traceTransfers) or as call-frame `value` fields (callTracer);
40
+ // both are normalised to this sentinel so the caller can compare native deltas
41
+ // against a quote's inputMint/outputMint uniformly with ERC-20 deltas.
42
+ export const EVM_NATIVE_SENTINEL = '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee';
43
+ const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
44
+
45
+ // callTracer frame types that can actually move ETH. STATICCALL forbids value
46
+ // and DELEGATECALL runs in the caller's context (its `value` mirrors the parent
47
+ // frame rather than being a transfer), so both must be excluded from native
48
+ // delta accounting — some nodes populate their `value` field regardless, which
49
+ // would otherwise double-count or invent ETH movement.
50
+ //
51
+ // SELFDESTRUCT is deliberately excluded too. The wallet is the EOA signer, so it
52
+ // can never itself SELFDESTRUCT — its real native outflow is always a top-level
53
+ // CALL. Some nodes additionally surface a SELFDESTRUCT refund frame whose value
54
+ // lands on the wallet; counting that as an inflow can cancel or partially offset
55
+ // the wallet's real CALL outflow, letting the balance-delta assertion pass for a
56
+ // mismatched result. Dropping it means we may under-count a genuine selfdestruct
57
+ // refund into the wallet (rare, and largely neutered by EIP-6780), which only
58
+ // makes the net native delta stricter — fail-closed, never the reverse.
59
+ const ETH_MOVING_FRAME_TYPES = new Set(['CALL', 'CALLCODE', 'CREATE', 'CREATE2']);
60
+
61
+ /**
62
+ * A simulation error the caller can distinguish from a genuine outcome mismatch.
63
+ * `code` is one of:
64
+ * NO_SIM_RPC - no simulation endpoint configured for the chain
65
+ * NOT_SIM_CAPABLE - endpoint reachable but does not support any trace method
66
+ * SIM_RPC_ERROR - transport/parse failure talking to the endpoint
67
+ * SIM_REVERTED - the swap call itself reverted in simulation
68
+ * The first three are degrade conditions (warn, proceed per policy); the caller
69
+ * decides. SIM_REVERTED is an outcome problem and should not be silently ignored.
70
+ */
71
+ export class SwapSimulationError extends Error {
72
+ constructor(code, message) {
73
+ super(message);
74
+ this.name = 'SwapSimulationError';
75
+ this.code = code;
76
+ }
77
+ }
78
+
79
+ /** Whether a sim-capable endpoint is configured for this chain. */
80
+ export function hasSimulationRpc(chain) {
81
+ return Boolean(SIMULATION_RPCS[chain]);
82
+ }
83
+
84
+ /** Last 20 bytes of a 32-byte topic, as a lowercased 0x address. */
85
+ function topicToAddress(topic) {
86
+ if (typeof topic !== 'string') return null;
87
+ const hex = topic.replace(/^0x/, '').padStart(64, '0');
88
+ return '0x' + hex.slice(-40).toLowerCase();
89
+ }
90
+
91
+ /** Parse a hex data field as a uint256; returns 0n on anything unparseable. */
92
+ function hexToBigInt(hex) {
93
+ if (typeof hex !== 'string' || hex === '0x' || hex === '') return 0n;
94
+ try {
95
+ return BigInt(hex.startsWith('0x') ? hex : '0x' + hex);
96
+ } catch {
97
+ return 0n;
98
+ }
99
+ }
100
+
101
+ /** Normalise a token address, mapping the zero address (native) to the sentinel. */
102
+ function normalizeToken(addr) {
103
+ if (typeof addr !== 'string') return null;
104
+ const lower = addr.toLowerCase();
105
+ return lower === ZERO_ADDRESS ? EVM_NATIVE_SENTINEL : lower;
106
+ }
107
+
108
+ /**
109
+ * Fold a flat list of logs into per-token deltas for `wallet`, the ERC-20
110
+ * Approvals the wallet granted, any non-fungible (ERC-721/ERC-1155) transfer OUT
111
+ * of the wallet, and any non-fungible approval the wallet GRANTED. `deltas` is
112
+ * signed: positive = received, negative = sent. Tokens with a net-zero delta are
113
+ * dropped.
114
+ *
115
+ * `nftOut` / `nftApprovals` exist because the fungible `deltas` (and the ERC-20
116
+ * `approvals`) map cannot represent an NFT: a DEX swap should only move native
117
+ * currency and ERC-20 tokens, so an ERC-721 / ERC-1155 leaving the wallet OR the
118
+ * wallet granting an NFT operator approval is an out-of-scope, asset-endangering
119
+ * event the caller must fail closed on (assertSwapOutcome) rather than silently
120
+ * ignore. A single-NFT Approval carries empty `data`, so it would otherwise fold
121
+ * into `approvals` as amount 0n and be mistaken for a harmless revoke; an
122
+ * ApprovalForAll is not an ERC-20 Approval at all and would be dropped entirely.
123
+ * Inbound NFTs, self-transfers, and NFT revokes are harmless and not recorded.
124
+ *
125
+ * @param {Array} logs - [{ address, topics, data }]
126
+ * @param {string} wallet - the sender whose balance changes we care about
127
+ */
128
+ function foldLogs(logs, wallet) {
129
+ const w = wallet.toLowerCase();
130
+ const deltas = {}; // token -> bigint (signed)
131
+ const approvals = []; // { token, spender, amount } — ERC-20 approvals
132
+ const nftOut = []; // { standard, token } — non-fungible transfers leaving `w`
133
+ const nftApprovals = []; // { standard, token, operator } — NFT approvals `w` granted
134
+
135
+ for (const lg of logs || []) {
136
+ const topics = lg?.topics || [];
137
+ const topic0 = (topics[0] || '').toLowerCase();
138
+
139
+ if (topic0 === TRANSFER_TOPIC && topics.length >= 4) {
140
+ // ERC-721: shares the ERC-20 Transfer signature but indexes the tokenId as
141
+ // a 4th topic. Flag only when the NFT leaves the wallet for someone else (a
142
+ // self-transfer is a no-op, mirroring the ERC-20 net-zero cleanup).
143
+ const from = topicToAddress(topics[1]);
144
+ const to = topicToAddress(topics[2]);
145
+ if (from === w && to !== w) nftOut.push({ standard: 'ERC-721', token: normalizeToken(lg.address) });
146
+ } else if (topic0 === TRANSFER_TOPIC && topics.length >= 3) {
147
+ const from = topicToAddress(topics[1]);
148
+ const to = topicToAddress(topics[2]);
149
+ // eth_simulateV1 emits native transfers from the zero address; those carry
150
+ // the moved value in `data` and their log `address` is 0x0 too — both
151
+ // normalise to the native sentinel.
152
+ const token = normalizeToken(lg.address);
153
+ const amount = hexToBigInt(lg.data);
154
+ if (!token || amount === 0n) continue;
155
+ if (to === w) deltas[token] = (deltas[token] || 0n) + amount;
156
+ if (from === w) deltas[token] = (deltas[token] || 0n) - amount;
157
+ } else if ((topic0 === ERC1155_SINGLE_TOPIC || topic0 === ERC1155_BATCH_TOPIC) && topics.length >= 4) {
158
+ // ERC-1155 TransferSingle/Batch index (operator, from, to); `from` is the
159
+ // 3rd topic, `to` the 4th. Flag only a real outbound transfer.
160
+ const from = topicToAddress(topics[2]);
161
+ const to = topicToAddress(topics[3]);
162
+ if (from === w && to !== w) nftOut.push({ standard: 'ERC-1155', token: normalizeToken(lg.address) });
163
+ } else if (topic0 === APPROVAL_TOPIC && topics.length >= 4) {
164
+ // ERC-721 single-token Approval (owner, approved, tokenId indexed; empty
165
+ // data). Approving the zero address is a revoke and grants nothing.
166
+ const owner = topicToAddress(topics[1]);
167
+ const approved = topicToAddress(topics[2]);
168
+ if (owner === w && approved && approved !== ZERO_ADDRESS) {
169
+ nftApprovals.push({ standard: 'ERC-721', token: normalizeToken(lg.address), operator: approved });
170
+ }
171
+ } else if (topic0 === APPROVAL_TOPIC && topics.length >= 3) {
172
+ const owner = topicToAddress(topics[1]);
173
+ const spender = topicToAddress(topics[2]);
174
+ if (owner === w) {
175
+ approvals.push({
176
+ token: normalizeToken(lg.address),
177
+ spender,
178
+ amount: hexToBigInt(lg.data),
179
+ });
180
+ }
181
+ } else if (topic0 === APPROVAL_FOR_ALL_TOPIC && topics.length >= 3) {
182
+ // ERC-721/ERC-1155 ApprovalForAll(owner, operator indexed; bool in data).
183
+ // data == 0 is a revoke (grants nothing); any non-zero flag is a grant of
184
+ // control over the wallet's whole collection.
185
+ const owner = topicToAddress(topics[1]);
186
+ const operator = topicToAddress(topics[2]);
187
+ if (owner === w && hexToBigInt(lg.data) !== 0n) {
188
+ nftApprovals.push({ standard: 'ERC-721/1155 (all)', token: normalizeToken(lg.address), operator });
189
+ }
190
+ }
191
+ }
192
+
193
+ for (const t of Object.keys(deltas)) {
194
+ if (deltas[t] === 0n) delete deltas[t];
195
+ }
196
+ return { deltas, approvals, nftOut, nftApprovals };
197
+ }
198
+
199
+ // ============= eth_simulateV1 (primary) =============
200
+
201
+ function buildSimRpcBody(method, params) {
202
+ return JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
203
+ }
204
+
205
+ async function postSim(rpcUrl, apiKey, method, params, timeoutMs) {
206
+ const controller = new AbortController();
207
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
208
+ try {
209
+ // Attach the Nansen API key ONLY when the endpoint is Nansen-hosted. A
210
+ // NANSEN_BASE_SIM_RPC override can point at any host (dev node, third-party
211
+ // trace RPC); forwarding the user's credential there would leak it, so an
212
+ // untrusted endpoint is always called anonymously (see isNansenHostedUrl).
213
+ const sendApiKey = Boolean(apiKey) && isNansenHostedUrl(rpcUrl);
214
+ const res = await fetch(rpcUrl, {
215
+ method: 'POST',
216
+ headers: {
217
+ 'Content-Type': 'application/json',
218
+ ...(sendApiKey ? { apikey: apiKey } : {}),
219
+ },
220
+ body: buildSimRpcBody(method, params),
221
+ signal: controller.signal,
222
+ });
223
+ const text = await res.text();
224
+ let body;
225
+ try {
226
+ body = JSON.parse(text);
227
+ } catch {
228
+ throw new SwapSimulationError(
229
+ 'SIM_RPC_ERROR',
230
+ `Simulation RPC returned non-JSON (HTTP ${res.status}) for ${method}: ${text.slice(0, 120)}`,
231
+ );
232
+ }
233
+ // A non-2xx is a transport/auth failure (e.g. 401 from the hosted proxy when
234
+ // the API key is missing/invalid), not a simulation outcome. The proxy phrases
235
+ // these as `{message}` — not a JSON-RPC `{error}` — so without this check the
236
+ // body flows on, yields no `result.calls[0]`, and degrades with a misleading
237
+ // "returned no call result". Surface the real status + message so the warning
238
+ // is actionable (per the repo's actionable-errors rule); still SIM_RPC_ERROR,
239
+ // so the caller degrades (warn + proceed) rather than blocking the trade.
240
+ const ok = res.ok ?? (res.status >= 200 && res.status < 300);
241
+ if (!ok) {
242
+ const detail =
243
+ body?.error?.message ||
244
+ body?.message ||
245
+ (typeof body?.error === 'string' ? body.error : null) ||
246
+ text.slice(0, 120);
247
+ throw new SwapSimulationError('SIM_RPC_ERROR', `Simulation RPC HTTP ${res.status} for ${method}: ${detail}`);
248
+ }
249
+ return body;
250
+ } catch (e) {
251
+ if (e instanceof SwapSimulationError) throw e;
252
+ if (e.name === 'AbortError') {
253
+ throw new SwapSimulationError('SIM_RPC_ERROR', `Simulation RPC timed out after ${timeoutMs}ms (${method})`);
254
+ }
255
+ throw new SwapSimulationError('SIM_RPC_ERROR', `Simulation RPC request failed (${method}): ${e.message}`);
256
+ } finally {
257
+ clearTimeout(timer);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * True when an RPC error indicates the method itself is unavailable, as opposed
263
+ * to a normal execution failure. We only fall back to another trace method on
264
+ * "unsupported", never on a genuine revert or bad-params error.
265
+ */
266
+ function isMethodUnsupported(rpcError) {
267
+ const msg = (rpcError?.message || '').toLowerCase();
268
+ const code = rpcError?.code;
269
+ // -32601 = method not found (JSON-RPC). Providers also phrase disabled trace
270
+ // methods as "method ... not supported"/"not available"/"not enabled".
271
+ return (
272
+ code === -32601 ||
273
+ msg.includes('method not found') ||
274
+ msg.includes('not supported') ||
275
+ msg.includes('not available') ||
276
+ msg.includes('not enabled') ||
277
+ msg.includes('unsupported method')
278
+ );
279
+ }
280
+
281
+ /**
282
+ * True when a top-level JSON-RPC error denotes an in-EVM revert rather than a
283
+ * transport/params problem. The conformant eth_simulateV1 / callTracer shapes
284
+ * report a revert per-call (`calls[0].status === '0x0'` or a frame `error`), but
285
+ * a proxy may instead collapse a reverting simulation into a top-level `error`.
286
+ * Classifying that as SIM_RPC_ERROR would DEGRADE (warn + proceed), waving a
287
+ * reverting swap through; treat it as SIM_REVERTED so it blocks (fail closed).
288
+ * Checked only AFTER isMethodUnsupported, so a "method not available" error is
289
+ * never misread as a revert.
290
+ */
291
+ function isRevertError(rpcError) {
292
+ // EIP-1474: code 3 is the execution (revert) error. Also match the standard
293
+ // geth phrasing, but NOT a bare "revert": messages like "gas estimation would
294
+ // revert" or "request reverted by upstream policy" are estimation/transport
295
+ // noise, and treating those as a revert would hard-block (skip the quote)
296
+ // instead of degrading. Checked only AFTER isMethodUnsupported.
297
+ if (rpcError?.code === 3) return true;
298
+ return (rpcError?.message || '').toLowerCase().includes('execution reverted');
299
+ }
300
+
301
+ async function simulateViaEthSimulateV1(rpcUrl, apiKey, { from, to, data, value }, timeoutMs) {
302
+ const params = [
303
+ {
304
+ blockStateCalls: [
305
+ {
306
+ calls: [{ from, to, data: data || '0x', value: value || '0x0' }],
307
+ },
308
+ ],
309
+ // Surface native ETH movements as synthetic Transfer logs, and don't let
310
+ // validation (nonce/balance) reject the pre-broadcast sim.
311
+ traceTransfers: true,
312
+ validation: false,
313
+ },
314
+ 'latest',
315
+ ];
316
+ const body = await postSim(rpcUrl, apiKey, 'eth_simulateV1', params, timeoutMs);
317
+ if (body.error) {
318
+ if (isMethodUnsupported(body.error)) {
319
+ throw new SwapSimulationError('NOT_SIM_CAPABLE', `eth_simulateV1 unavailable: ${body.error.message}`);
320
+ }
321
+ if (isRevertError(body.error)) {
322
+ throw new SwapSimulationError('SIM_REVERTED', `Swap reverts in simulation: ${body.error.message}`);
323
+ }
324
+ throw new SwapSimulationError('SIM_RPC_ERROR', `eth_simulateV1 error: ${body.error.message}`);
325
+ }
326
+ const blockResult = Array.isArray(body.result) ? body.result[0] : body.result;
327
+ const call = blockResult?.calls?.[0];
328
+ if (!call) {
329
+ throw new SwapSimulationError('SIM_RPC_ERROR', 'eth_simulateV1 returned no call result');
330
+ }
331
+ // status is '0x1' on success, '0x0' on revert. A non-conformant bare '0x'/''
332
+ // (no hex digits) is indeterminate: BigInt('0x') would throw a TypeError that
333
+ // is NOT a SwapSimulationError, so verifySwapOutcome would misclassify it as a
334
+ // hard block (proceed:false) with an opaque message instead of degrading.
335
+ // Skip the status check for those and let the balance-delta assertions judge
336
+ // the real outcome (a swap that truly delivered nothing still fails assertion
337
+ // 2). Numeric statuses stay handled by BigInt via the plain constructor.
338
+ if (call.status != null && call.status !== '0x' && call.status !== '' && BigInt(call.status) === 0n) {
339
+ throw new SwapSimulationError(
340
+ 'SIM_REVERTED',
341
+ `Swap reverts in simulation${call.error?.message ? `: ${call.error.message}` : ''}`,
342
+ );
343
+ }
344
+ const { deltas, approvals, nftOut, nftApprovals } = foldLogs(call.logs, from);
345
+ return { deltas, approvals, nftOut, nftApprovals, method: 'eth_simulateV1' };
346
+ }
347
+
348
+ // ============= debug_traceCall + callTracer (fallback) =============
349
+
350
+ /** Depth-first flatten a callTracer frame tree into { logs, frames }. */
351
+ function flattenFrames(root) {
352
+ const logs = [];
353
+ const frames = [];
354
+ const stack = [root];
355
+ while (stack.length) {
356
+ const f = stack.pop();
357
+ if (!f) continue;
358
+ frames.push(f);
359
+ for (const lg of f.logs || []) logs.push(lg);
360
+ for (const child of f.calls || []) stack.push(child);
361
+ }
362
+ return { logs, frames };
363
+ }
364
+
365
+ async function simulateViaDebugTraceCall(rpcUrl, apiKey, { from, to, data, value }, timeoutMs) {
366
+ const params = [
367
+ { from, to, data: data || '0x', value: value || '0x0' },
368
+ 'latest',
369
+ { tracer: 'callTracer', tracerConfig: { withLog: true } },
370
+ ];
371
+ const body = await postSim(rpcUrl, apiKey, 'debug_traceCall', params, timeoutMs);
372
+ if (body.error) {
373
+ if (isMethodUnsupported(body.error)) {
374
+ throw new SwapSimulationError('NOT_SIM_CAPABLE', `debug_traceCall unavailable: ${body.error.message}`);
375
+ }
376
+ if (isRevertError(body.error)) {
377
+ throw new SwapSimulationError('SIM_REVERTED', `Swap reverts in simulation: ${body.error.message}`);
378
+ }
379
+ throw new SwapSimulationError('SIM_RPC_ERROR', `debug_traceCall error: ${body.error.message}`);
380
+ }
381
+ const root = body.result;
382
+ if (!root) throw new SwapSimulationError('SIM_RPC_ERROR', 'debug_traceCall returned no result');
383
+ if (root.error) {
384
+ throw new SwapSimulationError('SIM_REVERTED', `Swap reverts in simulation: ${root.error}`);
385
+ }
386
+
387
+ const { logs, frames } = flattenFrames(root);
388
+ const { deltas, approvals, nftOut, nftApprovals } = foldLogs(logs, from);
389
+
390
+ // callTracer does NOT emit synthetic logs for native ETH, so derive native
391
+ // movement from the `value` on each frame: value the wallet sends is an
392
+ // outflow, value it receives is an inflow. Mirrors traceTransfers semantics.
393
+ //
394
+ // Only value-carrying opcodes actually move ETH: skip STATICCALL (value
395
+ // forbidden) and DELEGATECALL (runs in the caller's context, its `value`
396
+ // mirrors the parent rather than transferring) so a node that populates their
397
+ // `value` field anyway can't invent or double-count native flow. A frame with
398
+ // no `type` (unusual) is treated as non-moving and skipped.
399
+ const w = from.toLowerCase();
400
+ let native = 0n;
401
+ for (const f of frames) {
402
+ if (!ETH_MOVING_FRAME_TYPES.has((f.type || '').toUpperCase())) continue;
403
+ const v = hexToBigInt(f.value);
404
+ if (v === 0n) continue;
405
+ if ((f.to || '').toLowerCase() === w) native += v;
406
+ if ((f.from || '').toLowerCase() === w) native -= v;
407
+ }
408
+ if (native !== 0n) {
409
+ deltas[EVM_NATIVE_SENTINEL] = (deltas[EVM_NATIVE_SENTINEL] || 0n) + native;
410
+ if (deltas[EVM_NATIVE_SENTINEL] === 0n) delete deltas[EVM_NATIVE_SENTINEL];
411
+ }
412
+
413
+ // callTracer only sets `error` on the frame that reverted; a top-level call can
414
+ // "succeed" while a sub-call reverts silently, moving nothing. If the trace
415
+ // yielded no deltas and no approvals but some frame errored, surface that as a
416
+ // revert — clearer than letting an all-zero outcome fail downstream as a
417
+ // mismatch. (The primary eth_simulateV1 path reports status directly.)
418
+ //
419
+ // Deliberately scoped to the moved-nothing case: DO NOT widen this to throw on
420
+ // any errored frame. Aggregators routinely make sub-calls that revert and are
421
+ // caught (probe pool A, revert, fall back to pool B) inside an otherwise
422
+ // successful swap, so those frame errors are normal. When tokens actually
423
+ // moved, assertSwapOutcome judges the real outcome (a partial swap that failed
424
+ // to deliver the output still fails assertion 2), so an errored frame there is
425
+ // not a reliable revert signal and would false-positive on legitimate swaps.
426
+ if (
427
+ Object.keys(deltas).length === 0 &&
428
+ approvals.length === 0 &&
429
+ nftOut.length === 0 &&
430
+ nftApprovals.length === 0
431
+ ) {
432
+ const errored = frames.find((f) => f.error);
433
+ if (errored) {
434
+ throw new SwapSimulationError('SIM_REVERTED', `Swap reverts in simulation: ${errored.error}`);
435
+ }
436
+ }
437
+ return { deltas, approvals, nftOut, nftApprovals, method: 'debug_traceCall' };
438
+ }
439
+
440
+ // ============= public entry point =============
441
+
442
+ /**
443
+ * Simulate a single swap transaction and return the normalised asset changes it
444
+ * causes to `from`'s wallet.
445
+ *
446
+ * Placement: call this on the swap call alone, AFTER any required approval is
447
+ * confirmed on-chain, so the live allowance is reflected on `latest` and a
448
+ * single-transaction simulation matches what the broadcast swap will do.
449
+ *
450
+ * @param {string} chain - chain key (only 'base' is wired today)
451
+ * @param {{ to: string, data: string, value?: string }} swapCall - the swap tx
452
+ * @param {{ from: string, apiKey?: string|null, timeoutMs?: number }} opts
453
+ * @returns {Promise<{ deltas: Record<string,bigint>, approvals: Array<{token,spender,amount}>, nftOut: Array<{standard,token}>, nftApprovals: Array<{standard,token,operator}>, method: string }>}
454
+ * @throws {SwapSimulationError} on any degrade condition or an in-sim revert.
455
+ */
456
+ export async function simulateAssetChanges(chain, swapCall, { from, apiKey = null, timeoutMs = 20000 } = {}) {
457
+ const rpcUrl = SIMULATION_RPCS[chain];
458
+ if (!rpcUrl) {
459
+ throw new SwapSimulationError('NO_SIM_RPC', `No simulation RPC configured for chain '${chain}'.`);
460
+ }
461
+ if (!from) {
462
+ throw new SwapSimulationError('SIM_RPC_ERROR', 'simulateAssetChanges requires a `from` address.');
463
+ }
464
+
465
+ const call = { from, to: swapCall.to, data: swapCall.data, value: swapCall.value };
466
+
467
+ // Primary: eth_simulateV1 (native transfers as synthetic logs, single round
468
+ // trip). Fall back to debug_traceCall only when eth_simulateV1 is unavailable.
469
+ try {
470
+ return await simulateViaEthSimulateV1(rpcUrl, apiKey, call, timeoutMs);
471
+ } catch (e) {
472
+ if (e instanceof SwapSimulationError && e.code === 'NOT_SIM_CAPABLE') {
473
+ return await simulateViaDebugTraceCall(rpcUrl, apiKey, call, timeoutMs);
474
+ }
475
+ throw e;
476
+ }
477
+ }
@@ -821,9 +821,11 @@ const BARE_ERC20_OUTER_SELECTORS = {
821
821
  };
822
822
 
823
823
  /**
824
- * Reject a same-chain swap whose transaction calldata is a bare ERC-20
825
- * transfer/approve/transferFrom rather than a router call. No-op when the
826
- * calldata is absent or too short to carry a 4-byte selector.
824
+ * Reject a swap or bridge whose transaction calldata is a bare ERC-20
825
+ * transfer/approve/transferFrom rather than a router call. Applies to both
826
+ * same-chain and cross-chain EVM quotes (a legit bridge also routes through a
827
+ * router). No-op when the calldata is absent or too short to carry a 4-byte
828
+ * selector.
827
829
  *
828
830
  * @param {string} data - The swap transaction's calldata (quote.transaction.data)
829
831
  */
@@ -837,3 +839,213 @@ export function assertSwapCalldataNotBareTransfer(data) {
837
839
  );
838
840
  }
839
841
  }
842
+
843
+ // ============= Swap-outcome verification (balance-delta simulation) =============
844
+
845
+ /**
846
+ * Assert that a SIMULATED swap's asset changes match the user's intent, failing
847
+ * closed on any mismatch. This is a defence-in-depth outcome check that
848
+ * complements the static calldata checks (validateSwapTarget /
849
+ * assertSwapCalldataNotBareTransfer): it verifies what the swap actually does to
850
+ * the wallet's balances, not just what the calldata looks like.
851
+ *
852
+ * Run it on the swap-call-alone simulation AFTER any required approval is
853
+ * confirmed on-chain, so the live allowance is reflected on `latest` and a
854
+ * single-transaction sim matches the broadcast swap (see swap-simulation.js).
855
+ *
856
+ * Four assertions, all derived from the persisted request intent + the quote:
857
+ * 1. the input token leaves the wallet by no MORE than maxInputAmount. Native
858
+ * input excludes gas: the sim deltas are log-based, so gas (not a transfer
859
+ * log) is never counted.
860
+ * 2. the output token arrives by AT LEAST minOut — exactOut: >= the requested
861
+ * output; exactIn: the quoted output reduced by the slippage in effect.
862
+ * 3. NO token other than the input leaves the wallet.
863
+ * 4. the wallet grants no Approval to a spender outside `expectedSpenders`.
864
+ *
865
+ * @param {object} request - persisted intent (quoteData.request); required
866
+ * @param {object} quote - the quote being executed
867
+ * @param {{deltas: Record<string, bigint|string|number>, approvals?: Array<{token?:string, spender?:string, amount?:any}>}} sim
868
+ * - the normalised result from simulateAssetChanges()
869
+ * @param {object} [ctx]
870
+ * @param {number} [ctx.slippage] - slippage fraction in effect (quoteData.slippage);
871
+ * defaults to 3% to match approvalAmountForSwap when omitted
872
+ * @param {Set<string>|string[]} [ctx.expectedSpenders] - spenders the wallet may
873
+ * legitimately (re)approve during the swap (e.g. the approval target and the
874
+ * router); anything else fails assertion 4. Compared case-insensitively.
875
+ * @param {bigint} [ctx.siblingDustThreshold=0n] - non-input outflow tolerated
876
+ * before assertion 3 fires (for fee-on-transfer / rounding). Strict 0 default.
877
+ * @throws {Error} with `code = 'SWAP_OUTCOME_MISMATCH'` on any failed assertion.
878
+ */
879
+ export function assertSwapOutcome(request, quote, sim, { slippage, expectedSpenders, siblingDustThreshold = 0n } = {}) {
880
+ const fail = (detail) => {
881
+ const e = new Error(`Swap outcome mismatch (SWAP_OUTCOME_MISMATCH): ${detail} Refusing to sign.`);
882
+ e.code = 'SWAP_OUTCOME_MISMATCH';
883
+ return e;
884
+ };
885
+
886
+ if (!request) throw fail('no request intent to verify the outcome against.');
887
+ if (!sim || typeof sim !== 'object' || sim.deltas == null) {
888
+ throw fail('simulation returned no asset changes to verify.');
889
+ }
890
+
891
+ // Normalise deltas to a lowercased-key BigInt map. A non-integer delta is a
892
+ // corrupt sim result — fail closed rather than coerce it to 0.
893
+ const deltas = {};
894
+ for (const [k, v] of Object.entries(sim.deltas)) {
895
+ let amt;
896
+ try {
897
+ amt = typeof v === 'bigint' ? v : BigInt(v);
898
+ } catch {
899
+ throw fail(`simulated delta for ${k} (${v}) is not an integer.`);
900
+ }
901
+ deltas[k.toLowerCase()] = amt;
902
+ }
903
+
904
+ const inputToken = quote?.inputMint ? String(quote.inputMint).toLowerCase() : null;
905
+ const outputToken = quote?.outputMint ? String(quote.outputMint).toLowerCase() : null;
906
+ if (!inputToken || !outputToken) {
907
+ throw fail('quote is missing the input or output token address.');
908
+ }
909
+ // Fail closed on a same-token quote: assertion 3 skips the input token, so if
910
+ // output == input a drain of that token would slip past unverified. A real
911
+ // swap never sells and buys the same token (also rejected upstream).
912
+ if (inputToken === outputToken) {
913
+ throw fail(`quote input and output tokens are the same (${inputToken}); refusing to verify.`);
914
+ }
915
+
916
+ // --- Assertion 1: input outflow within the spend ceiling ---
917
+ // This bounds the outflow by maxInputAmount (the slippage-buffered ceiling),
918
+ // NOT the exact expected input: for exactOut the aggregator may legitimately
919
+ // pull anywhere up to that ceiling. The tighter exactIn bound (outflow ==
920
+ // request.amount) is enforced by assertQuoteMatchesRequest, which the execute
921
+ // paths run earlier in the same iteration. Keep that call ahead of this one on
922
+ // any new signing path — Assertion 1 alone does not re-check exactIn inflation.
923
+ if (request.maxInputAmount == null) {
924
+ throw fail('request has no maximum input to bound the outflow against.');
925
+ }
926
+ let cap;
927
+ try {
928
+ cap = BigInt(request.maxInputAmount);
929
+ } catch {
930
+ throw fail(`maximum input (${request.maxInputAmount}) is not an integer.`);
931
+ }
932
+ const inputDelta = deltas[inputToken] || 0n;
933
+ const outflow = inputDelta < 0n ? -inputDelta : 0n;
934
+ if (outflow > cap) {
935
+ throw fail(`the input token (${inputToken}) left the wallet by ${outflow}, exceeding your maximum input (${cap}).`);
936
+ }
937
+
938
+ // --- Assertion 2: output arrives at or above the minimum acceptable ---
939
+ const swapMode = request.swapMode ?? 'exactIn';
940
+ const outputDelta = deltas[outputToken] || 0n;
941
+ let minOut;
942
+ if (swapMode === 'exactOut') {
943
+ if (request.amount == null) throw fail('exactOut request is missing the requested output amount.');
944
+ try {
945
+ minOut = BigInt(request.amount);
946
+ } catch {
947
+ throw fail(`requested output amount (${request.amount}) is not an integer.`);
948
+ }
949
+ // Mirror the exactIn non-positive guard: a zero/negative requested output
950
+ // makes minOut <= 0 and turns assertion 2 into a no-op (outputDelta >= 0
951
+ // always holds), so a swap delivering nothing would pass. Upstream rejects
952
+ // zero amounts, but this helper is a self-contained fail-closed boundary.
953
+ if (minOut <= 0n) {
954
+ throw fail(`exactOut request has a non-positive output amount (${minOut}); cannot compute a minimum acceptable output.`);
955
+ }
956
+ } else {
957
+ const quotedRaw = quote.outAmount ?? quote.outputAmount;
958
+ if (quotedRaw == null) {
959
+ throw fail('quote is missing the quoted output amount; cannot compute the minimum acceptable output.');
960
+ }
961
+ let quoted;
962
+ try {
963
+ quoted = BigInt(quotedRaw);
964
+ } catch {
965
+ throw fail(`quoted output amount (${quotedRaw}) is not an integer.`);
966
+ }
967
+ // A non-positive quoted output makes minOut <= 0, so a sim receiving nothing
968
+ // (or losing the output token) would pass assertion 2 (outputDelta < minOut is
969
+ // false when minOut <= 0). exactIn has no upstream positive-output guard
970
+ // (unlike exactOut), so a rogue outAmount of "0" or a negative value would
971
+ // otherwise slip through.
972
+ if (quoted <= 0n) {
973
+ throw fail(`quote has a non-positive output amount (${quoted}); cannot compute a minimum acceptable output.`);
974
+ }
975
+ // Floor of quoted × (1 − slippage), in basis points to stay in BigInt. This
976
+ // mirrors the slippage the user actually set (quoteData.slippage), defaulting
977
+ // to 3% to match approvalAmountForSwap when it wasn't supplied.
978
+ //
979
+ // Cap the slippage used HERE at 50%, independent of what the user accepted:
980
+ // the upstream quote command allows --slippage up to 1.0 (100%), which would
981
+ // make minOut 0 and neuter this assertion — a route delivering nothing would
982
+ // pass (outputDelta >= 0). This is a defence-in-depth floor, not the user's
983
+ // execution tolerance; a real swap never loses more than half the quoted
984
+ // output, so requiring at least 50% keeps the guard meaningful while leaving
985
+ // enormous headroom over a normal few-percent deviation.
986
+ const rawSlip = Number.isFinite(slippage) && slippage >= 0 ? slippage : 0.03;
987
+ const slip = Math.min(rawSlip, 0.5);
988
+ const bps = BigInt(Math.min(10000, Math.round(slip * 10000)));
989
+ minOut = (quoted * (10000n - bps)) / 10000n;
990
+ }
991
+ if (outputDelta < minOut) {
992
+ throw fail(`the output token (${outputToken}) increased by only ${outputDelta}, below the minimum acceptable output (${minOut}).`);
993
+ }
994
+
995
+ // --- Assertion 3: no token other than the input leaves the wallet ---
996
+ const dust = siblingDustThreshold > 0n ? siblingDustThreshold : 0n;
997
+ for (const [token, delta] of Object.entries(deltas)) {
998
+ if (token === inputToken) continue; // its outflow is bounded by assertion 1
999
+ if (delta < 0n && -delta > dust) {
1000
+ throw fail(`a token other than the one you are selling (${token}) left the wallet (delta ${delta}); a swap must not move any token except the input.`);
1001
+ }
1002
+ }
1003
+
1004
+ // --- Assertion 3b: no non-fungible asset leaves the wallet ---
1005
+ // The signed `deltas` map only models native + ERC-20 balances, so an NFT
1006
+ // drain is invisible to assertion 3. A DEX swap should never move an ERC-721 or
1007
+ // ERC-1155 out of the wallet, so fail closed if the sim surfaced one. (Inbound
1008
+ // NFTs are harmless and are not recorded by foldLogs.)
1009
+ for (const nft of sim.nftOut || []) {
1010
+ throw fail(
1011
+ `a non-fungible asset (${nft.standard}${nft.token ? ` ${nft.token}` : ''}) left the wallet; a swap must not transfer any NFT.`,
1012
+ );
1013
+ }
1014
+
1015
+ // --- Assertion 3c: no non-fungible approval is granted ---
1016
+ // A DEX swap never needs to approve an NFT, so any ERC-721 / ERC-1155 approval
1017
+ // the wallet grants (single-token Approval or ApprovalForAll) is fail-closed —
1018
+ // it would let the operator move the NFT out AFTER the swap, invisibly to the
1019
+ // transfer checks above. The ERC-20 spender allowlist (assertion 4) does NOT
1020
+ // cover these: a single-NFT Approval folds in as a zero-amount "revoke" and an
1021
+ // ApprovalForAll is not an ERC-20 Approval at all.
1022
+ for (const ap of sim.nftApprovals || []) {
1023
+ throw fail(
1024
+ `the swap grants a non-fungible approval (${ap.standard}${ap.token ? ` ${ap.token}` : ''}) to ${ap.operator || 'an operator'}; a swap must not approve any NFT.`,
1025
+ );
1026
+ }
1027
+
1028
+ // --- Assertion 4: no approval to an unexpected spender ---
1029
+ const allowed = new Set(
1030
+ (expectedSpenders instanceof Set ? [...expectedSpenders] : expectedSpenders || [])
1031
+ .filter(Boolean)
1032
+ .map((s) => String(s).toLowerCase()),
1033
+ );
1034
+ for (const ap of sim.approvals || []) {
1035
+ if (!ap || !ap.spender) continue;
1036
+ // A revoke (approve to 0) grants no allowance, so it is never a concern.
1037
+ if (ap.amount != null) {
1038
+ try {
1039
+ if (BigInt(ap.amount) === 0n) continue;
1040
+ } catch { /* non-integer amount → treat as a real approval below */ }
1041
+ }
1042
+ const spender = String(ap.spender).toLowerCase();
1043
+ if (!allowed.has(spender)) {
1044
+ throw fail(
1045
+ `the swap grants an approval to an unexpected spender (${spender}); a swap should only (re)approve ${allowed.size ? [...allowed].join(', ') : 'nothing'}.`,
1046
+ );
1047
+ }
1048
+ }
1049
+
1050
+ return { verified: true };
1051
+ }
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 } from './trade-validation.js';
17
17
  import { CHAIN_RPCS } from './rpc-urls.js';
18
- import { packageVersion, CommandError, telemetryHeaders } from './api.js';
18
+ import { simulateAssetChanges, SwapSimulationError, hasSimulationRpc } from './swap-simulation.js';
19
+ import { packageVersion, CommandError, telemetryHeaders, loadConfig } from './api.js';
19
20
 
20
21
  // ============= Constants =============
21
22
 
@@ -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.
@@ -1761,7 +1848,18 @@ CROSS-CHAIN NOTES (when using --to-chain):
1761
1848
  const quoteId = options.quote || options['quote-id'] || args[0];
1762
1849
  const walletName = options.wallet;
1763
1850
  const noSimulate = flags['no-simulate'];
1851
+ const noVerifyOutcome = flags['no-verify-outcome'];
1764
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
+ })();
1765
1863
 
1766
1864
  if (!quoteId) {
1767
1865
  throw new CommandError(`Usage: nansen trade execute --quote <quoteId> [options]
@@ -1769,7 +1867,8 @@ CROSS-CHAIN NOTES (when using --to-chain):
1769
1867
  OPTIONS:
1770
1868
  --quote <id> Quote ID from 'nansen quote'
1771
1869
  --wallet <name> Wallet name (default: default wallet)
1772
- --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)
1773
1872
  --gasless Relay-only: have Relay's solver pay gas (no WalletConnect)
1774
1873
 
1775
1874
  EXAMPLES:
@@ -1939,15 +2038,15 @@ EXAMPLES:
1939
2038
  assertCompleteEvmRequestIntent(quoteData.request);
1940
2039
  assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
1941
2040
 
1942
- // Same-chain only: a legit swap's outer call is a router method,
1943
- // never a bare ERC-20 transfer/approve. Reject that drain shape.
1944
- // Cross-chain routes skip THIS selector check, but a bare transfer
1945
- // still targets the token contract, so validateSwapTarget's
1946
- // `to === inputMint` gate above already refuses it on both paths —
1947
- // cross-chain bare transfers are not actually waved through here.
1948
- if (!quoteData.toChain) {
1949
- assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
1950
- }
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);
1951
2050
 
1952
2051
  // Validate transaction.value (same checks as local wallet)
1953
2052
  const isNative = isNativeToken(currentQuote.inputMint);
@@ -2048,6 +2147,20 @@ EXAMPLES:
2048
2147
  }
2049
2148
  }
2050
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
+
2051
2164
  // Gas resolution — fall back to eth_estimateGas if quote has no gas
2052
2165
  const txData = currentQuote.transaction;
2053
2166
  const apiGas = parseInt(currentQuote.gas || '0');
@@ -2175,15 +2288,15 @@ EXAMPLES:
2175
2288
  assertCompleteEvmRequestIntent(quoteData.request);
2176
2289
  assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress: wcAddress, slippage: quoteData.slippage });
2177
2290
 
2178
- // Same-chain only: a legit swap's outer call is a router method,
2179
- // never a bare ERC-20 transfer/approve. Reject that drain shape.
2180
- // Cross-chain routes skip THIS selector check, but a bare transfer
2181
- // still targets the token contract, so validateSwapTarget's
2182
- // `to === inputMint` gate above already refuses it on both paths —
2183
- // cross-chain bare transfers are not actually waved through here.
2184
- if (!quoteData.toChain) {
2185
- assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2186
- }
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);
2187
2300
 
2188
2301
  // Validate transaction.value (same checks as local wallet)
2189
2302
  const txValue = BigInt(currentQuote.transaction.value || '0');
@@ -2282,6 +2395,20 @@ EXAMPLES:
2282
2395
  }
2283
2396
  }
2284
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
+
2285
2412
  // Resolve gas
2286
2413
  const txData = currentQuote.transaction;
2287
2414
  const apiGas = parseInt(currentQuote.gas || "0");
@@ -2379,15 +2506,15 @@ EXAMPLES:
2379
2506
  assertCompleteEvmRequestIntent(quoteData.request);
2380
2507
  assertQuoteMatchesRequest(quoteData.request, currentQuote, { chain, walletAddress, slippage: quoteData.slippage });
2381
2508
 
2382
- // Same-chain only: a legit swap's outer call is a router method,
2383
- // never a bare ERC-20 transfer/approve. Reject that drain shape.
2384
- // Cross-chain routes skip THIS selector check, but a bare transfer
2385
- // still targets the token contract, so validateSwapTarget's
2386
- // `to === inputMint` gate above already refuses it on both paths —
2387
- // cross-chain bare transfers are not actually waved through here.
2388
- if (!quoteData.toChain) {
2389
- assertSwapCalldataNotBareTransfer(currentQuote.transaction.data);
2390
- }
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);
2391
2518
 
2392
2519
  // Handle approval if needed — skip for native ETH
2393
2520
  // Check existing allowance first to avoid unnecessary approve txs
@@ -2501,6 +2628,20 @@ EXAMPLES:
2501
2628
  }
2502
2629
  }
2503
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
+
2504
2645
  // Use the Trading API's gas estimation (quote.gas) directly.
2505
2646
  // The API already applies a 1.5x buffer over eth_estimateGas.
2506
2647
  // Skip client-side re-estimation — it adds latency and the API value is reliable.