@morpho-org/evm-simulation 1.0.0 → 2.0.1

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.
@@ -1,29 +1,45 @@
1
1
  import type { SimulationLogger, SimulationTransaction, Transfer } from "../types.js";
2
2
  /**
3
- * Screen all relevant addresses from a simulation for sanctioned/compromised addresses.
3
+ * Screens every address that appears in a simulated bundle (transfer senders and recipients,
4
+ * plus tx `from` / `to`) against the static {@link sanctionedAddresses} list and, when
5
+ * configured, the Chainalysis Entity API.
4
6
  *
5
- * Screening targets (deduped, lowercased, zero-addr removed):
6
- * - Transfer senders and recipients from simulation result
7
- * - `from` and `to` of every tx in `simulationTxs` (catches native-value flows)
7
+ * Addresses are deduped and lowercased; the zero address is removed. Checks run in order:
8
8
  *
9
- * Checks run in order:
10
- * 1. Static {@link sanctionedAddresses} set (fast, no network, always on).
11
- * 2. Chainalysis Entity API — one lookup per address, lookups across addresses
12
- * run in parallel. Only fires if `chainalysisApiKey` is provided.
13
- * **Fail-open on transport errors before registration** (network / timeout /
14
- * non-ok POST): the address is treated as clean, a warn is logged, and the
15
- * simulation proceeds. This is the documented posture — a Chainalysis
16
- * outage must not block all simulations.
17
- * **Fail-closed on partial registration**: if the registration POST succeeded
18
- * but the subsequent lookup GET fails, the address is treated as Severe.
19
- * The data already persisted server-side, so failing open at this point
20
- * would let a pass slip through based on a half-completed check.
21
- * **Fail-closed on schema drift**: if Chainalysis returns an unknown risk
22
- * tier or a body that doesn't parse, the address is treated as Severe so a
23
- * future stricter tier isn't silently downgraded.
9
+ * 1. **Static sanctioned list** — always on, no network. Throws on the first hit.
10
+ * 2. **Chainalysis Entity API** — one lookup per address, lookups parallelized. Only runs when
11
+ * `chainalysisApiKey` is provided.
12
+ * - **Fail-open on transport errors before registration** (network / timeout / non-ok POST):
13
+ * treat the address as clean and log a warn. A Chainalysis outage must not block all
14
+ * simulations.
15
+ * - **Fail-closed on partial registration**: if the registration POST succeeded but the
16
+ * lookup GET fails, treat the address as Severe.
17
+ * - **Fail-closed on schema drift**: an unknown risk tier or unparsed body is treated as
18
+ * Severe so a future stricter tier is not silently downgraded.
24
19
  *
25
- * Throws `AddressScreeningError` on any Severe hit. Intended to be called after
26
- * `simulate`, passing the returned `simulationTxs` and `transfers`.
20
+ * Intended to be called after {@link simulate}, passing the returned `simulationTxs` and
21
+ * `transfers`.
22
+ *
23
+ * @param params.simulationTxs - The resolved transactions returned by `simulate()`.
24
+ * @param params.transfers - The parsed transfers returned by `simulate()`.
25
+ * @param params.chainalysisApiKey - Optional Chainalysis token. When absent, only the static
26
+ * sanctioned list runs.
27
+ * @param params.signal - Optional `AbortSignal` propagated to every Chainalysis request.
28
+ * @param params.logger - Optional `SimulationLogger` for warn-level diagnostics.
29
+ * @returns Resolves to `void` on a clean screening; throws on any Severe hit.
30
+ * @throws {AddressScreeningError} when any address matches the static sanctioned list or
31
+ * resolves to Severe via Chainalysis.
32
+ * @example
33
+ * ```ts
34
+ * import { screenAddresses, simulate } from "@morpho-org/evm-simulation";
35
+ *
36
+ * const result = await simulate(config, params);
37
+ * await screenAddresses({
38
+ * simulationTxs: result.simulationTxs,
39
+ * transfers: result.transfers,
40
+ * chainalysisApiKey: process.env.CHAINALYSIS_API_KEY,
41
+ * });
42
+ * ```
27
43
  */
