nansen-cli 1.38.0 → 1.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +56 -1
- package/package.json +1 -1
- package/skills/nansen-wallet-batch/SKILL.md +1 -1
- package/skills/nansen-wallet-profiler/SKILL.md +1 -1
- package/src/api.js +4 -3
- package/src/cli.js +8 -3
- package/src/limit-order.js +30 -7
- package/src/response-meta.js +2 -2
- package/src/rpc-urls.js +67 -0
- package/src/schema.json +20 -3
- package/src/swap-simulation.js +477 -0
- package/src/trade-validation.js +237 -6
- package/src/trading.js +566 -70
- package/src/transfer.js +25 -3
- package/src/walletconnect-trading.js +4 -2
|
@@ -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
|
+
}
|