@morpho-org/evm-simulation 4.1.4 → 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 +5 -5
@@ -4,8 +4,11 @@ import type { SimulateParams, SimulationConfig, SimulationResult } from "../type
4
4
  *
5
5
  * Validates input → resolves authorizations into prepended approve txs → runs the bundle
6
6
  * through Tenderly RPC (primary) or `eth_simulateV1` (fallback) with a shared timeout
7
- * budget → parses ERC20/WETH transfers from per-tx logs → asserts no funds are retained
8
- * by `bundler3` → returns the full result set. The caller reads whichever fields they need:
7
+ * budget → parses ERC20 transfers and WETH9 events from per-tx logs, restricting WETH9
8
+ * events to the registered wrapped-native token, rejecting them on known tokenless chains,
9
+ * and retaining signature-based parsing for unknown chains → asserts no funds are retained
10
+ * by `bundler3` or the standalone `bundles` periphery contracts → returns the full result
11
+ * set. The caller reads whichever fields they need:
9
12
  *
10
13
  * - `transfers` → user-facing preview / server-side verification.
11
14
  * - `simulationTxs` + `transfers` → server-side verification before broadcast.
@@ -30,8 +33,9 @@ import type { SimulateParams, SimulationConfig, SimulationResult } from "../type
30
33
  * empty transactions, malformed authorizations).
31
34
  * @throws {UnsupportedChainError} when the chain is not configured for any backend.
32
35
  * @throws {SimulationRevertedError} when the bundle reverts on either backend.
33
- * @throws {BlacklistViolationError} when the simulation leaves value retained by
34
- * a `bundler3` address beyond the dust threshold.
36
+ * @throws {BlacklistViolationError} when the simulation leaves value retained beyond
37
+ * the dust threshold by a restricted `bundler3` address or a `bundles` periphery
38
+ * contract (VaultExitBundlesV1, VaultBundlesV1, BlueBundlesV1). Never bypassable.
35
39
  * @throws {ExternalServiceError} (a) when both backends are unavailable within the
36
40
  * timeout budget, or (b) when a backend returns a `calls` array whose length does
37
41
  * not match the resolved `simulationTxs` — refusing to map transfers with mismatched
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.simulate = simulate;
4
+ const blue_sdk_1 = require("@morpho-org/blue-sdk");
4
5
  const errors_js_1 = require("../errors.js");
5
6
  const index_js_1 = require("./parsing/index.js");
6
7
  const index_js_2 = require("./pipeline/index.js");
@@ -9,8 +10,11 @@ const index_js_2 = require("./pipeline/index.js");
9
10
  *
10
11
  * Validates input → resolves authorizations into prepended approve txs → runs the bundle
11
12
  * through Tenderly RPC (primary) or `eth_simulateV1` (fallback) with a shared timeout
12
- * budget → parses ERC20/WETH transfers from per-tx logs → asserts no funds are retained
13
- * by `bundler3` → returns the full result set. The caller reads whichever fields they need:
13
+ * budget → parses ERC20 transfers and WETH9 events from per-tx logs, restricting WETH9
14
+ * events to the registered wrapped-native token, rejecting them on known tokenless chains,
15
+ * and retaining signature-based parsing for unknown chains → asserts no funds are retained
16
+ * by `bundler3` or the standalone `bundles` periphery contracts → returns the full result
17
+ * set. The caller reads whichever fields they need:
14
18
  *
15
19
  * - `transfers` → user-facing preview / server-side verification.
16
20
  * - `simulationTxs` + `transfers` → server-side verification before broadcast.
@@ -35,8 +39,9 @@ const index_js_2 = require("./pipeline/index.js");
35
39
  * empty transactions, malformed authorizations).
36
40
  * @throws {UnsupportedChainError} when the chain is not configured for any backend.
37
41
  * @throws {SimulationRevertedError} when the bundle reverts on either backend.