28
44
  export declare function screenAddresses(params: {
29
45
  simulationTxs: SimulationTransaction[];
@@ -16,30 +16,46 @@ const chainalysisEntitySchema = z
16
16
  })
17
17
  .passthrough();
18
18
  /**
19
- * Screen all relevant addresses from a simulation for sanctioned/compromised addresses.
19
+ * Screens every address that appears in a simulated bundle (transfer senders and recipients,
20
+ * plus tx `from` / `to`) against the static {@link sanctionedAddresses} list and, when
21
+ * configured, the Chainalysis Entity API.
20
22
  *
21
- * Screening targets (deduped, lowercased, zero-addr removed):
22
- * - Transfer senders and recipients from simulation result
23
- * - `from` and `to` of every tx in `simulationTxs` (catches native-value flows)
23
+ * Addresses are deduped and lowercased; the zero address is removed. Checks run in order:
24
24
  *
25
- * Checks run in order:
26
- * 1. Static {@link sanctionedAddresses} set (fast, no network, always on).
27
- * 2. Chainalysis Entity API — one lookup per address, lookups across addresses
28
- * run in parallel. Only fires if `chainalysisApiKey` is provided.
29
- * **Fail-open on transport errors before registration** (network / timeout /
30
- * non-ok POST): the address is treated as clean, a warn is logged, and the
31
- * simulation proceeds. This is the documented posture — a Chainalysis
32
- * outage must not block all simulations.
33
- * **Fail-closed on partial registration**: if the registration POST succeeded
34
- * but the subsequent lookup GET fails, the address is treated as Severe.
35
- * The data already persisted server-side, so failing open at this point
36
- * would let a pass slip through based on a half-completed check.
37
- * **Fail-closed on schema drift**: if Chainalysis returns an unknown risk
38
- * tier or a body that doesn't parse, the address is treated as Severe so a
39
- * future stricter tier isn't silently downgraded.
25
+ * 1. **Static sanctioned list** — always on, no network. Throws on the first hit.
26
+ * 2. **Chainalysis Entity API** — one lookup per address, lookups parallelized. Only runs when
27
+ * `chainalysisApiKey` is provided.
28
+ * - **Fail-open on transport errors before registration** (network / timeout / non-ok POST):
29
+ * treat the address as clean and log a warn. A Chainalysis outage must not block all
30
+ * simulations.
31
+ * - **Fail-closed on partial registration**: if the registration POST succeeded but the
32
+ * lookup GET fails, treat the address as Severe.
33
+ * - **Fail-closed on schema drift**: an unknown risk tier or unparsed body is treated as
34
+ * Severe so a future stricter tier is not silently downgraded.
40
35
  *
41
- * Throws `AddressScreeningError` on any Severe hit. Intended to be called after
42
- * `simulate`, passing the returned `simulationTxs` and `transfers`.
36
+ * Intended to be called after {@link simulate}, passing the returned `simulationTxs` and
37
+ * `transfers`.
38
+ *
39
+ * @param params.simulationTxs - The resolved transactions returned by `simulate()`.
40
+ * @param params.transfers - The parsed transfers returned by `simulate()`.
41
+ * @param params.chainalysisApiKey - Optional Chainalysis token. When absent, only the static
42
+ * sanctioned list runs.
43
+ * @param params.signal - Optional `AbortSignal` propagated to every Chainalysis request.
44
+ * @param params.logger - Optional `SimulationLogger` for warn-level diagnostics.
45
+ * @returns Resolves to `void` on a clean screening; throws on any Severe hit.
46
+ * @throws {AddressScreeningError} when any address matches the static sanctioned list or
47
+ * resolves to Severe via Chainalysis.
48
+ * @example
49
+ * ```ts
50
+ * import { screenAddresses, simulate } from "@morpho-org/evm-simulation";
51
+ *
52
+ * const result = await simulate(config, params);
53
+ * await screenAddresses({
54
+ * simulationTxs: result.simulationTxs,
55
+ * transfers: result.transfers,
56
+ * chainalysisApiKey: process.env.CHAINALYSIS_API_KEY,
57
+ * });
58
+ * ```
43
59
  */
