@morpho-org/evm-simulation 4.1.5 → 4.1.6-next.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.
Files changed (31) hide show
  1. package/lib/cjs/errors.d.ts +1 -1
  2. package/lib/cjs/errors.js +1 -1
  3. package/lib/cjs/simulate/asset-changes.d.ts +16 -1
  4. package/lib/cjs/simulate/asset-changes.js +20 -0
  5. package/lib/cjs/simulate/backends/eth-simulate-v1.d.ts +2 -1
  6. package/lib/cjs/simulate/backends/eth-simulate-v1.js +2 -2
  7. package/lib/cjs/simulate/backends/tenderly-rpc.js +1 -1
  8. package/lib/cjs/simulate/parsing/transfers.d.ts +29 -15
  9. package/lib/cjs/simulate/parsing/transfers.js +50 -55
  10. package/lib/cjs/simulate/pipeline/bundler-retention.d.ts +26 -8
  11. package/lib/cjs/simulate/pipeline/bundler-retention.js +66 -43
  12. package/lib/cjs/simulate/pipeline/execute-simulation.d.ts +2 -1
  13. package/lib/cjs/simulate/pipeline/execute-simulation.js +3 -1
  14. package/lib/cjs/simulate/simulate.d.ts +8 -4
  15. package/lib/cjs/simulate/simulate.js +15 -5
  16. package/lib/esm/errors.d.ts +1 -1
  17. package/lib/esm/errors.js +1 -1
  18. package/lib/esm/simulate/asset-changes.d.ts +16 -1
  19. package/lib/esm/simulate/asset-changes.js +20 -1
  20. package/lib/esm/simulate/backends/eth-simulate-v1.d.ts +2 -1
  21. package/lib/esm/simulate/backends/eth-simulate-v1.js +2 -2
  22. package/lib/esm/simulate/backends/tenderly-rpc.js +3 -3
  23. package/lib/esm/simulate/parsing/transfers.d.ts +29 -15
  24. package/lib/esm/simulate/parsing/transfers.js +51 -56
  25. package/lib/esm/simulate/pipeline/bundler-retention.d.ts +26 -8
  26. package/lib/esm/simulate/pipeline/bundler-retention.js +66 -43
  27. package/lib/esm/simulate/pipeline/execute-simulation.d.ts +2 -1
  28. package/lib/esm/simulate/pipeline/execute-simulation.js +3 -1
  29. package/lib/esm/simulate/simulate.d.ts +8 -4
  30. package/lib/esm/simulate/simulate.js +15 -5
  31. package/package.json +3 -3
@@ -19,7 +19,7 @@ interface RetainedAsset {
19
19
  token: string | undefined;
20
20
  netRetained: string;
21
21
  }
22
- /** Funds would flow to bundler3 contract addresses. Never bypassable. */
22
+ /** Funds would flow to restricted bundler3 or bundles contract addresses. Never bypassable. */
23
23
  export declare class BlacklistViolationError extends SimulationPackageError {
24
24
  readonly assetChanges?: RetainedAsset[] | undefined;
25
25
  readonly code = "BLACKLIST_ERROR";
package/lib/cjs/errors.js CHANGED
@@ -24,7 +24,7 @@ class SimulationRevertedError extends SimulationPackageError {
24
24
  }
25
25
  }
26
26
  exports.SimulationRevertedError = SimulationRevertedError;
