@morpho-org/evm-simulation 4.0.0 → 4.1.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.
@@ -6,14 +6,13 @@ import type { RawSimulationResult, SimulationTransaction } from "../../types.js"
6
6
  * Uses stateOverride to set the user's ETH balance high enough to avoid
7
7
  * false "insufficient balance for gas" reverts.
8
8
  *
9
- * Derives `assetChanges` (net per-token deltas grouped by account) from the
10
- * emitted transfer logs, plus native ETH from each transaction's top-level
11
- * `value` (payer debited, recipient credited, under viem's `ethAddress`).
12
- *
13
- * Coverage on this path is therefore narrower than the Tenderly backend:
14
- * native ETH moved through internal calls (e.g. a `WETH.withdraw` refund, a
15
- * swap that pays out ETH) emits no log and is not reflected in `value`, so it
16
- * is not captured. For full native-ETH accounting use Tenderly.
9
+ * Runs with `traceTransfers` enabled, so the node synthesizes native-ETH moves
10
+ * (the top-level `value` *and* ETH moved through internal calls — e.g. a
11
+ * `WETH.withdraw` refund or a swap that pays out ETH) as `Transfer` events from
12
+ * the native sentinel. Derives `assetChanges` (net per-token deltas grouped by
13
+ * account) entirely from the emitted transfer logs, with native ETH normalized
14
+ * to viem's `ethAddress`. Top-level `value` is intentionally *not* added on top:
15
+ * `traceTransfers` already logs it, so adding it would double-count.
17
16
  */
18
17
  export declare function simulateV1(params: {
19
18
  rpcUrl: string;
@@ -11,14 +11,13 @@ const index_js_1 = require("../parsing/index.js");
11
11
  * Uses stateOverride to set the user's ETH balance high enough to avoid
12
12
  * false "insufficient balance for gas" reverts.
13
13
  *
14
- * Derives `assetChanges` (net per-token deltas grouped by account) from the
15
- * emitted transfer logs, plus native ETH from each transaction's top-level
16
- * `value` (payer debited, recipient credited, under viem's `ethAddress`).
17
- *
18
- * Coverage on this path is therefore narrower than the Tenderly backend:
19
- * native ETH moved through internal calls (e.g. a `WETH.withdraw` refund, a
20
- * swap that pays out ETH) emits no log and is not reflected in `value`, so it
21
- * is not captured. For full native-ETH accounting use Tenderly.
14
+ * Runs with `traceTransfers` enabled, so the node synthesizes native-ETH moves
15
+ * (the top-level `value` *and* ETH moved through internal calls — e.g. a
16
+ * `WETH.withdraw` refund or a swap that pays out ETH) as `Transfer` events from
17
+ * the native sentinel. Derives `assetChanges` (net per-token deltas grouped by
18
+ * account) entirely from the emitted transfer logs, with native ETH normalized
19
+ * to viem's `ethAddress`. Top-level `value` is intentionally *not* added on top:
20
+ * `traceTransfers` already logs it, so adding it would double-count.
22
21
  */
23
22
  async function simulateV1(params) {
24
23
  const { rpcUrl, transactions, blockNumber, signal } = params;
@@ -53,9 +52,17 @@ async function simulateV1(params) {
53
52
  account: sender,
54
53
  calls,
55
54
  ...blockParam,
55
+ // Synthesize native-ETH moves (top-level value + internal calls) as
56
+ // Transfer logs from the native sentinel, so `parseTransfers` captures
57
+ // ETH that emits no real log (e.g. a WETH.withdraw refund).
58
+ traceTransfers: true,
56
59
  // Inflate sender ETH balance to prevent false "insufficient gas" reverts.
57
60
  // Without this, valid ERC20 flows fail when the sender has low ETH.
58
- stateOverrides: [{ address: sender, balance: viem_1.maxUint256 }],
61
+ // Use half of uint256 (not the ceiling) so the override leaves headroom
62
+ // for inbound native ETH — e.g. a WETH.withdraw refund or a swap payout
63
+ // would overflow the recipient balance and revert the value transfer if
64
+ // the sender were pinned at maxUint256.
65
+ stateOverrides: [{ address: sender, balance: viem_1.maxUint256 / 2n }],
59
66
  });
60
67
  const results = simulationResult.results;
61
68
  if (!Array.isArray(results)) {
@@ -77,10 +84,7 @@ async function simulateV1(params) {
77
84
  }));
78
85
  return {
79
86
  calls: rawCalls,
80
- assetChanges: toAssetChanges({
81
- transfers: (0, index_js_1.parseTransfers)(rawCalls),
82
- transactions,
83
- }),
87
+ assetChanges: toAssetChanges((0, index_js_1.parseTransfers)(rawCalls)),
84
88
  };
85
89
  }
86
90
  catch (error) {
@@ -92,20 +96,13 @@ async function simulateV1(params) {
92
96
  }
93
97
  }
94
98
  /**
95
- * Reduce parsed transfer logs and top-level transaction values to net per-token
96
- * balance changes grouped by account. Native ETH is taken from the top-level
97
- * `value`s (payer debited, recipient credited — internal moves are not logged,
98
- * see `simulateV1` for the coverage caveat).
99
+ * Reduce parsed transfer logs to net per-token balance changes grouped by
100
+ * account. With `traceTransfers` enabled, native ETH (top-level and internal)
101
+ * arrives as transfer logs under viem's `ethAddress`, so no separate top-level
102
+ * `value` accounting is needed — doing so would double-count.
99
103
  */
100
- function toAssetChanges(params) {
101
- const { transfers, transactions } = params;
104
+ function toAssetChanges(transfers) {
102
105
  const entries = [];
103
- for (const tx of transactions) {
104
- if (!tx.value)
105
- continue;
106
- entries.push({ account: tx.from, token: viem_1.ethAddress, diff: -tx.value });
107
- entries.push({ account: tx.to, token: viem_1.ethAddress, diff: tx.value });
108
- }
109
106
  for (const { token, from, to, amount } of transfers) {
110
107
  entries.push({ account: to, token, diff: amount });
111
108
  entries.push({ account: from, token, diff: -amount });
@@ -156,7 +156,11 @@ function buildCall(tx) {
156
156
  };
157
157
  }
158
158
  function buildStateOverrides(sender) {
159
- return { [sender]: { balance: (0, viem_1.numberToHex)(viem_1.maxUint256) } };
159
+ // Half of uint256 (not the ceiling) so the override leaves headroom for
160
+ // inbound native ETH — a WETH.withdraw refund or swap payout would overflow
161
+ // the recipient balance and revert the value transfer if the sender were
162
+ // pinned at maxUint256.
163
+ return { [sender]: { balance: (0, viem_1.numberToHex)(viem_1.maxUint256 / 2n) } };
160
164
  }
161
165
  function encodeBlock(blockNumber) {
162
166
  if (blockNumber === undefined)
@@ -14,6 +14,15 @@ export declare const DEPOSIT_TOPIC = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d
14
14
  * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
15
15
  * see an incomplete transfer list.
16
16
  *
17
+ * **Native ETH (`eth_simulateV1` + `traceTransfers`).** When the backend runs
18
+ * `eth_simulateV1` with `traceTransfers` enabled, native-ETH moves — including
19
+ * those made through internal calls (e.g. a `WETH.withdraw` refund) — are
20
+ * synthesized as `Transfer` events emitted from the native sentinel
21
+ * `0xeee…eee`. These are parsed like any ERC20 transfer, with `token`
22
+ * collapsed to viem's `ethAddress` so native deltas key consistently across
23
+ * backends. Tenderly does not emit these synthetic logs (it derives native ETH
24
+ * separately), so this path is inert there.
25
+ *
17
26
  * **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
18
27
  * the same tx: a zero-address `Transfer` is suppressed only if its paired
19
28
  * `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
@@ -23,6 +23,15 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
23
23
  * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
24
24
  * see an incomplete transfer list.
25
25
  *
26
+ * **Native ETH (`eth_simulateV1` + `traceTransfers`).** When the backend runs
27
+ * `eth_simulateV1` with `traceTransfers` enabled, native-ETH moves — including
28
+ * those made through internal calls (e.g. a `WETH.withdraw` refund) — are
29
+ * synthesized as `Transfer` events emitted from the native sentinel
30
+ * `0xeee…eee`. These are parsed like any ERC20 transfer, with `token`
31
+ * collapsed to viem's `ethAddress` so native deltas key consistently across
32
+ * backends. Tenderly does not emit these synthetic logs (it derives native ETH
33
+ * separately), so this path is inert there.
34
+ *
26
35
  * **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
27
36
  * the same tx: a zero-address `Transfer` is suppressed only if its paired
28
37
  * `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
@@ -132,7 +141,7 @@ function parseTransfers(calls, logger) {
132
141
  }
133
142
  }
134
143
  transfers.push({
135
- token: (0, viem_1.getAddress)(log.address),
144
+ token: normalizeTransferToken(log.address),
136
145
  from: (0, viem_1.getAddress)(`0x${fromTopic.slice(26)}`),
137
146
  to: (0, viem_1.getAddress)(`0x${toTopic.slice(26)}`),
138
147
  amount: BigInt(log.data),
@@ -153,6 +162,21 @@ function parseTransfers(calls, logger) {
153
162
  }
154
163
  return sortTransfers(transfers);
155
164
  }
165
+ /**
166
+ * Normalize a `Transfer` log's emitting address into a transfer `token`.
167
+ *
168
+ * `eth_simulateV1` with `traceTransfers` enabled synthesizes native-ETH moves
169
+ * (including internal calls) as `Transfer` events emitted from the native
170
+ * sentinel `0xeee…eee`. The rest of the SDK keys native ETH by viem's
171
+ * `ethAddress` constant, so we collapse the sentinel to that exact value rather
172
+ * than the checksummed form `getAddress` would produce — keeping native deltas
173
+ * on a single map key across backends. Real ERC20 tokens are checksummed.
174
+ */
175
+ function normalizeTransferToken(address) {
176
+ return address.toLowerCase() === viem_1.ethAddress
177
+ ? viem_1.ethAddress
178
+ : (0, viem_1.getAddress)(address);
179
+ }
156
180
  function isTopicHex(value) {
157
181
  return typeof value === "string" && value.length === TOPIC_HEX_LENGTH;
158
182
  }
@@ -9,11 +9,11 @@ interface AssertNoBundlerRetentionParams {
9
9
  *
10
10
  * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
11
11
  * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
12
- * vault), so gross inbound would fire false positives. We only flag when
13
- * `|net| > DUST_THRESHOLD` — catching both **retention** (tokens stuck in
14
- * bundler, positive net) and **drain** (bundler sending more than it received
15
- * in this bundle, negative net — which implies a pre-existing or state-override
16
- * balance is being drawn down, a red flag for pre-broadcast integrity).
12
+ * vault), so gross inbound would fire false positives. We only block positive
13
+ * net flow above `DUST_THRESHOLD` — tokens stuck in a bundler address. Negative
14
+ * net flow means the simulated bundle sweeps a pre-existing or state-overridden
15
+ * bundler balance. That is useful telemetry, but not current-bundle retention,
16
+ * so it is warned instead of raising a blacklist violation.
17
17
  */
18
18
  export declare function assertNoBundlerRetention(params: AssertNoBundlerRetentionParams): void;
19
19
  export {};
@@ -47,11 +47,11 @@ function getBundlerAddresses(chainId, logger) {
47
47
  *
48
48
  * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
49
49
  * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
50
- * vault), so gross inbound would fire false positives. We only flag when
51
- * `|net| > DUST_THRESHOLD` — catching both **retention** (tokens stuck in
52
- * bundler, positive net) and **drain** (bundler sending more than it received
53
- * in this bundle, negative net — which implies a pre-existing or state-override
54
- * balance is being drawn down, a red flag for pre-broadcast integrity).
50
+ * vault), so gross inbound would fire false positives. We only block positive
51
+ * net flow above `DUST_THRESHOLD` — tokens stuck in a bundler address. Negative
52
+ * net flow means the simulated bundle sweeps a pre-existing or state-overridden
53
+ * bundler balance. That is useful telemetry, but not current-bundle retention,
54
+ * so it is warned instead of raising a blacklist violation.
55
55
  */
56
56
  function assertNoBundlerRetention(params) {
57
57
  const { chainId, transfers, logger } = params;
@@ -77,15 +77,23 @@ function assertNoBundlerRetention(params) {
77
77
  if (bundlerAddresses.has(t.from))
78
78
  recordFlow(t.from, t.token, -t.amount);
79
79
  }
80
- const flagged = [...flow.values()].filter((e) => absBigInt(e.netChange) > DUST_THRESHOLD);
81
- if (flagged.length > 0) {
82
- throw new errors_js_1.BlacklistViolationError("Simulation detected asset transfers retained or drained by restricted bundler contracts", flagged.map((e) => ({
80
+ const entries = [...flow.values()];
81
+ const drained = entries.filter((e) => e.netChange < -DUST_THRESHOLD);
82
+ if (drained.length > 0) {
83
+ logger?.warn("Simulation detected pre-existing bundler balance being swept", {
84
+ changes: drained.map((e) => ({
85
+ address: e.address,
86
+ token: e.token,
87
+ netSwept: (-e.netChange).toString(),
88
+ })),
89
+ });
90
+ }
91
+ const retained = entries.filter((e) => e.netChange > DUST_THRESHOLD);
92
+ if (retained.length > 0) {
93
+ throw new errors_js_1.BlacklistViolationError("Simulation detected asset transfers retained by restricted bundler contracts", retained.map((e) => ({
83
94
  address: e.address,
84
95
  token: e.token,
85
96
  netRetained: e.netChange.toString(),
86
97
  })));
87
98
  }
88
99
  }
89
- function absBigInt(x) {
90
- return x < 0n ? -x : x;
91
- }
@@ -29,8 +29,8 @@ import type { SimulateParams, SimulationConfig, SimulationResult } from "../type
29
29
  * empty transactions, malformed authorizations).
30
30
  * @throws {UnsupportedChainError} when the chain is not configured for any backend.
31
31
  * @throws {SimulationRevertedError} when the bundle reverts on either backend.
32
- * @throws {BlacklistViolationError} when the simulation leaves value retained by or
33
- * drained from a `bundler3` address beyond the dust threshold.
32
+ * @throws {BlacklistViolationError} when the simulation leaves value retained by
33
+ * a `bundler3` address beyond the dust threshold.
34
34
  * @throws {ExternalServiceError} (a) when both backends are unavailable within the
35
35
  * timeout budget, or (b) when a backend returns a `calls` array whose length does
36
36
  * not match the resolved `simulationTxs` — refusing to map transfers with mismatched
@@ -34,8 +34,8 @@ const index_js_2 = require("./pipeline/index.js");
34
34
  * empty transactions, malformed authorizations).
35
35
  * @throws {UnsupportedChainError} when the chain is not configured for any backend.
36
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.
37
+ * @throws {BlacklistViolationError} when the simulation leaves value retained by
38
+ * a `bundler3` address beyond the dust threshold.
39
39
  * @throws {ExternalServiceError} (a) when both backends are unavailable within the
40
40
  * timeout budget, or (b) when a backend returns a `calls` array whose length does
41
41
  * not match the resolved `simulationTxs` — refusing to map transfers with mismatched
@@ -82,10 +82,10 @@ export interface AssetChange {
82
82
  * alike (the zero address is kept for mints/burns). Accounts and their `changes`
83
83
  * are sorted by address for deterministic, cross-backend output.
84
84
  *
85
- * Native-ETH coverage differs by backend: Tenderly reports the full net ETH
86
- * delta (including ETH moved via internal calls), while the `eth_simulateV1`
87
- * fallback derives native ETH only from the top-level transaction `value`s —
88
- * ETH moved internally (e.g. a `WETH.withdraw` refund) is not captured there.
85
+ * Both backends report the full net native-ETH delta, including ETH moved via
86
+ * internal calls (e.g. a `WETH.withdraw` refund): Tenderly derives it from its
87
+ * trace, and the `eth_simulateV1` fallback runs with `traceTransfers` so the
88
+ * node synthesizes native moves as transfer logs under viem's `ethAddress`.
89
89
  */
90
90
  export interface AccountAssetChanges {
91
91
  readonly account: Address;
@@ -6,14 +6,13 @@ import type { RawSimulationResult, SimulationTransaction } from "../../types.js"
6
6
  * Uses stateOverride to set the user's ETH balance high enough to avoid
7
7
  * false "insufficient balance for gas" reverts.
8
8
  *
9
- * Derives `assetChanges` (net per-token deltas grouped by account) from the
10
- * emitted transfer logs, plus native ETH from each transaction's top-level
11
- * `value` (payer debited, recipient credited, under viem's `ethAddress`).
12
- *
13
- * Coverage on this path is therefore narrower than the Tenderly backend:
14
- * native ETH moved through internal calls (e.g. a `WETH.withdraw` refund, a
15
- * swap that pays out ETH) emits no log and is not reflected in `value`, so it
16
- * is not captured. For full native-ETH accounting use Tenderly.
9
+ * Runs with `traceTransfers` enabled, so the node synthesizes native-ETH moves
10
+ * (the top-level `value` *and* ETH moved through internal calls — e.g. a
11
+ * `WETH.withdraw` refund or a swap that pays out ETH) as `Transfer` events from
12
+ * the native sentinel. Derives `assetChanges` (net per-token deltas grouped by
13
+ * account) entirely from the emitted transfer logs, with native ETH normalized
14
+ * to viem's `ethAddress`. Top-level `value` is intentionally *not* added on top:
15
+ * `traceTransfers` already logs it, so adding it would double-count.
17
16
  */
18
17
  export declare function simulateV1(params: {
19
18
  rpcUrl: string;
@@ -1,4 +1,4 @@
1
- import { createPublicClient, ethAddress, getAddress, http, maxUint256, } from "viem";
1
+ import { createPublicClient, getAddress, http, maxUint256, } from "viem";
2
2
  import { ExternalServiceError, SimulationRevertedError, SimulationValidationError, } from "../../errors.js";
3
3
  import { groupAssetChanges } from "../asset-changes.js";
4
4
  import { parseTransfers } from "../parsing/index.js";
@@ -8,14 +8,13 @@ import { parseTransfers } from "../parsing/index.js";
8
8
  * Uses stateOverride to set the user's ETH balance high enough to avoid
9
9
  * false "insufficient balance for gas" reverts.
10
10
  *
11
- * Derives `assetChanges` (net per-token deltas grouped by account) from the
12
- * emitted transfer logs, plus native ETH from each transaction's top-level
13
- * `value` (payer debited, recipient credited, under viem's `ethAddress`).
14
- *
15
- * Coverage on this path is therefore narrower than the Tenderly backend:
16
- * native ETH moved through internal calls (e.g. a `WETH.withdraw` refund, a
17
- * swap that pays out ETH) emits no log and is not reflected in `value`, so it
18
- * is not captured. For full native-ETH accounting use Tenderly.
11
+ * Runs with `traceTransfers` enabled, so the node synthesizes native-ETH moves
12
+ * (the top-level `value` *and* ETH moved through internal calls — e.g. a
13
+ * `WETH.withdraw` refund or a swap that pays out ETH) as `Transfer` events from
14
+ * the native sentinel. Derives `assetChanges` (net per-token deltas grouped by
15
+ * account) entirely from the emitted transfer logs, with native ETH normalized
16
+ * to viem's `ethAddress`. Top-level `value` is intentionally *not* added on top:
17
+ * `traceTransfers` already logs it, so adding it would double-count.
19
18
  */
20
19
  export async function simulateV1(params) {
21
20
  const { rpcUrl, transactions, blockNumber, signal } = params;
@@ -50,9 +49,17 @@ export async function simulateV1(params) {
50
49
  account: sender,
51
50
  calls,
52
51
  ...blockParam,
52
+ // Synthesize native-ETH moves (top-level value + internal calls) as
53
+ // Transfer logs from the native sentinel, so `parseTransfers` captures
54
+ // ETH that emits no real log (e.g. a WETH.withdraw refund).
55
+ traceTransfers: true,
53
56
  // Inflate sender ETH balance to prevent false "insufficient gas" reverts.
54
57
  // Without this, valid ERC20 flows fail when the sender has low ETH.
55
- stateOverrides: [{ address: sender, balance: maxUint256 }],
58
+ // Use half of uint256 (not the ceiling) so the override leaves headroom
59
+ // for inbound native ETH — e.g. a WETH.withdraw refund or a swap payout
60
+ // would overflow the recipient balance and revert the value transfer if
61
+ // the sender were pinned at maxUint256.
62
+ stateOverrides: [{ address: sender, balance: maxUint256 / 2n }],
56
63
  });
57
64
  const results = simulationResult.results;
58
65
  if (!Array.isArray(results)) {
@@ -74,10 +81,7 @@ export async function simulateV1(params) {
74
81
  }));
75
82
  return {
76
83
  calls: rawCalls,
77
- assetChanges: toAssetChanges({
78
- transfers: parseTransfers(rawCalls),
79
- transactions,
80
- }),
84
+ assetChanges: toAssetChanges(parseTransfers(rawCalls)),
81
85
  };
82
86
  }
83
87
  catch (error) {
@@ -89,20 +93,13 @@ export async function simulateV1(params) {
89
93
  }
90
94
  }
91
95
  /**
92
- * Reduce parsed transfer logs and top-level transaction values to net per-token
93
- * balance changes grouped by account. Native ETH is taken from the top-level
94
- * `value`s (payer debited, recipient credited — internal moves are not logged,
95
- * see `simulateV1` for the coverage caveat).
96
+ * Reduce parsed transfer logs to net per-token balance changes grouped by
97
+ * account. With `traceTransfers` enabled, native ETH (top-level and internal)
98
+ * arrives as transfer logs under viem's `ethAddress`, so no separate top-level
99
+ * `value` accounting is needed — doing so would double-count.
96
100
  */
97
- function toAssetChanges(params) {
98
- const { transfers, transactions } = params;
101
+ function toAssetChanges(transfers) {
99
102
  const entries = [];
100
- for (const tx of transactions) {
101
- if (!tx.value)
102
- continue;
103
- entries.push({ account: tx.from, token: ethAddress, diff: -tx.value });
104
- entries.push({ account: tx.to, token: ethAddress, diff: tx.value });
105
- }
106
103
  for (const { token, from, to, amount } of transfers) {
107
104
  entries.push({ account: to, token, diff: amount });
108
105
  entries.push({ account: from, token, diff: -amount });
@@ -153,7 +153,11 @@ function buildCall(tx) {
153
153
  };
154
154
  }
155
155
  function buildStateOverrides(sender) {
156
- return { [sender]: { balance: numberToHex(maxUint256) } };
156
+ // Half of uint256 (not the ceiling) so the override leaves headroom for
157
+ // inbound native ETH — a WETH.withdraw refund or swap payout would overflow
158
+ // the recipient balance and revert the value transfer if the sender were
159
+ // pinned at maxUint256.
160
+ return { [sender]: { balance: numberToHex(maxUint256 / 2n) } };
157
161
  }
158
162
  function encodeBlock(blockNumber) {
159
163
  if (blockNumber === undefined)
@@ -14,6 +14,15 @@ export declare const DEPOSIT_TOPIC = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d
14
14
  * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
15
15
  * see an incomplete transfer list.
16
16
  *
17
+ * **Native ETH (`eth_simulateV1` + `traceTransfers`).** When the backend runs
18
+ * `eth_simulateV1` with `traceTransfers` enabled, native-ETH moves — including
19
+ * those made through internal calls (e.g. a `WETH.withdraw` refund) — are
20
+ * synthesized as `Transfer` events emitted from the native sentinel
21
+ * `0xeee…eee`. These are parsed like any ERC20 transfer, with `token`
22
+ * collapsed to viem's `ethAddress` so native deltas key consistently across
23
+ * backends. Tenderly does not emit these synthetic logs (it derives native ETH
24
+ * separately), so this path is inert there.
25
+ *
17
26
  * **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
18
27
  * the same tx: a zero-address `Transfer` is suppressed only if its paired
19
28
  * `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
@@ -1,4 +1,4 @@
1
- import { getAddress, zeroAddress, zeroHash } from "viem";
1
+ import { ethAddress, getAddress, zeroAddress, zeroHash, } from "viem";
2
2
  // keccak256("Transfer(address,address,uint256)") — ERC-20 transfer event
3
3
  export const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
4
4
  // keccak256("Withdrawal(address,uint256)") — WETH9 unwrap event
@@ -19,6 +19,15 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
19
19
  * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
20
20
  * see an incomplete transfer list.
21
21
  *
22
+ * **Native ETH (`eth_simulateV1` + `traceTransfers`).** When the backend runs
23
+ * `eth_simulateV1` with `traceTransfers` enabled, native-ETH moves — including
24
+ * those made through internal calls (e.g. a `WETH.withdraw` refund) — are
25
+ * synthesized as `Transfer` events emitted from the native sentinel
26
+ * `0xeee…eee`. These are parsed like any ERC20 transfer, with `token`
27
+ * collapsed to viem's `ethAddress` so native deltas key consistently across
28
+ * backends. Tenderly does not emit these synthetic logs (it derives native ETH
29
+ * separately), so this path is inert there.
30
+ *
22
31
  * **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
23
32
  * the same tx: a zero-address `Transfer` is suppressed only if its paired
24
33
  * `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
@@ -128,7 +137,7 @@ export function parseTransfers(calls, logger) {
128
137
  }
129
138
  }
130
139
  transfers.push({
131
- token: getAddress(log.address),
140
+ token: normalizeTransferToken(log.address),
132
141
  from: getAddress(`0x${fromTopic.slice(26)}`),
133
142
  to: getAddress(`0x${toTopic.slice(26)}`),
134
143
  amount: BigInt(log.data),
@@ -149,6 +158,21 @@ export function parseTransfers(calls, logger) {
149
158
  }
150
159
  return sortTransfers(transfers);
151
160
  }
161
+ /**
162
+ * Normalize a `Transfer` log's emitting address into a transfer `token`.
163
+ *
164
+ * `eth_simulateV1` with `traceTransfers` enabled synthesizes native-ETH moves
165
+ * (including internal calls) as `Transfer` events emitted from the native
166
+ * sentinel `0xeee…eee`. The rest of the SDK keys native ETH by viem's
167
+ * `ethAddress` constant, so we collapse the sentinel to that exact value rather
168
+ * than the checksummed form `getAddress` would produce — keeping native deltas
169
+ * on a single map key across backends. Real ERC20 tokens are checksummed.
170
+ */
171
+ function normalizeTransferToken(address) {
172
+ return address.toLowerCase() === ethAddress
173
+ ? ethAddress
174
+ : getAddress(address);
175
+ }
152
176
  function isTopicHex(value) {
153
177
  return typeof value === "string" && value.length === TOPIC_HEX_LENGTH;
154
178
  }
@@ -9,11 +9,11 @@ interface AssertNoBundlerRetentionParams {
9
9
  *
10
10
  * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
11
11
  * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
12
- * vault), so gross inbound would fire false positives. We only flag when
13
- * `|net| > DUST_THRESHOLD` — catching both **retention** (tokens stuck in
14
- * bundler, positive net) and **drain** (bundler sending more than it received
15
- * in this bundle, negative net — which implies a pre-existing or state-override
16
- * balance is being drawn down, a red flag for pre-broadcast integrity).
12
+ * vault), so gross inbound would fire false positives. We only block positive
13
+ * net flow above `DUST_THRESHOLD` — tokens stuck in a bundler address. Negative
14
+ * net flow means the simulated bundle sweeps a pre-existing or state-overridden
15
+ * bundler balance. That is useful telemetry, but not current-bundle retention,
16
+ * so it is warned instead of raising a blacklist violation.
17
17
  */
18
18
  export declare function assertNoBundlerRetention(params: AssertNoBundlerRetentionParams): void;
19
19
  export {};
@@ -44,11 +44,11 @@ function getBundlerAddresses(chainId, logger) {
44
44
  *
45
45
  * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
46
46
  * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
47
- * vault), so gross inbound would fire false positives. We only flag when
48
- * `|net| > DUST_THRESHOLD` — catching both **retention** (tokens stuck in
49
- * bundler, positive net) and **drain** (bundler sending more than it received
50
- * in this bundle, negative net — which implies a pre-existing or state-override
51
- * balance is being drawn down, a red flag for pre-broadcast integrity).
47
+ * vault), so gross inbound would fire false positives. We only block positive
48
+ * net flow above `DUST_THRESHOLD` — tokens stuck in a bundler address. Negative
49
+ * net flow means the simulated bundle sweeps a pre-existing or state-overridden
50
+ * bundler balance. That is useful telemetry, but not current-bundle retention,
51
+ * so it is warned instead of raising a blacklist violation.
52
52
  */
53
53
  export function assertNoBundlerRetention(params) {
54
54
  const { chainId, transfers, logger } = params;
@@ -74,15 +74,23 @@ export function assertNoBundlerRetention(params) {
74
74
  if (bundlerAddresses.has(t.from))
75
75
  recordFlow(t.from, t.token, -t.amount);
76
76
  }
77
- const flagged = [...flow.values()].filter((e) => absBigInt(e.netChange) > DUST_THRESHOLD);
78
- if (flagged.length > 0) {
79
- throw new BlacklistViolationError("Simulation detected asset transfers retained or drained by restricted bundler contracts", flagged.map((e) => ({
77
+ const entries = [...flow.values()];
78
+ const drained = entries.filter((e) => e.netChange < -DUST_THRESHOLD);
79
+ if (drained.length > 0) {
80
+ logger?.warn("Simulation detected pre-existing bundler balance being swept", {
81
+ changes: drained.map((e) => ({
82
+ address: e.address,
83
+ token: e.token,
84
+ netSwept: (-e.netChange).toString(),
85
+ })),
86
+ });
87
+ }
88
+ const retained = entries.filter((e) => e.netChange > DUST_THRESHOLD);
89
+ if (retained.length > 0) {
90
+ throw new BlacklistViolationError("Simulation detected asset transfers retained by restricted bundler contracts", retained.map((e) => ({
80
91
  address: e.address,
81
92
  token: e.token,
82
93
  netRetained: e.netChange.toString(),
83
94
  })));
84
95
  }
85
96
  }
86
- function absBigInt(x) {
87
- return x < 0n ? -x : x;
88
- }
@@ -29,8 +29,8 @@ import type { SimulateParams, SimulationConfig, SimulationResult } from "../type
29
29
  * empty transactions, malformed authorizations).
30
30
  * @throws {UnsupportedChainError} when the chain is not configured for any backend.
31
31
  * @throws {SimulationRevertedError} when the bundle reverts on either backend.
32
- * @throws {BlacklistViolationError} when the simulation leaves value retained by or
33
- * drained from a `bundler3` address beyond the dust threshold.
32
+ * @throws {BlacklistViolationError} when the simulation leaves value retained by
33
+ * a `bundler3` address beyond the dust threshold.
34
34
  * @throws {ExternalServiceError} (a) when both backends are unavailable within the
35
35
  * timeout budget, or (b) when a backend returns a `calls` array whose length does
36
36
  * not match the resolved `simulationTxs` — refusing to map transfers with mismatched
@@ -31,8 +31,8 @@ import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, valida
31
31
  * empty transactions, malformed authorizations).
32
32
  * @throws {UnsupportedChainError} when the chain is not configured for any backend.
33
33
  * @throws {SimulationRevertedError} when the bundle reverts on either backend.
34
- * @throws {BlacklistViolationError} when the simulation leaves value retained by or
35
- * drained from a `bundler3` address beyond the dust threshold.
34
+ * @throws {BlacklistViolationError} when the simulation leaves value retained by
35
+ * a `bundler3` address beyond the dust threshold.
36
36
  * @throws {ExternalServiceError} (a) when both backends are unavailable within the
37
37
  * timeout budget, or (b) when a backend returns a `calls` array whose length does
38
38
  * not match the resolved `simulationTxs` — refusing to map transfers with mismatched
@@ -82,10 +82,10 @@ export interface AssetChange {
82
82
  * alike (the zero address is kept for mints/burns). Accounts and their `changes`
83
83
  * are sorted by address for deterministic, cross-backend output.
84
84
  *
85
- * Native-ETH coverage differs by backend: Tenderly reports the full net ETH
86
- * delta (including ETH moved via internal calls), while the `eth_simulateV1`
87
- * fallback derives native ETH only from the top-level transaction `value`s —
88
- * ETH moved internally (e.g. a `WETH.withdraw` refund) is not captured there.
85
+ * Both backends report the full net native-ETH delta, including ETH moved via
86
+ * internal calls (e.g. a `WETH.withdraw` refund): Tenderly derives it from its
87
+ * trace, and the `eth_simulateV1` fallback runs with `traceTransfers` so the
88
+ * node synthesizes native moves as transfer logs under viem's `ethAddress`.
89
89
  */
90
90
  export interface AccountAssetChanges {
91
91
  readonly account: Address;
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": "4.0.0",
4
+ "version": "4.1.0",
5
5
  "author": "Morpho Association <contact@morpho.org>",
6
6
  "contributors": [
7
7
  "Rubilmax <rmilon@gmail.com>"
@@ -25,16 +25,16 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "zod": "^4.4.3",
28
- "@morpho-org/blue-sdk": "^6.1.0",
29
- "@morpho-org/morpho-ts": "^2.6.0"
28
+ "@morpho-org/blue-sdk": "^6.3.0",
29
+ "@morpho-org/morpho-ts": "^2.7.0"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "viem": "^2.0.0"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^6.0.3",
36
- "viem": "^2.50.4",
37
- "vitest": "^4.1.7",
36
+ "viem": "^2.54.2",
37
+ "vitest": "^4.1.9",
38
38
  "@morpho-org/test": "^2.8.1"
39
39
  },
40
40
  "scripts": {