@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
|
@@ -14,104 +14,139 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
|
|
|
14
14
|
/**
|
|
15
15
|
* Parse raw EVM logs into individual Transfer events.
|
|
16
16
|
*
|
|
17
|
+
* Iterates over per-tx `calls` and stamps each emitted `Transfer.txIdx`
|
|
18
|
+
* with the index of the originating call.
|
|
19
|
+
*
|
|
17
20
|
* **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
|
|
18
21
|
* `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
|
|
19
22
|
* events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
|
|
20
23
|
* ERC1155 transfer events are **not** parsed — consumers with NFT flows will
|
|
21
24
|
* see an incomplete transfer list.
|
|
22
25
|
*
|
|
23
|
-
* **WETH9
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
+
* `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
|
|
31
|
+
* atomically inside a single call frame. A **non-canonical wrapped-native**
|
|
32
|
+
* that splits these emissions across two txs in the same bundle would leave
|
|
33
|
+
* a phantom zero-address `Transfer` in the parsed output, which can be summed
|
|
34
|
+
* 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`).
|
|
27
41
|
*
|
|
28
42
|
* **Failure mode:** on a per-log parse failure (malformed topic length,
|
|
29
43
|
* non-hex data), the log is skipped and a `warn` is emitted via the logger.
|
|
30
|
-
* The caller receives a possibly-incomplete transfer list. Upstream sources
|
|
31
|
-
* (e.g., Tenderly response schemas) should validate log shape before calling
|
|
32
|
-
* here to avoid silent drops.
|
|
33
44
|
*
|
|
34
45
|
* Output is sorted canonically by token, from, to, amount for determinism.
|
|
46
|
+
* `txIdx` is attached but does not influence sort order.
|
|
35
47
|
*/
|
|
36
|
-
function parseTransfers(
|
|
48
|
+
function parseTransfers(calls, logger) {
|
|
37
49
|
const transfers = [];
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
transfers.push({
|
|
51
|
-
token: (0, viem_1.getAddress)(log.address),
|
|
52
|
-
from: (0, viem_1.getAddress)(`0x${fromTopic.slice(26)}`),
|
|
53
|
-
to: viem_1.zeroAddress,
|
|
54
|
-
amount: BigInt(log.data),
|
|
55
|
-
});
|
|
50
|
+
// Tokens that emit `Deposit` or `Withdrawal` somewhere in the bundle look
|
|
51
|
+
// wnative-shaped. If a zero-address `Transfer` for one of these tokens
|
|
52
|
+
// misses same-tx dedup, the contract is likely emitting non-canonically
|
|
53
|
+
// (split across txs) and a phantom transfer would leak into retention.
|
|
54
|
+
const wnativeShapedTokens = collectWnativeShapedTokens(calls);
|
|
55
|
+
for (let txIdx = 0; txIdx < calls.length; txIdx++) {
|
|
56
|
+
const logs = calls[txIdx].logs;
|
|
57
|
+
for (const log of logs) {
|
|
58
|
+
try {
|
|
59
|
+
const topic0 = log.topics[0];
|
|
60
|
+
if (topic0 === undefined)
|
|
56
61
|
continue;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
switch (topic0) {
|
|
63
|
+
case exports.WITHDRAWAL_TOPIC: {
|
|
64
|
+
const fromTopic = log.topics[1];
|
|
65
|
+
if (!isTopicHex(fromTopic) || !isUint256Hex(log.data)) {
|
|
66
|
+
warnMalformed(logger, log, "WETH9 Withdrawal: bad topic[1] or data length");
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
transfers.push({
|
|
70
|
+
token: (0, viem_1.getAddress)(log.address),
|
|
71
|
+
from: (0, viem_1.getAddress)(`0x${fromTopic.slice(26)}`),
|
|
72
|
+
to: viem_1.zeroAddress,
|
|
73
|
+
amount: BigInt(log.data),
|
|
74
|
+
txIdx,
|
|
75
|
+
});
|
|
62
76
|
continue;
|
|
63
77
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const fromTopic = log.topics[1];
|
|
78
|
-
const toTopic = log.topics[2];
|
|
79
|
-
if (!isTopicHex(fromTopic) ||
|
|
80
|
-
!isTopicHex(toTopic) ||
|
|
81
|
-
!isUint256Hex(log.data)) {
|
|
82
|
-
warnMalformed(logger, log, "ERC20 Transfer: bad topic/data length");
|
|
78
|
+
case exports.DEPOSIT_TOPIC: {
|
|
79
|
+
const toTopic = log.topics[1];
|
|
80
|
+
if (!isTopicHex(toTopic) || !isUint256Hex(log.data)) {
|
|
81
|
+
warnMalformed(logger, log, "WETH9 Deposit: bad topic[1] or data length");
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
transfers.push({
|
|
85
|
+
token: (0, viem_1.getAddress)(log.address),
|
|
86
|
+
from: viem_1.zeroAddress,
|
|
87
|
+
to: (0, viem_1.getAddress)(`0x${toTopic.slice(26)}`),
|
|
88
|
+
amount: BigInt(log.data),
|
|
89
|
+
txIdx,
|
|
90
|
+
});
|
|
83
91
|
continue;
|
|
84
92
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
case exports.TRANSFER_TOPIC: {
|
|
94
|
+
// ERC-20 Transfer has 3 topics; ERC-721 Transfer has 4 (tokenId indexed).
|
|
95
|
+
// ERC-721 flows are out of scope.
|
|
96
|
+
if (log.topics.length !== 3)
|
|
97
|
+
continue;
|
|
98
|
+
const fromTopic = log.topics[1];
|
|
99
|
+
const toTopic = log.topics[2];
|
|
100
|
+
if (!isTopicHex(fromTopic) ||
|
|
101
|
+
!isTopicHex(toTopic) ||
|
|
102
|
+
!isUint256Hex(log.data)) {
|
|
103
|
+
warnMalformed(logger, log, "ERC20 Transfer: bad topic/data length");
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// WETH9 unwrap dedup: Transfer to zero paired with a Withdrawal of
|
|
107
|
+
// equal amount in the SAME tx.
|
|
108
|
+
if (toTopic === viem_1.zeroHash) {
|
|
109
|
+
const paired = logs.some((other) => other.topics[0] === exports.WITHDRAWAL_TOPIC &&
|
|
110
|
+
other.address === log.address &&
|
|
111
|
+
other.data === log.data &&
|
|
112
|
+
other.topics.length === 2 &&
|
|
113
|
+
other.topics[1] === fromTopic);
|
|
114
|
+
if (paired)
|
|
115
|
+
continue;
|
|
116
|
+
if (wnativeShapedTokens.has(log.address.toLowerCase())) {
|
|
117
|
+
warnNonCanonicalWnative(logger, log, "burn", txIdx);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// WETH9 wrap dedup: Transfer from zero paired with a Deposit of
|
|
121
|
+
// equal amount in the SAME tx.
|
|
122
|
+
if (fromTopic === viem_1.zeroHash) {
|
|
123
|
+
const paired = logs.some((other) => other.topics[0] === exports.DEPOSIT_TOPIC &&
|
|
124
|
+
other.address === log.address &&
|
|
125
|
+
other.data === log.data &&
|
|
126
|
+
other.topics.length === 2 &&
|
|
127
|
+
other.topics[1] === toTopic);
|
|
128
|
+
if (paired)
|
|
129
|
+
continue;
|
|
130
|
+
if (wnativeShapedTokens.has(log.address.toLowerCase())) {
|
|
131
|
+
warnNonCanonicalWnative(logger, log, "mint", txIdx);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
transfers.push({
|
|
135
|
+
token: (0, viem_1.getAddress)(log.address),
|
|
136
|
+
from: (0, viem_1.getAddress)(`0x${fromTopic.slice(26)}`),
|
|
137
|
+
to: (0, viem_1.getAddress)(`0x${toTopic.slice(26)}`),
|
|
138
|
+
amount: BigInt(log.data),
|
|
139
|
+
txIdx,
|
|
140
|
+
});
|
|
100
141
|
continue;
|
|
101
|
-
|
|
102
|
-
token: (0, viem_1.getAddress)(log.address),
|
|
103
|
-
from: (0, viem_1.getAddress)(`0x${fromTopic.slice(26)}`),
|
|
104
|
-
to: (0, viem_1.getAddress)(`0x${toTopic.slice(26)}`),
|
|
105
|
-
amount: BigInt(log.data),
|
|
106
|
-
});
|
|
107
|
-
continue;
|
|
142
|
+
}
|
|
108
143
|
}
|
|
109
144
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
catch (error) {
|
|
146
|
+
// Safety net for anything that slips past the length checks above
|
|
147
|
+
// (e.g., viem checksum throws on non-hex input). Loud enough to diagnose.
|
|
148
|
+
warnMalformed(logger, log, error instanceof Error ? error.message : String(error));
|
|
149
|
+
}
|
|
115
150
|
}
|
|
116
151
|
}
|
|
117
152
|
return sortTransfers(transfers);
|
|
@@ -122,6 +157,7 @@ function isTopicHex(value) {
|
|
|
122
157
|
function isUint256Hex(value) {
|
|
123
158
|
return typeof value === "string" && value.length === UINT256_HEX_LENGTH;
|
|
124
159
|
}
|
|
160
|
+
// biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
|
|
125
161
|
function warnMalformed(logger, log, reason) {
|
|
126
162
|
logger?.warn("Skipping malformed log during transfer parsing", {
|
|
127
163
|
address: log.address,
|
|
@@ -129,6 +165,34 @@ function warnMalformed(logger, log, reason) {
|
|
|
129
165
|
reason,
|
|
130
166
|
});
|
|
131
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Collect token addresses (lowercased) that emit `Deposit` or `Withdrawal`
|
|
170
|
+
* anywhere in the bundle. Used to detect when a zero-address `Transfer` that
|
|
171
|
+
* misses same-tx dedup is on a wnative-shaped contract — a strong signal
|
|
172
|
+
* that the contract is emitting non-canonically and a phantom transfer is
|
|
173
|
+
* about to leak into bundler retention.
|
|
174
|
+
*/
|
|
175
|
+
function collectWnativeShapedTokens(calls) {
|
|
176
|
+
const set = new Set();
|
|
177
|
+
for (const c of calls) {
|
|
178
|
+
for (const l of c.logs) {
|
|
179
|
+
const t0 = l.topics[0];
|
|
180
|
+
if (t0 === exports.WITHDRAWAL_TOPIC || t0 === exports.DEPOSIT_TOPIC) {
|
|
181
|
+
set.add(l.address.toLowerCase());
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return set;
|
|
186
|
+
}
|
|
187
|
+
// biome-ignore lint/complexity/useMaxParams: structured warn with full context
|
|
188
|
+
function warnNonCanonicalWnative(logger, log, kind, txIdx) {
|
|
189
|
+
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", {
|
|
190
|
+
token: log.address,
|
|
191
|
+
kind,
|
|
192
|
+
txIdx,
|
|
193
|
+
data: log.data,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
132
196
|
/**
|
|
133
197
|
* Canonical sort: by lowercased token, from, to, then amount. Addresses are
|
|
134
198
|
* pre-lowercased once to avoid redundant `.toLowerCase()` calls per comparison.
|
|
@@ -60,6 +60,7 @@ function assertNoBundlerRetention(params) {
|
|
|
60
60
|
return;
|
|
61
61
|
// Map keyed by (bundler, token) → structured entry. Avoids string parse-back.
|
|
62
62
|
const flow = new Map();
|
|
63
|
+
// biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
|
|
63
64
|
const recordFlow = (address, token, delta) => {
|
|
64
65
|
const key = `${address.toLowerCase()}:${token.toLowerCase()}`;
|
|
65
66
|
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,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.simulate = simulate;
|
|
4
|
+
const errors_js_1 = require("../errors.js");
|
|
4
5
|
const index_js_1 = require("./parsing/index.js");
|
|
5
6
|
const index_js_2 = require("./pipeline/index.js");
|
|
6
7
|
/**
|
|
@@ -8,18 +9,58 @@ const index_js_2 = require("./pipeline/index.js");
|
|
|
8
9
|
*
|
|
9
10
|
* Validates input → resolves authorizations into prepended approve txs → runs the bundle
|
|
10
11
|
* through Tenderly REST (primary) or `eth_simulateV1` (fallback) with a shared timeout
|
|
11
|
-
* budget → parses ERC20/WETH transfers from logs → asserts no funds are retained
|
|
12
|
-
* `bundler3` → returns the full result set. The caller reads whichever fields they need:
|
|
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
14
|
*
|
|
14
15
|
* - `transfers` + `tenderlyUrl` → user-facing preview.
|
|
15
16
|
* - `simulationTxs` + `transfers` → server-side verification before broadcast, then pass
|
|
16
17
|
* both to `screenAddresses` for compliance.
|
|
18
|
+
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed`,
|
|
19
|
+
* and Tenderly-only `assetChanges`). Aligned 1:1 with `simulationTxs[i]`.
|
|
20
|
+
* - `transfers[k].txIdx` → index into `simulationTxs` of the tx that emitted the
|
|
21
|
+
* underlying log; consumers map back via `simulationTxs[transfer.txIdx]`.
|
|
17
22
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
23
|
+
* @param config - Backend configuration: Tenderly credentials, per-chain RPC URLs, optional
|
|
24
|
+
* logger, and the overall timeout budget.
|
|
25
|
+
* @param params - Per-call simulation input.
|
|
26
|
+
* @param params.chainId - Chain id the bundle targets.
|
|
27
|
+
* @param params.transactions - The bundle's transactions, in execution order. All must share the
|
|
28
|
+
* same `from`.
|
|
29
|
+
* @param params.authorizations - Optional token authorizations resolved into prepended approve
|
|
30
|
+
* transactions before the main bundle runs.
|
|
31
|
+
* @param params.blockNumber - Optional pinned block number or `BlockTag`. Defaults to `latest`.
|
|
32
|
+
* @param options - Per-call simulation options.
|
|
33
|
+
* @param options.shareable - When `true`, persists the simulation in Tenderly and returns a
|
|
34
|
+
* shareable `tenderlyUrl`. Honored only on the Tenderly backend, not on the `eth_simulateV1`
|
|
35
|
+
* fallback. Defaults to `false`.
|
|
36
|
+
* @throws {SimulationValidationError} for invalid input (mixed senders, bad addresses,
|
|
37
|
+
* empty transactions, malformed authorizations).
|
|
38
|
+
* @throws {UnsupportedChainError} when the chain is not configured for any backend.
|
|
39
|
+
* @throws {SimulationRevertedError} when the bundle reverts on either backend.
|
|
40
|
+
* @throws {BlacklistViolationError} when the simulation leaves value retained by or
|
|
41
|
+
* drained from a `bundler3` address beyond the dust threshold.
|
|
42
|
+
* @throws {ExternalServiceError} (a) when both backends are unavailable within the
|
|
43
|
+
* timeout budget, or (b) when a backend returns a `calls` array whose length does
|
|
44
|
+
* not match the resolved `simulationTxs` — refusing to map transfers with mismatched
|
|
45
|
+
* per-tx output.
|
|
46
|
+
* @returns A {@link SimulationResult} carrying the resolved `simulationTxs`, per-tx
|
|
47
|
+
* `calls` (aligned 1:1 with `simulationTxs`), parsed `transfers` (each stamped with
|
|
48
|
+
* `txIdx`), and the optional `tenderlyUrl`.
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* import { simulate } from "@morpho-org/evm-simulation";
|
|
52
|
+
*
|
|
53
|
+
* const result = await simulate(
|
|
54
|
+
* { chains: new Map([[1, { simulateV1Url: rpcUrl }]]) },
|
|
55
|
+
* {
|
|
56
|
+
* chainId: 1,
|
|
57
|
+
* transactions: [{ from: user, to: vaultAddress, data: encodedCalldata, value: 0n }],
|
|
58
|
+
* },
|
|
59
|
+
* );
|
|
60
|
+
* // result satisfies SimulationResult
|
|
61
|
+
* ```
|
|
22
62
|
*/
|
|
63
|
+
// biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
|
|
23
64
|
async function simulate(config, params, options = {}) {
|
|
24
65
|
const { shareable = false } = options;
|
|
25
66
|
(0, index_js_2.validateInput)(params);
|
|
@@ -31,7 +72,10 @@ async function simulate(config, params, options = {}) {
|
|
|
31
72
|
blockNumber: params.blockNumber,
|
|
32
73
|
shareable,
|
|
33
74
|
});
|
|
34
|
-
|
|
75
|
+
if (result.calls.length !== simulationTxs.length) {
|
|
76
|
+
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`);
|
|
77
|
+
}
|
|
78
|
+
const transfers = (0, index_js_1.parseTransfers)(result.calls, config.logger);
|
|
35
79
|
(0, index_js_2.assertNoBundlerRetention)({
|
|
36
80
|
chainId: params.chainId,
|
|
37
81
|
transfers,
|
|
@@ -39,8 +83,8 @@ async function simulate(config, params, options = {}) {
|
|
|
39
83
|
});
|
|
40
84
|
return {
|
|
41
85
|
simulationTxs,
|
|
86
|
+
calls: result.calls,
|
|
42
87
|
transfers,
|
|
43
88
|
tenderlyUrl: result.tenderlyUrl,
|
|
44
|
-
assetChanges: result.rawAssetChanges,
|
|
45
89
|
};
|
|
46
90
|
}
|
package/lib/cjs/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/lib/esm/errors.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Base
|
|
3
|
-
*
|
|
4
|
-
* Consumers use try/catch + instanceof to handle specific error types.
|
|
2
|
+
* Base class for every error this package throws. Transport-agnostic — no HTTP status codes.
|
|
3
|
+
* Consumers pattern-match with `instanceof` on the concrete subclass.
|
|
5
4
|
*/
|
|
6
5
|
export declare abstract class SimulationPackageError extends Error {
|
|
6
|
+
/** Stable string discriminator for log aggregation and external mapping. */
|
|
7
7
|
abstract readonly code: string;
|
|
8
8
|
constructor(message: string, options?: ErrorOptions);
|
|
9
9
|
}
|
package/lib/esm/errors.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Base
|
|
3
|
-
*
|
|
4
|
-
* Consumers use try/catch + instanceof to handle specific error types.
|
|
2
|
+
* Base class for every error this package throws. Transport-agnostic — no HTTP status codes.
|
|
3
|
+
* Consumers pattern-match with `instanceof` on the concrete subclass.
|
|
5
4
|
*/
|
|
6
5
|
export class SimulationPackageError extends Error {
|
|
7
6
|
constructor(message, options) {
|
package/lib/esm/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
+
export { AddressScreeningError, BlacklistViolationError, ExternalServiceError, SimulationPackageError, SimulationRevertedError, SimulationValidationError, UnsupportedChainError, } from "./errors.js";
|
|
1
2
|
export { screenAddresses } from "./screen-addresses/index.js";
|
|
2
3
|
export { simulate } from "./simulate/index.js";
|
|
3
|
-
export type { ChainSimulationConfig, SimulateParams, SimulationAuthorization, SimulationConfig, SimulationLogger, SimulationResult, SimulationTransaction, TenderlyRestConfig, Transfer, } from "./types.js";
|
|
4
|
-
export { SimulationPackageError, SimulationRevertedError, BlacklistViolationError, AddressScreeningError, ExternalServiceError, SimulationValidationError, UnsupportedChainError, } from "./errors.js";
|
|
4
|
+
export type { ChainSimulationConfig, RawLog, SimulateParams, SimulationAuthorization, SimulationCall, SimulationConfig, SimulationLogger, SimulationResult, SimulationTransaction, TenderlyRestConfig, Transfer, } from "./types.js";
|
package/lib/esm/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Public fns (via feature-folder barrels)
|
|
2
|
+
// Errors (for instanceof checks by consumers)
|
|
3
|
+
export { AddressScreeningError, BlacklistViolationError, ExternalServiceError, SimulationPackageError, SimulationRevertedError, SimulationValidationError, UnsupportedChainError, } from "./errors.js";
|
|
2
4
|
export { screenAddresses } from "./screen-addresses/index.js";
|
|
3
5
|
export { simulate } from "./simulate/index.js";
|
|
4
|
-
// Errors (for instanceof checks by consumers)
|
|
5
|
-
export { SimulationPackageError, SimulationRevertedError, BlacklistViolationError, AddressScreeningError, ExternalServiceError, SimulationValidationError, UnsupportedChainError, } from "./errors.js";
|