@morpho-org/evm-simulation 1.0.0 → 2.0.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.
@@ -10,104 +10,139 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
10
10
  /**
11
11
  * Parse raw EVM logs into individual Transfer events.
12
12
  *
13
+ * Iterates over per-tx `calls` and stamps each emitted `Transfer.txIdx`
14
+ * with the index of the originating call.
15
+ *
13
16
  * **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
14
17
  * `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
15
18
  * events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
16
19
  * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
17
20
  * see an incomplete transfer list.
18
21
  *
19
- * **WETH9-specific dedup:** matches Transfer-to/from zero against the
20
- * matching Deposit/Withdrawal event on the same contract with the same data
21
- * and withdrawer. Non-standard wrapped-native implementations (e.g., rebasing
22
- * or fee-on-withdraw wrappers) may escape the dedup and produce double-counts.
22
+ * **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
23
+ * the same tx: a zero-address `Transfer` is suppressed only if its paired
24
+ * `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
25
+ * is correct for canonical WETH9, which always emits the `Deposit`/
26
+ * `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
27
+ * atomically inside a single call frame. A **non-canonical wrapped-native**
28
+ * that splits these emissions across two txs in the same bundle would leave
29
+ * a phantom zero-address `Transfer` in the parsed output, which can be summed
30
+ * by `assertNoBundlerRetention` and produce a false `BlacklistViolationError`.
31
+ * When a zero-address `Transfer` misses same-tx dedup *and* its token has
32
+ * emitted a `Deposit`/`Withdrawal` somewhere else in the bundle (i.e. it
33
+ * looks wnative-shaped), the parser emits a `warn` so the assumption break
34
+ * is observable before it reaches retention. None of `morpho-sdk`'s currently
35
+ * supported chains require cross-tx dedup; the assumption is rechecked when
36
+ * onboarding a new chain (see `evm-simulation/CLAUDE.md`).
23
37
  *
24
38
  * **Failure mode:** on a per-log parse failure (malformed topic length,
25
39
  * non-hex data), the log is skipped and a `warn` is emitted via the logger.
26
- * The caller receives a possibly-incomplete transfer list. Upstream sources
27
- * (e.g., Tenderly response schemas) should validate log shape before calling
28
- * here to avoid silent drops.
29
40
  *
30
41
  * Output is sorted canonically by token, from, to, amount for determinism.
42
+ * `txIdx` is attached but does not influence sort order.
31
43
  */
