@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
@@ -12,43 +12,18 @@ import { BlacklistViolationError } from "../../errors.js";
12
12
  * acceptable retention amount.
13
13
  */
14
14
  const DUST_THRESHOLD = 100n;
15
- function getBundlerAddresses(chainId, logger) {
16
- try {
17
- const addresses = getChainAddresses(chainId);
18
- if (!addresses.bundler3) {
19
- // blue-sdk knows the chain but didn't catalog a bundler3 for it.
20
- // Treat the same as UnsupportedChainIdError — retention check skipped.
21
- logger?.warn("Chain known to blue-sdk but has no bundler3 config, retention check skipped", {
22
- chainId,
23
- });
24
- return new Set();
25
- }
26
- return new Set(Object.values(addresses.bundler3)
27
- .filter(isDefined)
28
- .map((addr) => getAddress(addr)));
29
- }
30
- catch (error) {
31
- if (error instanceof UnsupportedChainIdError) {
32
- // Loud warn: this disables a "never bypassable" check for the chain.
33
- // Consumers relying on the guarantee must handle this signal.
34
- logger?.warn("Chain not supported by blue-sdk, retention check skipped", {
35
- chainId,
36
- });
37
- return new Set();
38
- }
39
- throw error;
40
- }
41
- }
42
15
  /**
43
- * Assert that no value is retained by bundler3 contract addresses.
16
+ * Assert that no value is retained by restricted contract addresses — the
17
+ * `bundler3` executor and adapters, plus the standalone `bundles` periphery
18
+ * contracts (`VaultExitBundlesV1`, `VaultBundlesV1`, `BlueBundlesV1`).
44
19
  *
45
- * Uses net flow (inbound minus outbound) per (bundler address, token) pair.
46
- * Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
47
- * vault), so gross inbound would fire false positives. We only block positive
48
- * net flow above `DUST_THRESHOLD` — value stuck in a bundler address. Negative
49
- * net flow means the simulated bundle sweeps a pre-existing or state-overridden
50
- * bundler balance. That is useful telemetry, but not current-bundle retention,
51
- * so it is warned instead of raising a blacklist violation.
20
+ * Uses net flow (inbound minus outbound) per (restricted address, token) pair.
21
+ * These contracts legitimately receive tokens as an intermediary (user →
22
+ * bundler → vault), so gross inbound would fire false positives. We only block
23
+ * positive net flow above `DUST_THRESHOLD` — value stuck in a restricted
24
+ * address. Negative net flow means the simulated bundle sweeps a pre-existing or
25
+ * state-overridden balance. That is useful telemetry, but not current-bundle
26
+ * retention, so it is warned instead of raising a blacklist violation.
52
27
  *
53
28
  * **Two flow sources, one per asset class.** ERC20 / WETH9 retention is read
54
29
  * from parsed `transfers` (log-derived, identical across backends). Native ETH
@@ -64,13 +39,61 @@ function getBundlerAddresses(chainId, logger) {
64
39
  * native move exists both as a synthetic `ethAddress` transfer log *and* in the
65
40
  * `assetChanges` derived from it — native transfer logs are only used as a
66
41
  * fallback for bundler addresses that carry no native `assetChanges` entry.
42
+ *
43
+ * @internal Internal pipeline stage, composed by `simulate`; not part of the
44
+ * package's public API (only re-exported from the internal pipeline barrel).
45
+ * @param params.chainId - Chain whose blue-sdk `bundler3` and `bundles`
46
+ * registries define the restricted address set. Chains cataloging neither are
47
+ * skipped (a `logger.warn` records the skip).
48
+ * @param params.transfers - Parsed ERC20 / WETH9 transfer flows (plus the
49
+ * `eth_simulateV1` synthetic native sentinel) scanned for net retention.
50
+ * @param params.assetChanges - Per-account native-ETH deltas, the cross-backend
51
+ * source of truth for native value stuck in a restricted contract.
52
+ * @param params.logger - Optional logger. Receives retention-check skip warnings
53
+ * and pre-existing-balance sweep telemetry (net-negative flow).
54
+ * @returns Nothing. Returns silently when no restricted contract retains value
55
+ * above `DUST_THRESHOLD`; otherwise throws.
56
+ * @throws {BlacklistViolationError} when net inbound flow to a restricted
57
+ * `bundler3` or `bundles` contract exceeds `DUST_THRESHOLD` for any token.
67
58
  */
