@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
|
@@ -1,29 +1,45 @@
|
|
|
1
1
|
import type { SimulationLogger, SimulationTransaction, Transfer } from "../types.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Screens every address that appears in a simulated bundle (transfer senders and recipients,
|
|
4
|
+
* plus tx `from` / `to`) against the static {@link sanctionedAddresses} list and, when
|
|
5
|
+
* configured, the Chainalysis Entity API.
|
|
4
6
|
*
|
|
5
|
-
*
|
|
6
|
-
* - Transfer senders and recipients from simulation result
|
|
7
|
-
* - `from` and `to` of every tx in `simulationTxs` (catches native-value flows)
|
|
7
|
+
* Addresses are deduped and lowercased; the zero address is removed. Checks run in order:
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* **Fail-closed on
|
|
18
|
-
*
|
|
19
|
-
* The data already persisted server-side, so failing open at this point
|
|
20
|
-
* would let a pass slip through based on a half-completed check.
|
|
21
|
-
* **Fail-closed on schema drift**: if Chainalysis returns an unknown risk
|
|
22
|
-
* tier or a body that doesn't parse, the address is treated as Severe so a
|
|
23
|
-
* future stricter tier isn't silently downgraded.
|
|
9
|
+
* 1. **Static sanctioned list** — always on, no network. Throws on the first hit.
|
|
10
|
+
* 2. **Chainalysis Entity API** — one lookup per address, lookups parallelized. Only runs when
|
|
11
|
+
* `chainalysisApiKey` is provided.
|
|
12
|
+
* - **Fail-open on transport errors before registration** (network / timeout / non-ok POST):
|
|
13
|
+
* treat the address as clean and log a warn. A Chainalysis outage must not block all
|
|
14
|
+
* simulations.
|
|
15
|
+
* - **Fail-closed on partial registration**: if the registration POST succeeded but the
|
|
16
|
+
* lookup GET fails, treat the address as Severe.
|
|
17
|
+
* - **Fail-closed on schema drift**: an unknown risk tier or unparsed body is treated as
|
|
18
|
+
* Severe so a future stricter tier is not silently downgraded.
|
|
24
19
|
*
|
|
25
|
-
*
|
|
26
|
-
* `
|
|
20
|
+
* Intended to be called after {@link simulate}, passing the returned `simulationTxs` and
|
|
21
|
+
* `transfers`.
|
|
22
|
+
*
|
|
23
|
+
* @param params.simulationTxs - The resolved transactions returned by `simulate()`.
|
|
24
|
+
* @param params.transfers - The parsed transfers returned by `simulate()`.
|
|
25
|
+
* @param params.chainalysisApiKey - Optional Chainalysis token. When absent, only the static
|
|
26
|
+
* sanctioned list runs.
|
|
27
|
+
* @param params.signal - Optional `AbortSignal` propagated to every Chainalysis request.
|
|
28
|
+
* @param params.logger - Optional `SimulationLogger` for warn-level diagnostics.
|
|
29
|
+
* @returns Resolves to `void` on a clean screening; throws on any Severe hit.
|
|
30
|
+
* @throws {AddressScreeningError} when any address matches the static sanctioned list or
|
|
31
|
+
* resolves to Severe via Chainalysis.
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* import { screenAddresses, simulate } from "@morpho-org/evm-simulation";
|
|
35
|
+
*
|
|
36
|
+
* const result = await simulate(config, params);
|
|
37
|
+
* await screenAddresses({
|
|
38
|
+
* simulationTxs: result.simulationTxs,
|
|
39
|
+
* transfers: result.transfers,
|
|
40
|
+
* chainalysisApiKey: process.env.CHAINALYSIS_API_KEY,
|
|
41
|
+
* });
|
|
42
|
+
* ```
|
|
27
43
|
*/
|
|
28
44
|
export declare function screenAddresses(params: {
|
|
29
45
|
simulationTxs: SimulationTransaction[];
|
|
@@ -16,30 +16,46 @@ const chainalysisEntitySchema = z
|
|
|
16
16
|
})
|
|
17
17
|
.passthrough();
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
19
|
+
* Screens every address that appears in a simulated bundle (transfer senders and recipients,
|
|
20
|
+
* plus tx `from` / `to`) against the static {@link sanctionedAddresses} list and, when
|
|
21
|
+
* configured, the Chainalysis Entity API.
|
|
20
22
|
*
|
|
21
|
-
*
|
|
22
|
-
* - Transfer senders and recipients from simulation result
|
|
23
|
-
* - `from` and `to` of every tx in `simulationTxs` (catches native-value flows)
|
|
23
|
+
* Addresses are deduped and lowercased; the zero address is removed. Checks run in order:
|
|
24
24
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* **Fail-closed on
|
|
34
|
-
*
|
|
35
|
-
* The data already persisted server-side, so failing open at this point
|
|
36
|
-
* would let a pass slip through based on a half-completed check.
|
|
37
|
-
* **Fail-closed on schema drift**: if Chainalysis returns an unknown risk
|
|
38
|
-
* tier or a body that doesn't parse, the address is treated as Severe so a
|
|
39
|
-
* future stricter tier isn't silently downgraded.
|
|
25
|
+
* 1. **Static sanctioned list** — always on, no network. Throws on the first hit.
|
|
26
|
+
* 2. **Chainalysis Entity API** — one lookup per address, lookups parallelized. Only runs when
|
|
27
|
+
* `chainalysisApiKey` is provided.
|
|
28
|
+
* - **Fail-open on transport errors before registration** (network / timeout / non-ok POST):
|
|
29
|
+
* treat the address as clean and log a warn. A Chainalysis outage must not block all
|
|
30
|
+
* simulations.
|
|
31
|
+
* - **Fail-closed on partial registration**: if the registration POST succeeded but the
|
|
32
|
+
* lookup GET fails, treat the address as Severe.
|
|
33
|
+
* - **Fail-closed on schema drift**: an unknown risk tier or unparsed body is treated as
|
|
34
|
+
* Severe so a future stricter tier is not silently downgraded.
|
|
40
35
|
*
|
|
41
|
-
*
|
|
42
|
-
* `
|
|
36
|
+
* Intended to be called after {@link simulate}, passing the returned `simulationTxs` and
|
|
37
|
+
* `transfers`.
|
|
38
|
+
*
|
|
39
|
+
* @param params.simulationTxs - The resolved transactions returned by `simulate()`.
|
|
40
|
+
* @param params.transfers - The parsed transfers returned by `simulate()`.
|
|
41
|
+
* @param params.chainalysisApiKey - Optional Chainalysis token. When absent, only the static
|
|
42
|
+
* sanctioned list runs.
|
|
43
|
+
* @param params.signal - Optional `AbortSignal` propagated to every Chainalysis request.
|
|
44
|
+
* @param params.logger - Optional `SimulationLogger` for warn-level diagnostics.
|
|
45
|
+
* @returns Resolves to `void` on a clean screening; throws on any Severe hit.
|
|
46
|
+
* @throws {AddressScreeningError} when any address matches the static sanctioned list or
|
|
47
|
+
* resolves to Severe via Chainalysis.
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* import { screenAddresses, simulate } from "@morpho-org/evm-simulation";
|
|
51
|
+
*
|
|
52
|
+
* const result = await simulate(config, params);
|
|
53
|
+
* await screenAddresses({
|
|
54
|
+
* simulationTxs: result.simulationTxs,
|
|
55
|
+
* transfers: result.transfers,
|
|
56
|
+
* chainalysisApiKey: process.env.CHAINALYSIS_API_KEY,
|
|
57
|
+
* });
|
|
58
|
+
* ```
|
|
43
59
|
*/
|
|
44
60
|
export async function screenAddresses(params) {
|
|
45
61
|
const { simulationTxs, transfers, chainalysisApiKey, signal, logger } = params;
|
|
@@ -100,7 +116,10 @@ export async function screenAddresses(params) {
|
|
|
100
116
|
*
|
|
101
117
|
* A SINGLE deadline covers both the POST and the GET, keeping the per-address
|
|
102
118
|
* wall-time budget at `CHAINALYSIS_TIMEOUT_MS` (5 s) rather than doubling it.
|
|
119
|
+
*
|
|
120
|
+
* @internal
|
|
103
121
|
*/
|
|
122
|
+
// biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
|
|
104
123
|
async function screenWithChainalysis(address, apiKey, parentSignal) {
|
|
105
124
|
const headers = {
|
|
106
125
|
"Content-Type": "application/json",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createPublicClient, getAddress, http, maxUint256, } from "viem";
|
|
2
2
|
import { ExternalServiceError, SimulationRevertedError, SimulationValidationError, } from "../../errors.js";
|
|
3
3
|
/**
|
|
4
4
|
* Simulate transactions using eth_simulateV1 (viem simulateCalls).
|
|
@@ -51,17 +51,17 @@ export async function simulateV1(params) {
|
|
|
51
51
|
if (failedResult) {
|
|
52
52
|
throw new SimulationRevertedError(failedResult.error?.message ?? "Simulation failed", results);
|
|
53
53
|
}
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
return {
|
|
54
|
+
const rawCalls = results.map((r) => ({
|
|
55
|
+
logs: (r.logs ?? []).map((log) => ({
|
|
56
|
+
address: log.address,
|
|
57
|
+
topics: [...log.topics],
|
|
58
|
+
data: log.data ?? "0x",
|
|
59
|
+
})),
|
|
60
|
+
status: r.status === "success",
|
|
61
|
+
returnData: r.data ?? "0x",
|
|
62
|
+
gasUsed: r.gasUsed,
|
|
63
|
+
}));
|
|
64
|
+
return { calls: rawCalls };
|
|
65
65
|
}
|
|
66
66
|
catch (error) {
|
|
67
67
|
if (error instanceof SimulationRevertedError)
|
|
@@ -19,6 +19,7 @@ const tenderlyRawResponseSchema = z
|
|
|
19
19
|
transaction: z
|
|
20
20
|
.object({
|
|
21
21
|
error_message: z.string().optional(),
|
|
22
|
+
gas_used: z.number(),
|
|
22
23
|
transaction_info: z
|
|
23
24
|
.object({
|
|
24
25
|
logs: z
|
|
@@ -36,6 +37,11 @@ const tenderlyRawResponseSchema = z
|
|
|
36
37
|
.passthrough())
|
|
37
38
|
.optional(),
|
|
38
39
|
asset_changes: z.unknown().optional(),
|
|
40
|
+
call_trace: z
|
|
41
|
+
.object({
|
|
42
|
+
output: hexSchema,
|
|
43
|
+
})
|
|
44
|
+
.passthrough(),
|
|
39
45
|
})
|
|
40
46
|
.passthrough(),
|
|
41
47
|
})
|
|
@@ -98,6 +104,7 @@ function buildBaseUrl(config) {
|
|
|
98
104
|
return `${config.apiBaseUrl}/api/v1/account/${account}/project/${project}`;
|
|
99
105
|
}
|
|
100
106
|
/** Build the `TenderlySimulateRequest` body for one transaction. Shared by single + bundle. */
|
|
107
|
+
// biome-ignore lint/complexity/useMaxParams: TODO refactor to ≤2 params
|
|
101
108
|
function buildTxBody(tx, chainId, shareable, blockNumber) {
|
|
102
109
|
return {
|
|
103
110
|
network_id: String(chainId),
|
|
@@ -129,19 +136,16 @@ async function simulateSingle(params) {
|
|
|
129
136
|
throw new ExternalServiceError(`Tenderly REST API returned ${response.status}: ${response.statusText}`);
|
|
130
137
|
}
|
|
131
138
|
const data = tenderlyRawResponseSchema.parse(await response.json());
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
result.tenderlyUrl = undefined;
|
|
143
|
-
}
|
|
144
|
-
return result;
|
|
139
|
+
const call = parseTenderlyCall(data);
|
|
140
|
+
const tenderlyUrl = await resolveTenderlyUrl({
|
|
141
|
+
baseUrl,
|
|
142
|
+
config,
|
|
143
|
+
simulationId: data.simulation.id,
|
|
144
|
+
shareable,
|
|
145
|
+
signal,
|
|
146
|
+
logger,
|
|
147
|
+
});
|
|
148
|
+
return { calls: [call], tenderlyUrl };
|
|
145
149
|
}
|
|
146
150
|
async function simulateBundle(params) {
|
|
147
151
|
const { baseUrl, config, chainId, transactions, blockNumber, shareable, signal, logger, } = params;
|
|
@@ -162,39 +166,40 @@ async function simulateBundle(params) {
|
|
|
162
166
|
}
|
|
163
167
|
const data = tenderlyBundleRawResponseSchema.parse(await response.json());
|
|
164
168
|
const simulations = data.simulation_results;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
169
|
+
const calls = simulations.map((sim) => parseTenderlyCall(sim));
|
|
170
|
+
const lastSim = simulations[simulations.length - 1];
|
|
171
|
+
const tenderlyUrl = await resolveTenderlyUrl({
|
|
172
|
+
baseUrl,
|
|
173
|
+
config,
|
|
174
|
+
simulationId: lastSim.simulation.id,
|
|
175
|
+
shareable,
|
|
176
|
+
signal,
|
|
177
|
+
logger,
|
|
178
|
+
});
|
|
179
|
+
return { calls, tenderlyUrl };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Build the shareable Tenderly dashboard URL for the given simulation, if
|
|
183
|
+
* `shareable=true` and `/share` succeeds. Returns `undefined` otherwise.
|
|
184
|
+
*
|
|
185
|
+
* Mirrors the legacy "build optimistically, clear on failure" pattern: the
|
|
186
|
+
* URL is constructed up-front and cleared if `shareSimulation` returns false.
|
|
187
|
+
*/
|
|
188
|
+
async function resolveTenderlyUrl(params) {
|
|
189
|
+
const { baseUrl, config, simulationId, shareable, signal, logger } = params;
|
|
190
|
+
if (!shareable || !simulationId)
|
|
191
|
+
return undefined;
|
|
192
|
+
let tenderlyUrl = `https://dashboard.tenderly.co/shared/simulation/${simulationId}`;
|
|
193
|
+
const shared = await shareSimulation({
|
|
194
|
+
baseUrl,
|
|
195
|
+
config,
|
|
196
|
+
simulationId,
|
|
197
|
+
signal,
|
|
198
|
+
logger,
|
|
199
|
+
});
|
|
200
|
+
if (!shared)
|
|
201
|
+
tenderlyUrl = undefined;
|
|
202
|
+
return tenderlyUrl;
|
|
198
203
|
}
|
|
199
204
|
/**
|
|
200
205
|
* Try to make a simulation publicly shareable. Returns true iff Tenderly's
|
|
@@ -233,7 +238,13 @@ async function shareSimulation(params) {
|
|
|
233
238
|
return false;
|
|
234
239
|
}
|
|
235
240
|
}
|
|
236
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Parse one Tenderly simulation response into a `RawCall`. The caller
|
|
243
|
+
* (single-sim or bundle) assembles the `RawSimulationResult` around it.
|
|
244
|
+
*
|
|
245
|
+
* Throws `SimulationRevertedError` if `simulation.status !== true`.
|
|
246
|
+
*/
|
|
247
|
+
function parseTenderlyCall(data) {
|
|
237
248
|
const { simulation, transaction } = data;
|
|
238
249
|
if (simulation.status !== true) {
|
|
239
250
|
const errorMessage = transaction.error_message ||
|
|
@@ -255,12 +266,11 @@ function parseTenderlyResponse(data, shareable) {
|
|
|
255
266
|
});
|
|
256
267
|
}
|
|
257
268
|
}
|
|
258
|
-
const tenderlyUrl = shareable && simulation.id
|
|
259
|
-
? `https://dashboard.tenderly.co/shared/simulation/${simulation.id}`
|
|
260
|
-
: undefined;
|
|
261
269
|
return {
|
|
262
270
|
logs: rawLogs,
|
|
263
|
-
|
|
264
|
-
|
|
271
|
+
status: true,
|
|
272
|
+
returnData: transaction.transaction_info.call_trace.output,
|
|
273
|
+
gasUsed: BigInt(transaction.gas_used),
|
|
274
|
+
assetChanges: transaction.transaction_info.asset_changes,
|
|
265
275
|
};
|
|
266
276
|
}
|
|
@@ -1,27 +1,39 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RawCall, SimulationLogger, Transfer } from "../../types.js";
|
|
2
2
|
export declare const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
3
3
|
export declare const WITHDRAWAL_TOPIC = "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65";
|
|
4
4
|
export declare const DEPOSIT_TOPIC = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c";
|
|
5
5
|
/**
|
|
6
6
|
* Parse raw EVM logs into individual Transfer events.
|
|
7
7
|
*
|
|
8
|
+
* Iterates over per-tx `calls` and stamps each emitted `Transfer.txIdx`
|
|
9
|
+
* with the index of the originating call.
|
|
10
|
+
*
|
|
8
11
|
* **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
|
|
9
12
|
* `Deposit(to, amount)` / `Withdrawal(from, amount)`. WETH9 mint/burn Transfer
|
|
10
13
|
* events paired with their Deposit/Withdrawal are deduplicated. ERC721 and
|
|
11
14
|
* ERC1155 transfer events are **not** parsed — consumers with NFT flows will
|
|
12
15
|
* see an incomplete transfer list.
|
|
13
16
|
*
|
|
14
|
-
* **WETH9
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* **WETH9 dedup assumption — canonical atomic emission.** Dedup is scoped to
|
|
18
|
+
* the same tx: a zero-address `Transfer` is suppressed only if its paired
|
|
19
|
+
* `Deposit`/`Withdrawal` appears in the same `calls[txIdx].logs` slice. This
|
|
20
|
+
* is correct for canonical WETH9, which always emits the `Deposit`/
|
|
21
|
+
* `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
|
|
22
|
+
* atomically inside a single call frame. A **non-canonical wrapped-native**
|
|
23
|
+
* that splits these emissions across two txs in the same bundle would leave
|
|
24
|
+
* a phantom zero-address `Transfer` in the parsed output, which can be summed
|
|
25
|
+
* by `assertNoBundlerRetention` and produce a false `BlacklistViolationError`.
|
|
26
|
+
* When a zero-address `Transfer` misses same-tx dedup *and* its token has
|
|
27
|
+
* emitted a `Deposit`/`Withdrawal` somewhere else in the bundle (i.e. it
|
|
28
|
+
* looks wnative-shaped), the parser emits a `warn` so the assumption break
|
|
29
|
+
* is observable before it reaches retention. None of `morpho-sdk`'s currently
|
|
30
|
+
* supported chains require cross-tx dedup; the assumption is rechecked when
|
|
31
|
+
* onboarding a new chain (see `evm-simulation/CLAUDE.md`).
|
|
18
32
|
*
|
|
19
33
|
* **Failure mode:** on a per-log parse failure (malformed topic length,
|
|
20
34
|
* non-hex data), the log is skipped and a `warn` is emitted via the logger.
|
|
21
|
-
* The caller receives a possibly-incomplete transfer list. Upstream sources
|
|
22
|
-
* (e.g., Tenderly response schemas) should validate log shape before calling
|
|
23
|
-
* here to avoid silent drops.
|
|
24
35
|
*
|
|
25
36
|
* Output is sorted canonically by token, from, to, amount for determinism.
|
|
37
|
+
* `txIdx` is attached but does not influence sort order.
|
|
26
38
|
*/
|
|
27
|
-
export declare function parseTransfers(
|
|
39
|
+
export declare function parseTransfers(calls: readonly RawCall[], logger?: SimulationLogger): Transfer[];
|