@morpho-org/evm-simulation 4.0.1 → 4.1.2

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
  }
@@ -1,7 +1,8 @@
1
- import type { SimulationLogger, Transfer } from "../../types.js";
1
+ import type { AccountAssetChanges, SimulationLogger, Transfer } from "../../types.js";
2
2
  interface AssertNoBundlerRetentionParams {
3
3
  chainId: number;
4
4
  transfers: Transfer[];
5
+ assetChanges: readonly AccountAssetChanges[];
5
6
  logger?: SimulationLogger;
6
7
  }
7
8
  /**
@@ -10,10 +11,25 @@ interface AssertNoBundlerRetentionParams {
10
11
  * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
11
12
  * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
12
13
  * 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 above `DUST_THRESHOLD` — value stuck in a bundler address. Negative
14
15
  * net flow means the simulated bundle sweeps a pre-existing or state-overridden
15
16
  * bundler balance. That is useful telemetry, but not current-bundle retention,
16
17
  * so it is warned instead of raising a blacklist violation.
18
+ *
19
+ * **Two flow sources, one per asset class.** ERC20 / WETH9 retention is read
20
+ * from parsed `transfers` (log-derived, identical across backends). Native ETH
21
+ * emits no event log, so it can never appear in `transfers` on the Tenderly
22
+ * primary backend — Tenderly derives native moves into `assetChanges` instead.
23
+ * Native ETH is therefore read from `assetChanges`, the cross-backend source of
24
+ * truth for native value: Tenderly derives it from its trace, and
25
+ * `eth_simulateV1` derives it from the synthetic `traceTransfers` logs. Without
26
+ * this, native ETH stuck in bundler3 would pass the guard undetected on the
27
+ * Tenderly path (Cantina finding 1440).
28
+ *
29
+ * To avoid double-counting native ETH on `eth_simulateV1` — where the same
30
+ * native move exists both as a synthetic `ethAddress` transfer log *and* in the
31
+ * `assetChanges` derived from it — native transfer logs are only used as a
32
+ * fallback for bundler addresses that carry no native `assetChanges` entry.
17
33
  */
18
34
  export declare function assertNoBundlerRetention(params: AssertNoBundlerRetentionParams): void;
19
35
  export {};
