@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
|
@@ -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,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.simulate = simulate;
|
|
4
|
+
const blue_sdk_1 = require("@morpho-org/blue-sdk");
|
|
4
5
|
const errors_js_1 = require("../errors.js");
|
|
5
6
|
const index_js_1 = require("./parsing/index.js");
|
|
6
7
|
const index_js_2 = require("./pipeline/index.js");
|
|
@@ -9,8 +10,11 @@ const index_js_2 = require("./pipeline/index.js");
|
|
|
9
10
|
*
|
|
10
11
|
* Validates input → resolves authorizations into prepended approve txs → runs the bundle
|
|
11
12
|
* through Tenderly RPC (primary) or `eth_simulateV1` (fallback) with a shared timeout
|
|
12
|
-
* budget → parses ERC20
|
|
13
|
-
*
|
|
13
|
+
* budget → parses ERC20 transfers and WETH9 events from per-tx logs, restricting WETH9
|
|
14
|
+
* events to the registered wrapped-native token, rejecting them on known tokenless chains,
|
|
15
|
+
* and retaining signature-based parsing for unknown chains → asserts no funds are retained
|
|
16
|
+
* by `bundler3` or the standalone `bundles` periphery contracts → returns the full result
|
|
17
|
+
* set. The caller reads whichever fields they need:
|
|
14
18
|
*
|
|
15
19
|
* - `transfers` → user-facing preview / server-side verification.
|
|
16
20
|
* - `simulationTxs` + `transfers` → server-side verification before broadcast.
|
|
@@ -35,8 +39,9 @@ const index_js_2 = require("./pipeline/index.js");
|
|
|
35
39
|
* empty transactions, malformed authorizations).
|
|
36
40
|
* @throws {UnsupportedChainError} when the chain is not configured for any backend.
|
|
37
41
|
* @throws {SimulationRevertedError} when the bundle reverts on either backend.
|
|
38
|
-
* @throws {BlacklistViolationError} when the simulation leaves value retained
|
|
39
|
-
* a `bundler3` address
|
|
42
|
+
* @throws {BlacklistViolationError} when the simulation leaves value retained beyond
|
|
43
|
+
* the dust threshold by a restricted `bundler3` address or a `bundles` periphery
|
|
44
|
+
* contract (VaultExitBundlesV1, VaultBundlesV1, BlueBundlesV1). Never bypassable.
|
|
40
45
|
* @throws {ExternalServiceError} (a) when both backends are unavailable within the
|
|
41
46
|
* timeout budget, or (b) when a backend returns a `calls` array whose length does
|
|
42
47
|
* not match the resolved `simulationTxs` — refusing to map transfers with mismatched
|
|
@@ -67,17 +72,22 @@ const index_js_2 = require("./pipeline/index.js");
|
|
|
67
72
|
*/
|
|
68
73
|
async function simulate(config, params) {
|
|
69
74
|
(0, index_js_2.validateInput)(params);
|
|
75
|
+
const wNative = (0, blue_sdk_1._try)(() => (0, blue_sdk_1.getChainAddresses)(params.chainId).wNative ?? null, blue_sdk_1.UnsupportedChainIdError);
|
|
70
76
|
const simulationTxs = (0, index_js_2.buildSimulationTxs)(params);
|
|
71
77
|
const result = await (0, index_js_2.executeSimulation)({
|
|
72
78
|
config,
|
|
73
79
|
chainId: params.chainId,
|
|
74
80
|
transactions: simulationTxs,
|
|
75
81
|
blockNumber: params.blockNumber,
|
|
82
|
+
wNative,
|
|
76
83
|
});
|
|
77
84
|
if (result.calls.length !== simulationTxs.length) {
|
|
78
85
|
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`);
|
|
79
86
|
}
|
|
80
|
-
const transfers = (0, index_js_1.parseTransfers)(result.calls,
|
|
87
|
+
const transfers = (0, index_js_1.parseTransfers)(result.calls, {
|
|
88
|
+
wNative,
|
|
89
|
+
logger: config.logger,
|
|
90
|
+
});
|
|
81
91
|
(0, index_js_2.assertNoBundlerRetention)({
|
|
82
92
|
chainId: params.chainId,
|
|
83
93
|
transfers,
|
package/lib/esm/errors.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ interface RetainedAsset {
|
|
|
19
19
|
token: string | undefined;
|
|
20
20
|
netRetained: string;
|
|
21
21
|
}
|
|
22
|
-
/** Funds would flow to bundler3 contract addresses. Never bypassable. */
|
|
22
|
+
/** Funds would flow to restricted bundler3 or bundles contract addresses. Never bypassable. */
|
|
23
23
|
export declare class BlacklistViolationError extends SimulationPackageError {
|
|
24
24
|
readonly assetChanges?: RetainedAsset[] | undefined;
|
|
25
25
|
readonly code = "BLACKLIST_ERROR";
|
package/lib/esm/errors.js
CHANGED
|
@@ -19,7 +19,7 @@ export class SimulationRevertedError extends SimulationPackageError {
|
|
|
19
19
|
this.details = details;
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
-
/** Funds would flow to bundler3 contract addresses. Never bypassable. */
|
|
22
|
+
/** Funds would flow to restricted bundler3 or bundles contract addresses. Never bypassable. */
|
|
23
23
|
export class BlacklistViolationError extends SimulationPackageError {
|
|
24
24
|
assetChanges;
|
|
25
25
|
code = "BLACKLIST_ERROR";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Address } from "viem";
|
|
1
|
+
import { type Address, type Hex } from "viem";
|
|
2
2
|
import type { AccountAssetChanges } from "../types.js";
|
|
3
3
|
/** A single signed contribution to one account's balance for one token. */
|
|
4
4
|
export interface AssetChangeEntry {
|
|
@@ -8,6 +8,21 @@ export interface AssetChangeEntry {
|
|
|
8
8
|
symbol?: string;
|
|
9
9
|
decimals?: number;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Collapse the native-ETH sentinel to viem's lowercase `ethAddress`, checksumming
|
|
13
|
+
* any real token address. `eth_simulateV1` synthesizes native moves from the
|
|
14
|
+
* sentinel `0xeee…eee`, and Tenderly may echo it — in checksummed or any other
|
|
15
|
+
* case — as an asset change's `contractAddress`. Native ETH is keyed by the exact
|
|
16
|
+
* `ethAddress` constant here and in the bundler-retention guard, so a checksummed
|
|
17
|
+
* sentinel would land on a separate map key and silently escape retention checks.
|
|
18
|
+
* This is the single source of truth for token normalization shared by the
|
|
19
|
+
* transfer-log parser and the Tenderly asset-change mapper.
|
|
20
|
+
*
|
|
21
|
+
* @param address - Token address emitting a transfer or carried by an asset change.
|
|
22
|
+
* @returns `ethAddress` for the native sentinel, else the checksummed address.
|
|
23
|
+
* @internal
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeAssetToken(address: Hex): Address;
|
|
11
26
|
/**
|
|
12
27
|
* Net signed per-(account, token) contributions into balance changes grouped by
|
|
13
28
|
* account. Accounts and their `changes` are sorted by address for deterministic
|
|
@@ -1,4 +1,23 @@
|
|
|
1
|
-
import { getAddress } from "viem";
|
|
1
|
+
import { ethAddress, getAddress } from "viem";
|
|
2
|
+
/**
|
|
3
|
+
* Collapse the native-ETH sentinel to viem's lowercase `ethAddress`, checksumming
|
|
4
|
+
* any real token address. `eth_simulateV1` synthesizes native moves from the
|
|
5
|
+
* sentinel `0xeee…eee`, and Tenderly may echo it — in checksummed or any other
|
|
6
|
+
* case — as an asset change's `contractAddress`. Native ETH is keyed by the exact
|
|
7
|
+
* `ethAddress` constant here and in the bundler-retention guard, so a checksummed
|
|
8
|
+
* sentinel would land on a separate map key and silently escape retention checks.
|
|
9
|
+
* This is the single source of truth for token normalization shared by the
|
|
10
|
+
* transfer-log parser and the Tenderly asset-change mapper.
|
|
11
|
+
*
|
|
12
|
+
* @param address - Token address emitting a transfer or carried by an asset change.
|
|
13
|
+
* @returns `ethAddress` for the native sentinel, else the checksummed address.
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeAssetToken(address) {
|
|
17
|
+
return address.toLowerCase() === ethAddress
|
|
18
|
+
? ethAddress
|
|
19
|
+
: getAddress(address);
|
|
20
|
+
}
|
|
2
21
|
// Locale-independent byte-order compare on lowercased hex, matching `sortTransfers`.
|
|
3
22
|
const compareAddress = (a, b) => {
|
|
4
23
|
const al = a.toLowerCase();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type BlockTag } from "viem";
|
|
1
|
+
import { type Address, type BlockTag } from "viem";
|
|
2
2
|
import type { RawSimulationResult, SimulationTransaction } from "../../types.js";
|
|
3
3
|
/**
|
|
4
4
|
* Simulate transactions using eth_simulateV1 (viem simulateCalls).
|
|
@@ -19,5 +19,6 @@ export declare function simulateV1(params: {
|
|
|
19
19
|
chainId: number;
|
|
20
20
|
transactions: SimulationTransaction[];
|
|
21
21
|
blockNumber?: bigint | BlockTag;
|
|
22
|
+
wNative?: Address | null;
|
|
22
23
|
signal?: AbortSignal;
|
|
23
24
|
}): Promise<RawSimulationResult>;
|
|
@@ -17,7 +17,7 @@ import { parseTransfers } from "../parsing/index.js";
|
|
|
17
17
|
* `traceTransfers` already logs it, so adding it would double-count.
|
|
18
18
|
*/
|
|
19
19
|
export async function simulateV1(params) {
|
|
20
|
-
const { rpcUrl, transactions, blockNumber, signal } = params;
|
|
20
|
+
const { rpcUrl, transactions, blockNumber, wNative, signal } = params;
|
|
21
21
|
const client = createPublicClient({
|
|
22
22
|
transport: http(rpcUrl, {
|
|
23
23
|
fetchOptions: signal ? { signal } : undefined,
|
|
@@ -81,7 +81,7 @@ export async function simulateV1(params) {
|
|
|
81
81
|
}));
|
|
82
82
|
return {
|
|
83
83
|
calls: rawCalls,
|
|
84
|
-
assetChanges: toAssetChanges(parseTransfers(rawCalls)),
|
|
84
|
+
assetChanges: toAssetChanges(parseTransfers(rawCalls, { wNative })),
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
catch (error) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { ethAddress,
|
|
1
|
+
import { ethAddress, isAddress, isHex, maxUint256, numberToHex, } from "viem";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { ExternalServiceError, SimulationRevertedError, SimulationValidationError, } from "../../errors.js";
|
|
4
|
-
import { groupAssetChanges } from "../asset-changes.js";
|
|
4
|
+
import { groupAssetChanges, normalizeAssetToken, } from "../asset-changes.js";
|
|
5
5
|
const addressSchema = z.custom((val) => typeof val === "string" && isAddress(val));
|
|
6
6
|
const hexSchema = z.custom((val) => typeof val === "string" && isHex(val));
|
|
7
7
|
const traceFrameSchema = z
|
|
@@ -206,7 +206,7 @@ function toAssetChanges(results) {
|
|
|
206
206
|
for (const change of result.assetChanges ?? []) {
|
|
207
207
|
const amount = BigInt(change.rawAmount);
|
|
208
208
|
const token = change.assetInfo.contractAddress
|
|
209
|
-
?
|
|
209
|
+
? normalizeAssetToken(change.assetInfo.contractAddress)
|
|
210
210
|
: ethAddress;
|
|
211
211
|
const { symbol, decimals } = change.assetInfo;
|
|
212
212
|
if (change.to)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Address } from "viem";
|
|
1
2
|
import type { RawCall, SimulationLogger, Transfer } from "../../types.js";
|
|
2
3
|
export declare const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
3
4
|
export declare const WITHDRAWAL_TOPIC = "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65";
|
|
@@ -9,10 +10,13 @@ export declare const DEPOSIT_TOPIC = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d
|
|
|
9
10
|
* with the index of the originating call.
|
|
10
11
|
*
|
|
11
12
|
* **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
|
|
12
|
-
* `Deposit(to, amount)` / `Withdrawal(from, amount)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* `Deposit(to, amount)` / `Withdrawal(from, amount)` from the registered
|
|
14
|
+
* wrapped-native token. Known chains without a wrapped-native token ignore
|
|
15
|
+
* WETH9 events and skip wrapped-native deduplication; chains without registry
|
|
16
|
+
* metadata retain legacy signature-based parsing. On enabled paths, paired
|
|
17
|
+
* mint/burn Transfer events are deduplicated. ERC721 and ERC1155 transfer
|
|
18
|
+
* events are **not** parsed — consumers with NFT flows will see an incomplete
|
|
19
|
+
* transfer list.
|
|
16
20
|
*
|
|
17
21
|
* **Native ETH (`eth_simulateV1` + `traceTransfers`).** When the backend runs
|
|
18
22
|
* `eth_simulateV1` with `traceTransfers` enabled, native-ETH moves — including
|
|
@@ -23,26 +27,36 @@ export declare const DEPOSIT_TOPIC = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d
|
|
|
23
27
|
* backends. Tenderly does not emit these synthetic logs (it derives native ETH
|
|
24
28
|
* separately), so this path is inert there.
|
|
25
29
|
*
|
|
26
|
-
* **WETH9 dedup assumption — canonical atomic emission.** Dedup
|
|
27
|
-
* the same tx: a zero-address
|
|
28
|
-
* `
|
|
29
|
-
* is correct for canonical WETH9,
|
|
30
|
+
* **WETH9 dedup assumption — canonical atomic emission.** Dedup for the
|
|
31
|
+
* registered wrapped-native token is scoped to the same tx: a zero-address
|
|
32
|
+
* `Transfer` is suppressed only if its paired `Deposit`/`Withdrawal` appears
|
|
33
|
+
* in the same `calls[txIdx].logs` slice. This is correct for canonical WETH9,
|
|
34
|
+
* which always emits the `Deposit`/
|
|
30
35
|
* `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
|
|
31
36
|
* atomically inside a single call frame. A **non-canonical wrapped-native**
|
|
32
37
|
* that splits these emissions across two txs in the same bundle would leave
|
|
33
38
|
* a phantom zero-address `Transfer` in the parsed output, which can be summed
|
|
34
39
|
* by `assertNoBundlerRetention` and produce a false `BlacklistViolationError`.
|
|
35
|
-
* When
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* onboarding a new chain (see `evm-simulation/CLAUDE.md`).
|
|
40
|
+
* When its zero-address `Transfer` misses same-tx dedup, the parser emits a
|
|
41
|
+
* `warn` so the assumption break is observable before it reaches retention.
|
|
42
|
+
* None of `morpho-sdk`'s currently supported chains require cross-tx dedup;
|
|
43
|
+
* the assumption is rechecked when onboarding a new chain (see
|
|
44
|
+
* `evm-simulation/CLAUDE.md`).
|
|
41
45
|
*
|
|
42
46
|
* **Failure mode:** on a per-log parse failure (malformed topic length,
|
|
43
47
|
* non-hex data), the log is skipped and a `warn` is emitted via the logger.
|
|
44
48
|
*
|
|
45
49
|
* Output is sorted canonically by token, from, to, amount for determinism.
|
|
46
50
|
* `txIdx` is attached but does not influence sort order.
|
|
51
|
+
*
|
|
52
|
+
* @param calls - Per-transaction call results to parse.
|
|
53
|
+
* @param options - Wrapped-native metadata and optional logger. Pass `null` for
|
|
54
|
+
* a known chain without a wrapped-native token; omit it for legacy parsing on
|
|
55
|
+
* an unknown chain.
|
|
56
|
+
* @returns Canonically sorted transfers with their originating transaction index.
|
|
57
|
+
* @internal
|
|
47
58
|
*/
|
|
48
|
-
export declare function parseTransfers(calls: readonly RawCall[],
|
|
59
|
+
export declare function parseTransfers(calls: readonly RawCall[], options?: {
|
|
60
|
+
readonly wNative?: Address | null;
|
|
61
|
+
readonly logger?: SimulationLogger;
|
|
62
|
+
}): Transfer[];
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getAddress, isAddressEqual, zeroAddress, zeroHash, } from "viem";
|
|
2
|
+
import { normalizeAssetToken } from "../asset-changes.js";
|
|
2
3
|
// keccak256("Transfer(address,address,uint256)") — ERC-20 transfer event
|
|
3
4
|
export const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
4
5
|
// keccak256("Withdrawal(address,uint256)") — WETH9 unwrap event
|
|
@@ -14,10 +15,13 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
|
|
|
14
15
|
* with the index of the originating call.
|
|
15
16
|
*
|
|
16
17
|
* **Supported event types:** ERC20 `Transfer(from, to, amount)` and WETH9
|
|
17
|
-
* `Deposit(to, amount)` / `Withdrawal(from, amount)
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* `Deposit(to, amount)` / `Withdrawal(from, amount)` from the registered
|
|
19
|
+
* wrapped-native token. Known chains without a wrapped-native token ignore
|
|
20
|
+
* WETH9 events and skip wrapped-native deduplication; chains without registry
|
|
21
|
+
* metadata retain legacy signature-based parsing. On enabled paths, paired
|
|
22
|
+
* mint/burn Transfer events are deduplicated. ERC721 and ERC1155 transfer
|
|
23
|
+
* events are **not** parsed — consumers with NFT flows will see an incomplete
|
|
24
|
+
* transfer list.
|
|
21
25
|
*
|
|
22
26
|
* **Native ETH (`eth_simulateV1` + `traceTransfers`).** When the backend runs
|
|
23
27
|
* `eth_simulateV1` with `traceTransfers` enabled, native-ETH moves — including
|
|
@@ -28,35 +32,41 @@ const UINT256_HEX_LENGTH = 66; // "0x" + 32 bytes
|
|
|
28
32
|
* backends. Tenderly does not emit these synthetic logs (it derives native ETH
|
|
29
33
|
* separately), so this path is inert there.
|
|
30
34
|
*
|
|
31
|
-
* **WETH9 dedup assumption — canonical atomic emission.** Dedup
|
|
32
|
-
* the same tx: a zero-address
|
|
33
|
-
* `
|
|
34
|
-
* is correct for canonical WETH9,
|
|
35
|
+
* **WETH9 dedup assumption — canonical atomic emission.** Dedup for the
|
|
36
|
+
* registered wrapped-native token is scoped to the same tx: a zero-address
|
|
37
|
+
* `Transfer` is suppressed only if its paired `Deposit`/`Withdrawal` appears
|
|
38
|
+
* in the same `calls[txIdx].logs` slice. This is correct for canonical WETH9,
|
|
39
|
+
* which always emits the `Deposit`/
|
|
35
40
|
* `Withdrawal` and the matching `Transfer(0x0, …)` / `Transfer(…, 0x0)`
|
|
36
41
|
* atomically inside a single call frame. A **non-canonical wrapped-native**
|
|
37
42
|
* that splits these emissions across two txs in the same bundle would leave
|
|
38
43
|
* a phantom zero-address `Transfer` in the parsed output, which can be summed
|
|
39
44
|
* by `assertNoBundlerRetention` and produce a false `BlacklistViolationError`.
|
|
40
|
-
* When
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* onboarding a new chain (see `evm-simulation/CLAUDE.md`).
|
|
45
|
+
* When its zero-address `Transfer` misses same-tx dedup, the parser emits a
|
|
46
|
+
* `warn` so the assumption break is observable before it reaches retention.
|
|
47
|
+
* None of `morpho-sdk`'s currently supported chains require cross-tx dedup;
|
|
48
|
+
* the assumption is rechecked when onboarding a new chain (see
|
|
49
|
+
* `evm-simulation/CLAUDE.md`).
|
|
46
50
|
*
|
|
47
51
|
* **Failure mode:** on a per-log parse failure (malformed topic length,
|
|
48
52
|
* non-hex data), the log is skipped and a `warn` is emitted via the logger.
|
|
49
53
|
*
|
|
50
54
|
* Output is sorted canonically by token, from, to, amount for determinism.
|
|
51
55
|
* `txIdx` is attached but does not influence sort order.
|
|
56
|
+
*
|
|
57
|
+
* @param calls - Per-transaction call results to parse.
|
|
58
|
+
* @param options - Wrapped-native metadata and optional logger. Pass `null` for
|
|
59
|
+
* a known chain without a wrapped-native token; omit it for legacy parsing on
|
|
60
|
+
* an unknown chain.
|
|
61
|
+
* @returns Canonically sorted transfers with their originating transaction index.
|
|
62
|
+
* @internal
|
|
52
63
|
*/
|
|
53
|
-
export function parseTransfers(calls,
|
|
64
|
+
export function parseTransfers(calls, options = {}) {
|
|
54
65
|
const transfers = [];
|
|
55
|
-
|
|
56
|
-
// wnative-shaped. If a zero-address `Transfer` for one of these tokens
|
|
57
|
-
// misses same-tx dedup, the contract is likely emitting non-canonically
|
|
58
|
-
// (split across txs) and a phantom transfer would leak into retention.
|
|
66
|
+
const { logger, wNative } = options;
|
|
59
67
|
const wnativeShapedTokens = collectWnativeShapedTokens(calls);
|
|
68
|
+
const acceptsWnativeEvent = (address) => wNative === undefined ||
|
|
69
|
+
(wNative !== null && isAddressEqual(address, wNative));
|
|
60
70
|
for (let txIdx = 0; txIdx < calls.length; txIdx++) {
|
|
61
71
|
const logs = calls[txIdx].logs;
|
|
62
72
|
for (const log of logs) {
|
|
@@ -66,6 +76,8 @@ export function parseTransfers(calls, logger) {
|
|
|
66
76
|
continue;
|
|
67
77
|
switch (topic0) {
|
|
68
78
|
case WITHDRAWAL_TOPIC: {
|
|
79
|
+
if (!acceptsWnativeEvent(log.address))
|
|
80
|
+
continue;
|
|
69
81
|
const fromTopic = log.topics[1];
|
|
70
82
|
if (!isTopicHex(fromTopic) || !isUint256Hex(log.data)) {
|
|
71
83
|
warnMalformed(logger, log, "WETH9 Withdrawal: bad topic[1] or data length");
|
|
@@ -81,6 +93,8 @@ export function parseTransfers(calls, logger) {
|
|
|
81
93
|
continue;
|
|
82
94
|
}
|
|
83
95
|
case DEPOSIT_TOPIC: {
|
|
96
|
+
if (!acceptsWnativeEvent(log.address))
|
|
97
|
+
continue;
|
|
84
98
|
const toTopic = log.topics[1];
|
|
85
99
|
if (!isTopicHex(toTopic) || !isUint256Hex(log.data)) {
|
|
86
100
|
warnMalformed(logger, log, "WETH9 Deposit: bad topic[1] or data length");
|
|
@@ -110,34 +124,36 @@ export function parseTransfers(calls, logger) {
|
|
|
110
124
|
}
|
|
111
125
|
// WETH9 unwrap dedup: Transfer to zero paired with a Withdrawal of
|
|
112
126
|
// equal amount in the SAME tx.
|
|
113
|
-
if (toTopic === zeroHash) {
|
|
127
|
+
if (toTopic === zeroHash && acceptsWnativeEvent(log.address)) {
|
|
114
128
|
const paired = logs.some((other) => other.topics[0] === WITHDRAWAL_TOPIC &&
|
|
115
|
-
other.address
|
|
129
|
+
isAddressEqual(other.address, log.address) &&
|
|
116
130
|
other.data === log.data &&
|
|
117
131
|
other.topics.length === 2 &&
|
|
118
132
|
other.topics[1] === fromTopic);
|
|
119
133
|
if (paired)
|
|
120
134
|
continue;
|
|
121
|
-
if (
|
|
135
|
+
if (wNative !== undefined ||
|
|
136
|
+
wnativeShapedTokens.has(log.address.toLowerCase())) {
|
|
122
137
|
warnNonCanonicalWnative(logger, log, "burn", txIdx);
|
|
123
138
|
}
|
|
124
139
|
}
|
|
125
140
|
// WETH9 wrap dedup: Transfer from zero paired with a Deposit of
|
|
126
141
|
// equal amount in the SAME tx.
|
|
127
|
-
if (fromTopic === zeroHash) {
|
|
142
|
+
if (fromTopic === zeroHash && acceptsWnativeEvent(log.address)) {
|
|
128
143
|
const paired = logs.some((other) => other.topics[0] === DEPOSIT_TOPIC &&
|
|
129
|
-
other.address
|
|
144
|
+
isAddressEqual(other.address, log.address) &&
|
|
130
145
|
other.data === log.data &&
|
|
131
146
|
other.topics.length === 2 &&
|
|
132
147
|
other.topics[1] === toTopic);
|
|
133
148
|
if (paired)
|
|
134
149
|
continue;
|
|
135
|
-
if (
|
|
150
|
+
if (wNative !== undefined ||
|
|
151
|
+
wnativeShapedTokens.has(log.address.toLowerCase())) {
|
|
136
152
|
warnNonCanonicalWnative(logger, log, "mint", txIdx);
|
|
137
153
|
}
|
|
138
154
|
}
|
|
139
155
|
transfers.push({
|
|
140
|
-
token:
|
|
156
|
+
token: normalizeAssetToken(log.address),
|
|
141
157
|
from: getAddress(`0x${fromTopic.slice(26)}`),
|
|
142
158
|
to: getAddress(`0x${toTopic.slice(26)}`),
|
|
143
159
|
amount: BigInt(log.data),
|
|
@@ -158,21 +174,6 @@ export function parseTransfers(calls, logger) {
|
|
|
158
174
|
}
|
|
159
175
|
return sortTransfers(transfers);
|
|
160
176
|
}
|
|
161
|
-
/**
|
|
162
|
-
* Normalize a `Transfer` log's emitting address into a transfer `token`.
|
|
163
|
-
*
|
|
164
|
-
* `eth_simulateV1` with `traceTransfers` enabled synthesizes native-ETH moves
|
|
165
|
-
* (including internal calls) as `Transfer` events emitted from the native
|
|
166
|
-
* sentinel `0xeee…eee`. The rest of the SDK keys native ETH by viem's
|
|
167
|
-
* `ethAddress` constant, so we collapse the sentinel to that exact value rather
|
|
168
|
-
* than the checksummed form `getAddress` would produce — keeping native deltas
|
|
169
|
-
* on a single map key across backends. Real ERC20 tokens are checksummed.
|
|
170
|
-
*/
|
|
171
|
-
function normalizeTransferToken(address) {
|
|
172
|
-
return address.toLowerCase() === ethAddress
|
|
173
|
-
? ethAddress
|
|
174
|
-
: getAddress(address);
|
|
175
|
-
}
|
|
176
177
|
function isTopicHex(value) {
|
|
177
178
|
return typeof value === "string" && value.length === TOPIC_HEX_LENGTH;
|
|
178
179
|
}
|
|
@@ -187,24 +188,18 @@ function warnMalformed(logger, log, reason) {
|
|
|
187
188
|
reason,
|
|
188
189
|
});
|
|
189
190
|
}
|
|
190
|
-
/**
|
|
191
|
-
* Collect token addresses (lowercased) that emit `Deposit` or `Withdrawal`
|
|
192
|
-
* anywhere in the bundle. Used to detect when a zero-address `Transfer` that
|
|
193
|
-
* misses same-tx dedup is on a wnative-shaped contract — a strong signal
|
|
194
|
-
* that the contract is emitting non-canonically and a phantom transfer is
|
|
195
|
-
* about to leak into bundler retention.
|
|
196
|
-
*/
|
|
191
|
+
/** Collect contracts that emit WETH9-shaped events anywhere in the bundle. */
|
|
197
192
|
function collectWnativeShapedTokens(calls) {
|
|
198
|
-
const
|
|
199
|
-
for (const
|
|
200
|
-
for (const
|
|
201
|
-
const
|
|
202
|
-
if (
|
|
203
|
-
|
|
193
|
+
const tokens = new Set();
|
|
194
|
+
for (const call of calls) {
|
|
195
|
+
for (const log of call.logs) {
|
|
196
|
+
const topic0 = log.topics[0];
|
|
197
|
+
if (topic0 === WITHDRAWAL_TOPIC || topic0 === DEPOSIT_TOPIC) {
|
|
198
|
+
tokens.add(log.address.toLowerCase());
|
|
204
199
|
}
|
|
205
200
|
}
|
|
206
201
|
}
|
|
207
|
-
return
|
|
202
|
+
return tokens;
|
|
208
203
|
}
|
|
209
204
|
// biome-ignore lint/complexity/useMaxParams: structured warn with full context
|
|
210
205
|
function warnNonCanonicalWnative(logger, log, kind, txIdx) {
|
|
@@ -6,15 +6,17 @@ interface AssertNoBundlerRetentionParams {
|
|
|
6
6
|
logger?: SimulationLogger;
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
|
-
* Assert that no value is retained by
|
|
9
|
+
* Assert that no value is retained by restricted contract addresses — the
|
|
10
|
+
* `bundler3` executor and adapters, plus the standalone `bundles` periphery
|
|
11
|
+
* contracts (`VaultExitBundlesV1`, `VaultBundlesV1`, `BlueBundlesV1`).
|
|
10
12
|
*
|
|
11
|
-
* Uses net flow (inbound minus outbound) per (
|
|
12
|
-
*
|
|
13
|
-
* vault), so gross inbound would fire false positives. We only block
|
|
14
|
-
* net flow above `DUST_THRESHOLD` — value stuck in a
|
|
15
|
-
* net flow means the simulated bundle sweeps a pre-existing or
|
|
16
|
-
*
|
|
17
|
-
* so it is warned instead of raising a blacklist violation.
|
|
13
|
+
* Uses net flow (inbound minus outbound) per (restricted address, token) pair.
|
|
14
|
+
* These contracts legitimately receive tokens as an intermediary (user →
|
|
15
|
+
* bundler → vault), so gross inbound would fire false positives. We only block
|
|
16
|
+
* positive net flow above `DUST_THRESHOLD` — value stuck in a restricted
|
|
17
|
+
* address. Negative net flow means the simulated bundle sweeps a pre-existing or
|
|
18
|
+
* state-overridden balance. That is useful telemetry, but not current-bundle
|
|
19
|
+
* retention, so it is warned instead of raising a blacklist violation.
|
|
18
20
|
*
|
|
19
21
|
* **Two flow sources, one per asset class.** ERC20 / WETH9 retention is read
|
|
20
22
|
* from parsed `transfers` (log-derived, identical across backends). Native ETH
|
|
@@ -30,6 +32,22 @@ interface AssertNoBundlerRetentionParams {
|
|
|
30
32
|
* native move exists both as a synthetic `ethAddress` transfer log *and* in the
|
|
31
33
|
* `assetChanges` derived from it — native transfer logs are only used as a
|
|
32
34
|
* fallback for bundler addresses that carry no native `assetChanges` entry.
|
|
35
|
+
*
|
|
36
|
+
* @internal Internal pipeline stage, composed by `simulate`; not part of the
|
|
37
|
+
* package's public API (only re-exported from the internal pipeline barrel).
|
|
38
|
+
* @param params.chainId - Chain whose blue-sdk `bundler3` and `bundles`
|
|
39
|
+
* registries define the restricted address set. Chains cataloging neither are
|
|
40
|
+
* skipped (a `logger.warn` records the skip).
|
|
41
|
+
* @param params.transfers - Parsed ERC20 / WETH9 transfer flows (plus the
|
|
42
|
+
* `eth_simulateV1` synthetic native sentinel) scanned for net retention.
|
|
43
|
+
* @param params.assetChanges - Per-account native-ETH deltas, the cross-backend
|
|
44
|
+
* source of truth for native value stuck in a restricted contract.
|
|
45
|
+
* @param params.logger - Optional logger. Receives retention-check skip warnings
|
|
46
|
+
* and pre-existing-balance sweep telemetry (net-negative flow).
|
|
47
|
+
* @returns Nothing. Returns silently when no restricted contract retains value
|
|
48
|
+
* above `DUST_THRESHOLD`; otherwise throws.
|
|
49
|
+
* @throws {BlacklistViolationError} when net inbound flow to a restricted
|
|
50
|
+
* `bundler3` or `bundles` contract exceeds `DUST_THRESHOLD` for any token.
|
|
33
51
|
*/
|
|
34
52
|
export declare function assertNoBundlerRetention(params: AssertNoBundlerRetentionParams): void;
|
|
35
53
|
export {};
|