27
- /** Funds would flow to bundler3 contract addresses. Never bypassable. */
27
+ /** Funds would flow to restricted bundler3 or bundles contract addresses. Never bypassable. */
28
28
  class BlacklistViolationError extends SimulationPackageError {
29
29
  assetChanges;
30
30
  code = "BLACKLIST_ERROR";
@@ -1,4 +1,4 @@
1
- import { type Address } from "viem";
1
+ import { type Address, type Hex } from "viem";
2
2
  import type { AccountAssetChanges } from "../types.js";
3
3
  /** A single signed contribution to one account's balance for one token. */
4
4
  export interface AssetChangeEntry {
@@ -8,6 +8,21 @@ export interface AssetChangeEntry {
8
8
  symbol?: string;
9
9
  decimals?: number;
10
10
  }
11
+ /**
12
+ * Collapse the native-ETH sentinel to viem's lowercase `ethAddress`, checksumming
13
+ * any real token address. `eth_simulateV1` synthesizes native moves from the
14
+ * sentinel `0xeee…eee`, and Tenderly may echo it — in checksummed or any other
15
+ * case — as an asset change's `contractAddress`. Native ETH is keyed by the exact
16
+ * `ethAddress` constant here and in the bundler-retention guard, so a checksummed
17
+ * sentinel would land on a separate map key and silently escape retention checks.
18
+ * This is the single source of truth for token normalization shared by the
19
+ * transfer-log parser and the Tenderly asset-change mapper.
20
+ *
21
+ * @param address - Token address emitting a transfer or carried by an asset change.
22
+ * @returns `ethAddress` for the native sentinel, else the checksummed address.
23
+ * @internal
24
+ */
25
+ export declare function normalizeAssetToken(address: Hex): Address;
11
26
  /**
12
27
  * Net signed per-(account, token) contributions into balance changes grouped by
13
28
  * account. Accounts and their `changes` are sorted by address for deterministic
@@ -1,7 +1,27 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeAssetToken = normalizeAssetToken;
3
4
  exports.groupAssetChanges = groupAssetChanges;
4
5
  const viem_1 = require("viem");
6
+ /**
7
+ * Collapse the native-ETH sentinel to viem's lowercase `ethAddress`, checksumming
8
+ * any real token address. `eth_simulateV1` synthesizes native moves from the
9
+ * sentinel `0xeee…eee`, and Tenderly may echo it — in checksummed or any other
10
+ * case — as an asset change's `contractAddress`. Native ETH is keyed by the exact
11
+ * `ethAddress` constant here and in the bundler-retention guard, so a checksummed
12
+ * sentinel would land on a separate map key and silently escape retention checks.
13
+ * This is the single source of truth for token normalization shared by the
14
+ * transfer-log parser and the Tenderly asset-change mapper.
15
+ *
16
+ * @param address - Token address emitting a transfer or carried by an asset change.
17
+ * @returns `ethAddress` for the native sentinel, else the checksummed address.
18
+ * @internal
19
+ */
20
+ function normalizeAssetToken(address) {
21
+ return address.toLowerCase() === viem_1.ethAddress
22
+ ? viem_1.ethAddress
23
+ : (0, viem_1.getAddress)(address);
24
+ }
5
25
  // Locale-independent byte-order compare on lowercased hex, matching `sortTransfers`.
6
26
  const compareAddress = (a, b) => {
7
27
  const al = a.toLowerCase();
@@ -1,4 +1,4 @@
1
- import { type BlockTag } from "viem";
1
+ import { type Address, type BlockTag } from "viem";
2
2
  import type { RawSimulationResult, SimulationTransaction } from "../../types.js";
3
3
  /**
4
4
  * Simulate transactions using eth_simulateV1 (viem simulateCalls).
@@ -19,5 +19,6 @@ export declare function simulateV1(params: {
19
19
  chainId: number;
20
20
  transactions: SimulationTransaction[];
21
21
  blockNumber?: bigint | BlockTag;
22
+ wNative?: Address | null;
22
23
  signal?: AbortSignal;
23
24
  }): Promise<RawSimulationResult>;
@@ -20,7 +20,7 @@ const index_js_1 = require("../parsing/index.js");
20
20
  * `traceTransfers` already logs it, so adding it would double-count.
21
21
  */
22
22
  async function simulateV1(params) {
23
- const { rpcUrl, transactions, blockNumber, signal } = params;
23
+ const { rpcUrl, transactions, blockNumber, wNative, signal } = params;
24
24
  const client = (0, viem_1.createPublicClient)({
25
25
  transport: (0, viem_1.http)(rpcUrl, {
26
26
  fetchOptions: signal ? { signal } : undefined,
@@ -84,7 +84,7 @@ async function simulateV1(params) {
84
84
  }));
85
85
  return {
86
86
  calls: rawCalls,
87
- assetChanges: toAssetChanges((0, index_js_1.parseTransfers)(rawCalls)),
87
+ assetChanges: toAssetChanges((0, index_js_1.parseTransfers)(rawCalls, { wNative })),
88
88
  };
89
89
  }
90
90
  catch (error) {
@@ -209,7 +209,7 @@ function toAssetChanges(results) {
209
209
  for (const change of result.assetChanges ?? []) {
210
210
  const amount = BigInt(change.rawAmount);
211
211
  const token = change.assetInfo.contractAddress
212
- ? (0, viem_1.getAddress)(change.assetInfo.contractAddress)
212
+ ? (0, asset_changes_js_1.normalizeAssetToken)(change.assetInfo.contractAddress)
213
213
  : viem_1.ethAddress;
214
214
  const { symbol, decimals } = change.assetInfo;
215
215
  if (change.to)
@@ -1,3 +1,4 @@
1
+ import { type Address } from "viem";
1
2
  import type { RawCall, SimulationLogger, Transfer } from "../../types.js";
2
3
  export declare const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
3
4
  export declare const WITHDRAWAL_TOPIC = "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65";
@@ -9,10 +10,13 @@ export declare const DEPOSIT_TOPIC = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d
9
10
  * with the index of the originating call.
10
11
  *
11
12
  * **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
12
- * `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
13
- * events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
14
- * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
15
- * see an incomplete transfer list.
13
+ * `Deposit(to, amount)` / `Withdrawal(from, amount)` from the registered
14
+ * wrapped-native token. Known chains without a wrapped-native token ignore
15
+ * WETH9 events and skip wrapped-native deduplication; chains without registry
16
+ * metadata retain legacy signature-based parsing. On enabled paths, paired
17
+ * mint/burn Transfer events are deduplicated. ERC721 and ERC1155 transfer
18
+ * events are **not** parsed — consumers with NFT flows will see an incomplete
19
+ * transfer list.
16
20
  *
17
21
  * **Native ETH (`eth_simulateV1` + `traceTransfers`).** When the backend runs
18
22
  * `eth_simulateV1` with `traceTransfers` enabled, native-ETH moves — including
@@ -23,26 +27,36 @@ export declare const DEPOSIT_TOPIC = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d
23
27
  * backends. Tenderly does not emit these synthetic logs (it derives native ETH
24
28
  * separately), so this path is inert there.
25
29
  *
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
+ * **WETH9 dedup assumption — canonical atomic emission.** Dedup for the
31
+ * registered wrapped-native token is scoped to the same tx: a zero-address
32
+ * `Transfer` is suppressed only if its paired `Deposit`/`Withdrawal` appears
33
+ * in the same `calls[txIdx].logs` slice. This is correct for canonical WETH9,
34
+ * which always emits the `Deposit`/
30
35
  * `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
31
36
  * atomically inside a single call frame. A **non-canonical wrapped-native**
32
37
  * that splits these emissions across two txs in the same bundle would leave
33
38
  * a phantom zero-address `Transfer` in the parsed output, which can be summed
34
39
  * 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`).
40
+ * When its zero-address `Transfer` misses same-tx dedup, the parser emits a
41
+ * `warn` so the assumption break is observable before it reaches retention.
42
+ * None of `morpho-sdk`'s currently supported chains require cross-tx dedup;
43
+ * the assumption is rechecked when onboarding a new chain (see
44
+ * `evm-simulation/CLAUDE.md`).
41
45
  *
42
46
  * **Failure mode:** on a per-log parse failure (malformed topic length,
43
47
  * non-hex data), the log is skipped and a `warn` is emitted via the logger.
44
48
  *
45
49
  * Output is sorted canonically by token, from, to, amount for determinism.
46
50
  * `txIdx` is attached but does not influence sort order.
51
+ *
52
+ * @param calls - Per-transaction call results to parse.
53
+ * @param options - Wrapped-native metadata and optional logger. Pass `null` for
54
+ * a known chain without a wrapped-native token; omit it for legacy parsing on
55
+ * an unknown chain.
56
+ * @returns Canonically sorted transfers with their originating transaction index.
57
+ * @internal
47
58
  */
48
- export declare function parseTransfers(calls: readonly RawCall[], logger?: SimulationLogger): Transfer[];
59
+ export declare function parseTransfers(calls: readonly RawCall[], options?: {
60
+ readonly wNative?: Address | null;
61
+ readonly logger?: SimulationLogger;
62
+ }): Transfer[];
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEPOSIT_TOPIC = exports.WITHDRAWAL_TOPIC = exports.TRANSFER_TOPIC = void 0;
4
4
  exports.parseTransfers = parseTransfers;
5
5
  const viem_1 = require("viem");
6
+ const asset_changes_js_1 = require("../asset-changes.js");
6
7
  // keccak256("Transfer(address,address,uint256)") — ERC-20 transfer event
7
8
  exports.TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
8
9
  // keccak256("Withdrawal(address,uint256)") — WETH9 unwrap event
@@ -18,10 +19,13 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
18
19
  * with the index of the originating call.
19
20
  *
20
21
  * **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
21
- * `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
22
- * events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
23
- * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
24
- * see an incomplete transfer list.
22
+ * `Deposit(to, amount)` / `Withdrawal(from, amount)` from the registered
23
+ * wrapped-native token. Known chains without a wrapped-native token ignore
24
+ * WETH9 events and skip wrapped-native deduplication; chains without registry
25
+ * metadata retain legacy signature-based parsing. On enabled paths, paired
26
+ * mint/burn Transfer events are deduplicated. ERC721 and ERC1155 transfer
27
+ * events are **not** parsed — consumers with NFT flows will see an incomplete
28
+ * transfer list.
25
29
  *
26
30
  * **Native ETH (`eth_simulateV1` + `traceTransfers`).** When the backend runs
27
31
  * `eth_simulateV1` with `traceTransfers` enabled, native-ETH moves — including
@@ -32,35 +36,41 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
32
36
  * backends. Tenderly does not emit these synthetic logs (it derives native ETH
33
37
  * separately), so this path is inert there.
34
38
  *
35
- * **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
36
- * the same tx: a zero-address `Transfer` is suppressed only if its paired
37
- * `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
38
- * is correct for canonical WETH9, which always emits the `Deposit`/
39
+ * **WETH9 dedup assumption — canonical atomic emission.** Dedup for the
40
+ * registered wrapped-native token is scoped to the same tx: a zero-address
41
+ * `Transfer` is suppressed only if its paired `Deposit`/`Withdrawal` appears
42
+ * in the same `calls[txIdx].logs` slice. This is correct for canonical WETH9,
43
+ * which always emits the `Deposit`/
39
44
  * `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
40
45
  * atomically inside a single call frame. A **non-canonical wrapped-native**
41
46
  * that splits these emissions across two txs in the same bundle would leave
42
47
  * a phantom zero-address `Transfer` in the parsed output, which can be summed
43
48
  * by `assertNoBundlerRetention` and produce a false `BlacklistViolationError`.
44
- * When a zero-address `Transfer` misses same-tx dedup *and* its token has
45
- * emitted a `Deposit`/`Withdrawal` somewhere else in the bundle (i.e. it
46
- * looks wnative-shaped), the parser emits a `warn` so the assumption break
47
- * is observable before it reaches retention. None of `morpho-sdk`'s currently
48
- * supported chains require cross-tx dedup; the assumption is rechecked when
49
- * onboarding a new chain (see `evm-simulation/CLAUDE.md`).
49
+ * When its zero-address `Transfer` misses same-tx dedup, the parser emits a
50
+ * `warn` so the assumption break is observable before it reaches retention.
51
+ * None of `morpho-sdk`'s currently supported chains require cross-tx dedup;
52
+ * the assumption is rechecked when onboarding a new chain (see
53
+ * `evm-simulation/CLAUDE.md`).
50
54
  *
51
55
  * **Failure mode:** on a per-log parse failure (malformed topic length,
52
56
  * non-hex data), the log is skipped and a `warn` is emitted via the logger.
53
57
  *
54
58
  * Output is sorted canonically by token, from, to, amount for determinism.
55
59
  * `txIdx` is attached but does not influence sort order.
60
+ *
61
+ * @param calls - Per-transaction call results to parse.
62
+ * @param options - Wrapped-native metadata and optional logger. Pass `null` for
63
+ * a known chain without a wrapped-native token; omit it for legacy parsing on
64
+ * an unknown chain.
65
+ * @returns Canonically sorted transfers with their originating transaction index.
66
+ * @internal
56
67
  */
57
- function parseTransfers(calls, logger) {
68
+ function parseTransfers(calls, options = {}) {
58
69
  const transfers = [];
59
- // Tokens that emit `Deposit` or `Withdrawal` somewhere in the bundle look
60
- // wnative-shaped. If a zero-address `Transfer` for one of these tokens
61
- // misses same-tx dedup, the contract is likely emitting non-canonically
62
- // (split across txs) and a phantom transfer would leak into retention.
70
+ const { logger, wNative } = options;
63
71
  const wnativeShapedTokens = collectWnativeShapedTokens(calls);
72
+ const acceptsWnativeEvent = (address) => wNative === undefined ||
73
+ (wNative !== null && (0, viem_1.isAddressEqual)(address, wNative));
64
74
  for (let txIdx = 0; txIdx < calls.length; txIdx++) {
65
75
  const logs = calls[txIdx].logs;
66
76
  for (const log of logs) {
@@ -70,6 +80,8 @@ function parseTransfers(calls, logger) {
70
80
  continue;
71
81
  switch (topic0) {
72
82
  case exports.WITHDRAWAL_TOPIC: {
83
+ if (!acceptsWnativeEvent(log.address))
84
+ continue;
73
85
  const fromTopic = log.topics[1];
74
86
  if (!isTopicHex(fromTopic) || !isUint256Hex(log.data)) {
75
87
  warnMalformed(logger, log, "WETH9 Withdrawal: bad topic[1] or data length");
@@ -85,6 +97,8 @@ function parseTransfers(calls, logger) {
85
97
  continue;
86
98
  }
87
99
  case exports.DEPOSIT_TOPIC: {
100
+ if (!acceptsWnativeEvent(log.address))
101
+ continue;
88
102
  const toTopic = log.topics[1];
89
103
  if (!isTopicHex(toTopic) || !isUint256Hex(log.data)) {
90
104
  warnMalformed(logger, log, "WETH9 Deposit: bad topic[1] or data length");
@@ -114,34 +128,36 @@ function parseTransfers(calls, logger) {
114
128
  }
115
129
  // WETH9 unwrap dedup: Transfer to zero paired with a Withdrawal of
116
130
  // equal amount in the SAME tx.
117
- if (toTopic === viem_1.zeroHash) {
131
+ if (toTopic === viem_1.zeroHash && acceptsWnativeEvent(log.address)) {
118
132
  const paired = logs.some((other) => other.topics[0] === exports.WITHDRAWAL_TOPIC &&
119
- other.address === log.address &&
133
+ (0, viem_1.isAddressEqual)(other.address, log.address) &&
120
134
  other.data === log.data &&
121
135
  other.topics.length === 2 &&
122
136
  other.topics[1] === fromTopic);
123
137
  if (paired)
124
138
  continue;
125
- if (wnativeShapedTokens.has(log.address.toLowerCase())) {
139
+ if (wNative !== undefined ||
140
+ wnativeShapedTokens.has(log.address.toLowerCase())) {
126
141
  warnNonCanonicalWnative(logger, log, "burn", txIdx);
127
142
  }
128
143
  }
129
144
  // WETH9 wrap dedup: Transfer from zero paired with a Deposit of
130
145
  // equal amount in the SAME tx.
131
- if (fromTopic === viem_1.zeroHash) {
146
+ if (fromTopic === viem_1.zeroHash && acceptsWnativeEvent(log.address)) {
132
147
  const paired = logs.some((other) => other.topics[0] === exports.DEPOSIT_TOPIC &&
133
- other.address === log.address &&
148
+ (0, viem_1.isAddressEqual)(other.address, log.address) &&
134
149
  other.data === log.data &&
135
150
  other.topics.length === 2 &&
136
151
  other.topics[1] === toTopic);
137
152
  if (paired)
138
153
  continue;
139
- if (wnativeShapedTokens.has(log.address.toLowerCase())) {
154
+ if (wNative !== undefined ||
155
+ wnativeShapedTokens.has(log.address.toLowerCase())) {
140
156
  warnNonCanonicalWnative(logger, log, "mint", txIdx);
141
157
  }
142
158
  }
143
159
  transfers.push({
144
- token: normalizeTransferToken(log.address),
160
+ token: (0, asset_changes_js_1.normalizeAssetToken)(log.address),
145
161
  from: (0, viem_1.getAddress)(`0x${fromTopic.slice(26)}`),
146
162
  to: (0, viem_1.getAddress)(`0x${toTopic.slice(26)}`),
147
163
  amount: BigInt(log.data),
@@ -162,21 +178,6 @@ function parseTransfers(calls, logger) {
162
178
  }
163
179
  return sortTransfers(transfers);
164
180
  }
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
- }
180
181
  function isTopicHex(value) {
181
182
  return typeof value === "string" && value.length === TOPIC_HEX_LENGTH;
182
183
  }
@@ -191,24 +192,18 @@ function warnMalformed(logger, log, reason) {
191
192
  reason,
192
193
  });
193
194
  }
194
- /**
195
- * Collect token addresses (lowercased) that emit `Deposit` or `Withdrawal`
196
- * anywhere in the bundle. Used to detect when a zero-address `Transfer` that
197
- * misses same-tx dedup is on a wnative-shaped contract — a strong signal
198
- * that the contract is emitting non-canonically and a phantom transfer is
199
- * about to leak into bundler retention.
200
- */
195
+ /** Collect contracts that emit WETH9-shaped events anywhere in the bundle. */
201
196
  function collectWnativeShapedTokens(calls) {
202
- const set = new Set();
203
- for (const c of calls) {
204
- for (const l of c.logs) {
205
- const t0 = l.topics[0];
206
- if (t0 === exports.WITHDRAWAL_TOPIC || t0 === exports.DEPOSIT_TOPIC) {
207
- set.add(l.address.toLowerCase());
197
+ const tokens = new Set();
198
+ for (const call of calls) {
199
+ for (const log of call.logs) {
200
+ const topic0 = log.topics[0];
201
+ if (topic0 === exports.WITHDRAWAL_TOPIC || topic0 === exports.DEPOSIT_TOPIC) {
202
+ tokens.add(log.address.toLowerCase());
208
203
  }
209
204
  }
210
205
  }
211
- return set;
206
+ return tokens;
212
207
  }
213
208
  // biome-ignore lint/complexity/useMaxParams: structured warn with full context
214
209
  function warnNonCanonicalWnative(logger, log, kind, txIdx) {
@@ -6,15 +6,17 @@ interface AssertNoBundlerRetentionParams {
6
6
  logger?: SimulationLogger;
7
7
  }
8
8
  /**
9
- * Assert that no value is retained by bundler3 contract addresses.
9
+ * Assert that no value is retained by restricted contract addresses — the
10
+ * `bundler3` executor and adapters, plus the standalone `bundles` periphery
11
+ * contracts (`VaultExitBundlesV1`, `VaultBundlesV1`, `BlueBundlesV1`).
10
12
  *
11
- * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
12
- * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
13
- * vault), so gross inbound would fire false positives. We only block positive
14
- * net flow above `DUST_THRESHOLD` — value stuck in a bundler address. Negative
15
- * net flow means the simulated bundle sweeps a pre-existing or state-overridden
16
- * bundler balance. That is useful telemetry, but not current-bundle retention,
17
- * so it is warned instead of raising a blacklist violation.
13
+ * Uses net flow (inbound minus outbound) per (restricted address, token) pair.
14
+ * These contracts legitimately receive tokens as an intermediary (user →
15
+ * bundler → vault), so gross inbound would fire false positives. We only block
16
+ * positive net flow above `DUST_THRESHOLD` — value stuck in a restricted
17
+ * address. Negative net flow means the simulated bundle sweeps a pre-existing or
18
+ * state-overridden balance. That is useful telemetry, but not current-bundle
19
+ * retention, so it is warned instead of raising a blacklist violation.
18
20
  *
19
21
  * **Two flow sources, one per asset class.** ERC20 / WETH9 retention is read
20
22
  * from parsed `transfers` (log-derived, identical across backends). Native ETH
@@ -30,6 +32,22 @@ interface AssertNoBundlerRetentionParams {
30
32
  * native move exists both as a synthetic `ethAddress` transfer log *and* in the
31
33
  * `assetChanges` derived from it — native transfer logs are only used as a
32
34
  * fallback for bundler addresses that carry no native `assetChanges` entry.
35
+ *
36
+ * @internal Internal pipeline stage, composed by `simulate`; not part of the
37
+ * package's public API (only re-exported from the internal pipeline barrel).
38
+ * @param params.chainId - Chain whose blue-sdk `bundler3` and `bundles`
39
+ * registries define the restricted address set. Chains cataloging neither are
40
+ * skipped (a `logger.warn` records the skip).
41
+ * @param params.transfers - Parsed ERC20 / WETH9 transfer flows (plus the
42
+ * `eth_simulateV1` synthetic native sentinel) scanned for net retention.
43
+ * @param params.assetChanges - Per-account native-ETH deltas, the cross-backend
44
+ * source of truth for native value stuck in a restricted contract.
45
+ * @param params.logger - Optional logger. Receives retention-check skip warnings
46
+ * and pre-existing-balance sweep telemetry (net-negative flow).
47
+ * @returns Nothing. Returns silently when no restricted contract retains value
48
+ * above `DUST_THRESHOLD`; otherwise throws.
49
+ * @throws {BlacklistViolationError} when net inbound flow to a restricted
50
+ * `bundler3` or `bundles` contract exceeds `DUST_THRESHOLD` for any token.
33
51
  */
34
52
  export declare function assertNoBundlerRetention(params: AssertNoBundlerRetentionParams): void;
35
53
  export {};
@@ -15,43 +15,18 @@ const errors_js_1 = require("../../errors.js");
15
15
  * acceptable retention amount.
16
16
  */
17
17
  const DUST_THRESHOLD = 100n;
18
- function getBundlerAddresses(chainId, logger) {
19
- try {
20
- const addresses = (0, blue_sdk_1.getChainAddresses)(chainId);
21
- if (!addresses.bundler3) {
22
- // blue-sdk knows the chain but didn't catalog a bundler3 for it.
23
- // Treat the same as UnsupportedChainIdError — retention check skipped.
24
- logger?.warn("Chain known to blue-sdk but has no bundler3 config, retention check skipped", {
25
- chainId,
26
- });
27
- return new Set();
28
- }
29
- return new Set(Object.values(addresses.bundler3)
30
- .filter(morpho_ts_1.isDefined)
31
- .map((addr) => (0, viem_1.getAddress)(addr)));
32
- }
33
- catch (error) {
34
- if (error instanceof blue_sdk_1.UnsupportedChainIdError) {
35
- // Loud warn: this disables a "never bypassable" check for the chain.
36
- // Consumers relying on the guarantee must handle this signal.
37
- logger?.warn("Chain not supported by blue-sdk, retention check skipped", {
38
- chainId,
39
- });
40
- return new Set();
41
- }
42
- throw error;
43
- }
44
- }
45
18
  /**
46
- * Assert that no value is retained by bundler3 contract addresses.
19
+ * Assert that no value is retained by restricted contract addresses — the
20
+ * `bundler3` executor and adapters, plus the standalone `bundles` periphery
21
+ * contracts (`VaultExitBundlesV1`, `VaultBundlesV1`, `BlueBundlesV1`).
47
22
  *
48
- * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
49
- * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
50
- * vault), so gross inbound would fire false positives. We only block positive
51
- * net flow above `DUST_THRESHOLD` — value 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.
23
+ * Uses net flow (inbound minus outbound) per (restricted address, token) pair.
24
+ * These contracts legitimately receive tokens as an intermediary (user →
25
+ * bundler → vault), so gross inbound would fire false positives. We only block
26
+ * positive net flow above `DUST_THRESHOLD` — value stuck in a restricted
27
+ * address. Negative net flow means the simulated bundle sweeps a pre-existing or
28
+ * state-overridden balance. That is useful telemetry, but not current-bundle
29
+ * retention, so it is warned instead of raising a blacklist violation.
55
30
  *
56
31
  * **Two flow sources, one per asset class.** ERC20 / WETH9 retention is read
57
32
  * from parsed `transfers` (log-derived, identical across backends). Native ETH
@@ -67,13 +42,61 @@ function getBundlerAddresses(chainId, logger) {
67
42
  * native move exists both as a synthetic `ethAddress` transfer log *and* in the
68
43
  * `assetChanges` derived from it — native transfer logs are only used as a
69
44
  * fallback for bundler addresses that carry no native `assetChanges` entry.
45
+ *
46
+ * @internal Internal pipeline stage, composed by `simulate`; not part of the
47
+ * package's public API (only re-exported from the internal pipeline barrel).
48
+ * @param params.chainId - Chain whose blue-sdk `bundler3` and `bundles`
49
+ * registries define the restricted address set. Chains cataloging neither are
50
+ * skipped (a `logger.warn` records the skip).
51
+ * @param params.transfers - Parsed ERC20 / WETH9 transfer flows (plus the
52
+ * `eth_simulateV1` synthetic native sentinel) scanned for net retention.
53
+ * @param params.assetChanges - Per-account native-ETH deltas, the cross-backend
54
+ * source of truth for native value stuck in a restricted contract.
55
+ * @param params.logger - Optional logger. Receives retention-check skip warnings
56
+ * and pre-existing-balance sweep telemetry (net-negative flow).
57
+ * @returns Nothing. Returns silently when no restricted contract retains value
58
+ * above `DUST_THRESHOLD`; otherwise throws.
59
+ * @throws {BlacklistViolationError} when net inbound flow to a restricted
60
+ * `bundler3` or `bundles` contract exceeds `DUST_THRESHOLD` for any token.
70
61
  */
71
62
  function assertNoBundlerRetention(params) {
72
63
  const { chainId, transfers, assetChanges, logger } = params;
73
- const bundlerAddresses = getBundlerAddresses(chainId, logger);
74
- if (bundlerAddresses.size === 0)
64
+ // Restricted address set: the full `bundler3` sub-registry (executor + every
65
+ // adapter) plus the standalone `bundles` periphery contracts
66
+ // (`VaultExitBundlesV1`, `VaultBundlesV1`, `BlueBundlesV1`). Both families are
67
+ // transient intermediaries that route user value and must never retain it, so
68
+ // both are guarded identically.
69
+ let addresses;
70
+ try {
71
+ addresses = (0, blue_sdk_1.getChainAddresses)(chainId);
72
+ }
73
+ catch (error) {
74
+ if (error instanceof blue_sdk_1.UnsupportedChainIdError) {
75
+ // Loud warn: this disables a "never bypassable" check for the chain.
76
+ // Consumers relying on the guarantee must handle this signal.
77
+ logger?.warn("Chain not supported by blue-sdk, retention check skipped", {
78
+ chainId,
79
+ });
80
+ return;
81
+ }
82
+ throw error;
83
+ }
84
+ const restrictedAddresses = new Set();
85
+ if (addresses.bundler3) {
86
+ for (const addr of Object.values(addresses.bundler3).filter(morpho_ts_1.isDefined))
87
+ restrictedAddresses.add((0, viem_1.getAddress)(addr));
88
+ }
89
+ if (addresses.bundles) {
90
+ for (const addr of Object.values(addresses.bundles).filter(morpho_ts_1.isDefined))
91
+ restrictedAddresses.add((0, viem_1.getAddress)(addr));
92
+ }
93
+ if (restrictedAddresses.size === 0) {
94
+ // blue-sdk knows the chain but cataloged neither bundler3 nor bundles for
95
+ // it. Treat the same as UnsupportedChainIdError — retention check skipped.
96
+ logger?.warn("Chain known to blue-sdk but has no bundler3 or bundles config, retention check skipped", { chainId });
75
97
  return;
76
- // Map keyed by (bundler, token) → structured entry. Avoids string parse-back.
98
+ }
99
+ // Map keyed by (address, token) → structured entry. Avoids string parse-back.
77
100
  const flow = new Map();
78
101
  // biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
79
102
  const recordFlow = (address, token, delta) => {
@@ -91,7 +114,7 @@ function assertNoBundlerRetention(params) {
91
114
  // does not re-add the same native move on `eth_simulateV1`.
92
115
  const nativeFromAssetChanges = new Set();
93
116
  for (const { account, changes } of assetChanges) {
94
- if (!bundlerAddresses.has(account))
117
+ if (!restrictedAddresses.has(account))
95
118
  continue;
96
119
  for (const change of changes) {
97
120
  if (change.token !== viem_1.ethAddress)
@@ -106,15 +129,15 @@ function assertNoBundlerRetention(params) {
106
129
  // of the `assetChanges` value already recorded for that address.
107
130
  const usesAssetChangeNative = (t, addr) => t.token === viem_1.ethAddress && nativeFromAssetChanges.has(addr.toLowerCase());
108
131
  for (const t of transfers) {
109
- if (bundlerAddresses.has(t.to) && !usesAssetChangeNative(t, t.to))
132
+ if (restrictedAddresses.has(t.to) && !usesAssetChangeNative(t, t.to))
110
133
  recordFlow(t.to, t.token, t.amount);
111
- if (bundlerAddresses.has(t.from) && !usesAssetChangeNative(t, t.from))
134
+ if (restrictedAddresses.has(t.from) && !usesAssetChangeNative(t, t.from))
112
135
  recordFlow(t.from, t.token, -t.amount);
113
136
  }
114
137
  const entries = [...flow.values()];
115
138
  const drained = entries.filter((e) => e.netChange < -DUST_THRESHOLD);
116
139
  if (drained.length > 0) {
117
- logger?.warn("Simulation detected pre-existing bundler balance being swept", {
140
+ logger?.warn("Simulation detected pre-existing balance being swept from a restricted bundler or bundles contract", {
118
141
  changes: drained.map((e) => ({
119
142
  address: e.address,
120
143
  token: e.token,
@@ -124,7 +147,7 @@ function assertNoBundlerRetention(params) {
124
147
  }
125
148
  const retained = entries.filter((e) => e.netChange > DUST_THRESHOLD);
126
149
  if (retained.length > 0) {
127
- throw new errors_js_1.BlacklistViolationError("Simulation detected asset transfers retained by restricted bundler contracts", retained.map((e) => ({
150
+ throw new errors_js_1.BlacklistViolationError("Simulation detected asset transfers retained by restricted bundler or bundles contracts", retained.map((e) => ({
128
151
  address: e.address,
129
152
  token: e.token,
130
153
  netRetained: e.netChange.toString(),
@@ -1,4 +1,4 @@
1
- import type { BlockTag } from "viem";
1
+ import type { Address, BlockTag } from "viem";
2
2
  import type { RawSimulationResult, SimulationConfig, SimulationTransaction } from "../../types.js";
3
3
  /**
4
4
  * Stage 4 of the simulate() pipeline.
@@ -18,4 +18,5 @@ export declare function executeSimulation(params: {
18
18
  chainId: number;
19
19
  transactions: SimulationTransaction[];
20
20
  blockNumber?: bigint | BlockTag;
21
+ wNative?: Address | null;
21
22
  }): Promise<RawSimulationResult>;
@@ -32,7 +32,7 @@ const FALLBACK_MIN_BUDGET_MS = 1500;
32
32
  * - If only `eth_simulateV1` is configured, it gets the full timeout.
33
33
  */
34
34
  async function executeSimulation(params) {
35
- const { config, chainId, transactions, blockNumber } = params;
35
+ const { config, chainId, transactions, blockNumber, wNative } = params;
36
36
  const chain = (0, resolve_chain_js_1.resolveChain)(config, chainId);
37
37
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
38
38
  const deadline = Date.now() + timeoutMs;
@@ -62,6 +62,7 @@ async function executeSimulation(params) {
62
62
  chainId,
63
63
  transactions,
64
64
  blockNumber,
65
+ wNative,
65
66
  signal: AbortSignal.timeout(fallbackBudget),
66
67
  });
67
68
  }
@@ -75,6 +76,7 @@ async function executeSimulation(params) {
75
76
  chainId,
76
77
  transactions,
77
78
  blockNumber,
79
+ wNative,
78
80
  signal: AbortSignal.timeout(timeoutMs),
79
81
  });
80
82
  }