@morpho-org/evm-simulation 1.0.0 → 2.0.1
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/README.md +14 -4
- package/lib/cjs/errors.d.ts +3 -3
- package/lib/cjs/errors.js +2 -3
- package/lib/cjs/index.d.ts +2 -2
- package/lib/cjs/index.js +9 -9
- package/lib/cjs/screen-addresses/screen-addresses.d.ts +37 -21
- package/lib/cjs/screen-addresses/screen-addresses.js +40 -21
- package/lib/cjs/simulate/backends/eth-simulate-v1.js +11 -11
- package/lib/cjs/simulate/backends/tenderly-rest.js +62 -52
- package/lib/cjs/simulate/parsing/transfers.d.ts +21 -9
- package/lib/cjs/simulate/parsing/transfers.js +141 -77
- package/lib/cjs/simulate/pipeline/bundler-retention.js +1 -0
- package/lib/cjs/simulate/simulate.d.ts +45 -6
- package/lib/cjs/simulate/simulate.js +52 -8
- package/lib/cjs/types.d.ts +81 -25
- package/lib/esm/errors.d.ts +3 -3
- package/lib/esm/errors.js +2 -3
- package/lib/esm/index.d.ts +2 -2
- package/lib/esm/index.js +2 -2
- package/lib/esm/screen-addresses/screen-addresses.d.ts +37 -21
- package/lib/esm/screen-addresses/screen-addresses.js +40 -21
- package/lib/esm/simulate/backends/eth-simulate-v1.js +12 -12
- package/lib/esm/simulate/backends/tenderly-rest.js +62 -52
- package/lib/esm/simulate/parsing/transfers.d.ts +21 -9
- package/lib/esm/simulate/parsing/transfers.js +141 -77
- package/lib/esm/simulate/pipeline/bundler-retention.js +2 -1
- package/lib/esm/simulate/simulate.d.ts +45 -6
- package/lib/esm/simulate/simulate.js +52 -8
- package/lib/esm/types.d.ts +81 -25
- package/package.json +6 -6
- package/src/index.ts +13 -12
|
@@ -10,104 +10,139 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
|
|
|
10
10
|
/**
|
|
11
11
|
* Parse raw EVM logs into individual Transfer events.
|
|
12
12
|
*
|
|
13
|
+
* Iterates over per-tx `calls` and stamps each emitted `Transfer.txIdx`
|
|
14
|
+
* with the index of the originating call.
|
|
15
|
+
*
|
|
13
16
|
* **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
|
|
14
17
|
* `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
|
|
15
18
|
* events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
|
|
16
19
|
* ERC1155 transfer events are **not** parsed — consumers with NFT flows will
|
|
17
20
|
* see an incomplete transfer list.
|
|
18
21
|
*
|
|
19
|
-
* **WETH9
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
22
|
+
* **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
|
|
23
|
+
* the same tx: a zero-address `Transfer` is suppressed only if its paired
|
|
24
|
+
* `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
|
|
25
|
+
* is correct for canonical WETH9, which always emits the `Deposit`/
|
|
26
|
+
* `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
|
|
27
|
+
* atomically inside a single call frame. A **non-canonical wrapped-native**
|
|
28
|
+
* that splits these emissions across two txs in the same bundle would leave
|
|
29
|
+
* a phantom zero-address `Transfer` in the parsed output, which can be summed
|
|
30
|
+
* by `assertNoBundlerRetention` and produce a false `BlacklistViolationError`.
|
|
31
|
+
* When a zero-address `Transfer` misses same-tx dedup *and* its token has
|
|
32
|
+
* emitted a `Deposit`/`Withdrawal` somewhere else in the bundle (i.e. it
|
|
33
|
+
* looks wnative-shaped), the parser emits a `warn` so the assumption break
|
|
34
|
+
* is observable before it reaches retention. None of `morpho-sdk`'s currently
|
|
35
|
+
* supported chains require cross-tx dedup; the assumption is rechecked when
|
|
36
|
+
* onboarding a new chain (see `evm-simulation/CLAUDE.md`).
|
|
23
37
|
*
|
|
24
38
|
* **Failure mode:** on a per-log parse failure (malformed topic length,
|
|
25
39
|
* non-hex data), the log is skipped and a `warn` is emitted via the logger.
|
|
26
|
-
* The caller receives a possibly-incomplete transfer list. Upstream sources
|
|
27
|
-
* (e.g., Tenderly response schemas) should validate log shape before calling
|
|
28
|
-
* here to avoid silent drops.
|
|
29
40
|
*
|
|
30
41
|
* Output is sorted canonically by token, from, to, amount for determinism.
|
|
42
|
+
* `txIdx` is attached but does not influence sort order.
|
|
31
43
|
*/
|
|
32
|
-
export function parseTransfers(
|
|
44
|
+
export function parseTransfers(calls, logger) {
|
|
33
45
|
const transfers = [];
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
transfers.push({
|
|
47
|
-
token: getAddress(log.address),
|
|
48
|
-
from: getAddress(`0x${fromTopic.slice(26)}`),
|
|
49
|
-
to: zeroAddress,
|
|
50
|
-
amount: BigInt(log.data),
|
|
51
|
-
});
|
|
46
|
+
// Tokens that emit `Deposit` or `Withdrawal` somewhere in the bundle look
|
|
47
|
+
// wnative-shaped. If a zero-address `Transfer` for one of these tokens
|
|
48
|
+
// misses same-tx dedup, the contract is likely emitting non-canonically
|
|
49
|
+
// (split across txs) and a phantom transfer would leak into retention.
|
|
50
|
+
const wnativeShapedTokens = collectWnativeShapedTokens(calls);
|
|
51
|
+
for (let txIdx = 0; txIdx < calls.length; txIdx++) {
|
|
52
|
+
const logs = calls[txIdx].logs;
|
|
53
|
+
for (const log of logs) {
|
|
54
|
+
try {
|
|
55
|
+
const topic0 = log.topics[0];
|
|
56
|
+
if (topic0 === undefined)
|
|
52
57
|
continue;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
switch (topic0) {
|
|
59
|
+
case WITHDRAWAL_TOPIC: {
|
|
60
|
+
const fromTopic = log.topics[1];
|
|
61
|
+
if (!isTopicHex(fromTopic) || !isUint256Hex(log.data)) {
|
|
62
|
+
warnMalformed(logger, log, "WETH9 Withdrawal: bad topic[1] or data length");
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
transfers.push({
|
|
66
|
+
token: getAddress(log.address),
|
|
67
|
+
from: getAddress(`0x${fromTopic.slice(26)}`),
|
|
68
|
+
to: zeroAddress,
|
|
69
|
+
amount: BigInt(log.data),
|
|
70
|
+
txIdx,
|
|
71
|
+
});
|
|
58
72
|
continue;
|
|
59
73
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const fromTopic = log.topics[1];
|
|
74
|
-
const toTopic = log.topics[2];
|
|
75
|
-
if (!isTopicHex(fromTopic) ||
|
|
76
|
-
!isTopicHex(toTopic) ||
|
|
77
|
-
!isUint256Hex(log.data)) {
|
|
78
|
-
warnMalformed(logger, log, "ERC20 Transfer: bad topic/data length");
|
|
74
|
+
case DEPOSIT_TOPIC: {
|
|
75
|
+
const toTopic = log.topics[1];
|
|
76
|
+
if (!isTopicHex(toTopic) || !isUint256Hex(log.data)) {
|
|
77
|
+
warnMalformed(logger, log, "WETH9 Deposit: bad topic[1] or data length");
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
transfers.push({
|
|
81
|
+
token: getAddress(log.address),
|
|
82
|
+
from: zeroAddress,
|
|
83
|
+
to: getAddress(`0x${toTopic.slice(26)}`),
|
|
84
|
+
amount: BigInt(log.data),
|
|
85
|
+
txIdx,
|
|
86
|
+
});
|
|
79
87
|
continue;
|
|
80
88
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
case TRANSFER_TOPIC: {
|
|
90
|
+
// ERC-20 Transfer has 3 topics; ERC-721 Transfer has 4 (tokenId indexed).
|
|
91
|
+
// ERC-721 flows are out of scope.
|
|
92
|
+
if (log.topics.length !== 3)
|
|
93
|
+
continue;
|
|
94
|
+
const fromTopic = log.topics[1];
|
|
95
|
+
const toTopic = log.topics[2];
|
|
96
|
+
if (!isTopicHex(fromTopic) ||
|
|
97
|
+
!isTopicHex(toTopic) ||
|
|
98
|
+
!isUint256Hex(log.data)) {
|
|
99
|
+
warnMalformed(logger, log, "ERC20 Transfer: bad topic/data length");
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// WETH9 unwrap dedup: Transfer to zero paired with a Withdrawal of
|
|
103
|
+
// equal amount in the SAME tx.
|
|
104
|
+
if (toTopic === zeroHash) {
|
|
105
|
+
const paired = logs.some((other) => other.topics[0] === WITHDRAWAL_TOPIC &&
|
|
106
|
+
other.address === log.address &&
|
|
107
|
+
other.data === log.data &&
|
|
108
|
+
other.topics.length === 2 &&
|
|
109
|
+
other.topics[1] === fromTopic);
|
|
110
|
+
if (paired)
|
|
111
|
+
continue;
|
|
112
|
+
if (wnativeShapedTokens.has(log.address.toLowerCase())) {
|
|
113
|
+
warnNonCanonicalWnative(logger, log, "burn", txIdx);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// WETH9 wrap dedup: Transfer from zero paired with a Deposit of
|
|
117
|
+
// equal amount in the SAME tx.
|
|
118
|
+
if (fromTopic === zeroHash) {
|
|
119
|
+
const paired = logs.some((other) => other.topics[0] === DEPOSIT_TOPIC &&
|
|
120
|
+
other.address === log.address &&
|
|
121
|
+
other.data === log.data &&
|
|
122
|
+
other.topics.length === 2 &&
|
|
123
|
+
other.topics[1] === toTopic);
|
|
124
|
+
if (paired)
|
|
125
|
+
continue;
|
|
126
|
+
if (wnativeShapedTokens.has(log.address.toLowerCase())) {
|
|
127
|
+
warnNonCanonicalWnative(logger, log, "mint", txIdx);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
transfers.push({
|
|
131
|
+
token: getAddress(log.address),
|
|
132
|
+
from: getAddress(`0x${fromTopic.slice(26)}`),
|
|
133
|
+
to: getAddress(`0x${toTopic.slice(26)}`),
|
|
134
|
+
amount: BigInt(log.data),
|
|
135
|
+
txIdx,
|
|
136
|
+
});
|
|
96
137
|
continue;
|
|
97
|
-
|
|
98
|
-
token: getAddress(log.address),
|
|
99
|
-
from: getAddress(`0x${fromTopic.slice(26)}`),
|
|
100
|
-
to: getAddress(`0x${toTopic.slice(26)}`),
|
|
101
|
-
amount: BigInt(log.data),
|
|
102
|
-
});
|
|
103
|
-
continue;
|
|
138
|
+
}
|
|
104
139
|
}
|
|
105
140
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
141
|
+
catch (error) {
|
|
142
|
+
// Safety net for anything that slips past the length checks above
|
|
143
|
+
// (e.g., viem checksum throws on non-hex input). Loud enough to diagnose.
|
|
144
|
+
warnMalformed(logger, log, error instanceof Error ? error.message : String(error));
|
|
145
|
+
}
|
|
111
146
|
}
|
|
112
147
|
}
|
|
113
148
|
return sortTransfers(transfers);
|
|
@@ -118,6 +153,7 @@ function isTopicHex(value) {
|
|
|
118
153
|
function isUint256Hex(value) {
|
|
119
154
|
return typeof value === "string" && value.length === UINT256_HEX_LENGTH;
|
|
120
155
|
}
|
|
156
|
+
// biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
|
|
121
157
|
function warnMalformed(logger, log, reason) {
|
|
122
158
|
logger?.warn("Skipping malformed log during transfer parsing", {
|
|
123
159
|
address: log.address,
|
|
@@ -125,6 +161,34 @@ function warnMalformed(logger, log, reason) {
|
|
|
125
161
|
reason,
|
|
126
162
|
});
|
|
127
163
|
}
|
|
164
|
+
/**
|
|
165
|
+
* Collect token addresses (lowercased) that emit `Deposit` or `Withdrawal`
|
|
166
|
+
* anywhere in the bundle. Used to detect when a zero-address `Transfer` that
|
|
167
|
+
* misses same-tx dedup is on a wnative-shaped contract — a strong signal
|
|
168
|
+
* that the contract is emitting non-canonically and a phantom transfer is
|
|
169
|
+
* about to leak into bundler retention.
|
|
170
|
+
*/
|
|
171
|
+
function collectWnativeShapedTokens(calls) {
|
|
172
|
+
const set = new Set();
|
|
173
|
+
for (const c of calls) {
|
|
174
|
+
for (const l of c.logs) {
|
|
175
|
+
const t0 = l.topics[0];
|
|
176
|
+
if (t0 === WITHDRAWAL_TOPIC || t0 === DEPOSIT_TOPIC) {
|
|
177
|
+
set.add(l.address.toLowerCase());
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return set;
|
|
182
|
+
}
|
|
183
|
+
// biome-ignore lint/complexity/useMaxParams: structured warn with full context
|
|
184
|
+
function warnNonCanonicalWnative(logger, log, kind, txIdx) {
|
|
185
|
+
logger?.warn("WETH9 dedup miss: zero-address Transfer on wnative-shaped token without paired same-tx Deposit/Withdrawal — possible non-canonical wrapped-native, may cause bundler retention false positive", {
|
|
186
|
+
token: log.address,
|
|
187
|
+
kind,
|
|
188
|
+
txIdx,
|
|
189
|
+
data: log.data,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
128
192
|
/**
|
|
129
193
|
* Canonical sort: by lowercased token, from, to, then amount. Addresses are
|
|
130
194
|
* pre-lowercased once to avoid redundant `.toLowerCase()` calls per comparison.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getChainAddresses, UnsupportedChainIdError, } from "@morpho-org/blue-sdk";
|
|
2
2
|
import { isDefined } from "@morpho-org/morpho-ts";
|
|
3
3
|
import { getAddress } from "viem";
|
|
4
4
|
import { BlacklistViolationError } from "../../errors.js";
|
|
@@ -57,6 +57,7 @@ export function assertNoBundlerRetention(params) {
|
|
|
57
57
|
return;
|
|
58
58
|
// Map keyed by (bundler, token) → structured entry. Avoids string parse-back.
|
|
59
59
|
const flow = new Map();
|
|
60
|
+
// biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
|
|
60
61
|
const recordFlow = (address, token, delta) => {
|
|
61
62
|
const key = `${address.toLowerCase()}:${token.toLowerCase()}`;
|
|
62
63
|
const existing = flow.get(key);
|
|
@@ -4,17 +4,56 @@ 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 REST (primary) or `eth_simulateV1` (fallback) with a shared timeout
|
|
7
|
-
* budget → parses ERC20/WETH transfers from logs → asserts no funds are retained
|
|
8
|
-
* `bundler3` → returns the full result set. The caller reads whichever fields they need:
|
|
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:
|
|
9
9
|
*
|
|
10
10
|
* - `transfers` + `tenderlyUrl` → user-facing preview.
|
|
11
11
|
* - `simulationTxs` + `transfers` → server-side verification before broadcast, then pass
|
|
12
12
|
* both to `screenAddresses` for compliance.
|
|
13
|
+
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed`,
|
|
14
|
+
* and Tenderly-only `assetChanges`). Aligned 1:1 with `simulationTxs[i]`.
|
|
15
|
+
* - `transfers[k].txIdx` → index into `simulationTxs` of the tx that emitted the
|
|
16
|
+
* underlying log; consumers map back via `simulationTxs[transfer.txIdx]`.
|
|
13
17
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
+
* @param config - Backend configuration: Tenderly credentials, per-chain RPC URLs, optional
|
|
19
|
+
* logger, and the overall timeout budget.
|
|
20
|
+
* @param params - Per-call simulation input.
|
|
21
|
+
* @param params.chainId - Chain id the bundle targets.
|
|
22
|
+
* @param params.transactions - The bundle's transactions, in execution order. All must share the
|
|
23
|
+
* same `from`.
|
|
24
|
+
* @param params.authorizations - Optional token authorizations resolved into prepended approve
|
|
25
|
+
* transactions before the main bundle runs.
|
|
26
|
+
* @param params.blockNumber - Optional pinned block number or `BlockTag`. Defaults to `latest`.
|
|
27
|
+
* @param options - Per-call simulation options.
|
|
28
|
+
* @param options.shareable - When `true`, persists the simulation in Tenderly and returns a
|
|
29
|
+
* shareable `tenderlyUrl`. Honored only on the Tenderly backend, not on the `eth_simulateV1`
|
|
30
|
+
* fallback. Defaults to `false`.
|
|
31
|
+
* @throws {SimulationValidationError} for invalid input (mixed senders, bad addresses,
|
|
32
|
+
* empty transactions, malformed authorizations).
|
|
33
|
+
* @throws {UnsupportedChainError} when the chain is not configured for any backend.
|
|
34
|
+
* @throws {SimulationRevertedError} when the bundle reverts on either backend.
|
|
35
|
+
* @throws {BlacklistViolationError} when the simulation leaves value retained by or
|
|
36
|
+
* drained from a `bundler3` address beyond the dust threshold.
|
|
37
|
+
* @throws {ExternalServiceError} (a) when both backends are unavailable within the
|
|
38
|
+
* timeout budget, or (b) when a backend returns a `calls` array whose length does
|
|
39
|
+
* not match the resolved `simulationTxs` — refusing to map transfers with mismatched
|
|
40
|
+
* per-tx output.
|
|
41
|
+
* @returns A {@link SimulationResult} carrying the resolved `simulationTxs`, per-tx
|
|
42
|
+
* `calls` (aligned 1:1 with `simulationTxs`), parsed `transfers` (each stamped with
|
|
43
|
+
* `txIdx`), and the optional `tenderlyUrl`.
|
|
44
|
+
* @example
|
|
45
|
+
* ```ts
|
|
46
|
+
* import { simulate } from "@morpho-org/evm-simulation";
|
|
47
|
+
*
|
|
48
|
+
* const result = await simulate(
|
|
49
|
+
* { chains: new Map([[1, { simulateV1Url: rpcUrl }]]) },
|
|
50
|
+
* {
|
|
51
|
+
* chainId: 1,
|
|
52
|
+
* transactions: [{ from: user, to: vaultAddress, data: encodedCalldata, value: 0n }],
|
|
53
|
+
* },
|
|
54
|
+
* );
|
|
55
|
+
* // result satisfies SimulationResult
|
|
56
|
+
* ```
|
|
18
57
|
*/
|
|
19
58
|
export declare function simulate(config: SimulationConfig, params: SimulateParams, options?: {
|
|
20
59
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ExternalServiceError } from "../errors.js";
|
|
1
2
|
import { parseTransfers } from "./parsing/index.js";
|
|
2
3
|
import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, validateInput, } from "./pipeline/index.js";
|
|
3
4
|
/**
|
|
@@ -5,18 +6,58 @@ import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, valida
|
|
|
5
6
|
*
|
|
6
7
|
* Validates input → resolves authorizations into prepended approve txs → runs the bundle
|
|
7
8
|
* through Tenderly REST (primary) or `eth_simulateV1` (fallback) with a shared timeout
|
|
8
|
-
* budget → parses ERC20/WETH transfers from logs → asserts no funds are retained
|
|
9
|
-
* `bundler3` → returns the full result set. The caller reads whichever fields they need:
|
|
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
11
|
*
|
|
11
12
|
* - `transfers` + `tenderlyUrl` → user-facing preview.
|
|
12
13
|
* - `simulationTxs` + `transfers` → server-side verification before broadcast, then pass
|
|
13
14
|
* both to `screenAddresses` for compliance.
|
|
15
|
+
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed`,
|
|
16
|
+
* and Tenderly-only `assetChanges`). Aligned 1:1 with `simulationTxs[i]`.
|
|
17
|
+
* - `transfers[k].txIdx` → index into `simulationTxs` of the tx that emitted the
|
|
18
|
+
* underlying log; consumers map back via `simulationTxs[transfer.txIdx]`.
|
|
14
19
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
20
|
+
* @param config - Backend configuration: Tenderly credentials, per-chain RPC URLs, optional
|
|
21
|
+
* logger, and the overall timeout budget.
|
|
22
|
+
* @param params - Per-call simulation input.
|
|
23
|
+
* @param params.chainId - Chain id the bundle targets.
|
|
24
|
+
* @param params.transactions - The bundle's transactions, in execution order. All must share the
|
|
25
|
+
* same `from`.
|
|
26
|
+
* @param params.authorizations - Optional token authorizations resolved into prepended approve
|
|
27
|
+
* transactions before the main bundle runs.
|
|
28
|
+
* @param params.blockNumber - Optional pinned block number or `BlockTag`. Defaults to `latest`.
|
|
29
|
+
* @param options - Per-call simulation options.
|
|
30
|
+
* @param options.shareable - When `true`, persists the simulation in Tenderly and returns a
|
|
31
|
+
* shareable `tenderlyUrl`. Honored only on the Tenderly backend, not on the `eth_simulateV1`
|
|
32
|
+
* fallback. Defaults to `false`.
|
|
33
|
+
* @throws {SimulationValidationError} for invalid input (mixed senders, bad addresses,
|
|
34
|
+
* empty transactions, malformed authorizations).
|
|
35
|
+
* @throws {UnsupportedChainError} when the chain is not configured for any backend.
|
|
36
|
+
* @throws {SimulationRevertedError} when the bundle reverts on either backend.
|
|
37
|
+
* @throws {BlacklistViolationError} when the simulation leaves value retained by or
|
|
38
|
+
* drained from a `bundler3` address beyond the dust threshold.
|
|
39
|
+
* @throws {ExternalServiceError} (a) when both backends are unavailable within the
|
|
40
|
+
* timeout budget, or (b) when a backend returns a `calls` array whose length does
|
|
41
|
+
* not match the resolved `simulationTxs` — refusing to map transfers with mismatched
|
|
42
|
+
* per-tx output.
|
|
43
|
+
* @returns A {@link SimulationResult} carrying the resolved `simulationTxs`, per-tx
|
|
44
|
+
* `calls` (aligned 1:1 with `simulationTxs`), parsed `transfers` (each stamped with
|
|
45
|
+
* `txIdx`), and the optional `tenderlyUrl`.
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* import { simulate } from "@morpho-org/evm-simulation";
|
|
49
|
+
*
|
|
50
|
+
* const result = await simulate(
|
|
51
|
+
* { chains: new Map([[1, { simulateV1Url: rpcUrl }]]) },
|
|
52
|
+
* {
|
|
53
|
+
* chainId: 1,
|
|
54
|
+
* transactions: [{ from: user, to: vaultAddress, data: encodedCalldata, value: 0n }],
|
|
55
|
+
* },
|
|
56
|
+
* );
|
|
57
|
+
* // result satisfies SimulationResult
|
|
58
|
+
* ```
|
|
19
59
|
*/
|
|
60
|
+
// biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
|
|
20
61
|
export async function simulate(config, params, options = {}) {
|
|
21
62
|
const { shareable = false } = options;
|
|
22
63
|
validateInput(params);
|
|
@@ -28,7 +69,10 @@ export async function simulate(config, params, options = {}) {
|
|
|
28
69
|
blockNumber: params.blockNumber,
|
|
29
70
|
shareable,
|
|
30
71
|
});
|
|
31
|
-
|
|
72
|
+
if (result.calls.length !== simulationTxs.length) {
|
|
73
|
+
throw new ExternalServiceError(`Backend returned ${result.calls.length} call result(s) for ${simulationTxs.length} transaction(s) — refusing to map transfers with mismatched lengths`);
|
|
74
|
+
}
|
|
75
|
+
const transfers = parseTransfers(result.calls, config.logger);
|
|
32
76
|
assertNoBundlerRetention({
|
|
33
77
|
chainId: params.chainId,
|
|
34
78
|
transfers,
|
|
@@ -36,8 +80,8 @@ export async function simulate(config, params, options = {}) {
|
|
|
36
80
|
});
|
|
37
81
|
return {
|
|
38
82
|
simulationTxs,
|
|
83
|
+
calls: result.calls,
|
|
39
84
|
transfers,
|
|
40
85
|
tenderlyUrl: result.tenderlyUrl,
|
|
41
|
-
assetChanges: result.rawAssetChanges,
|
|
42
86
|
};
|
|
43
87
|
}
|
package/lib/esm/types.d.ts
CHANGED
|
@@ -71,33 +71,43 @@ export type SimulationAuthorization = {
|
|
|
71
71
|
* `SimulationResult.transfers`.
|
|
72
72
|
*/
|
|
73
73
|
export interface Transfer {
|
|
74
|
-
token: Address;
|
|
75
|
-
from: Address;
|
|
76
|
-
to: Address;
|
|
77
|
-
amount: bigint;
|
|
74
|
+
readonly token: Address;
|
|
75
|
+
readonly from: Address;
|
|
76
|
+
readonly to: Address;
|
|
77
|
+
readonly amount: bigint;
|
|
78
|
+
/**
|
|
79
|
+
* Index into `SimulationResult.simulationTxs` of the transaction that
|
|
80
|
+
* emitted the underlying log. For bundles with prepended authorization
|
|
81
|
+
* approvals, indices `[0, simulationTxs.length - params.transactions.length)`
|
|
82
|
+
* are authorization txs; the remainder are caller-supplied.
|
|
83
|
+
*/
|
|
84
|
+
readonly txIdx: number;
|
|
78
85
|
}
|
|
79
86
|
/**
|
|
80
|
-
* Happy-path return of `simulate`. All failures throw typed errors.
|
|
81
|
-
* returned regardless of `options.shareable` — fields unused by your use case can be
|
|
82
|
-
* ignored:
|
|
83
|
-
*
|
|
84
|
-
* - Preview (`shareable: true`): read `transfers` + `tenderlyUrl`.
|
|
85
|
-
* - Verify (`shareable: false`, default): read `simulationTxs` + `transfers`, pass both
|
|
86
|
-
* to `screenAddresses`.
|
|
87
|
+
* Happy-path return of `simulate`. All failures throw typed errors.
|
|
87
88
|
*
|
|
88
|
-
* `
|
|
89
|
-
*
|
|
90
|
-
* raw
|
|
89
|
+
* - `simulationTxs` is the full resolved transaction list (including
|
|
90
|
+
* prepended authorization txs).
|
|
91
|
+
* - `calls[i]` corresponds 1:1 with `simulationTxs[i]` — read raw logs,
|
|
92
|
+
* status, returnData/gasUsed, and (Tenderly only) assetChanges per tx.
|
|
93
|
+
* - `transfers[k].txIdx` indexes into `simulationTxs` to attribute each
|
|
94
|
+
* transfer to its emitting transaction.
|
|
95
|
+
* - `tenderlyUrl` is set only when `shareable: true` AND the Tenderly backend
|
|
96
|
+
* ran successfully (not the `eth_simulateV1` fallback).
|
|
91
97
|
*/
|
|
92
98
|
export interface SimulationResult {
|
|
93
99
|
/** The full resolved transaction list (including prepended authorization txs). */
|
|
94
|
-
simulationTxs: SimulationTransaction[];
|
|
100
|
+
readonly simulationTxs: readonly SimulationTransaction[];
|
|
101
|
+
/**
|
|
102
|
+
* Per-transaction normalized output. `calls[i]` corresponds 1:1 with
|
|
103
|
+
* `simulationTxs[i]`. Use this to read raw logs, status, return data, gas
|
|
104
|
+
* used, and (Tenderly only) asset changes per transaction.
|
|
105
|
+
*/
|
|
106
|
+
readonly calls: readonly SimulationCall[];
|
|
95
107
|
/** Parsed ERC-20 / WETH9 transfers from the simulation. */
|
|
96
|
-
transfers: Transfer[];
|
|
108
|
+
readonly transfers: readonly Transfer[];
|
|
97
109
|
/** Shareable Tenderly URL. Present only when `shareable: true` and Tenderly ran (not fallback). */
|
|
98
|
-
tenderlyUrl?: string;
|
|
99
|
-
/** Raw Tenderly `asset_changes` payload. Opaque `unknown` — do not destructure without validation. */
|
|
100
|
-
assetChanges?: unknown;
|
|
110
|
+
readonly tenderlyUrl?: string;
|
|
101
111
|
}
|
|
102
112
|
/** Minimal structured logger the package calls for warnings and info. */
|
|
103
113
|
export interface SimulationLogger {
|
|
@@ -117,15 +127,61 @@ export interface SimulateParams {
|
|
|
117
127
|
}
|
|
118
128
|
/**
|
|
119
129
|
* Internal raw result from a simulation adapter before normalization.
|
|
120
|
-
*
|
|
130
|
+
* `calls[i]` corresponds 1:1 with the i-th transaction passed to the
|
|
131
|
+
* backend.
|
|
121
132
|
*/
|
|
122
133
|
export interface RawSimulationResult {
|
|
123
|
-
|
|
134
|
+
calls: RawCall[];
|
|
124
135
|
tenderlyUrl?: string;
|
|
125
|
-
rawAssetChanges?: unknown;
|
|
126
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* Normalized EVM log emitted by a single simulated call. The shape is the
|
|
139
|
+
* common subset both backends (`eth_simulateV1` via viem and Tenderly REST)
|
|
140
|
+
* produce after schema validation. Returned indirectly via
|
|
141
|
+
* `SimulationCall.logs` and consumed by the SDK's transfer parser.
|
|
142
|
+
*/
|
|
127
143
|
export interface RawLog {
|
|
128
|
-
address: Address;
|
|
129
|
-
topics: Hex[];
|
|
130
|
-
data: Hex;
|
|
144
|
+
readonly address: Address;
|
|
145
|
+
readonly topics: readonly Hex[];
|
|
146
|
+
readonly data: Hex;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Internal mirror of `SimulationCall`, mutable during construction by the
|
|
150
|
+
* simulation backends.
|
|
151
|
+
*
|
|
152
|
+
* @internal
|
|
153
|
+
*/
|
|
154
|
+
export interface RawCall {
|
|
155
|
+
logs: RawLog[];
|
|
156
|
+
status: boolean;
|
|
157
|
+
returnData: Hex;
|
|
158
|
+
gasUsed: bigint;
|
|
159
|
+
/** Tenderly-only `asset_changes` payload. Absent on `eth_simulateV1`. */
|
|
160
|
+
assetChanges?: unknown;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Per-transaction normalized output from the simulation backend.
|
|
164
|
+
*
|
|
165
|
+
* `SimulationResult.calls[i]` corresponds 1:1 with
|
|
166
|
+
* `SimulationResult.simulationTxs[i]`. Use this to read raw logs, status,
|
|
167
|
+
* return data, gas used, and (Tenderly only) asset changes per transaction.
|
|
168
|
+
*/
|
|
169
|
+
export interface SimulationCall {
|
|
170
|
+
readonly logs: readonly RawLog[];
|
|
171
|
+
/**
|
|
172
|
+
* True iff the call succeeded. The bundle as a whole reverts via
|
|
173
|
+
* `SimulationRevertedError` before the result is returned, so on a
|
|
174
|
+
* successful `simulate()` every entry is `true` today. Field kept for
|
|
175
|
+
* forward-compatibility with backends that may surface per-call status.
|
|
176
|
+
*/
|
|
177
|
+
readonly status: boolean;
|
|
178
|
+
/** Return data from the top-level call. */
|
|
179
|
+
readonly returnData: Hex;
|
|
180
|
+
/** Gas used by this call (root frame). */
|
|
181
|
+
readonly gasUsed: bigint;
|
|
182
|
+
/**
|
|
183
|
+
* Tenderly-only `asset_changes` payload for this tx. Opaque `unknown` —
|
|
184
|
+
* do not destructure without validation. Absent on `eth_simulateV1`.
|
|
185
|
+
*/
|
|
186
|
+
readonly assetChanges?: unknown;
|
|
131
187
|
}
|
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
|
+
"version": "2.0.1",
|
|
5
5
|
"author": "Morpho Association <contact@morpho.org>",
|
|
6
6
|
"contributors": [
|
|
7
7
|
"Rubilmax <rmilon@gmail.com>"
|
|
@@ -20,17 +20,17 @@
|
|
|
20
20
|
"lib"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"zod": "^4.
|
|
24
|
-
"@morpho-org/blue-sdk": "^
|
|
23
|
+
"zod": "^4.4.3",
|
|
24
|
+
"@morpho-org/blue-sdk": "^6.0.0",
|
|
25
25
|
"@morpho-org/morpho-ts": "^2.5.1"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"viem": "^2.0.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"typescript": "^
|
|
32
|
-
"viem": "^2.
|
|
33
|
-
"vitest": "^4.1.
|
|
31
|
+
"typescript": "^6.0.3",
|
|
32
|
+
"viem": "^2.49.3",
|
|
33
|
+
"vitest": "^4.1.6"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"prepublish": "$npm_execpath build",
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
// Public fns (via feature-folder barrels)
|
|
2
|
+
|
|
3
|
+
// Errors (for instanceof checks by consumers)
|
|
4
|
+
export {
|
|
5
|
+
AddressScreeningError,
|
|
6
|
+
BlacklistViolationError,
|
|
7
|
+
ExternalServiceError,
|
|
8
|
+
SimulationPackageError,
|
|
9
|
+
SimulationRevertedError,
|
|
10
|
+
SimulationValidationError,
|
|
11
|
+
UnsupportedChainError,
|
|
12
|
+
} from "./errors.js";
|
|
2
13
|
export { screenAddresses } from "./screen-addresses/index.js";
|
|
3
14
|
export { simulate } from "./simulate/index.js";
|
|
4
|
-
|
|
5
15
|
// Types
|
|
6
16
|
export type {
|
|
7
17
|
ChainSimulationConfig,
|
|
18
|
+
RawLog,
|
|
8
19
|
SimulateParams,
|
|
9
20
|
SimulationAuthorization,
|
|
21
|
+
SimulationCall,
|
|
10
22
|
SimulationConfig,
|
|
11
23
|
SimulationLogger,
|
|
12
24
|
SimulationResult,
|
|
@@ -14,14 +26,3 @@ export type {
|
|
|
14
26
|
TenderlyRestConfig,
|
|
15
27
|
Transfer,
|
|
16
28
|
} from "./types.js";
|
|
17
|
-
|
|
18
|
-
// Errors (for instanceof checks by consumers)
|
|
19
|
-
export {
|
|
20
|
-
SimulationPackageError,
|
|
21
|
-
SimulationRevertedError,
|
|
22
|
-
BlacklistViolationError,
|
|
23
|
-
AddressScreeningError,
|
|
24
|
-
ExternalServiceError,
|
|
25
|
-
SimulationValidationError,
|
|
26
|
-
UnsupportedChainError,
|
|
27
|
-
} from "./errors.js";
|