@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.
@@ -14,104 +14,139 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
14
14
  /**
15
15
  * Parse raw EVM logs into individual Transfer events.
16
16
  *
17
+ * Iterates over per-tx `calls` and stamps each emitted `Transfer.txIdx`
18
+ * with the index of the originating call.
19
+ *
17
20
  * **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
18
21
  * `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
19
22
  * events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
20
23
  * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
21
24
  * see an incomplete transfer list.
22
25
  *
23
- * **WETH9-specific dedup:** matches Transfer-to/from zero against the
24
- * matching Deposit/Withdrawal event on the same contract with the same data
25
- * and withdrawer. Non-standard wrapped-native implementations (e.g., rebasing
26
- * or fee-on-withdraw wrappers) may escape the dedup and produce double-counts.
26
+ * **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
27
+ * the same tx: a zero-address `Transfer` is suppressed only if its paired
28
+ * `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
29
+ * is correct for canonical WETH9, which always emits the `Deposit`/
30
+ * `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
31
+ * atomically inside a single call frame. A **non-canonical wrapped-native**
32
+ * that splits these emissions across two txs in the same bundle would leave
33
+ * a phantom zero-address `Transfer` in the parsed output, which can be summed
34
+ * by `assertNoBundlerRetention` and produce a false `BlacklistViolationError`.
35
+ * When a zero-address `Transfer` misses same-tx dedup *and* its token has
36
+ * emitted a `Deposit`/`Withdrawal` somewhere else in the bundle (i.e. it
37
+ * looks wnative-shaped), the parser emits a `warn` so the assumption break
38
+ * is observable before it reaches retention. None of `morpho-sdk`'s currently
39
+ * supported chains require cross-tx dedup; the assumption is rechecked when
40
+ * onboarding a new chain (see `evm-simulation/CLAUDE.md`).
27
41
  *
28
42
  * **Failure mode:** on a per-log parse failure (malformed topic length,
29
43
  * non-hex data), the log is skipped and a `warn` is emitted via the logger.
30
- * The caller receives a possibly-incomplete transfer list. Upstream sources
31
- * (e.g., Tenderly response schemas) should validate log shape before calling
32
- * here to avoid silent drops.
33
44
  *
34
45
  * Output is sorted canonically by token, from, to, amount for determinism.
46
+ * `txIdx` is attached but does not influence sort order.
35
47
  */