@@ -48,13 +48,28 @@ function getBundlerAddresses(chainId, logger) {
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
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
51
+ * net flow above `DUST_THRESHOLD` — value stuck in a bundler address. Negative
52
52
  * net flow means the simulated bundle sweeps a pre-existing or state-overridden
53
53
  * bundler balance. That is useful telemetry, but not current-bundle retention,
54
54
  * so it is warned instead of raising a blacklist violation.
55
+ *
56
+ * **Two flow sources, one per asset class.** ERC20 / WETH9 retention is read
57
+ * from parsed `transfers` (log-derived, identical across backends). Native ETH
58
+ * emits no event log, so it can never appear in `transfers` on the Tenderly
59
+ * primary backend — Tenderly derives native moves into `assetChanges` instead.
60
+ * Native ETH is therefore read from `assetChanges`, the cross-backend source of
61
+ * truth for native value: Tenderly derives it from its trace, and
62
+ * `eth_simulateV1` derives it from the synthetic `traceTransfers` logs. Without
63
+ * this, native ETH stuck in bundler3 would pass the guard undetected on the
64
+ * Tenderly path (Cantina finding 1440).
65
+ *
66
+ * To avoid double-counting native ETH on `eth_simulateV1` — where the same
67
+ * native move exists both as a synthetic `ethAddress` transfer log *and* in the
68
+ * `assetChanges` derived from it — native transfer logs are only used as a
69
+ * fallback for bundler addresses that carry no native `assetChanges` entry.
55
70
  */
56
71
  function assertNoBundlerRetention(params) {
57
- const { chainId, transfers, logger } = params;
72
+ const { chainId, transfers, assetChanges, logger } = params;
58
73
  const bundlerAddresses = getBundlerAddresses(chainId, logger);
59
74
  if (bundlerAddresses.size === 0)
60
75
  return;
@@ -71,10 +86,29 @@ function assertNoBundlerRetention(params) {
71
86
  flow.set(key, { address, token, netChange: delta });
72
87
  }
73
88
  };
89
+ // Native ETH from `assetChanges` (authoritative, cross-backend). Track which
90
+ // bundlers already have a native entry here so the transfer-log pass below
91
+ // does not re-add the same native move on `eth_simulateV1`.
92
+ const nativeFromAssetChanges = new Set();
93
+ for (const { account, changes } of assetChanges) {
94
+ if (!bundlerAddresses.has(account))
95
+ continue;
96
+ for (const change of changes) {
97
+ if (change.token !== viem_1.ethAddress)
98
+ continue;
99
+ recordFlow(account, viem_1.ethAddress, change.diff);
100
+ nativeFromAssetChanges.add(account.toLowerCase());
101
+ }
102
+ }
103
+ // ERC20 / WETH9 retention from transfer logs. Native ETH transfers (the
104
+ // `eth_simulateV1` synthetic sentinel) only backfill a bundler endpoint that
105
+ // is absent from `nativeFromAssetChanges`, so native is never summed on top
106
+ // of the `assetChanges` value already recorded for that address.
107
+ const usesAssetChangeNative = (t, addr) => t.token === viem_1.ethAddress && nativeFromAssetChanges.has(addr.toLowerCase());
74
108
  for (const t of transfers) {
75
- if (bundlerAddresses.has(t.to))
109
+ if (bundlerAddresses.has(t.to) && !usesAssetChangeNative(t, t.to))
76
110
  recordFlow(t.to, t.token, t.amount);
77
- if (bundlerAddresses.has(t.from))
111
+ if (bundlerAddresses.has(t.from) && !usesAssetChangeNative(t, t.from))
78
112
  recordFlow(t.from, t.token, -t.amount);
79
113
  }
80
114
  const entries = [...flow.values()];
@@ -80,6 +80,7 @@ async function simulate(config, params) {
80
80
  (0, index_js_2.assertNoBundlerRetention)({
81
81
  chainId: params.chainId,
82
82
  transfers,
83
+ assetChanges: result.assetChanges,
83
84
  logger: config.logger,
84
85
  });
85
86
  return {
@@ -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
  }
@@ -1,7 +1,8 @@
1
- import type { SimulationLogger, Transfer } from "../../types.js";
1
+ import type { AccountAssetChanges, SimulationLogger, Transfer } from "../../types.js";
2
2
  interface AssertNoBundlerRetentionParams {
3
3
  chainId: number;
4
4
  transfers: Transfer[];
5
+ assetChanges: readonly AccountAssetChanges[];
5
6
  logger?: SimulationLogger;
6
7
  }
7
8
  /**
@@ -10,10 +11,25 @@ interface AssertNoBundlerRetentionParams {
10
11
  * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
11
12
  * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
12
13
  * 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 above `DUST_THRESHOLD` — value stuck in a bundler address. Negative
14
15
  * net flow means the simulated bundle sweeps a pre-existing or state-overridden
15
16
  * bundler balance. That is useful telemetry, but not current-bundle retention,
16
17
  * so it is warned instead of raising a blacklist violation.
18
+ *
19
+ * **Two flow sources, one per asset class.** ERC20 / WETH9 retention is read
20
+ * from parsed `transfers` (log-derived, identical across backends). Native ETH
21
+ * emits no event log, so it can never appear in `transfers` on the Tenderly
22
+ * primary backend — Tenderly derives native moves into `assetChanges` instead.
23
+ * Native ETH is therefore read from `assetChanges`, the cross-backend source of
24
+ * truth for native value: Tenderly derives it from its trace, and
25
+ * `eth_simulateV1` derives it from the synthetic `traceTransfers` logs. Without
26
+ * this, native ETH stuck in bundler3 would pass the guard undetected on the
27
+ * Tenderly path (Cantina finding 1440).
28
+ *
29
+ * To avoid double-counting native ETH on `eth_simulateV1` — where the same
30
+ * native move exists both as a synthetic `ethAddress` transfer log *and* in the
31
+ * `assetChanges` derived from it — native transfer logs are only used as a
32
+ * fallback for bundler addresses that carry no native `assetChanges` entry.
17
33
  */
18
34
  export declare function assertNoBundlerRetention(params: AssertNoBundlerRetentionParams): void;
19
35
  export {};
@@ -1,6 +1,6 @@
1
1
  import { getChainAddresses, UnsupportedChainIdError, } from "@morpho-org/blue-sdk";
2
2
  import { isDefined } from "@morpho-org/morpho-ts";
3
- import { getAddress } from "viem";
3
+ import { ethAddress, getAddress } from "viem";
4
4
  import { BlacklistViolationError } from "../../errors.js";
5
5
  /**
6
6
  * Dust tolerance for bundler retention, in raw token units.
@@ -45,13 +45,28 @@ function getBundlerAddresses(chainId, logger) {
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
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
48
+ * net flow above `DUST_THRESHOLD` — value stuck in a bundler address. Negative
49
49
  * net flow means the simulated bundle sweeps a pre-existing or state-overridden
50
50
  * bundler balance. That is useful telemetry, but not current-bundle retention,
51
51
  * so it is warned instead of raising a blacklist violation.
52
+ *
53
+ * **Two flow sources, one per asset class.** ERC20 / WETH9 retention is read
54
+ * from parsed `transfers` (log-derived, identical across backends). Native ETH
55
+ * emits no event log, so it can never appear in `transfers` on the Tenderly
56
+ * primary backend — Tenderly derives native moves into `assetChanges` instead.
57
+ * Native ETH is therefore read from `assetChanges`, the cross-backend source of
58
+ * truth for native value: Tenderly derives it from its trace, and
59
+ * `eth_simulateV1` derives it from the synthetic `traceTransfers` logs. Without
60
+ * this, native ETH stuck in bundler3 would pass the guard undetected on the
61
+ * Tenderly path (Cantina finding 1440).
62
+ *
63
+ * To avoid double-counting native ETH on `eth_simulateV1` — where the same
64
+ * native move exists both as a synthetic `ethAddress` transfer log *and* in the
65
+ * `assetChanges` derived from it — native transfer logs are only used as a
66
+ * fallback for bundler addresses that carry no native `assetChanges` entry.
52
67
  */
53
68
  export function assertNoBundlerRetention(params) {
54
- const { chainId, transfers, logger } = params;
69
+ const { chainId, transfers, assetChanges, logger } = params;
55
70
  const bundlerAddresses = getBundlerAddresses(chainId, logger);
56
71
  if (bundlerAddresses.size === 0)
57
72
  return;
@@ -68,10 +83,29 @@ export function assertNoBundlerRetention(params) {
68
83
  flow.set(key, { address, token, netChange: delta });
69
84
  }
70
85
  };
86
+ // Native ETH from `assetChanges` (authoritative, cross-backend). Track which
87
+ // bundlers already have a native entry here so the transfer-log pass below
88
+ // does not re-add the same native move on `eth_simulateV1`.
89
+ const nativeFromAssetChanges = new Set();
90
+ for (const { account, changes } of assetChanges) {
91
+ if (!bundlerAddresses.has(account))
92
+ continue;
93
+ for (const change of changes) {
94
+ if (change.token !== ethAddress)
95
+ continue;
96
+ recordFlow(account, ethAddress, change.diff);
97
+ nativeFromAssetChanges.add(account.toLowerCase());
98
+ }
99
+ }
100
+ // ERC20 / WETH9 retention from transfer logs. Native ETH transfers (the
101
+ // `eth_simulateV1` synthetic sentinel) only backfill a bundler endpoint that
102
+ // is absent from `nativeFromAssetChanges`, so native is never summed on top
103
+ // of the `assetChanges` value already recorded for that address.
104
+ const usesAssetChangeNative = (t, addr) => t.token === ethAddress && nativeFromAssetChanges.has(addr.toLowerCase());
71
105
  for (const t of transfers) {
72
- if (bundlerAddresses.has(t.to))
106
+ if (bundlerAddresses.has(t.to) && !usesAssetChangeNative(t, t.to))
73
107
  recordFlow(t.to, t.token, t.amount);
74
- if (bundlerAddresses.has(t.from))
108
+ if (bundlerAddresses.has(t.from) && !usesAssetChangeNative(t, t.from))
75
109
  recordFlow(t.from, t.token, -t.amount);
76
110
  }
77
111
  const entries = [...flow.values()];
@@ -77,6 +77,7 @@ export async function simulate(config, params) {
77
77
  assertNoBundlerRetention({
78
78
  chainId: params.chainId,
79
79
  transfers,
80
+ assetChanges: result.assetChanges,
80
81
  logger: config.logger,
81
82
  });
82
83
  return {
@@ -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.1",
4
+ "version": "4.1.2",
5
5
  "author": "Morpho Association <contact@morpho.org>",
6
6
  "contributors": [
7
7
  "Rubilmax <rmilon@gmail.com>"
@@ -19,23 +19,23 @@
19
19
  "license": "MIT",
20
20
  "type": "module",
21
21
  "sideEffects": false,
22
- "main": "src/index.ts",
22
+ "main": "./lib/cjs/index.js",
23
23
  "files": [
24
24
  "lib"
25
25
  ],
26
26
  "dependencies": {
27
27
  "zod": "^4.4.3",
28
- "@morpho-org/morpho-ts": "^2.6.0",
29
- "@morpho-org/blue-sdk": "^6.2.0"
28
+ "@morpho-org/blue-sdk": "^6.3.1",
29
+ "@morpho-org/morpho-ts": "^2.8.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.52.2",
37
- "vitest": "^4.1.8",
38
- "@morpho-org/test": "^2.8.1"
36
+ "viem": "^2.54.2",
37
+ "vitest": "^4.1.9",
38
+ "@morpho-org/test": "^2.8.2"
39
39
  },
40
40
  "scripts": {
41
41
  "prepublish": "$npm_execpath build",
@@ -45,6 +45,7 @@
45
45
  "test": "vitest --root ../.. --project evm-simulation",
46
46
  "test:fork": "dotenv -- vitest --root ../.. --project evm-simulation-fork"
47
47
  },
48
+ "types": "./lib/esm/index.d.ts",
48
49
  "exports": {
49
50
  ".": {
50
51
  "types": "./lib/esm/index.d.ts",
package/src/index.ts DELETED
@@ -1,28 +0,0 @@
1
- // Public fns (via feature-folder barrels)
2
-
3
- // Errors (for instanceof checks by consumers)
4
- export {
5
- BlacklistViolationError,
6
- ExternalServiceError,
7
- SimulationPackageError,
8
- SimulationRevertedError,
9
- SimulationValidationError,
10
- UnsupportedChainError,
11
- } from "./errors.js";
12
- export { simulate } from "./simulate/index.js";
13
- // Types
14
- export type {
15
- AccountAssetChanges,
16
- AssetChange,
17
- ChainSimulationConfig,
18
- RawLog,
19
- SimulateParams,
20
- SimulationAuthorization,
21
- SimulationCall,
22
- SimulationConfig,
23
- SimulationLogger,
24
- SimulationResult,
25
- SimulationTransaction,
26
- TenderlyRpcConfig,
27
- Transfer,
28
- } from "./types.js";