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 +16 -0
- package/README.md +56 -1
- package/package.json +1 -1
- package/skills/nansen-wallet-batch/SKILL.md +1 -1
- package/skills/nansen-wallet-profiler/SKILL.md +1 -1
- package/src/api.js +4 -3
- package/src/cli.js +7 -2
- package/src/response-meta.js +2 -2
- package/src/rpc-urls.js +67 -0
- package/src/schema.json +14 -1
- package/src/swap-simulation.js +477 -0
- package/src/trade-validation.js +215 -3
- package/src/trading.js +171 -30
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
|
@@ -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"),
|
|
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`
|
|
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/
|
|
912
|
-
|
|
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?.
|
|
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
|
-
|
|
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 });
|
package/src/response-meta.js
CHANGED
|
@@ -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
|
+
}
|
package/src/trade-validation.js
CHANGED
|
@@ -821,9 +821,11 @@ const BARE_ERC20_OUTER_SELECTORS = {
|
|
|
821
821
|
};
|
|
822
822
|
|
|
823
823
|
/**
|
|
824
|
-
* Reject a
|
|
825
|
-
* transfer/approve/transferFrom rather than a router call.
|
|
826
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
1943
|
-
//
|
|
1944
|
-
//
|
|
1945
|
-
//
|
|
1946
|
-
//
|
|
1947
|
-
//
|
|
1948
|
-
|
|
1949
|
-
|
|
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
|
-
//
|
|
2179
|
-
//
|
|
2180
|
-
//
|
|
2181
|
-
//
|
|
2182
|
-
//
|
|
2183
|
-
//
|
|
2184
|
-
|
|
2185
|
-
|
|
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
|
-
//
|
|
2383
|
-
//
|
|
2384
|
-
//
|
|
2385
|
-
//
|
|
2386
|
-
//
|
|
2387
|
-
//
|
|
2388
|
-
|
|
2389
|
-
|
|
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.
|