36
- function parseTransfers(logs, logger) {
48
+ function parseTransfers(calls, logger) {
37
49
  const transfers = [];
38
- for (const log of logs) {
39
- try {
40
- const topic0 = log.topics[0];
41
- if (topic0 === undefined)
42
- continue;
43
- switch (topic0) {
44
- case exports.WITHDRAWAL_TOPIC: {
45
- const fromTopic = log.topics[1];
46
- if (!isTopicHex(fromTopic) || !isUint256Hex(log.data)) {
47
- warnMalformed(logger, log, "WETH9 Withdrawal: bad topic[1] or data length");
48
- continue;
49
- }
50
- transfers.push({
51
- token: (0, viem_1.getAddress)(log.address),
52
- from: (0, viem_1.getAddress)(`0x${fromTopic.slice(26)}`),
53
- to: viem_1.zeroAddress,
54
- amount: BigInt(log.data),
55
- });
50
+ // Tokens that emit `Deposit` or `Withdrawal` somewhere in the bundle look
51
+ // wnative-shaped. If a zero-address `Transfer` for one of these tokens
52
+ // misses same-tx dedup, the contract is likely emitting non-canonically
53
+ // (split across txs) and a phantom transfer would leak into retention.
54
+ const wnativeShapedTokens = collectWnativeShapedTokens(calls);
55
+ for (let txIdx = 0; txIdx < calls.length; txIdx++) {
56
+ const logs = calls[txIdx].logs;
57
+ for (const log of logs) {
58
+ try {
59
+ const topic0 = log.topics[0];
60
+ if (topic0 === undefined)
56
61
  continue;
57
- }
58
- case exports.DEPOSIT_TOPIC: {
59
- const toTopic = log.topics[1];
60
- if (!isTopicHex(toTopic) || !isUint256Hex(log.data)) {
61
- warnMalformed(logger, log, "WETH9 Deposit: bad topic[1] or data length");
62
+ switch (topic0) {
63
+ case exports.WITHDRAWAL_TOPIC: {
64
+ const fromTopic = log.topics[1];
65
+ if (!isTopicHex(fromTopic) || !isUint256Hex(log.data)) {
66
+ warnMalformed(logger, log, "WETH9 Withdrawal: bad topic[1] or data length");
67
+ continue;
68
+ }
69
+ transfers.push({
70
+ token: (0, viem_1.getAddress)(log.address),
71
+ from: (0, viem_1.getAddress)(`0x${fromTopic.slice(26)}`),
72
+ to: viem_1.zeroAddress,
73
+ amount: BigInt(log.data),
74
+ txIdx,
75
+ });
62
76
  continue;
63
77
  }
64
- transfers.push({
65
- token: (0, viem_1.getAddress)(log.address),
66
- from: viem_1.zeroAddress,
67
- to: (0, viem_1.getAddress)(`0x${toTopic.slice(26)}`),
68
- amount: BigInt(log.data),
69
- });
70
- continue;
71
- }
72
- case exports.TRANSFER_TOPIC: {
73
- // ERC-20 Transfer has 3 topics; ERC-721 Transfer has 4 (tokenId indexed).
74
- // ERC-721 flows are out of scope.
75
- if (log.topics.length !== 3)
76
- continue;
77
- const fromTopic = log.topics[1];
78
- const toTopic = log.topics[2];
79
- if (!isTopicHex(fromTopic) ||
80
- !isTopicHex(toTopic) ||
81
- !isUint256Hex(log.data)) {
82
- warnMalformed(logger, log, "ERC20 Transfer: bad topic/data length");
78
+ case exports.DEPOSIT_TOPIC: {
79
+ const toTopic = log.topics[1];
80
+ if (!isTopicHex(toTopic) || !isUint256Hex(log.data)) {
81
+ warnMalformed(logger, log, "WETH9 Deposit: bad topic[1] or data length");
82
+ continue;
83
+ }
84
+ transfers.push({
85
+ token: (0, viem_1.getAddress)(log.address),
86
+ from: viem_1.zeroAddress,
87
+ to: (0, viem_1.getAddress)(`0x${toTopic.slice(26)}`),
88
+ amount: BigInt(log.data),
89
+ txIdx,
90
+ });
83
91
  continue;
84
92
  }
85
- // WETH9 unwrap dedup: Transfer to zero paired with a Withdrawal of equal amount.
86
- if (toTopic === viem_1.zeroHash &&
87
- logs.some((other) => other.topics[0] === exports.WITHDRAWAL_TOPIC &&
88
- other.address === log.address &&
89
- other.data === log.data &&
90
- other.topics.length === 2 &&
91
- other.topics[1] === fromTopic))
92
- continue;
93
- // WETH9 wrap dedup: Transfer from zero paired with a Deposit of equal amount.
94
- if (fromTopic === viem_1.zeroHash &&
95
- logs.some((other) => other.topics[0] === exports.DEPOSIT_TOPIC &&
96
- other.address === log.address &&
97
- other.data === log.data &&
98
- other.topics.length === 2 &&
99
- other.topics[1] === toTopic))
93
+ case exports.TRANSFER_TOPIC: {
94
+ // ERC-20 Transfer has 3 topics; ERC-721 Transfer has 4 (tokenId indexed).
95
+ // ERC-721 flows are out of scope.
96
+ if (log.topics.length !== 3)
97
+ continue;
98
+ const fromTopic = log.topics[1];
99
+ const toTopic = log.topics[2];
100
+ if (!isTopicHex(fromTopic) ||
101
+ !isTopicHex(toTopic) ||
102
+ !isUint256Hex(log.data)) {
103
+ warnMalformed(logger, log, "ERC20 Transfer: bad topic/data length");
104
+ continue;
105
+ }
106
+ // WETH9 unwrap dedup: Transfer to zero paired with a Withdrawal of
107
+ // equal amount in the SAME tx.
108
+ if (toTopic === viem_1.zeroHash) {
109
+ const paired = logs.some((other) => other.topics[0] === exports.WITHDRAWAL_TOPIC &&
110
+ other.address === log.address &&
111
+ other.data === log.data &&
112
+ other.topics.length === 2 &&
113
+ other.topics[1] === fromTopic);
114
+ if (paired)
115
+ continue;
116
+ if (wnativeShapedTokens.has(log.address.toLowerCase())) {
117
+ warnNonCanonicalWnative(logger, log, "burn", txIdx);
118
+ }
119
+ }
120
+ // WETH9 wrap dedup: Transfer from zero paired with a Deposit of
121
+ // equal amount in the SAME tx.
122
+ if (fromTopic === viem_1.zeroHash) {
123
+ const paired = logs.some((other) => other.topics[0] === exports.DEPOSIT_TOPIC &&
124
+ other.address === log.address &&
125
+ other.data === log.data &&
126
+ other.topics.length === 2 &&
127
+ other.topics[1] === toTopic);
128
+ if (paired)
129
+ continue;
130
+ if (wnativeShapedTokens.has(log.address.toLowerCase())) {
131
+ warnNonCanonicalWnative(logger, log, "mint", txIdx);
132
+ }
133
+ }
134
+ transfers.push({
135
+ token: (0, viem_1.getAddress)(log.address),
136
+ from: (0, viem_1.getAddress)(`0x${fromTopic.slice(26)}`),
137
+ to: (0, viem_1.getAddress)(`0x${toTopic.slice(26)}`),
138
+ amount: BigInt(log.data),
139
+ txIdx,
140
+ });
100
141
  continue;
101
- transfers.push({
102
- token: (0, viem_1.getAddress)(log.address),
103
- from: (0, viem_1.getAddress)(`0x${fromTopic.slice(26)}`),
104
- to: (0, viem_1.getAddress)(`0x${toTopic.slice(26)}`),
105
- amount: BigInt(log.data),
106
- });
107
- continue;
142
+ }
108
143
  }
109
144
  }
110
- }
111
- catch (error) {
112
- // Safety net for anything that slips past the length checks above
113
- // (e.g., viem checksum throws on non-hex input). Loud enough to diagnose.
114
- warnMalformed(logger, log, error instanceof Error ? error.message : String(error));
145
+ catch (error) {
146
+ // Safety net for anything that slips past the length checks above
147
+ // (e.g., viem checksum throws on non-hex input). Loud enough to diagnose.
148
+ warnMalformed(logger, log, error instanceof Error ? error.message : String(error));
149
+ }
115
150
  }
116
151
  }
117
152
  return sortTransfers(transfers);
@@ -122,6 +157,7 @@ function isTopicHex(value) {
122
157
  function isUint256Hex(value) {
123
158
  return typeof value === "string" && value.length === UINT256_HEX_LENGTH;
124
159
  }
160
+ // biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
125
161
  function warnMalformed(logger, log, reason) {
126
162
  logger?.warn("Skipping malformed log during transfer parsing", {
127
163
  address: log.address,
@@ -129,6 +165,34 @@ function warnMalformed(logger, log, reason) {
129
165
  reason,
130
166
  });
131
167
  }
168
+ /**
169
+ * Collect token addresses (lowercased) that emit `Deposit` or `Withdrawal`
170
+ * anywhere in the bundle. Used to detect when a zero-address `Transfer` that
171
+ * misses same-tx dedup is on a wnative-shaped contract — a strong signal
172
+ * that the contract is emitting non-canonically and a phantom transfer is
173
+ * about to leak into bundler retention.
174
+ */
175
+ function collectWnativeShapedTokens(calls) {
176
+ const set = new Set();
177
+ for (const c of calls) {
178
+ for (const l of c.logs) {
179
+ const t0 = l.topics[0];
180
+ if (t0 === exports.WITHDRAWAL_TOPIC || t0 === exports.DEPOSIT_TOPIC) {
181
+ set.add(l.address.toLowerCase());
182
+ }
183
+ }
184
+ }
185
+ return set;
186
+ }
187
+ // biome-ignore lint/complexity/useMaxParams: structured warn with full context
188
+ function warnNonCanonicalWnative(logger, log, kind, txIdx) {
189
+ 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", {
190
+ token: log.address,
191
+ kind,
192
+ txIdx,
193
+ data: log.data,
194
+ });
195
+ }
132
196
  /**
133
197
  * Canonical sort: by lowercased token, from, to, then amount. Addresses are
134
198
  * pre-lowercased once to avoid redundant `.toLowerCase()` calls per comparison.
@@ -60,6 +60,7 @@ function assertNoBundlerRetention(params) {
60
60
  return;
61
61
  // Map keyed by (bundler, token) → structured entry. Avoids string parse-back.
62
62
  const flow = new Map();
63
+ // biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
63
64
  const recordFlow = (address, token, delta) => {
64
65
  const key = `${address.toLowerCase()}:${token.toLowerCase()}`;
65
66
  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,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.simulate = simulate;
4
+ const errors_js_1 = require("../errors.js");
4
5
  const index_js_1 = require("./parsing/index.js");
5
6
  const index_js_2 = require("./pipeline/index.js");
6
7
  /**
@@ -8,18 +9,58 @@ const index_js_2 = require("./pipeline/index.js");
8
9
  *
9
10
  * Validates input → resolves authorizations into prepended approve txs → runs the bundle
10
11
  * through Tenderly REST (primary) or `eth_simulateV1` (fallback) with a shared timeout
11
- * budget → parses ERC20/WETH transfers from logs → asserts no funds are retained by
12
- * `bundler3` → returns the full result set. The caller reads whichever fields they need:
12
+ * budget → parses ERC20/WETH transfers from per-tx logs → asserts no funds are retained
13
+ * by `bundler3` → returns the full result set. The caller reads whichever fields they need:
13
14
  *
14
15
  * - `transfers` + `tenderlyUrl` → user-facing preview.
15
16
  * - `simulationTxs` + `transfers` → server-side verification before broadcast, then pass
16
17
  * both to `screenAddresses` for compliance.
18
+ * - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed`,
19
+ * and Tenderly-only `assetChanges`). Aligned 1:1 with `simulationTxs[i]`.
20
+ * - `transfers[k].txIdx` → index into `simulationTxs` of the tx that emitted the
21
+ * underlying log; consumers map back via `simulationTxs[transfer.txIdx]`.
17
22
  *
18
- * Throws `SimulationValidationError` for bad input, `UnsupportedChainError` when the chain
19
- * is not configured, `SimulationRevertedError` if the bundle reverts,
20
- * `BlacklistViolationError` if bundler retention is detected, or `ExternalServiceError` if
21
- * both backends are unavailable within the timeout budget.
23
+ * @param config - Backend configuration: Tenderly credentials, per-chain RPC URLs, optional
24
+ * logger, and the overall timeout budget.
25
+ * @param params - Per-call simulation input.
26
+ * @param params.chainId - Chain id the bundle targets.
27
+ * @param params.transactions - The bundle's transactions, in execution order. All must share the
28
+ * same `from`.
29
+ * @param params.authorizations - Optional token authorizations resolved into prepended approve
30
+ * transactions before the main bundle runs.
31
+ * @param params.blockNumber - Optional pinned block number or `BlockTag`. Defaults to `latest`.
32
+ * @param options - Per-call simulation options.
33
+ * @param options.shareable - When `true`, persists the simulation in Tenderly and returns a
34
+ * shareable `tenderlyUrl`. Honored only on the Tenderly backend, not on the `eth_simulateV1`
35
+ * fallback. Defaults to `false`.
36
+ * @throws {SimulationValidationError} for invalid input (mixed senders, bad addresses,
37
+ * empty transactions, malformed authorizations).
38
+ * @throws {UnsupportedChainError} when the chain is not configured for any backend.
39
+ * @throws {SimulationRevertedError} when the bundle reverts on either backend.
40
+ * @throws {BlacklistViolationError} when the simulation leaves value retained by or
41
+ * drained from a `bundler3` address beyond the dust threshold.
42
+ * @throws {ExternalServiceError} (a) when both backends are unavailable within the
43
+ * timeout budget, or (b) when a backend returns a `calls` array whose length does
44
+ * not match the resolved `simulationTxs` — refusing to map transfers with mismatched
45
+ * per-tx output.
46
+ * @returns A {@link SimulationResult} carrying the resolved `simulationTxs`, per-tx
47
+ * `calls` (aligned 1:1 with `simulationTxs`), parsed `transfers` (each stamped with
48
+ * `txIdx`), and the optional `tenderlyUrl`.
49
+ * @example
50
+ * ```ts
51
+ * import { simulate } from "@morpho-org/evm-simulation";
52
+ *
53
+ * const result = await simulate(
54
+ * { chains: new Map([[1, { simulateV1Url: rpcUrl }]]) },
55
+ * {
56
+ * chainId: 1,
57
+ * transactions: [{ from: user, to: vaultAddress, data: encodedCalldata, value: 0n }],
58
+ * },
59
+ * );
60
+ * // result satisfies SimulationResult
61
+ * ```
22
62
  */
63
+ // biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
23
64
  async function simulate(config, params, options = {}) {
24
65
  const { shareable = false } = options;
25
66
  (0, index_js_2.validateInput)(params);
@@ -31,7 +72,10 @@ async function simulate(config, params, options = {}) {
31
72
  blockNumber: params.blockNumber,
32
73
  shareable,
33
74
  });
34
- const transfers = (0, index_js_1.parseTransfers)(result.logs, config.logger);
75
+ if (result.calls.length !== simulationTxs.length) {
76
+ throw new errors_js_1.ExternalServiceError(`Backend returned ${result.calls.length} call result(s) for ${simulationTxs.length} transaction(s) — refusing to map transfers with mismatched lengths`);
77
+ }
78
+ const transfers = (0, index_js_1.parseTransfers)(result.calls, config.logger);
35
79
  (0, index_js_2.assertNoBundlerRetention)({
36
80
  chainId: params.chainId,
37
81
  transfers,
@@ -39,8 +83,8 @@ async function simulate(config, params, options = {}) {
39
83
  });
40
84
  return {
41
85
  simulationTxs,
86
+ calls: result.calls,
42
87
  transfers,
43
88
  tenderlyUrl: result.tenderlyUrl,
44
- assetChanges: result.rawAssetChanges,
45
89
  };
46
90
  }
@@ -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
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Base error for all simulation package errors.
3
- * Transport-agnostic — no HTTP status codes.
4
- * Consumers use try/catch + instanceof to handle specific error types.
2
+ * Base class for every error this package throws. Transport-agnostic — no HTTP status codes.
3
+ * Consumers pattern-match with `instanceof` on the concrete subclass.
5
4
  */
6
5
  export declare abstract class SimulationPackageError extends Error {
6
+ /** Stable string discriminator for log aggregation and external mapping. */
7
7
  abstract readonly code: string;
8
8
  constructor(message: string, options?: ErrorOptions);
9
9
  }
package/lib/esm/errors.js CHANGED
@@ -1,7 +1,6 @@
1
1
  /**
2
- * Base error for all simulation package errors.
3
- * Transport-agnostic — no HTTP status codes.
4
- * Consumers use try/catch + instanceof to handle specific error types.
2
+ * Base class for every error this package throws. Transport-agnostic — no HTTP status codes.
3
+ * Consumers pattern-match with `instanceof` on the concrete subclass.
5
4
  */
6
5
  export class SimulationPackageError extends Error {
7
6
  constructor(message, options) {
@@ -1,4 +1,4 @@
1
+ export { AddressScreeningError, BlacklistViolationError, ExternalServiceError, SimulationPackageError, SimulationRevertedError, SimulationValidationError, UnsupportedChainError, } from "./errors.js";
1
2
  export { screenAddresses } from "./screen-addresses/index.js";
2
3
  export { simulate } from "./simulate/index.js";
3
- export type { ChainSimulationConfig, SimulateParams, SimulationAuthorization, SimulationConfig, SimulationLogger, SimulationResult, SimulationTransaction, TenderlyRestConfig, Transfer, } from "./types.js";
4
- export { SimulationPackageError, SimulationRevertedError, BlacklistViolationError, AddressScreeningError, ExternalServiceError, SimulationValidationError, UnsupportedChainError, } from "./errors.js";
4
+ export type { ChainSimulationConfig, RawLog, SimulateParams, SimulationAuthorization, SimulationCall, SimulationConfig, SimulationLogger, SimulationResult, SimulationTransaction, TenderlyRestConfig, Transfer, } from "./types.js";
package/lib/esm/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Public fns (via feature-folder barrels)
2
+ // Errors (for instanceof checks by consumers)
3
+ export { AddressScreeningError, BlacklistViolationError, ExternalServiceError, SimulationPackageError, SimulationRevertedError, SimulationValidationError, UnsupportedChainError, } from "./errors.js";
2
4
  export { screenAddresses } from "./screen-addresses/index.js";
3
5
  export { simulate } from "./simulate/index.js";
4
- // Errors (for instanceof checks by consumers)
5
- export { SimulationPackageError, SimulationRevertedError, BlacklistViolationError, AddressScreeningError, ExternalServiceError, SimulationValidationError, UnsupportedChainError, } from "./errors.js";