44
60
  export async function screenAddresses(params) {
45
61
  const { simulationTxs, transfers, chainalysisApiKey, signal, logger } = params;
@@ -100,7 +116,10 @@ export async function screenAddresses(params) {
100
116
  *
101
117
  * A SINGLE deadline covers both the POST and the GET, keeping the per-address
102
118
  * wall-time budget at `CHAINALYSIS_TIMEOUT_MS` (5 s) rather than doubling it.
119
+ *
120
+ * @internal
103
121
  */
122
+ // biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
104
123
  async function screenWithChainalysis(address, apiKey, parentSignal) {
105
124
  const headers = {
106
125
  "Content-Type": "application/json",
@@ -1,4 +1,4 @@
1
- import { http, createPublicClient, getAddress, maxUint256, } from "viem";
1
+ import { createPublicClient, getAddress, http, maxUint256, } from "viem";
2
2
  import { ExternalServiceError, SimulationRevertedError, SimulationValidationError, } from "../../errors.js";
3
3
  /**
4
4
  * Simulate transactions using eth_simulateV1 (viem simulateCalls).
@@ -51,17 +51,17 @@ export async function simulateV1(params) {
51
51
  if (failedResult) {
52
52
  throw new SimulationRevertedError(failedResult.error?.message ?? "Simulation failed", results);
53
53
  }
54
- const rawLogs = [];
55
- for (const r of results) {
56
- for (const log of r.logs ?? []) {
57
- rawLogs.push({
58
- address: log.address,
59
- topics: [...log.topics],
60
- data: log.data ?? "0x",
61
- });
62
- }
63
- }
64
- return { logs: rawLogs };
54
+ const rawCalls = results.map((r) => ({
55
+ logs: (r.logs ?? []).map((log) => ({
56
+ address: log.address,
57
+ topics: [...log.topics],
58
+ data: log.data ?? "0x",
59
+ })),
60
+ status: r.status === "success",
61
+ returnData: r.data ?? "0x",
62
+ gasUsed: r.gasUsed,
63
+ }));
64
+ return { calls: rawCalls };
65
65
  }
66
66
  catch (error) {
67
67
  if (error instanceof SimulationRevertedError)
@@ -19,6 +19,7 @@ const tenderlyRawResponseSchema = z
19
19
  transaction: z
20
20
  .object({
21
21
  error_message: z.string().optional(),
22
+ gas_used: z.number(),
22
23
  transaction_info: z
23
24
  .object({
24
25
  logs: z
@@ -36,6 +37,11 @@ const tenderlyRawResponseSchema = z
36
37
  .passthrough())
37
38
  .optional(),
38
39
  asset_changes: z.unknown().optional(),
40
+ call_trace: z
41
+ .object({
42
+ output: hexSchema,
43
+ })
44
+ .passthrough(),
39
45
  })
40
46
  .passthrough(),
41
47
  })
@@ -98,6 +104,7 @@ function buildBaseUrl(config) {
98
104
  return `${config.apiBaseUrl}/api/v1/account/${account}/project/${project}`;
99
105
  }
100
106
  /** Build the `TenderlySimulateRequest` body for one transaction. Shared by single + bundle. */
107
+ // biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
101
108
  function buildTxBody(tx, chainId, shareable, blockNumber) {
102
109
  return {
103
110
  network_id: String(chainId),
@@ -129,19 +136,16 @@ async function simulateSingle(params) {
129
136
  throw new ExternalServiceError(`Tenderly REST API returned ${response.status}: ${response.statusText}`);
130
137
  }
131
138
  const data = tenderlyRawResponseSchema.parse(await response.json());
132
- const result = parseTenderlyResponse(data, shareable);
133
- if (shareable && data.simulation.id) {
134
- const shared = await shareSimulation({
135
- baseUrl,
136
- config,
137
- simulationId: data.simulation.id,
138
- signal,
139
- logger,
140
- });
141
- if (!shared)
142
- result.tenderlyUrl = undefined;
143
- }
144
- return result;
139
+ const call = parseTenderlyCall(data);
140
+ const tenderlyUrl = await resolveTenderlyUrl({
141
+ baseUrl,
142
+ config,
143
+ simulationId: data.simulation.id,
144
+ shareable,
145
+ signal,
146
+ logger,
147
+ });
148
+ return { calls: [call], tenderlyUrl };
145
149
  }
146
150
  async function simulateBundle(params) {
147
151
  const { baseUrl, config, chainId, transactions, blockNumber, shareable, signal, logger, } = params;
@@ -162,39 +166,40 @@ async function simulateBundle(params) {
162
166
  }
163
167
  const data = tenderlyBundleRawResponseSchema.parse(await response.json());
164
168
  const simulations = data.simulation_results;
165
- // Collect logs from ALL steps (approvals + main tx) for complete bundler retention coverage.
166
- // Only build the shareable URL from the last step; others parse with shareable=false.
167
- const allLogs = [];
168
- let lastResult;
169
- let lastSimulationId;
170
- for (let i = 0; i < simulations.length; i++) {
171
- const sim = simulations[i];
172
- const isLast = i === simulations.length - 1;
173
- const parsed = parseTenderlyResponse(sim, isLast && shareable);
174
- allLogs.push(...parsed.logs);
175
- if (isLast) {
176
- lastResult = parsed;
177
- lastSimulationId = sim.simulation.id;
178
- }
179
- }
180
- // Schema enforces `.min(1)` — lastResult is always defined after the loop.
181
- let tenderlyUrl = lastResult.tenderlyUrl;
182
- if (shareable && tenderlyUrl && lastSimulationId) {
183
- const shared = await shareSimulation({
184
- baseUrl,
185
- config,
186
- simulationId: lastSimulationId,
187
- signal,
188
- logger,
189
- });
190
- if (!shared)
191
- tenderlyUrl = undefined;
192
- }
193
- return {
194
- logs: allLogs,
195
- tenderlyUrl,
196
- rawAssetChanges: lastResult.rawAssetChanges,
197
- };
169
+ const calls = simulations.map((sim) => parseTenderlyCall(sim));
170
+ const lastSim = simulations[simulations.length - 1];
171
+ const tenderlyUrl = await resolveTenderlyUrl({
172
+ baseUrl,
173
+ config,
174
+ simulationId: lastSim.simulation.id,
175
+ shareable,
176
+ signal,
177
+ logger,
178
+ });
179
+ return { calls, tenderlyUrl };
180
+ }
181
+ /**
182
+ * Build the shareable Tenderly dashboard URL for the given simulation, if
183
+ * `shareable=true` and `/share` succeeds. Returns `undefined` otherwise.
184
+ *
185
+ * Mirrors the legacy "build optimistically, clear on failure" pattern: the
186
+ * URL is constructed up-front and cleared if `shareSimulation` returns false.
187
+ */
188
+ async function resolveTenderlyUrl(params) {
189
+ const { baseUrl, config, simulationId, shareable, signal, logger } = params;
190
+ if (!shareable || !simulationId)
191
+ return undefined;
192
+ let tenderlyUrl = `https://dashboard.tenderly.co/shared/simulation/${simulationId}`;
193
+ const shared = await shareSimulation({
194
+ baseUrl,
195
+ config,
196
+ simulationId,
197
+ signal,
198
+ logger,
199
+ });
200
+ if (!shared)
201
+ tenderlyUrl = undefined;
202
+ return tenderlyUrl;
198
203
  }
199
204
  /**
200
205
  * Try to make a simulation publicly shareable. Returns true iff Tenderly's
@@ -233,7 +238,13 @@ async function shareSimulation(params) {
233
238
  return false;
234
239
  }
235
240
  }
236
- function parseTenderlyResponse(data, shareable) {
241
+ /**
242
+ * Parse one Tenderly simulation response into a `RawCall`. The caller
243
+ * (single-sim or bundle) assembles the `RawSimulationResult` around it.
244
+ *
245
+ * Throws `SimulationRevertedError` if `simulation.status !== true`.
246
+ */
247
+ function parseTenderlyCall(data) {
237
248
  const { simulation, transaction } = data;
238
249
  if (simulation.status !== true) {
239
250
  const errorMessage = transaction.error_message ||
@@ -255,12 +266,11 @@ function parseTenderlyResponse(data, shareable) {
255
266
  });
256
267
  }
257
268
  }
258
- const tenderlyUrl = shareable && simulation.id
259
- ? `https://dashboard.tenderly.co/shared/simulation/${simulation.id}`
260
- : undefined;
261
269
  return {
262
270
  logs: rawLogs,
263
- tenderlyUrl,
264
- rawAssetChanges: transaction.transaction_info.asset_changes,
271
+ status: true,
272
+ returnData: transaction.transaction_info.call_trace.output,
273
+ gasUsed: BigInt(transaction.gas_used),
274
+ assetChanges: transaction.transaction_info.asset_changes,
265
275
  };
266
276
  }
@@ -1,27 +1,39 @@
1
- import type { RawLog, SimulationLogger, Transfer } from "../../types.js";
1
+ import type { RawCall, SimulationLogger, Transfer } from "../../types.js";
2
2
  export declare const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
3
3
  export declare const WITHDRAWAL_TOPIC = "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65";
4
4
  export declare const DEPOSIT_TOPIC = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c";
5
5
  /**
6
6
  * Parse raw EVM logs into individual Transfer events.
7
7
  *
8
+ * Iterates over per-tx `calls` and stamps each emitted `Transfer.txIdx`
9
+ * with the index of the originating call.
10
+ *
8
11
  * **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
9
12
  * `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
10
13
  * events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
11
14
  * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
12
15
  * see an incomplete transfer list.
13
16
  *
14
- * **WETH9-specific dedup:** matches Transfer-to/from zero against the
15
- * matching Deposit/Withdrawal event on the same contract with the same data
16
- * and withdrawer. Non-standard wrapped-native implementations (e.g., rebasing
17
- * or fee-on-withdraw wrappers) may escape the dedup and produce double-counts.
17
+ * **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
18
+ * the same tx: a zero-address `Transfer` is suppressed only if its paired
19
+ * `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
20
+ * is correct for canonical WETH9, which always emits the `Deposit`/
21
+ * `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
22
+ * atomically inside a single call frame. A **non-canonical wrapped-native**
23
+ * that splits these emissions across two txs in the same bundle would leave
24
+ * a phantom zero-address `Transfer` in the parsed output, which can be summed
25
+ * by `assertNoBundlerRetention` and produce a false `BlacklistViolationError`.
26
+ * When a zero-address `Transfer` misses same-tx dedup *and* its token has
27
+ * emitted a `Deposit`/`Withdrawal` somewhere else in the bundle (i.e. it
28
+ * looks wnative-shaped), the parser emits a `warn` so the assumption break
29
+ * is observable before it reaches retention. None of `morpho-sdk`'s currently
30
+ * supported chains require cross-tx dedup; the assumption is rechecked when
31
+ * onboarding a new chain (see `evm-simulation/CLAUDE.md`).
18
32
  *
19
33
  * **Failure mode:** on a per-log parse failure (malformed topic length,
20
34
  * non-hex data), the log is skipped and a `warn` is emitted via the logger.
21
- * The caller receives a possibly-incomplete transfer list. Upstream sources
22
- * (e.g., Tenderly response schemas) should validate log shape before calling
23
- * here to avoid silent drops.
24
35
  *
25
36
  * Output is sorted canonically by token, from, to, amount for determinism.
37
+ * `txIdx` is attached but does not influence sort order.
26
38
  */
27
- export declare function parseTransfers(logs: RawLog[], logger?: SimulationLogger): Transfer[];
39
+ export declare function parseTransfers(calls: readonly RawCall[], logger?: SimulationLogger): Transfer[];