32
- export function parseTransfers(logs, logger) {
44
+ export function parseTransfers(calls, logger) {
33
45
  const transfers = [];
34
- for (const log of logs) {
35
- try {
36
- const topic0 = log.topics[0];
37
- if (topic0 === undefined)
38
- continue;
39
- switch (topic0) {
40
- case WITHDRAWAL_TOPIC: {
41
- const fromTopic = log.topics[1];
42
- if (!isTopicHex(fromTopic) || !isUint256Hex(log.data)) {
43
- warnMalformed(logger, log, "WETH9 Withdrawal: bad topic[1] or data length");
44
- continue;
45
- }
46
- transfers.push({
47
- token: getAddress(log.address),
48
- from: getAddress(`0x${fromTopic.slice(26)}`),
49
- to: zeroAddress,
50
- amount: BigInt(log.data),
51
- });
46
+ // Tokens that emit `Deposit` or `Withdrawal` somewhere in the bundle look
47
+ // wnative-shaped. If a zero-address `Transfer` for one of these tokens
48
+ // misses same-tx dedup, the contract is likely emitting non-canonically
49
+ // (split across txs) and a phantom transfer would leak into retention.
50
+ const wnativeShapedTokens = collectWnativeShapedTokens(calls);
51
+ for (let txIdx = 0; txIdx < calls.length; txIdx++) {
52
+ const logs = calls[txIdx].logs;
53
+ for (const log of logs) {
54
+ try {
55
+ const topic0 = log.topics[0];
56
+ if (topic0 === undefined)
52
57
  continue;
53
- }
54
- case DEPOSIT_TOPIC: {
55
- const toTopic = log.topics[1];
56
- if (!isTopicHex(toTopic) || !isUint256Hex(log.data)) {
57
- warnMalformed(logger, log, "WETH9 Deposit: bad topic[1] or data length");
58
+ switch (topic0) {
59
+ case WITHDRAWAL_TOPIC: {
60
+ const fromTopic = log.topics[1];
61
+ if (!isTopicHex(fromTopic) || !isUint256Hex(log.data)) {
62
+ warnMalformed(logger, log, "WETH9 Withdrawal: bad topic[1] or data length");
63
+ continue;
64
+ }
65
+ transfers.push({
66
+ token: getAddress(log.address),
67
+ from: getAddress(`0x${fromTopic.slice(26)}`),
68
+ to: zeroAddress,
69
+ amount: BigInt(log.data),
70
+ txIdx,
71
+ });
58
72
  continue;
59
73
  }
60
- transfers.push({
61
- token: getAddress(log.address),
62
- from: zeroAddress,
63
- to: getAddress(`0x${toTopic.slice(26)}`),
64
- amount: BigInt(log.data),
65
- });
66
- continue;
67
- }
68
- case TRANSFER_TOPIC: {
69
- // ERC-20 Transfer has 3 topics; ERC-721 Transfer has 4 (tokenId indexed).
70
- // ERC-721 flows are out of scope.
71
- if (log.topics.length !== 3)
72
- continue;
73
- const fromTopic = log.topics[1];
74
- const toTopic = log.topics[2];
75
- if (!isTopicHex(fromTopic) ||
76
- !isTopicHex(toTopic) ||
77
- !isUint256Hex(log.data)) {
78
- warnMalformed(logger, log, "ERC20 Transfer: bad topic/data length");
74
+ case DEPOSIT_TOPIC: {
75
+ const toTopic = log.topics[1];
76
+ if (!isTopicHex(toTopic) || !isUint256Hex(log.data)) {
77
+ warnMalformed(logger, log, "WETH9 Deposit: bad topic[1] or data length");
78
+ continue;
79
+ }
80
+ transfers.push({
81
+ token: getAddress(log.address),
82
+ from: zeroAddress,
83
+ to: getAddress(`0x${toTopic.slice(26)}`),
84
+ amount: BigInt(log.data),
85
+ txIdx,
86
+ });
79
87
  continue;
80
88
  }
81
- // WETH9 unwrap dedup: Transfer to zero paired with a Withdrawal of equal amount.
82
- if (toTopic === zeroHash &&
83
- logs.some((other) => other.topics[0] === WITHDRAWAL_TOPIC &&
84
- other.address === log.address &&
85
- other.data === log.data &&
86
- other.topics.length === 2 &&
87
- other.topics[1] === fromTopic))
88
- continue;
89
- // WETH9 wrap dedup: Transfer from zero paired with a Deposit of equal amount.
90
- if (fromTopic === zeroHash &&
91
- logs.some((other) => other.topics[0] === DEPOSIT_TOPIC &&
92
- other.address === log.address &&
93
- other.data === log.data &&
94
- other.topics.length === 2 &&
95
- other.topics[1] === toTopic))
89
+ case TRANSFER_TOPIC: {
90
+ // ERC-20 Transfer has 3 topics; ERC-721 Transfer has 4 (tokenId indexed).
91
+ // ERC-721 flows are out of scope.
92
+ if (log.topics.length !== 3)
93
+ continue;
94
+ const fromTopic = log.topics[1];
95
+ const toTopic = log.topics[2];
96
+ if (!isTopicHex(fromTopic) ||
97
+ !isTopicHex(toTopic) ||
98
+ !isUint256Hex(log.data)) {
99
+ warnMalformed(logger, log, "ERC20 Transfer: bad topic/data length");
100
+ continue;
101
+ }
102
+ // WETH9 unwrap dedup: Transfer to zero paired with a Withdrawal of
103
+ // equal amount in the SAME tx.
104
+ if (toTopic === zeroHash) {
105
+ const paired = logs.some((other) => other.topics[0] === WITHDRAWAL_TOPIC &&
106
+ other.address === log.address &&
107
+ other.data === log.data &&
108
+ other.topics.length === 2 &&
109
+ other.topics[1] === fromTopic);
110
+ if (paired)
111
+ continue;
112
+ if (wnativeShapedTokens.has(log.address.toLowerCase())) {
113
+ warnNonCanonicalWnative(logger, log, "burn", txIdx);
114
+ }
115
+ }
116
+ // WETH9 wrap dedup: Transfer from zero paired with a Deposit of
117
+ // equal amount in the SAME tx.
118
+ if (fromTopic === zeroHash) {
119
+ const paired = logs.some((other) => other.topics[0] === DEPOSIT_TOPIC &&
120
+ other.address === log.address &&
121
+ other.data === log.data &&
122
+ other.topics.length === 2 &&
123
+ other.topics[1] === toTopic);
124
+ if (paired)
125
+ continue;
126
+ if (wnativeShapedTokens.has(log.address.toLowerCase())) {
127
+ warnNonCanonicalWnative(logger, log, "mint", txIdx);
128
+ }
129
+ }
130
+ transfers.push({
131
+ token: getAddress(log.address),
132
+ from: getAddress(`0x${fromTopic.slice(26)}`),
133
+ to: getAddress(`0x${toTopic.slice(26)}`),
134
+ amount: BigInt(log.data),
135
+ txIdx,
136
+ });
96
137
  continue;
97
- transfers.push({
98
- token: getAddress(log.address),
99
- from: getAddress(`0x${fromTopic.slice(26)}`),
100
- to: getAddress(`0x${toTopic.slice(26)}`),
101
- amount: BigInt(log.data),
102
- });
103
- continue;
138
+ }
104
139
  }
105
140
  }
106
- }
107
- catch (error) {
108
- // Safety net for anything that slips past the length checks above
109
- // (e.g., viem checksum throws on non-hex input). Loud enough to diagnose.
110
- warnMalformed(logger, log, error instanceof Error ? error.message : String(error));
141
+ catch (error) {
142
+ // Safety net for anything that slips past the length checks above
143
+ // (e.g., viem checksum throws on non-hex input). Loud enough to diagnose.
144
+ warnMalformed(logger, log, error instanceof Error ? error.message : String(error));
145
+ }
111
146
  }
112
147
  }
113
148
  return sortTransfers(transfers);
@@ -118,6 +153,7 @@ function isTopicHex(value) {
118
153
  function isUint256Hex(value) {
119
154
  return typeof value === "string" && value.length === UINT256_HEX_LENGTH;
120
155
  }
156
+ // biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
121
157
  function warnMalformed(logger, log, reason) {
122
158
  logger?.warn("Skipping malformed log during transfer parsing", {
123
159
  address: log.address,
@@ -125,6 +161,34 @@ function warnMalformed(logger, log, reason) {
125
161
  reason,
126
162
  });
127
163
  }
164
+ /**
165
+ * Collect token addresses (lowercased) that emit `Deposit` or `Withdrawal`
166
+ * anywhere in the bundle. Used to detect when a zero-address `Transfer` that
167
+ * misses same-tx dedup is on a wnative-shaped contract — a strong signal
168
+ * that the contract is emitting non-canonically and a phantom transfer is
169
+ * about to leak into bundler retention.
170
+ */
171
+ function collectWnativeShapedTokens(calls) {
172
+ const set = new Set();
173
+ for (const c of calls) {
174
+ for (const l of c.logs) {
175
+ const t0 = l.topics[0];
176
+ if (t0 === WITHDRAWAL_TOPIC || t0 === DEPOSIT_TOPIC) {
177
+ set.add(l.address.toLowerCase());
178
+ }
179
+ }
180
+ }
181
+ return set;
182
+ }
183
+ // biome-ignore lint/complexity/useMaxParams: structured warn with full context
184
+ function warnNonCanonicalWnative(logger, log, kind, txIdx) {
185
+ logger?.warn("WETH9 dedup miss: zero-address Transfer on wnative-shaped token without paired same-tx Deposit/Withdrawal — possible non-canonical wrapped-native, may cause bundler retention false positive", {
186
+ token: log.address,
187
+ kind,
188
+ txIdx,
189
+ data: log.data,
190
+ });
191
+ }
128
192
  /**
129
193
  * Canonical sort: by lowercased token, from, to, then amount. Addresses are
130
194
  * pre-lowercased once to avoid redundant `.toLowerCase()` calls per comparison.
@@ -1,4 +1,4 @@
1
- import { UnsupportedChainIdError, getChainAddresses, } from "@morpho-org/blue-sdk";
1
+ import { getChainAddresses, UnsupportedChainIdError, } from "@morpho-org/blue-sdk";
2
2
  import { isDefined } from "@morpho-org/morpho-ts";
3
3
  import { getAddress } from "viem";
4
4
  import { BlacklistViolationError } from "../../errors.js";
@@ -57,6 +57,7 @@ export function assertNoBundlerRetention(params) {
57
57
  return;
58
58
  // Map keyed by (bundler, token) → structured entry. Avoids string parse-back.
59
59
  const flow = new Map();
60
+ // biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
60
61
  const recordFlow = (address, token, delta) => {
61
62
  const key = `${address.toLowerCase()}:${token.toLowerCase()}`;
62
63
  const existing = flow.get(key);
@@ -4,17 +4,56 @@ import type { SimulateParams, SimulationConfig, SimulationResult } from "../type
4
4
  *
5
5
  * Validates input → resolves authorizations into prepended approve txs → runs the bundle
6
6
  * through Tenderly REST (primary) or `eth_simulateV1` (fallback) with a shared timeout
7
- * budget → parses ERC20/WETH transfers from logs → asserts no funds are retained by
8
- * `bundler3` → returns the full result set. The caller reads whichever fields they need:
7
+ * budget → parses ERC20/WETH transfers from per-tx logs → asserts no funds are retained
8
+ * by `bundler3` → returns the full result set. The caller reads whichever fields they need:
9
9
  *
10
10
  * - `transfers` + `tenderlyUrl` → user-facing preview.
11
11
  * - `simulationTxs` + `transfers` → server-side verification before broadcast, then pass
12
12
  * both to `screenAddresses` for compliance.
13
+ * - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed`,
14
+ * and Tenderly-only `assetChanges`). Aligned 1:1 with `simulationTxs[i]`.
15
+ * - `transfers[k].txIdx` → index into `simulationTxs` of the tx that emitted the
16
+ * underlying log; consumers map back via `simulationTxs[transfer.txIdx]`.
13
17
  *
14
- * Throws `SimulationValidationError` for bad input, `UnsupportedChainError` when the chain
15
- * is not configured, `SimulationRevertedError` if the bundle reverts,
16
- * `BlacklistViolationError` if bundler retention is detected, or `ExternalServiceError` if
17
- * both backends are unavailable within the timeout budget.
18
+ * @param config - Backend configuration: Tenderly credentials, per-chain RPC URLs, optional
19
+ * logger, and the overall timeout budget.
20
+ * @param params - Per-call simulation input.
21
+ * @param params.chainId - Chain id the bundle targets.
22
+ * @param params.transactions - The bundle's transactions, in execution order. All must share the
23
+ * same `from`.
24
+ * @param params.authorizations - Optional token authorizations resolved into prepended approve
25
+ * transactions before the main bundle runs.
26
+ * @param params.blockNumber - Optional pinned block number or `BlockTag`. Defaults to `latest`.
27
+ * @param options - Per-call simulation options.
28
+ * @param options.shareable - When `true`, persists the simulation in Tenderly and returns a
29
+ * shareable `tenderlyUrl`. Honored only on the Tenderly backend, not on the `eth_simulateV1`
30
+ * fallback. Defaults to `false`.
31
+ * @throws {SimulationValidationError} for invalid input (mixed senders, bad addresses,
32
+ * empty transactions, malformed authorizations).
33
+ * @throws {UnsupportedChainError} when the chain is not configured for any backend.
34
+ * @throws {SimulationRevertedError} when the bundle reverts on either backend.
35
+ * @throws {BlacklistViolationError} when the simulation leaves value retained by or
36
+ * drained from a `bundler3` address beyond the dust threshold.
37
+ * @throws {ExternalServiceError} (a) when both backends are unavailable within the
38
+ * timeout budget, or (b) when a backend returns a `calls` array whose length does
39
+ * not match the resolved `simulationTxs` — refusing to map transfers with mismatched
40
+ * per-tx output.
41
+ * @returns A {@link SimulationResult} carrying the resolved `simulationTxs`, per-tx
42
+ * `calls` (aligned 1:1 with `simulationTxs`), parsed `transfers` (each stamped with
43
+ * `txIdx`), and the optional `tenderlyUrl`.
44
+ * @example
45
+ * ```ts
46
+ * import { simulate } from "@morpho-org/evm-simulation";
47
+ *
48
+ * const result = await simulate(
49
+ * { chains: new Map([[1, { simulateV1Url: rpcUrl }]]) },
50
+ * {
51
+ * chainId: 1,
52
+ * transactions: [{ from: user, to: vaultAddress, data: encodedCalldata, value: 0n }],
53
+ * },
54
+ * );
55
+ * // result satisfies SimulationResult
56
+ * ```
18
57
  */
19
58
  export declare function simulate(config: SimulationConfig, params: SimulateParams, options?: {
20
59
  /**
@@ -1,3 +1,4 @@
1
+ import { ExternalServiceError } from "../errors.js";
1
2
  import { parseTransfers } from "./parsing/index.js";
2
3
  import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, validateInput, } from "./pipeline/index.js";
3
4
  /**
@@ -5,18 +6,58 @@ import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, valida
5
6
  *
6
7
  * Validates input → resolves authorizations into prepended approve txs → runs the bundle
7
8
  * through Tenderly REST (primary) or `eth_simulateV1` (fallback) with a shared timeout
8
- * budget → parses ERC20/WETH transfers from logs → asserts no funds are retained by
9
- * `bundler3` → returns the full result set. The caller reads whichever fields they need:
9
+ * budget → parses ERC20/WETH transfers from per-tx logs → asserts no funds are retained
10
+ * by `bundler3` → returns the full result set. The caller reads whichever fields they need:
10
11
  *
11
12
  * - `transfers` + `tenderlyUrl` → user-facing preview.
12
13
  * - `simulationTxs` + `transfers` → server-side verification before broadcast, then pass
13
14
  * both to `screenAddresses` for compliance.
15
+ * - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed`,
16
+ * and Tenderly-only `assetChanges`). Aligned 1:1 with `simulationTxs[i]`.
17
+ * - `transfers[k].txIdx` → index into `simulationTxs` of the tx that emitted the
18
+ * underlying log; consumers map back via `simulationTxs[transfer.txIdx]`.
14
19
  *
15
- * Throws `SimulationValidationError` for bad input, `UnsupportedChainError` when the chain
16
- * is not configured, `SimulationRevertedError` if the bundle reverts,
17
- * `BlacklistViolationError` if bundler retention is detected, or `ExternalServiceError` if
18
- * both backends are unavailable within the timeout budget.
20
+ * @param config - Backend configuration: Tenderly credentials, per-chain RPC URLs, optional
21
+ * logger, and the overall timeout budget.
22
+ * @param params - Per-call simulation input.
23
+ * @param params.chainId - Chain id the bundle targets.
24
+ * @param params.transactions - The bundle's transactions, in execution order. All must share the
25
+ * same `from`.
26
+ * @param params.authorizations - Optional token authorizations resolved into prepended approve
27
+ * transactions before the main bundle runs.
28
+ * @param params.blockNumber - Optional pinned block number or `BlockTag`. Defaults to `latest`.
29
+ * @param options - Per-call simulation options.
30
+ * @param options.shareable - When `true`, persists the simulation in Tenderly and returns a
31
+ * shareable `tenderlyUrl`. Honored only on the Tenderly backend, not on the `eth_simulateV1`
32
+ * fallback. Defaults to `false`.
33
+ * @throws {SimulationValidationError} for invalid input (mixed senders, bad addresses,
34
+ * empty transactions, malformed authorizations).
35
+ * @throws {UnsupportedChainError} when the chain is not configured for any backend.
36
+ * @throws {SimulationRevertedError} when the bundle reverts on either backend.
37
+ * @throws {BlacklistViolationError} when the simulation leaves value retained by or
38
+ * drained from a `bundler3` address beyond the dust threshold.
39
+ * @throws {ExternalServiceError} (a) when both backends are unavailable within the
40
+ * timeout budget, or (b) when a backend returns a `calls` array whose length does
41
+ * not match the resolved `simulationTxs` — refusing to map transfers with mismatched
42
+ * per-tx output.
43
+ * @returns A {@link SimulationResult} carrying the resolved `simulationTxs`, per-tx
44
+ * `calls` (aligned 1:1 with `simulationTxs`), parsed `transfers` (each stamped with
45
+ * `txIdx`), and the optional `tenderlyUrl`.
46
+ * @example
47
+ * ```ts
48
+ * import { simulate } from "@morpho-org/evm-simulation";
49
+ *
50
+ * const result = await simulate(
51
+ * { chains: new Map([[1, { simulateV1Url: rpcUrl }]]) },
52
+ * {
53
+ * chainId: 1,
54
+ * transactions: [{ from: user, to: vaultAddress, data: encodedCalldata, value: 0n }],
55
+ * },
56
+ * );
57
+ * // result satisfies SimulationResult
58
+ * ```
19
59
  */
60
+ // biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
20
61
  export async function simulate(config, params, options = {}) {
21
62
  const { shareable = false } = options;
22
63
  validateInput(params);
@@ -28,7 +69,10 @@ export async function simulate(config, params, options = {}) {
28
69
  blockNumber: params.blockNumber,
29
70
  shareable,
30
71
  });
31
- const transfers = parseTransfers(result.logs, config.logger);
72
+ if (result.calls.length !== simulationTxs.length) {
73
+ throw new ExternalServiceError(`Backend returned ${result.calls.length} call result(s) for ${simulationTxs.length} transaction(s) — refusing to map transfers with mismatched lengths`);
74
+ }
75
+ const transfers = parseTransfers(result.calls, config.logger);
32
76
  assertNoBundlerRetention({
33
77
  chainId: params.chainId,
34
78
  transfers,
@@ -36,8 +80,8 @@ export async function simulate(config, params, options = {}) {
36
80
  });
37
81
  return {
38
82
  simulationTxs,
83
+ calls: result.calls,
39
84
  transfers,
40
85
  tenderlyUrl: result.tenderlyUrl,
41
- assetChanges: result.rawAssetChanges,
42
86
  };
43
87
  }
@@ -71,33 +71,43 @@ export type SimulationAuthorization = {
71
71
  * `SimulationResult.transfers`.
72
72
  */
73
73
  export interface Transfer {
74
- token: Address;
75
- from: Address;
76
- to: Address;
77
- amount: bigint;
74
+ readonly token: Address;
75
+ readonly from: Address;
76
+ readonly to: Address;
77
+ readonly amount: bigint;
78
+ /**
79
+ * Index into `SimulationResult.simulationTxs` of the transaction that
80
+ * emitted the underlying log. For bundles with prepended authorization
81
+ * approvals, indices `[0, simulationTxs.length - params.transactions.length)`
82
+ * are authorization txs; the remainder are caller-supplied.
83
+ */
84
+ readonly txIdx: number;
78
85
  }
79
86
  /**
80
- * Happy-path return of `simulate`. All failures throw typed errors. The same shape is
81
- * returned regardless of `options.shareable` — fields unused by your use case can be
82
- * ignored:
83
- *
84
- * - Preview (`shareable: true`): read `transfers` + `tenderlyUrl`.
85
- * - Verify (`shareable: false`, default): read `simulationTxs` + `transfers`, pass both
86
- * to `screenAddresses`.
87
+ * Happy-path return of `simulate`. All failures throw typed errors.
87
88
  *
88
- * `tenderlyUrl` is set only when `shareable: true` AND the Tenderly backend ran
89
- * successfully (not the `eth_simulateV1` fallback). `assetChanges` is Tenderly-only
90
- * raw data.
89
+ * - `simulationTxs` is the full resolved transaction list (including
90
+ * prepended authorization txs).
91
+ * - `calls[i]` corresponds 1:1 with `simulationTxs[i]` — read raw logs,
92
+ * status, returnData/gasUsed, and (Tenderly only) assetChanges per tx.
93
+ * - `transfers[k].txIdx` indexes into `simulationTxs` to attribute each
94
+ * transfer to its emitting transaction.
95
+ * - `tenderlyUrl` is set only when `shareable: true` AND the Tenderly backend
96
+ * ran successfully (not the `eth_simulateV1` fallback).
91
97
  */
92
98
  export interface SimulationResult {
93
99
  /** The full resolved transaction list (including prepended authorization txs). */
94
- simulationTxs: SimulationTransaction[];
100
+ readonly simulationTxs: readonly SimulationTransaction[];
101
+ /**
102
+ * Per-transaction normalized output. `calls[i]` corresponds 1:1 with
103
+ * `simulationTxs[i]`. Use this to read raw logs, status, return data, gas
104
+ * used, and (Tenderly only) asset changes per transaction.
105
+ */
106
+ readonly calls: readonly SimulationCall[];
95
107
  /** Parsed ERC-20 / WETH9 transfers from the simulation. */
96
- transfers: Transfer[];
108
+ readonly transfers: readonly Transfer[];
97
109
  /** Shareable Tenderly URL. Present only when `shareable: true` and Tenderly ran (not fallback). */
98
- tenderlyUrl?: string;
99
- /** Raw Tenderly `asset_changes` payload. Opaque `unknown` — do not destructure without validation. */
100
- assetChanges?: unknown;
110
+ readonly tenderlyUrl?: string;
101
111
  }
102
112
  /** Minimal structured logger the package calls for warnings and info. */
103
113
  export interface SimulationLogger {
@@ -117,15 +127,61 @@ export interface SimulateParams {
117
127
  }
118
128
  /**
119
129
  * Internal raw result from a simulation adapter before normalization.
120
- * Both Tenderly and simulateV1 produce this.
130
+ * `calls[i]` corresponds 1:1 with the i-th transaction passed to the
131
+ * backend.
121
132
  */
122
133
  export interface RawSimulationResult {
123
- logs: RawLog[];
134
+ calls: RawCall[];
124
135
  tenderlyUrl?: string;
125
- rawAssetChanges?: unknown;
126
136
  }
137
+ /**
138
+ * Normalized EVM log emitted by a single simulated call. The shape is the
139
+ * common subset both backends (`eth_simulateV1` via viem and Tenderly REST)
140
+ * produce after schema validation. Returned indirectly via
141
+ * `SimulationCall.logs` and consumed by the SDK's transfer parser.
142
+ */
127
143
  export interface RawLog {
128
- address: Address;
129
- topics: Hex[];
130
- data: Hex;
144
+ readonly address: Address;
145
+ readonly topics: readonly Hex[];
146
+ readonly data: Hex;
147
+ }
148
+ /**
149
+ * Internal mirror of `SimulationCall`, mutable during construction by the
150
+ * simulation backends.
151
+ *
152
+ * @internal
153
+ */
154
+ export interface RawCall {
155
+ logs: RawLog[];
156
+ status: boolean;
157
+ returnData: Hex;
158
+ gasUsed: bigint;
159
+ /** Tenderly-only `asset_changes` payload. Absent on `eth_simulateV1`. */
160
+ assetChanges?: unknown;
161
+ }
162
+ /**
163
+ * Per-transaction normalized output from the simulation backend.
164
+ *
165
+ * `SimulationResult.calls[i]` corresponds 1:1 with
166
+ * `SimulationResult.simulationTxs[i]`. Use this to read raw logs, status,
167
+ * return data, gas used, and (Tenderly only) asset changes per transaction.
168
+ */
169
+ export interface SimulationCall {
170
+ readonly logs: readonly RawLog[];
171
+ /**
172
+ * True iff the call succeeded. The bundle as a whole reverts via
173
+ * `SimulationRevertedError` before the result is returned, so on a
174
+ * successful `simulate()` every entry is `true` today. Field kept for
175
+ * forward-compatibility with backends that may surface per-call status.
176
+ */
177
+ readonly status: boolean;
178
+ /** Return data from the top-level call. */
179
+ readonly returnData: Hex;
180
+ /** Gas used by this call (root frame). */
181
+ readonly gasUsed: bigint;
182
+ /**
183
+ * Tenderly-only `asset_changes` payload for this tx. Opaque `unknown` —
184
+ * do not destructure without validation. Absent on `eth_simulateV1`.
185
+ */
186
+ readonly assetChanges?: unknown;
131
187
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@morpho-org/evm-simulation",
3
3
  "description": "EVM simulation engine for Morpho transactions.",
4
- "version": "1.0.0",
4
+ "version": "2.0.0",
5
5
  "author": "Morpho Association <contact@morpho.org>",
6
6
  "contributors": [
7
7
  "Rubilmax <rmilon@gmail.com>"
@@ -20,17 +20,17 @@
20
20
  "lib"
21
21
  ],
22
22
  "dependencies": {
23
- "zod": "^4.1.12",
24
- "@morpho-org/blue-sdk": "^5.23.1",
23
+ "zod": "^4.4.3",
24
+ "@morpho-org/blue-sdk": "^5.23.3",
25
25
  "@morpho-org/morpho-ts": "^2.5.1"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "viem": "^2.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "typescript": "^5.9.3",
32
- "viem": "^2.41.2",
33
- "vitest": "^4.1.4"
31
+ "typescript": "^6.0.3",
32
+ "viem": "^2.49.3",
33
+ "vitest": "^4.1.6"
34
34
  },
35
35
  "scripts": {
36
36
  "prepublish": "$npm_execpath build",
package/src/index.ts CHANGED
@@ -1,12 +1,24 @@
1
1
  // Public fns (via feature-folder barrels)
2
+
3
+ // Errors (for instanceof checks by consumers)
4
+ export {
5
+ AddressScreeningError,
6
+ BlacklistViolationError,
7
+ ExternalServiceError,
8
+ SimulationPackageError,
9
+ SimulationRevertedError,
10
+ SimulationValidationError,
11
+ UnsupportedChainError,
12
+ } from "./errors.js";
2
13
  export { screenAddresses } from "./screen-addresses/index.js";
3
14
  export { simulate } from "./simulate/index.js";
4
-
5
15
  // Types
6
16
  export type {
7
17
  ChainSimulationConfig,
18
+ RawLog,
8
19
  SimulateParams,
9
20
  SimulationAuthorization,
21
+ SimulationCall,
10
22
  SimulationConfig,
11
23
  SimulationLogger,
12
24
  SimulationResult,
@@ -14,14 +26,3 @@ export type {
14
26
  TenderlyRestConfig,
15
27
  Transfer,
16
28
  } from "./types.js";
17
-
18
- // Errors (for instanceof checks by consumers)
19
- export {
20
- SimulationPackageError,
21
- SimulationRevertedError,
22
- BlacklistViolationError,
23
- AddressScreeningError,
24
- ExternalServiceError,
25
- SimulationValidationError,
26
- UnsupportedChainError,
27
- } from "./errors.js";