38
- * @throws {BlacklistViolationError} when the simulation leaves value retained by
39
- * a `bundler3` address beyond the dust threshold.
42
+ * @throws {BlacklistViolationError} when the simulation leaves value retained beyond
43
+ * the dust threshold by a restricted `bundler3` address or a `bundles` periphery
44
+ * contract (VaultExitBundlesV1, VaultBundlesV1, BlueBundlesV1). Never bypassable.
40
45
  * @throws {ExternalServiceError} (a) when both backends are unavailable within the
41
46
  * timeout budget, or (b) when a backend returns a `calls` array whose length does
42
47
  * not match the resolved `simulationTxs` — refusing to map transfers with mismatched
@@ -67,17 +72,22 @@ const index_js_2 = require("./pipeline/index.js");
67
72
  */
68
73
  async function simulate(config, params) {
69
74
  (0, index_js_2.validateInput)(params);
75
+ const wNative = (0, blue_sdk_1._try)(() => (0, blue_sdk_1.getChainAddresses)(params.chainId).wNative ?? null, blue_sdk_1.UnsupportedChainIdError);
70
76
  const simulationTxs = (0, index_js_2.buildSimulationTxs)(params);
71
77
  const result = await (0, index_js_2.executeSimulation)({
72
78
  config,
73
79
  chainId: params.chainId,
74
80
  transactions: simulationTxs,
75
81
  blockNumber: params.blockNumber,
82
+ wNative,
76
83
  });
77
84
  if (result.calls.length !== simulationTxs.length) {
78
85
  throw new errors_js_1.ExternalServiceError(`Backend returned ${result.calls.length} call result(s) for ${simulationTxs.length} transaction(s) — refusing to map transfers with mismatched lengths`);
79
86
  }
80
- const transfers = (0, index_js_1.parseTransfers)(result.calls, config.logger);
87
+ const transfers = (0, index_js_1.parseTransfers)(result.calls, {
88
+ wNative,
89
+ logger: config.logger,
90
+ });
81
91
  (0, index_js_2.assertNoBundlerRetention)({
82
92
  chainId: params.chainId,
83
93
  transfers,
@@ -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/esm/errors.js CHANGED
@@ -19,7 +19,7 @@ export class SimulationRevertedError extends SimulationPackageError {
19
19
  this.details = details;
20
20
  }
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 class BlacklistViolationError extends SimulationPackageError {
24
24
  assetChanges;
25
25
  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,4 +1,23 @@
1
- import { getAddress } from "viem";
1
+ import { ethAddress, getAddress } from "viem";
2
+ /**
3
+ * Collapse the native-ETH sentinel to viem's lowercase `ethAddress`, checksumming
4
+ * any real token address. `eth_simulateV1` synthesizes native moves from the
5
+ * sentinel `0xeee…eee`, and Tenderly may echo it — in checksummed or any other
6
+ * case — as an asset change's `contractAddress`. Native ETH is keyed by the exact
7
+ * `ethAddress` constant here and in the bundler-retention guard, so a checksummed
8
+ * sentinel would land on a separate map key and silently escape retention checks.
9
+ * This is the single source of truth for token normalization shared by the
10
+ * transfer-log parser and the Tenderly asset-change mapper.
11
+ *
12
+ * @param address - Token address emitting a transfer or carried by an asset change.
13
+ * @returns `ethAddress` for the native sentinel, else the checksummed address.
14
+ * @internal
15
+ */
16
+ export function normalizeAssetToken(address) {
17
+ return address.toLowerCase() === ethAddress
18
+ ? ethAddress
19
+ : getAddress(address);
20
+ }
2
21
  // Locale-independent byte-order compare on lowercased hex, matching `sortTransfers`.
3
22
  const compareAddress = (a, b) => {
4
23
  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>;
@@ -17,7 +17,7 @@ import { parseTransfers } from "../parsing/index.js";
17
17
  * `traceTransfers` already logs it, so adding it would double-count.
18
18
  */
19
19
  export async function simulateV1(params) {
20
- const { rpcUrl, transactions, blockNumber, signal } = params;
20
+ const { rpcUrl, transactions, blockNumber, wNative, signal } = params;
21
21
  const client = createPublicClient({
22
22
  transport: http(rpcUrl, {
23
23
  fetchOptions: signal ? { signal } : undefined,
@@ -81,7 +81,7 @@ export async function simulateV1(params) {
81
81
  }));
82
82
  return {
83
83
  calls: rawCalls,
84
- assetChanges: toAssetChanges(parseTransfers(rawCalls)),
84
+ assetChanges: toAssetChanges(parseTransfers(rawCalls, { wNative })),
85
85
  };
86
86
  }
87
87
  catch (error) {
@@ -1,7 +1,7 @@
1
- import { ethAddress, getAddress, isAddress, isHex, maxUint256, numberToHex, } from "viem";
1
+ import { ethAddress, isAddress, isHex, maxUint256, numberToHex, } from "viem";
2
2
  import { z } from "zod";
3
3
  import { ExternalServiceError, SimulationRevertedError, SimulationValidationError, } from "../../errors.js";
4
- import { groupAssetChanges } from "../asset-changes.js";
4
+ import { groupAssetChanges, normalizeAssetToken, } from "../asset-changes.js";
5
5
  const addressSchema = z.custom((val) => typeof val === "string" && isAddress(val));
6
6
  const hexSchema = z.custom((val) => typeof val === "string" && isHex(val));
7
7
  const traceFrameSchema = z
@@ -206,7 +206,7 @@ function toAssetChanges(results) {
206
206
  for (const change of result.assetChanges ?? []) {
207
207
  const amount = BigInt(change.rawAmount);
208
208
  const token = change.assetInfo.contractAddress
209
- ? getAddress(change.assetInfo.contractAddress)
209
+ ? normalizeAssetToken(change.assetInfo.contractAddress)
210
210
  : ethAddress;
211
211
  const { symbol, decimals } = change.assetInfo;
212
212
  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[];
@@ -1,4 +1,5 @@
1
- import { ethAddress, getAddress, zeroAddress, zeroHash, } from "viem";
1
+ import { getAddress, isAddressEqual, zeroAddress, zeroHash, } from "viem";
2
+ import { normalizeAssetToken } from "../asset-changes.js";
2
3
  // keccak256("Transfer(address,address,uint256)") — ERC-20 transfer event
3
4
  export const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
4
5
  // keccak256("Withdrawal(address,uint256)") — WETH9 unwrap event
@@ -14,10 +15,13 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
14
15
  * with the index of the originating call.
15
16
  *
16
17
  * **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
17
- * `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
18
- * events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
19
- * ERC1155 transfer events are **not** parsed — consumers with NFT flows will
20
- * see an incomplete transfer list.
18
+ * `Deposit(to, amount)` / `Withdrawal(from, amount)` from the registered
19
+ * wrapped-native token. Known chains without a wrapped-native token ignore
20
+ * WETH9 events and skip wrapped-native deduplication; chains without registry
21
+ * metadata retain legacy signature-based parsing. On enabled paths, paired
22
+ * mint/burn Transfer events are deduplicated. ERC721 and ERC1155 transfer
23
+ * events are **not** parsed — consumers with NFT flows will see an incomplete
24
+ * transfer list.
21
25
  *
22
26
  * **Native ETH (`eth_simulateV1` + `traceTransfers`).** When the backend runs
23
27
  * `eth_simulateV1` with `traceTransfers` enabled, native-ETH moves — including
@@ -28,35 +32,41 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
28
32
  * backends. Tenderly does not emit these synthetic logs (it derives native ETH
29
33
  * separately), so this path is inert there.
30
34
  *
31
- * **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
32
- * the same tx: a zero-address `Transfer` is suppressed only if its paired
33
- * `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
34
- * is correct for canonical WETH9, which always emits the `Deposit`/
35
+ * **WETH9 dedup assumption — canonical atomic emission.** Dedup for the
36
+ * registered wrapped-native token is scoped to the same tx: a zero-address
37
+ * `Transfer` is suppressed only if its paired `Deposit`/`Withdrawal` appears
38
+ * in the same `calls[txIdx].logs` slice. This is correct for canonical WETH9,
39
+ * which always emits the `Deposit`/
35
40
  * `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
36
41
  * atomically inside a single call frame. A **non-canonical wrapped-native**
37
42
  * that splits these emissions across two txs in the same bundle would leave
38
43
  * a phantom zero-address `Transfer` in the parsed output, which can be summed
39
44
  * by `assertNoBundlerRetention` and produce a false `BlacklistViolationError`.
40
- * When a zero-address `Transfer` misses same-tx dedup *and* its token has
41
- * emitted a `Deposit`/`Withdrawal` somewhere else in the bundle (i.e. it
42
- * looks wnative-shaped), the parser emits a `warn` so the assumption break
43
- * is observable before it reaches retention. None of `morpho-sdk`'s currently
44
- * supported chains require cross-tx dedup; the assumption is rechecked when
45
- * onboarding a new chain (see `evm-simulation/CLAUDE.md`).
45
+ * When its zero-address `Transfer` misses same-tx dedup, the parser emits a
46
+ * `warn` so the assumption break is observable before it reaches retention.
47
+ * None of `morpho-sdk`'s currently supported chains require cross-tx dedup;
48
+ * the assumption is rechecked when onboarding a new chain (see
49
+ * `evm-simulation/CLAUDE.md`).
46
50
  *
47
51
  * **Failure mode:** on a per-log parse failure (malformed topic length,
48
52
  * non-hex data), the log is skipped and a `warn` is emitted via the logger.
49
53
  *
50
54
  * Output is sorted canonically by token, from, to, amount for determinism.
51
55
  * `txIdx` is attached but does not influence sort order.
56
+ *
57
+ * @param calls - Per-transaction call results to parse.
58
+ * @param options - Wrapped-native metadata and optional logger. Pass `null` for
59
+ * a known chain without a wrapped-native token; omit it for legacy parsing on
60
+ * an unknown chain.
61
+ * @returns Canonically sorted transfers with their originating transaction index.
62
+ * @internal
52
63
  */
53
- export function parseTransfers(calls, logger) {
64
+ export function parseTransfers(calls, options = {}) {
54
65
  const transfers = [];
55
- // Tokens that emit `Deposit` or `Withdrawal` somewhere in the bundle look
56
- // wnative-shaped. If a zero-address `Transfer` for one of these tokens
57
- // misses same-tx dedup, the contract is likely emitting non-canonically
58
- // (split across txs) and a phantom transfer would leak into retention.
66
+ const { logger, wNative } = options;
59
67
  const wnativeShapedTokens = collectWnativeShapedTokens(calls);
68
+ const acceptsWnativeEvent = (address) => wNative === undefined ||
69
+ (wNative !== null && isAddressEqual(address, wNative));
60
70
  for (let txIdx = 0; txIdx < calls.length; txIdx++) {
61
71
  const logs = calls[txIdx].logs;
62
72
  for (const log of logs) {
@@ -66,6 +76,8 @@ export function parseTransfers(calls, logger) {
66
76
  continue;
67
77
  switch (topic0) {
68
78
  case WITHDRAWAL_TOPIC: {
79
+ if (!acceptsWnativeEvent(log.address))
80
+ continue;
69
81
  const fromTopic = log.topics[1];
70
82
  if (!isTopicHex(fromTopic) || !isUint256Hex(log.data)) {
71
83
  warnMalformed(logger, log, "WETH9 Withdrawal: bad topic[1] or data length");
@@ -81,6 +93,8 @@ export function parseTransfers(calls, logger) {
81
93
  continue;
82
94
  }
83
95
  case DEPOSIT_TOPIC: {
96
+ if (!acceptsWnativeEvent(log.address))
97
+ continue;
84
98
  const toTopic = log.topics[1];
85
99
  if (!isTopicHex(toTopic) || !isUint256Hex(log.data)) {
86
100
  warnMalformed(logger, log, "WETH9 Deposit: bad topic[1] or data length");
@@ -110,34 +124,36 @@ export function parseTransfers(calls, logger) {
110
124
  }
111
125
  // WETH9 unwrap dedup: Transfer to zero paired with a Withdrawal of
112
126
  // equal amount in the SAME tx.
113
- if (toTopic === zeroHash) {
127
+ if (toTopic === zeroHash && acceptsWnativeEvent(log.address)) {
114
128
  const paired = logs.some((other) => other.topics[0] === WITHDRAWAL_TOPIC &&
115
- other.address === log.address &&
129
+ isAddressEqual(other.address, log.address) &&
116
130
  other.data === log.data &&
117
131
  other.topics.length === 2 &&
118
132
  other.topics[1] === fromTopic);
119
133
  if (paired)
120
134
  continue;
121
- if (wnativeShapedTokens.has(log.address.toLowerCase())) {
135
+ if (wNative !== undefined ||
136
+ wnativeShapedTokens.has(log.address.toLowerCase())) {
122
137
  warnNonCanonicalWnative(logger, log, "burn", txIdx);
123
138
  }
124
139
  }
125
140
  // WETH9 wrap dedup: Transfer from zero paired with a Deposit of
126
141
  // equal amount in the SAME tx.
127
- if (fromTopic === zeroHash) {
142
+ if (fromTopic === zeroHash && acceptsWnativeEvent(log.address)) {
128
143
  const paired = logs.some((other) => other.topics[0] === DEPOSIT_TOPIC &&
129
- other.address === log.address &&
144
+ isAddressEqual(other.address, log.address) &&
130
145
  other.data === log.data &&
131
146
  other.topics.length === 2 &&
132
147
  other.topics[1] === toTopic);
133
148
  if (paired)
134
149
  continue;
135
- if (wnativeShapedTokens.has(log.address.toLowerCase())) {
150
+ if (wNative !== undefined ||
151
+ wnativeShapedTokens.has(log.address.toLowerCase())) {
136
152
  warnNonCanonicalWnative(logger, log, "mint", txIdx);
137
153
  }
138
154
  }
139
155
  transfers.push({
140
- token: normalizeTransferToken(log.address),
156
+ token: normalizeAssetToken(log.address),
141
157
  from: getAddress(`0x${fromTopic.slice(26)}`),
142
158
  to: getAddress(`0x${toTopic.slice(26)}`),
143
159
  amount: BigInt(log.data),
@@ -158,21 +174,6 @@ export function parseTransfers(calls, logger) {
158
174
  }
159
175
  return sortTransfers(transfers);
160
176
  }
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
- }
176
177
  function isTopicHex(value) {
177
178
  return typeof value === "string" && value.length === TOPIC_HEX_LENGTH;
178
179
  }
@@ -187,24 +188,18 @@ function warnMalformed(logger, log, reason) {
187
188
  reason,
188
189
  });
189
190
  }
190
- /**
191
- * Collect token addresses (lowercased) that emit `Deposit` or `Withdrawal`
192
- * anywhere in the bundle. Used to detect when a zero-address `Transfer` that
193
- * misses same-tx dedup is on a wnative-shaped contract — a strong signal
194
- * that the contract is emitting non-canonically and a phantom transfer is
195
- * about to leak into bundler retention.
196
- */
191
+ /** Collect contracts that emit WETH9-shaped events anywhere in the bundle. */
197
192
  function collectWnativeShapedTokens(calls) {
198
- const set = new Set();
199
- for (const c of calls) {
200
- for (const l of c.logs) {
201
- const t0 = l.topics[0];
202
- if (t0 === WITHDRAWAL_TOPIC || t0 === DEPOSIT_TOPIC) {
203
- set.add(l.address.toLowerCase());
193
+ const tokens = new Set();
194
+ for (const call of calls) {
195
+ for (const log of call.logs) {
196
+ const topic0 = log.topics[0];
197
+ if (topic0 === WITHDRAWAL_TOPIC || topic0 === DEPOSIT_TOPIC) {
198
+ tokens.add(log.address.toLowerCase());
204
199
  }
205
200
  }
206
201
  }
207
- return set;
202
+ return tokens;
208
203
  }
209
204
  // biome-ignore lint/complexity/useMaxParams: structured warn with full context
210
205
  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 {};