@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.
- package/lib/cjs/errors.d.ts +1 -1
- package/lib/cjs/errors.js +1 -1
- package/lib/cjs/simulate/asset-changes.d.ts +16 -1
- package/lib/cjs/simulate/asset-changes.js +20 -0
- package/lib/cjs/simulate/backends/eth-simulate-v1.d.ts +2 -1
- package/lib/cjs/simulate/backends/eth-simulate-v1.js +2 -2
- package/lib/cjs/simulate/backends/tenderly-rpc.js +1 -1
- package/lib/cjs/simulate/parsing/transfers.d.ts +29 -15
- package/lib/cjs/simulate/parsing/transfers.js +50 -55
- package/lib/cjs/simulate/pipeline/bundler-retention.d.ts +26 -8
- package/lib/cjs/simulate/pipeline/bundler-retention.js +66 -43
- package/lib/cjs/simulate/pipeline/execute-simulation.d.ts +2 -1
- package/lib/cjs/simulate/pipeline/execute-simulation.js +3 -1
- package/lib/cjs/simulate/simulate.d.ts +8 -4
- package/lib/cjs/simulate/simulate.js +15 -5
- package/lib/esm/errors.d.ts +1 -1
- package/lib/esm/errors.js +1 -1
- package/lib/esm/simulate/asset-changes.d.ts +16 -1
- package/lib/esm/simulate/asset-changes.js +20 -1
- package/lib/esm/simulate/backends/eth-simulate-v1.d.ts +2 -1
- package/lib/esm/simulate/backends/eth-simulate-v1.js +2 -2
- package/lib/esm/simulate/backends/tenderly-rpc.js +3 -3
- package/lib/esm/simulate/parsing/transfers.d.ts +29 -15
- package/lib/esm/simulate/parsing/transfers.js +51 -56
- package/lib/esm/simulate/pipeline/bundler-retention.d.ts +26 -8
- package/lib/esm/simulate/pipeline/bundler-retention.js +66 -43
- package/lib/esm/simulate/pipeline/execute-simulation.d.ts +2 -1
- package/lib/esm/simulate/pipeline/execute-simulation.js +3 -1
- package/lib/esm/simulate/simulate.d.ts +8 -4
- package/lib/esm/simulate/simulate.js +15 -5
- 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
|
|
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 (
|
|
46
|
-
*
|
|
47
|
-
* vault), so gross inbound would fire false positives. We only block
|
|
48
|
-
* net flow above `DUST_THRESHOLD` — value stuck in a
|
|
49
|
-
* net flow means the simulated bundle sweeps a pre-existing or
|
|
50
|
-
*
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (
|
|
129
|
+
if (restrictedAddresses.has(t.to) && !usesAssetChangeNative(t, t.to))
|
|
107
130
|
recordFlow(t.to, t.token, t.amount);
|
|
108
|
-
if (
|
|
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
|
|
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
|
|
8
|
-
*
|
|
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
|
|
34
|
-
* a `bundler3` address
|
|
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
|
|
10
|
-
*
|
|
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
|
|
36
|
-
* a `bundler3` address
|
|
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,
|
|
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
|
+
"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.
|
|
29
|
-
"@morpho-org/morpho-ts": "^2.
|
|
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": "
|
|
35
|
+
"typescript": "7.0.2",
|
|
36
36
|
"viem": "^2.55.10",
|
|
37
37
|
"vitest": "^4.1.10",
|
|
38
|
-
"@morpho-org/test": "^2.
|
|
38
|
+
"@morpho-org/test": "^2.10.0"
|
|
39
39
|
},
|
|
40
40
|
"scripts": {
|
|
41
41
|
"prepublish": "$npm_execpath build",
|