68
59
  export function assertNoBundlerRetention(params) {
69
60
  const { chainId, transfers, assetChanges, logger } = params;
70
- const bundlerAddresses = getBundlerAddresses(chainId, logger);
71
- if (bundlerAddresses.size === 0)
61
+ // Restricted address set: the full `bundler3` sub-registry (executor + every
62
+ // adapter) plus the standalone `bundles` periphery contracts
63
+ // (`VaultExitBundlesV1`, `VaultBundlesV1`, `BlueBundlesV1`). Both families are
64
+ // transient intermediaries that route user value and must never retain it, so
65
+ // both are guarded identically.
66
+ let addresses;
67
+ try {
68
+ addresses = getChainAddresses(chainId);
69
+ }
70
+ catch (error) {
71
+ if (error instanceof UnsupportedChainIdError) {
72
+ // Loud warn: this disables a "never bypassable" check for the chain.
73
+ // Consumers relying on the guarantee must handle this signal.
74
+ logger?.warn("Chain not supported by blue-sdk, retention check skipped", {
75
+ chainId,
76
+ });
77
+ return;
78
+ }
79
+ throw error;
80
+ }
81
+ const restrictedAddresses = new Set();
82
+ if (addresses.bundler3) {
83
+ for (const addr of Object.values(addresses.bundler3).filter(isDefined))
84
+ restrictedAddresses.add(getAddress(addr));
85
+ }
86
+ if (addresses.bundles) {
87
+ for (const addr of Object.values(addresses.bundles).filter(isDefined))
88
+ restrictedAddresses.add(getAddress(addr));
89
+ }
90
+ if (restrictedAddresses.size === 0) {
91
+ // blue-sdk knows the chain but cataloged neither bundler3 nor bundles for
92
+ // it. Treat the same as UnsupportedChainIdError — retention check skipped.
93
+ logger?.warn("Chain known to blue-sdk but has no bundler3 or bundles config, retention check skipped", { chainId });
72
94
  return;
73
- // Map keyed by (bundler, token) → structured entry. Avoids string parse-back.
95
+ }
96
+ // Map keyed by (address, token) → structured entry. Avoids string parse-back.
74
97
  const flow = new Map();
75
98
  // biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
76
99
  const recordFlow = (address, token, delta) => {
@@ -88,7 +111,7 @@ export function assertNoBundlerRetention(params) {
88
111
  // does not re-add the same native move on `eth_simulateV1`.
89
112
  const nativeFromAssetChanges = new Set();
90
113
  for (const { account, changes } of assetChanges) {
91
- if (!bundlerAddresses.has(account))
114
+ if (!restrictedAddresses.has(account))
92
115
  continue;
93
116
  for (const change of changes) {
94
117
  if (change.token !== ethAddress)
@@ -103,15 +126,15 @@ export function assertNoBundlerRetention(params) {
103
126
  // of the `assetChanges` value already recorded for that address.
104
127
  const usesAssetChangeNative = (t, addr) => t.token === ethAddress && nativeFromAssetChanges.has(addr.toLowerCase());
105
128
  for (const t of transfers) {
106
- if (bundlerAddresses.has(t.to) && !usesAssetChangeNative(t, t.to))
129
+ if (restrictedAddresses.has(t.to) && !usesAssetChangeNative(t, t.to))
107
130
  recordFlow(t.to, t.token, t.amount);
108
- if (bundlerAddresses.has(t.from) && !usesAssetChangeNative(t, t.from))
131
+ if (restrictedAddresses.has(t.from) && !usesAssetChangeNative(t, t.from))
109
132
  recordFlow(t.from, t.token, -t.amount);
110
133
  }
111
134
  const entries = [...flow.values()];
112
135
  const drained = entries.filter((e) => e.netChange < -DUST_THRESHOLD);
113
136
  if (drained.length > 0) {
114
- logger?.warn("Simulation detected pre-existing bundler balance being swept", {
137
+ logger?.warn("Simulation detected pre-existing balance being swept from a restricted bundler or bundles contract", {
115
138
  changes: drained.map((e) => ({
116
139
  address: e.address,
117
140
  token: e.token,
@@ -121,7 +144,7 @@ export function assertNoBundlerRetention(params) {
121
144
  }
122
145
  const retained = entries.filter((e) => e.netChange > DUST_THRESHOLD);
123
146
  if (retained.length > 0) {
124
- throw new BlacklistViolationError("Simulation detected asset transfers retained by restricted bundler contracts", retained.map((e) => ({
147
+ throw new BlacklistViolationError("Simulation detected asset transfers retained by restricted bundler or bundles contracts", retained.map((e) => ({
125
148
  address: e.address,
126
149
  token: e.token,
127
150
  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>;
@@ -29,7 +29,7 @@ const FALLBACK_MIN_BUDGET_MS = 1500;
29
29
  * - If only `eth_simulateV1` is configured, it gets the full timeout.
30
30
  */
31
31
  export async function executeSimulation(params) {
32
- const { config, chainId, transactions, blockNumber } = params;
32
+ const { config, chainId, transactions, blockNumber, wNative } = params;
33
33
  const chain = resolveChain(config, chainId);
34
34
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
35
35
  const deadline = Date.now() + timeoutMs;
@@ -59,6 +59,7 @@ export async function executeSimulation(params) {
59
59
  chainId,
60
60
  transactions,
61
61
  blockNumber,
62
+ wNative,
62
63
  signal: AbortSignal.timeout(fallbackBudget),
63
64
  });
64
65
  }
@@ -72,6 +73,7 @@ export async function executeSimulation(params) {
72
73
  chainId,
73
74
  transactions,
74
75
  blockNumber,
76
+ wNative,
75
77
  signal: AbortSignal.timeout(timeoutMs),
76
78
  });
77
79
  }
@@ -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,3 +1,4 @@
1
+ import { _try, getChainAddresses, UnsupportedChainIdError, } from "@morpho-org/blue-sdk";
1
2
  import { ExternalServiceError } from "../errors.js";
2
3
  import { parseTransfers } from "./parsing/index.js";
3
4
  import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, validateInput, } from "./pipeline/index.js";
@@ -6,8 +7,11 @@ import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, valida
6
7
  *
7
8
  * Validates input → resolves authorizations into prepended approve txs → runs the bundle
8
9
  * through Tenderly RPC (primary) or `eth_simulateV1` (fallback) with a shared timeout
9
- * budget → parses ERC20/WETH transfers from per-tx logs → asserts no funds are retained
10
- * by `bundler3` → returns the full result set. The caller reads whichever fields they need:
10
+ * budget → parses ERC20 transfers and WETH9 events from per-tx logs, restricting WETH9
11
+ * events to the registered wrapped-native token, rejecting them on known tokenless chains,
12
+ * and retaining signature-based parsing for unknown chains → asserts no funds are retained
13
+ * by `bundler3` or the standalone `bundles` periphery contracts → returns the full result
14
+ * set. The caller reads whichever fields they need:
11
15
  *
12
16
  * - `transfers` → user-facing preview / server-side verification.
13
17
  * - `simulationTxs` + `transfers` → server-side verification before broadcast.
@@ -32,8 +36,9 @@ import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, valida
32
36
  * empty transactions, malformed authorizations).
33
37
  * @throws {UnsupportedChainError} when the chain is not configured for any backend.
34
38
  * @throws {SimulationRevertedError} when the bundle reverts on either backend.
35
- * @throws {BlacklistViolationError} when the simulation leaves value retained by
36
- * a `bundler3` address beyond the dust threshold.
39
+ * @throws {BlacklistViolationError} when the simulation leaves value retained beyond
40
+ * the dust threshold by a restricted `bundler3` address or a `bundles` periphery
41
+ * contract (VaultExitBundlesV1, VaultBundlesV1, BlueBundlesV1). Never bypassable.
37
42
  * @throws {ExternalServiceError} (a) when both backends are unavailable within the
38
43
  * timeout budget, or (b) when a backend returns a `calls` array whose length does
39
44
  * not match the resolved `simulationTxs` — refusing to map transfers with mismatched
@@ -64,17 +69,22 @@ import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, valida
64
69
  */
65
70
  export async function simulate(config, params) {
66
71
  validateInput(params);
72
+ const wNative = _try(() => getChainAddresses(params.chainId).wNative ?? null, UnsupportedChainIdError);
67
73
  const simulationTxs = buildSimulationTxs(params);
68
74
  const result = await executeSimulation({
69
75
  config,
70
76
  chainId: params.chainId,
71
77
  transactions: simulationTxs,
72
78
  blockNumber: params.blockNumber,
79
+ wNative,
73
80
  });
74
81
  if (result.calls.length !== simulationTxs.length) {
75
82
  throw new ExternalServiceError(`Backend returned ${result.calls.length} call result(s) for ${simulationTxs.length} transaction(s) — refusing to map transfers with mismatched lengths`);
76
83
  }
77
- const transfers = parseTransfers(result.calls, config.logger);
84
+ const transfers = parseTransfers(result.calls, {
85
+ wNative,
86
+ logger: config.logger,
87
+ });
78
88
  assertNoBundlerRetention({
79
89
  chainId: params.chainId,
80
90
  transfers,
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.1.4",
4
+ "version": "4.1.6-next.0",
5
5
  "author": "Morpho Association <contact@morpho.org>",
6
6
  "contributors": [
7
7
  "Rubilmax <rmilon@gmail.com>"
@@ -25,17 +25,17 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "zod": "^4.4.3",
28
- "@morpho-org/blue-sdk": "^6.6.0",
29
- "@morpho-org/morpho-ts": "^2.10.0"
28
+ "@morpho-org/blue-sdk": "^6.7.0",
29
+ "@morpho-org/morpho-ts": "^2.11.1"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "viem": "^2.0.0"
33
33
  },
34
34
  "devDependencies": {
35
- "typescript": "^7.0.2",
35
+ "typescript": "7.0.2",
36
36
  "viem": "^2.55.10",
37
37
  "vitest": "^4.1.10",
38
- "@morpho-org/test": "^2.8.4"
38
+ "@morpho-org/test": "^2.10.0"
39
39
  },
40
40
  "scripts": {
41
41
  "prepublish": "$npm_execpath build",