@morpho-org/evm-simulation 3.0.0 → 4.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/lib/cjs/index.d.ts +1 -1
- package/lib/cjs/simulate/asset-changes.d.ts +19 -0
- package/lib/cjs/simulate/asset-changes.js +47 -0
- package/lib/cjs/simulate/backends/eth-simulate-v1.d.ts +9 -0
- package/lib/cjs/simulate/backends/eth-simulate-v1.js +39 -1
- package/lib/cjs/simulate/backends/tenderly-rpc.js +59 -5
- package/lib/cjs/simulate/pipeline/bundler-retention.d.ts +5 -5
- package/lib/cjs/simulate/pipeline/bundler-retention.js +19 -11
- package/lib/cjs/simulate/simulate.d.ts +8 -6
- package/lib/cjs/simulate/simulate.js +9 -6
- package/lib/cjs/types.d.ts +38 -12
- package/lib/esm/index.d.ts +1 -1
- package/lib/esm/simulate/asset-changes.d.ts +19 -0
- package/lib/esm/simulate/asset-changes.js +44 -0
- package/lib/esm/simulate/backends/eth-simulate-v1.d.ts +9 -0
- package/lib/esm/simulate/backends/eth-simulate-v1.js +40 -2
- package/lib/esm/simulate/backends/tenderly-rpc.js +60 -6
- package/lib/esm/simulate/pipeline/bundler-retention.d.ts +5 -5
- package/lib/esm/simulate/pipeline/bundler-retention.js +19 -11
- package/lib/esm/simulate/simulate.d.ts +8 -6
- package/lib/esm/simulate/simulate.js +9 -6
- package/lib/esm/types.d.ts +38 -12
- package/package.json +8 -6
- package/src/index.ts +2 -0
package/lib/cjs/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { BlacklistViolationError, ExternalServiceError, SimulationPackageError, SimulationRevertedError, SimulationValidationError, UnsupportedChainError, } from "./errors.js";
|
|
2
2
|
export { simulate } from "./simulate/index.js";
|
|
3
|
-
export type { ChainSimulationConfig, RawLog, SimulateParams, SimulationAuthorization, SimulationCall, SimulationConfig, SimulationLogger, SimulationResult, SimulationTransaction, TenderlyRpcConfig, Transfer, } from "./types.js";
|
|
3
|
+
export type { AccountAssetChanges, AssetChange, ChainSimulationConfig, RawLog, SimulateParams, SimulationAuthorization, SimulationCall, SimulationConfig, SimulationLogger, SimulationResult, SimulationTransaction, TenderlyRpcConfig, Transfer, } from "./types.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Address } from "viem";
|
|
2
|
+
import type { AccountAssetChanges } from "../types.js";
|
|
3
|
+
/** A single signed contribution to one account's balance for one token. */
|
|
4
|
+
export interface AssetChangeEntry {
|
|
5
|
+
account: Address;
|
|
6
|
+
token: Address;
|
|
7
|
+
diff: bigint;
|
|
8
|
+
symbol?: string;
|
|
9
|
+
decimals?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Net signed per-(account, token) contributions into balance changes grouped by
|
|
13
|
+
* account. Accounts and their `changes` are sorted by address for deterministic
|
|
14
|
+
* output; zero-net tokens and accounts are dropped.
|
|
15
|
+
*
|
|
16
|
+
* @param entries - Signed contributions; `diff` is added per (account, token).
|
|
17
|
+
* @returns One {@link AccountAssetChanges} per account with a non-zero change.
|
|
18
|
+
*/
|
|
19
|
+
export declare function groupAssetChanges(entries: AssetChangeEntry[]): AccountAssetChanges[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.groupAssetChanges = groupAssetChanges;
|
|
4
|
+
const viem_1 = require("viem");
|
|
5
|
+
// Locale-independent byte-order compare on lowercased hex, matching `sortTransfers`.
|
|
6
|
+
const compareAddress = (a, b) => {
|
|
7
|
+
const al = a.toLowerCase();
|
|
8
|
+
const bl = b.toLowerCase();
|
|
9
|
+
return al < bl ? -1 : al > bl ? 1 : 0;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Net signed per-(account, token) contributions into balance changes grouped by
|
|
13
|
+
* account. Accounts and their `changes` are sorted by address for deterministic
|
|
14
|
+
* output; zero-net tokens and accounts are dropped.
|
|
15
|
+
*
|
|
16
|
+
* @param entries - Signed contributions; `diff` is added per (account, token).
|
|
17
|
+
* @returns One {@link AccountAssetChanges} per account with a non-zero change.
|
|
18
|
+
*/
|
|
19
|
+
function groupAssetChanges(entries) {
|
|
20
|
+
const byAccount = new Map();
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
if (entry.diff === 0n)
|
|
23
|
+
continue;
|
|
24
|
+
const account = (0, viem_1.getAddress)(entry.account);
|
|
25
|
+
// `token` is normalized by callers (checksummed contract or `ethAddress`).
|
|
26
|
+
const token = entry.token;
|
|
27
|
+
let tokens = byAccount.get(account);
|
|
28
|
+
if (!tokens)
|
|
29
|
+
byAccount.set(account, (tokens = new Map()));
|
|
30
|
+
const prev = tokens.get(token);
|
|
31
|
+
tokens.set(token, {
|
|
32
|
+
token,
|
|
33
|
+
symbol: prev?.symbol ?? entry.symbol,
|
|
34
|
+
decimals: prev?.decimals ?? entry.decimals,
|
|
35
|
+
diff: (prev?.diff ?? 0n) + entry.diff,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return [...byAccount]
|
|
39
|
+
.map(([account, tokens]) => ({
|
|
40
|
+
account,
|
|
41
|
+
changes: [...tokens.values()]
|
|
42
|
+
.filter((c) => c.diff !== 0n)
|
|
43
|
+
.sort((x, y) => compareAddress(x.token, y.token)),
|
|
44
|
+
}))
|
|
45
|
+
.filter((a) => a.changes.length > 0)
|
|
46
|
+
.sort((a, b) => compareAddress(a.account, b.account));
|
|
47
|
+
}
|
|
@@ -5,6 +5,15 @@ import type { RawSimulationResult, SimulationTransaction } from "../../types.js"
|
|
|
5
5
|
*
|
|
6
6
|
* Uses stateOverride to set the user's ETH balance high enough to avoid
|
|
7
7
|
* false "insufficient balance for gas" reverts.
|
|
8
|
+
*
|
|
9
|
+
* Derives `assetChanges` (net per-token deltas grouped by account) from the
|
|
10
|
+
* emitted transfer logs, plus native ETH from each transaction's top-level
|
|
11
|
+
* `value` (payer debited, recipient credited, under viem's `ethAddress`).
|
|
12
|
+
*
|
|
13
|
+
* Coverage on this path is therefore narrower than the Tenderly backend:
|
|
14
|
+
* native ETH moved through internal calls (e.g. a `WETH.withdraw` refund, a
|
|
15
|
+
* swap that pays out ETH) emits no log and is not reflected in `value`, so it
|
|
16
|
+
* is not captured. For full native-ETH accounting use Tenderly.
|
|
8
17
|
*/
|
|
9
18
|
export declare function simulateV1(params: {
|
|
10
19
|
rpcUrl: string;
|
|
@@ -3,11 +3,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.simulateV1 = simulateV1;
|
|
4
4
|
const viem_1 = require("viem");
|
|
5
5
|
const errors_js_1 = require("../../errors.js");
|
|
6
|
+
const asset_changes_js_1 = require("../asset-changes.js");
|
|
7
|
+
const index_js_1 = require("../parsing/index.js");
|
|
6
8
|
/**
|
|
7
9
|
* Simulate transactions using eth_simulateV1 (viem simulateCalls).
|
|
8
10
|
*
|
|
9
11
|
* Uses stateOverride to set the user's ETH balance high enough to avoid
|
|
10
12
|
* false "insufficient balance for gas" reverts.
|
|
13
|
+
*
|
|
14
|
+
* Derives `assetChanges` (net per-token deltas grouped by account) from the
|
|
15
|
+
* emitted transfer logs, plus native ETH from each transaction's top-level
|
|
16
|
+
* `value` (payer debited, recipient credited, under viem's `ethAddress`).
|
|
17
|
+
*
|
|
18
|
+
* Coverage on this path is therefore narrower than the Tenderly backend:
|
|
19
|
+
* native ETH moved through internal calls (e.g. a `WETH.withdraw` refund, a
|
|
20
|
+
* swap that pays out ETH) emits no log and is not reflected in `value`, so it
|
|
21
|
+
* is not captured. For full native-ETH accounting use Tenderly.
|
|
11
22
|
*/
|
|
12
23
|
async function simulateV1(params) {
|
|
13
24
|
const { rpcUrl, transactions, blockNumber, signal } = params;
|
|
@@ -64,7 +75,13 @@ async function simulateV1(params) {
|
|
|
64
75
|
returnData: r.data ?? "0x",
|
|
65
76
|
gasUsed: r.gasUsed,
|
|
66
77
|
}));
|
|
67
|
-
return {
|
|
78
|
+
return {
|
|
79
|
+
calls: rawCalls,
|
|
80
|
+
assetChanges: toAssetChanges({
|
|
81
|
+
transfers: (0, index_js_1.parseTransfers)(rawCalls),
|
|
82
|
+
transactions,
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
68
85
|
}
|
|
69
86
|
catch (error) {
|
|
70
87
|
if (error instanceof errors_js_1.SimulationRevertedError)
|
|
@@ -74,3 +91,24 @@ async function simulateV1(params) {
|
|
|
74
91
|
throw new errors_js_1.ExternalServiceError(`eth_simulateV1 error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
75
92
|
}
|
|
76
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Reduce parsed transfer logs and top-level transaction values to net per-token
|
|
96
|
+
* balance changes grouped by account. Native ETH is taken from the top-level
|
|
97
|
+
* `value`s (payer debited, recipient credited — internal moves are not logged,
|
|
98
|
+
* see `simulateV1` for the coverage caveat).
|
|
99
|
+
*/
|
|
100
|
+
function toAssetChanges(params) {
|
|
101
|
+
const { transfers, transactions } = params;
|
|
102
|
+
const entries = [];
|
|
103
|
+
for (const tx of transactions) {
|
|
104
|
+
if (!tx.value)
|
|
105
|
+
continue;
|
|
106
|
+
entries.push({ account: tx.from, token: viem_1.ethAddress, diff: -tx.value });
|
|
107
|
+
entries.push({ account: tx.to, token: viem_1.ethAddress, diff: tx.value });
|
|
108
|
+
}
|
|
109
|
+
for (const { token, from, to, amount } of transfers) {
|
|
110
|
+
entries.push({ account: to, token, diff: amount });
|
|
111
|
+
entries.push({ account: from, token, diff: -amount });
|
|
112
|
+
}
|
|
113
|
+
return (0, asset_changes_js_1.groupAssetChanges)(entries);
|
|
114
|
+
}
|
|
@@ -4,6 +4,7 @@ exports.simulateTenderlyRpc = simulateTenderlyRpc;
|
|
|
4
4
|
const viem_1 = require("viem");
|
|
5
5
|
const zod_1 = require("zod");
|
|
6
6
|
const errors_js_1 = require("../../errors.js");
|
|
7
|
+
const asset_changes_js_1 = require("../asset-changes.js");
|
|
7
8
|
const addressSchema = zod_1.z.custom((val) => typeof val === "string" && (0, viem_1.isAddress)(val));
|
|
8
9
|
const hexSchema = zod_1.z.custom((val) => typeof val === "string" && (0, viem_1.isHex)(val));
|
|
9
10
|
const traceFrameSchema = zod_1.z
|
|
@@ -25,13 +26,27 @@ const logSchema = zod_1.z
|
|
|
25
26
|
.optional(),
|
|
26
27
|
})
|
|
27
28
|
.passthrough();
|
|
29
|
+
const assetChangeSchema = zod_1.z
|
|
30
|
+
.object({
|
|
31
|
+
assetInfo: zod_1.z
|
|
32
|
+
.object({
|
|
33
|
+
contractAddress: addressSchema.optional(),
|
|
34
|
+
symbol: zod_1.z.string().optional(),
|
|
35
|
+
decimals: zod_1.z.number().optional(),
|
|
36
|
+
})
|
|
37
|
+
.passthrough(),
|
|
38
|
+
from: addressSchema.optional(),
|
|
39
|
+
to: addressSchema.optional(),
|
|
40
|
+
rawAmount: hexSchema,
|
|
41
|
+
})
|
|
42
|
+
.passthrough();
|
|
28
43
|
const simResultSchema = zod_1.z
|
|
29
44
|
.object({
|
|
30
45
|
status: zod_1.z.boolean(),
|
|
31
46
|
gasUsed: hexSchema,
|
|
32
47
|
logs: zod_1.z.array(logSchema).optional(),
|
|
33
48
|
trace: zod_1.z.array(traceFrameSchema).optional(),
|
|
34
|
-
assetChanges: zod_1.z.
|
|
49
|
+
assetChanges: zod_1.z.array(assetChangeSchema).optional(),
|
|
35
50
|
error: zod_1.z.string().optional(),
|
|
36
51
|
errorMessage: zod_1.z.string().optional(),
|
|
37
52
|
})
|
|
@@ -84,7 +99,10 @@ async function simulateTenderlyRpc(params) {
|
|
|
84
99
|
signal,
|
|
85
100
|
});
|
|
86
101
|
const result = unwrapResult(singleEnvelope.parse(json));
|
|
87
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
calls: [toRawCall(result)],
|
|
104
|
+
assetChanges: toAssetChanges([result]),
|
|
105
|
+
};
|
|
88
106
|
}
|
|
89
107
|
const json = await rpcRequest({
|
|
90
108
|
rpcUrl: config.rpcUrl,
|
|
@@ -92,8 +110,11 @@ async function simulateTenderlyRpc(params) {
|
|
|
92
110
|
params: [transactions.map(buildCall), block, stateOverrides],
|
|
93
111
|
signal,
|
|
94
112
|
});
|
|
95
|
-
const
|
|
96
|
-
return {
|
|
113
|
+
const results = unwrapResult(bundleEnvelope.parse(json));
|
|
114
|
+
return {
|
|
115
|
+
calls: results.map(toRawCall),
|
|
116
|
+
assetChanges: toAssetChanges(results),
|
|
117
|
+
};
|
|
97
118
|
}
|
|
98
119
|
catch (error) {
|
|
99
120
|
if (error instanceof errors_js_1.SimulationRevertedError)
|
|
@@ -171,6 +192,39 @@ function toRawCall(data) {
|
|
|
171
192
|
status: true,
|
|
172
193
|
returnData: data.trace?.[0]?.output ?? "0x",
|
|
173
194
|
gasUsed: BigInt(data.gasUsed),
|
|
174
|
-
assetChanges: data.assetChanges,
|
|
175
195
|
};
|
|
176
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Reduce Tenderly's per-transfer asset changes to net per-token balance changes
|
|
199
|
+
* grouped by account. Both endpoints of each transfer are accounted (sender,
|
|
200
|
+
* counterparties, and the zero address for mints/burns).
|
|
201
|
+
*/
|
|
202
|
+
function toAssetChanges(results) {
|
|
203
|
+
const entries = [];
|
|
204
|
+
for (const result of results) {
|
|
205
|
+
for (const change of result.assetChanges ?? []) {
|
|
206
|
+
const amount = BigInt(change.rawAmount);
|
|
207
|
+
const token = change.assetInfo.contractAddress
|
|
208
|
+
? (0, viem_1.getAddress)(change.assetInfo.contractAddress)
|
|
209
|
+
: viem_1.ethAddress;
|
|
210
|
+
const { symbol, decimals } = change.assetInfo;
|
|
211
|
+
if (change.to)
|
|
212
|
+
entries.push({
|
|
213
|
+
account: change.to,
|
|
214
|
+
token,
|
|
215
|
+
diff: amount,
|
|
216
|
+
symbol,
|
|
217
|
+
decimals,
|
|
218
|
+
});
|
|
219
|
+
if (change.from)
|
|
220
|
+
entries.push({
|
|
221
|
+
account: change.from,
|
|
222
|
+
token,
|
|
223
|
+
diff: -amount,
|
|
224
|
+
symbol,
|
|
225
|
+
decimals,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return (0, asset_changes_js_1.groupAssetChanges)(entries);
|
|
230
|
+
}
|
|
@@ -9,11 +9,11 @@ interface AssertNoBundlerRetentionParams {
|
|
|
9
9
|
*
|
|
10
10
|
* Uses net flow (inbound minus outbound) per (bundler address, token) pair.
|
|
11
11
|
* Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
|
|
12
|
-
* vault), so gross inbound would fire false positives. We only
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* vault), so gross inbound would fire false positives. We only block positive
|
|
13
|
+
* net flow above `DUST_THRESHOLD` — tokens stuck in a bundler address. Negative
|
|
14
|
+
* net flow means the simulated bundle sweeps a pre-existing or state-overridden
|
|
15
|
+
* bundler balance. That is useful telemetry, but not current-bundle retention,
|
|
16
|
+
* so it is warned instead of raising a blacklist violation.
|
|
17
17
|
*/
|
|
18
18
|
export declare function assertNoBundlerRetention(params: AssertNoBundlerRetentionParams): void;
|
|
19
19
|
export {};
|
|
@@ -47,11 +47,11 @@ function getBundlerAddresses(chainId, logger) {
|
|
|
47
47
|
*
|
|
48
48
|
* Uses net flow (inbound minus outbound) per (bundler address, token) pair.
|
|
49
49
|
* Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
|
|
50
|
-
* vault), so gross inbound would fire false positives. We only
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* vault), so gross inbound would fire false positives. We only block positive
|
|
51
|
+
* net flow above `DUST_THRESHOLD` — tokens stuck in a bundler address. Negative
|
|
52
|
+
* net flow means the simulated bundle sweeps a pre-existing or state-overridden
|
|
53
|
+
* bundler balance. That is useful telemetry, but not current-bundle retention,
|
|
54
|
+
* so it is warned instead of raising a blacklist violation.
|
|
55
55
|
*/
|
|
56
56
|
function assertNoBundlerRetention(params) {
|
|
57
57
|
const { chainId, transfers, logger } = params;
|
|
@@ -77,15 +77,23 @@ function assertNoBundlerRetention(params) {
|
|
|
77
77
|
if (bundlerAddresses.has(t.from))
|
|
78
78
|
recordFlow(t.from, t.token, -t.amount);
|
|
79
79
|
}
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const entries = [...flow.values()];
|
|
81
|
+
const drained = entries.filter((e) => e.netChange < -DUST_THRESHOLD);
|
|
82
|
+
if (drained.length > 0) {
|
|
83
|
+
logger?.warn("Simulation detected pre-existing bundler balance being swept", {
|
|
84
|
+
changes: drained.map((e) => ({
|
|
85
|
+
address: e.address,
|
|
86
|
+
token: e.token,
|
|
87
|
+
netSwept: (-e.netChange).toString(),
|
|
88
|
+
})),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const retained = entries.filter((e) => e.netChange > DUST_THRESHOLD);
|
|
92
|
+
if (retained.length > 0) {
|
|
93
|
+
throw new errors_js_1.BlacklistViolationError("Simulation detected asset transfers retained by restricted bundler contracts", retained.map((e) => ({
|
|
83
94
|
address: e.address,
|
|
84
95
|
token: e.token,
|
|
85
96
|
netRetained: e.netChange.toString(),
|
|
86
97
|
})));
|
|
87
98
|
}
|
|
88
99
|
}
|
|
89
|
-
function absBigInt(x) {
|
|
90
|
-
return x < 0n ? -x : x;
|
|
91
|
-
}
|
|
@@ -9,8 +9,10 @@ import type { SimulateParams, SimulationConfig, SimulationResult } from "../type
|
|
|
9
9
|
*
|
|
10
10
|
* - `transfers` → user-facing preview / server-side verification.
|
|
11
11
|
* - `simulationTxs` + `transfers` → server-side verification before broadcast.
|
|
12
|
-
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed
|
|
13
|
-
*
|
|
12
|
+
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed`).
|
|
13
|
+
* Aligned 1:1 with `simulationTxs[i]`.
|
|
14
|
+
* - `assetChanges` → net per-asset balance changes grouped by account (sender and
|
|
15
|
+
* counterparties) over the whole bundle, normalized to the same shape across backends.
|
|
14
16
|
* - `transfers[k].txIdx` → index into `simulationTxs` of the tx that emitted the
|
|
15
17
|
* underlying log; consumers map back via `simulationTxs[transfer.txIdx]`.
|
|
16
18
|
*
|
|
@@ -27,15 +29,15 @@ import type { SimulateParams, SimulationConfig, SimulationResult } from "../type
|
|
|
27
29
|
* empty transactions, malformed authorizations).
|
|
28
30
|
* @throws {UnsupportedChainError} when the chain is not configured for any backend.
|
|
29
31
|
* @throws {SimulationRevertedError} when the bundle reverts on either backend.
|
|
30
|
-
* @throws {BlacklistViolationError} when the simulation leaves value retained by
|
|
31
|
-
*
|
|
32
|
+
* @throws {BlacklistViolationError} when the simulation leaves value retained by
|
|
33
|
+
* a `bundler3` address beyond the dust threshold.
|
|
32
34
|
* @throws {ExternalServiceError} (a) when both backends are unavailable within the
|
|
33
35
|
* timeout budget, or (b) when a backend returns a `calls` array whose length does
|
|
34
36
|
* not match the resolved `simulationTxs` — refusing to map transfers with mismatched
|
|
35
37
|
* per-tx output.
|
|
36
38
|
* @returns A {@link SimulationResult} carrying the resolved `simulationTxs`, per-tx
|
|
37
|
-
* `calls` (aligned 1:1 with `simulationTxs`),
|
|
38
|
-
* with `txIdx`)
|
|
39
|
+
* `calls` (aligned 1:1 with `simulationTxs`), parsed `transfers` (each stamped
|
|
40
|
+
* with `txIdx`), and per-account net `assetChanges`.
|
|
39
41
|
* @example
|
|
40
42
|
* ```ts
|
|
41
43
|
* import { simulate } from "@morpho-org/evm-simulation";
|
|
@@ -14,8 +14,10 @@ const index_js_2 = require("./pipeline/index.js");
|
|
|
14
14
|
*
|
|
15
15
|
* - `transfers` → user-facing preview / server-side verification.
|
|
16
16
|
* - `simulationTxs` + `transfers` → server-side verification before broadcast.
|
|
17
|
-
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed
|
|
18
|
-
*
|
|
17
|
+
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed`).
|
|
18
|
+
* Aligned 1:1 with `simulationTxs[i]`.
|
|
19
|
+
* - `assetChanges` → net per-asset balance changes grouped by account (sender and
|
|
20
|
+
* counterparties) over the whole bundle, normalized to the same shape across backends.
|
|
19
21
|
* - `transfers[k].txIdx` → index into `simulationTxs` of the tx that emitted the
|
|
20
22
|
* underlying log; consumers map back via `simulationTxs[transfer.txIdx]`.
|
|
21
23
|
*
|
|
@@ -32,15 +34,15 @@ const index_js_2 = require("./pipeline/index.js");
|
|
|
32
34
|
* empty transactions, malformed authorizations).
|
|
33
35
|
* @throws {UnsupportedChainError} when the chain is not configured for any backend.
|
|
34
36
|
* @throws {SimulationRevertedError} when the bundle reverts on either backend.
|
|
35
|
-
* @throws {BlacklistViolationError} when the simulation leaves value retained by
|
|
36
|
-
*
|
|
37
|
+
* @throws {BlacklistViolationError} when the simulation leaves value retained by
|
|
38
|
+
* a `bundler3` address beyond the dust threshold.
|
|
37
39
|
* @throws {ExternalServiceError} (a) when both backends are unavailable within the
|
|
38
40
|
* timeout budget, or (b) when a backend returns a `calls` array whose length does
|
|
39
41
|
* not match the resolved `simulationTxs` — refusing to map transfers with mismatched
|
|
40
42
|
* per-tx output.
|
|
41
43
|
* @returns A {@link SimulationResult} carrying the resolved `simulationTxs`, per-tx
|
|
42
|
-
* `calls` (aligned 1:1 with `simulationTxs`),
|
|
43
|
-
* with `txIdx`)
|
|
44
|
+
* `calls` (aligned 1:1 with `simulationTxs`), parsed `transfers` (each stamped
|
|
45
|
+
* with `txIdx`), and per-account net `assetChanges`.
|
|
44
46
|
* @example
|
|
45
47
|
* ```ts
|
|
46
48
|
* import { simulate } from "@morpho-org/evm-simulation";
|
|
@@ -84,5 +86,6 @@ async function simulate(config, params) {
|
|
|
84
86
|
simulationTxs,
|
|
85
87
|
calls: result.calls,
|
|
86
88
|
transfers,
|
|
89
|
+
assetChanges: result.assetChanges,
|
|
87
90
|
};
|
|
88
91
|
}
|
package/lib/cjs/types.d.ts
CHANGED
|
@@ -64,6 +64,33 @@ export type SimulationAuthorization = {
|
|
|
64
64
|
/** Defaults to maxUint256. Caller can set explicit amount for tokens that don't support max approval. */
|
|
65
65
|
amount?: bigint;
|
|
66
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* Net balance change for a single asset (one token) within an account's entry.
|
|
69
|
+
* Native ETH uses viem's `ethAddress` sentinel as `token`. `symbol`/`decimals`
|
|
70
|
+
* are best-effort and may be absent, notably on the `eth_simulateV1` fallback.
|
|
71
|
+
*/
|
|
72
|
+
export interface AssetChange {
|
|
73
|
+
readonly token: Address;
|
|
74
|
+
readonly symbol?: string;
|
|
75
|
+
readonly decimals?: number;
|
|
76
|
+
/** Signed net change of the account's balance, in raw token units. */
|
|
77
|
+
readonly diff: bigint;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Net per-token balance changes for one account over the whole bundle. Returned
|
|
81
|
+
* for every account that nets a non-zero change, the sender and counterparties
|
|
82
|
+
* alike (the zero address is kept for mints/burns). Accounts and their `changes`
|
|
83
|
+
* are sorted by address for deterministic, cross-backend output.
|
|
84
|
+
*
|
|
85
|
+
* Native-ETH coverage differs by backend: Tenderly reports the full net ETH
|
|
86
|
+
* delta (including ETH moved via internal calls), while the `eth_simulateV1`
|
|
87
|
+
* fallback derives native ETH only from the top-level transaction `value`s —
|
|
88
|
+
* ETH moved internally (e.g. a `WETH.withdraw` refund) is not captured there.
|
|
89
|
+
*/
|
|
90
|
+
export interface AccountAssetChanges {
|
|
91
|
+
readonly account: Address;
|
|
92
|
+
readonly changes: readonly AssetChange[];
|
|
93
|
+
}
|
|
67
94
|
/**
|
|
68
95
|
* A parsed ERC20 / WETH9 transfer extracted from simulation logs. Returned in
|
|
69
96
|
* `SimulationResult.transfers`.
|
|
@@ -87,7 +114,10 @@ export interface Transfer {
|
|
|
87
114
|
* - `simulationTxs` is the full resolved transaction list (including
|
|
88
115
|
* prepended authorization txs).
|
|
89
116
|
* - `calls[i]` corresponds 1:1 with `simulationTxs[i]` — read raw logs,
|
|
90
|
-
* status, returnData/gasUsed
|
|
117
|
+
* status, returnData/gasUsed.
|
|
118
|
+
* - `assetChanges` is the net per-asset balance change over the whole bundle,
|
|
119
|
+
* grouped by account (sender and counterparties), normalized to the same
|
|
120
|
+
* shape across backends — see `AccountAssetChanges`.
|
|
91
121
|
* - `transfers[k].txIdx` indexes into `simulationTxs` to attribute each
|
|
92
122
|
* transfer to its emitting transaction.
|
|
93
123
|
*/
|
|
@@ -96,12 +126,13 @@ export interface SimulationResult {
|
|
|
96
126
|
readonly simulationTxs: readonly SimulationTransaction[];
|
|
97
127
|
/**
|
|
98
128
|
* Per-transaction normalized output. `calls[i]` corresponds 1:1 with
|
|
99
|
-
* `simulationTxs[i]`. Use this to read raw logs, status, return data, gas
|
|
100
|
-
* used, and (Tenderly only) asset changes per transaction.
|
|
129
|
+
* `simulationTxs[i]`. Use this to read raw logs, status, return data, gas used.
|
|
101
130
|
*/
|
|
102
131
|
readonly calls: readonly SimulationCall[];
|
|
103
132
|
/** Parsed ERC-20 / WETH9 transfers from the simulation. */
|
|
104
133
|
readonly transfers: readonly Transfer[];
|
|
134
|
+
/** Net per-asset balance changes, grouped by account, over the whole bundle. */
|
|
135
|
+
readonly assetChanges: readonly AccountAssetChanges[];
|
|
105
136
|
}
|
|
106
137
|
/** Minimal structured logger the package calls for warnings and info. */
|
|
107
138
|
export interface SimulationLogger {
|
|
@@ -122,10 +153,11 @@ export interface SimulateParams {
|
|
|
122
153
|
/**
|
|
123
154
|
* Internal raw result from a simulation adapter before normalization.
|
|
124
155
|
* `calls[i]` corresponds 1:1 with the i-th transaction passed to the
|
|
125
|
-
* backend.
|
|
156
|
+
* backend; `assetChanges` is the bundle-level aggregate grouped by account.
|
|
126
157
|
*/
|
|
127
158
|
export interface RawSimulationResult {
|
|
128
159
|
calls: RawCall[];
|
|
160
|
+
assetChanges: AccountAssetChanges[];
|
|
129
161
|
}
|
|
130
162
|
/**
|
|
131
163
|
* Normalized EVM log emitted by a single simulated call. The shape is the
|
|
@@ -149,15 +181,14 @@ export interface RawCall {
|
|
|
149
181
|
status: boolean;
|
|
150
182
|
returnData: Hex;
|
|
151
183
|
gasUsed: bigint;
|
|
152
|
-
/** Tenderly-only `assetChanges` payload. Absent on `eth_simulateV1`. */
|
|
153
|
-
assetChanges?: unknown;
|
|
154
184
|
}
|
|
155
185
|
/**
|
|
156
186
|
* Per-transaction normalized output from the simulation backend.
|
|
157
187
|
*
|
|
158
188
|
* `SimulationResult.calls[i]` corresponds 1:1 with
|
|
159
189
|
* `SimulationResult.simulationTxs[i]`. Use this to read raw logs, status,
|
|
160
|
-
* return data, gas used
|
|
190
|
+
* return data, and gas used. Net asset changes are reported at the bundle
|
|
191
|
+
* level — see `SimulationResult.assetChanges`.
|
|
161
192
|
*/
|
|
162
193
|
export interface SimulationCall {
|
|
163
194
|
readonly logs: readonly RawLog[];
|
|
@@ -172,9 +203,4 @@ export interface SimulationCall {
|
|
|
172
203
|
readonly returnData: Hex;
|
|
173
204
|
/** Gas used by this call (root frame). */
|
|
174
205
|
readonly gasUsed: bigint;
|
|
175
|
-
/**
|
|
176
|
-
* Tenderly-only `assetChanges` payload for this tx. Opaque `unknown` —
|
|
177
|
-
* do not destructure without validation. Absent on `eth_simulateV1`.
|
|
178
|
-
*/
|
|
179
|
-
readonly assetChanges?: unknown;
|
|
180
206
|
}
|
package/lib/esm/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { BlacklistViolationError, ExternalServiceError, SimulationPackageError, SimulationRevertedError, SimulationValidationError, UnsupportedChainError, } from "./errors.js";
|
|
2
2
|
export { simulate } from "./simulate/index.js";
|
|
3
|
-
export type { ChainSimulationConfig, RawLog, SimulateParams, SimulationAuthorization, SimulationCall, SimulationConfig, SimulationLogger, SimulationResult, SimulationTransaction, TenderlyRpcConfig, Transfer, } from "./types.js";
|
|
3
|
+
export type { AccountAssetChanges, AssetChange, ChainSimulationConfig, RawLog, SimulateParams, SimulationAuthorization, SimulationCall, SimulationConfig, SimulationLogger, SimulationResult, SimulationTransaction, TenderlyRpcConfig, Transfer, } from "./types.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Address } from "viem";
|
|
2
|
+
import type { AccountAssetChanges } from "../types.js";
|
|
3
|
+
/** A single signed contribution to one account's balance for one token. */
|
|
4
|
+
export interface AssetChangeEntry {
|
|
5
|
+
account: Address;
|
|
6
|
+
token: Address;
|
|
7
|
+
diff: bigint;
|
|
8
|
+
symbol?: string;
|
|
9
|
+
decimals?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Net signed per-(account, token) contributions into balance changes grouped by
|
|
13
|
+
* account. Accounts and their `changes` are sorted by address for deterministic
|
|
14
|
+
* output; zero-net tokens and accounts are dropped.
|
|
15
|
+
*
|
|
16
|
+
* @param entries - Signed contributions; `diff` is added per (account, token).
|
|
17
|
+
* @returns One {@link AccountAssetChanges} per account with a non-zero change.
|
|
18
|
+
*/
|
|
19
|
+
export declare function groupAssetChanges(entries: AssetChangeEntry[]): AccountAssetChanges[];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { getAddress } from "viem";
|
|
2
|
+
// Locale-independent byte-order compare on lowercased hex, matching `sortTransfers`.
|
|
3
|
+
const compareAddress = (a, b) => {
|
|
4
|
+
const al = a.toLowerCase();
|
|
5
|
+
const bl = b.toLowerCase();
|
|
6
|
+
return al < bl ? -1 : al > bl ? 1 : 0;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Net signed per-(account, token) contributions into balance changes grouped by
|
|
10
|
+
* account. Accounts and their `changes` are sorted by address for deterministic
|
|
11
|
+
* output; zero-net tokens and accounts are dropped.
|
|
12
|
+
*
|
|
13
|
+
* @param entries - Signed contributions; `diff` is added per (account, token).
|
|
14
|
+
* @returns One {@link AccountAssetChanges} per account with a non-zero change.
|
|
15
|
+
*/
|
|
16
|
+
export function groupAssetChanges(entries) {
|
|
17
|
+
const byAccount = new Map();
|
|
18
|
+
for (const entry of entries) {
|
|
19
|
+
if (entry.diff === 0n)
|
|
20
|
+
continue;
|
|
21
|
+
const account = getAddress(entry.account);
|
|
22
|
+
// `token` is normalized by callers (checksummed contract or `ethAddress`).
|
|
23
|
+
const token = entry.token;
|
|
24
|
+
let tokens = byAccount.get(account);
|
|
25
|
+
if (!tokens)
|
|
26
|
+
byAccount.set(account, (tokens = new Map()));
|
|
27
|
+
const prev = tokens.get(token);
|
|
28
|
+
tokens.set(token, {
|
|
29
|
+
token,
|
|
30
|
+
symbol: prev?.symbol ?? entry.symbol,
|
|
31
|
+
decimals: prev?.decimals ?? entry.decimals,
|
|
32
|
+
diff: (prev?.diff ?? 0n) + entry.diff,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return [...byAccount]
|
|
36
|
+
.map(([account, tokens]) => ({
|
|
37
|
+
account,
|
|
38
|
+
changes: [...tokens.values()]
|
|
39
|
+
.filter((c) => c.diff !== 0n)
|
|
40
|
+
.sort((x, y) => compareAddress(x.token, y.token)),
|
|
41
|
+
}))
|
|
42
|
+
.filter((a) => a.changes.length > 0)
|
|
43
|
+
.sort((a, b) => compareAddress(a.account, b.account));
|
|
44
|
+
}
|
|
@@ -5,6 +5,15 @@ import type { RawSimulationResult, SimulationTransaction } from "../../types.js"
|
|
|
5
5
|
*
|
|
6
6
|
* Uses stateOverride to set the user's ETH balance high enough to avoid
|
|
7
7
|
* false "insufficient balance for gas" reverts.
|
|
8
|
+
*
|
|
9
|
+
* Derives `assetChanges` (net per-token deltas grouped by account) from the
|
|
10
|
+
* emitted transfer logs, plus native ETH from each transaction's top-level
|
|
11
|
+
* `value` (payer debited, recipient credited, under viem's `ethAddress`).
|
|
12
|
+
*
|
|
13
|
+
* Coverage on this path is therefore narrower than the Tenderly backend:
|
|
14
|
+
* native ETH moved through internal calls (e.g. a `WETH.withdraw` refund, a
|
|
15
|
+
* swap that pays out ETH) emits no log and is not reflected in `value`, so it
|
|
16
|
+
* is not captured. For full native-ETH accounting use Tenderly.
|
|
8
17
|
*/
|
|
9
18
|
export declare function simulateV1(params: {
|
|
10
19
|
rpcUrl: string;
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import { createPublicClient, getAddress, http, maxUint256, } from "viem";
|
|
1
|
+
import { createPublicClient, ethAddress, getAddress, http, maxUint256, } from "viem";
|
|
2
2
|
import { ExternalServiceError, SimulationRevertedError, SimulationValidationError, } from "../../errors.js";
|
|
3
|
+
import { groupAssetChanges } from "../asset-changes.js";
|
|
4
|
+
import { parseTransfers } from "../parsing/index.js";
|
|
3
5
|
/**
|
|
4
6
|
* Simulate transactions using eth_simulateV1 (viem simulateCalls).
|
|
5
7
|
*
|
|
6
8
|
* Uses stateOverride to set the user's ETH balance high enough to avoid
|
|
7
9
|
* false "insufficient balance for gas" reverts.
|
|
10
|
+
*
|
|
11
|
+
* Derives `assetChanges` (net per-token deltas grouped by account) from the
|
|
12
|
+
* emitted transfer logs, plus native ETH from each transaction's top-level
|
|
13
|
+
* `value` (payer debited, recipient credited, under viem's `ethAddress`).
|
|
14
|
+
*
|
|
15
|
+
* Coverage on this path is therefore narrower than the Tenderly backend:
|
|
16
|
+
* native ETH moved through internal calls (e.g. a `WETH.withdraw` refund, a
|
|
17
|
+
* swap that pays out ETH) emits no log and is not reflected in `value`, so it
|
|
18
|
+
* is not captured. For full native-ETH accounting use Tenderly.
|
|
8
19
|
*/
|
|
9
20
|
export async function simulateV1(params) {
|
|
10
21
|
const { rpcUrl, transactions, blockNumber, signal } = params;
|
|
@@ -61,7 +72,13 @@ export async function simulateV1(params) {
|
|
|
61
72
|
returnData: r.data ?? "0x",
|
|
62
73
|
gasUsed: r.gasUsed,
|
|
63
74
|
}));
|
|
64
|
-
return {
|
|
75
|
+
return {
|
|
76
|
+
calls: rawCalls,
|
|
77
|
+
assetChanges: toAssetChanges({
|
|
78
|
+
transfers: parseTransfers(rawCalls),
|
|
79
|
+
transactions,
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
65
82
|
}
|
|
66
83
|
catch (error) {
|
|
67
84
|
if (error instanceof SimulationRevertedError)
|
|
@@ -71,3 +88,24 @@ export async function simulateV1(params) {
|
|
|
71
88
|
throw new ExternalServiceError(`eth_simulateV1 error: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
72
89
|
}
|
|
73
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Reduce parsed transfer logs and top-level transaction values to net per-token
|
|
93
|
+
* balance changes grouped by account. Native ETH is taken from the top-level
|
|
94
|
+
* `value`s (payer debited, recipient credited — internal moves are not logged,
|
|
95
|
+
* see `simulateV1` for the coverage caveat).
|
|
96
|
+
*/
|
|
97
|
+
function toAssetChanges(params) {
|
|
98
|
+
const { transfers, transactions } = params;
|
|
99
|
+
const entries = [];
|
|
100
|
+
for (const tx of transactions) {
|
|
101
|
+
if (!tx.value)
|
|
102
|
+
continue;
|
|
103
|
+
entries.push({ account: tx.from, token: ethAddress, diff: -tx.value });
|
|
104
|
+
entries.push({ account: tx.to, token: ethAddress, diff: tx.value });
|
|
105
|
+
}
|
|
106
|
+
for (const { token, from, to, amount } of transfers) {
|
|
107
|
+
entries.push({ account: to, token, diff: amount });
|
|
108
|
+
entries.push({ account: from, token, diff: -amount });
|
|
109
|
+
}
|
|
110
|
+
return groupAssetChanges(entries);
|
|
111
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { isAddress, isHex, maxUint256, numberToHex, } from "viem";
|
|
1
|
+
import { ethAddress, getAddress, 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
5
|
const addressSchema = z.custom((val) => typeof val === "string" && isAddress(val));
|
|
5
6
|
const hexSchema = z.custom((val) => typeof val === "string" && isHex(val));
|
|
6
7
|
const traceFrameSchema = z
|
|
@@ -22,13 +23,27 @@ const logSchema = z
|
|
|
22
23
|
.optional(),
|
|
23
24
|
})
|
|
24
25
|
.passthrough();
|
|
26
|
+
const assetChangeSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
assetInfo: z
|
|
29
|
+
.object({
|
|
30
|
+
contractAddress: addressSchema.optional(),
|
|
31
|
+
symbol: z.string().optional(),
|
|
32
|
+
decimals: z.number().optional(),
|
|
33
|
+
})
|
|
34
|
+
.passthrough(),
|
|
35
|
+
from: addressSchema.optional(),
|
|
36
|
+
to: addressSchema.optional(),
|
|
37
|
+
rawAmount: hexSchema,
|
|
38
|
+
})
|
|
39
|
+
.passthrough();
|
|
25
40
|
const simResultSchema = z
|
|
26
41
|
.object({
|
|
27
42
|
status: z.boolean(),
|
|
28
43
|
gasUsed: hexSchema,
|
|
29
44
|
logs: z.array(logSchema).optional(),
|
|
30
45
|
trace: z.array(traceFrameSchema).optional(),
|
|
31
|
-
assetChanges: z.
|
|
46
|
+
assetChanges: z.array(assetChangeSchema).optional(),
|
|
32
47
|
error: z.string().optional(),
|
|
33
48
|
errorMessage: z.string().optional(),
|
|
34
49
|
})
|
|
@@ -81,7 +96,10 @@ export async function simulateTenderlyRpc(params) {
|
|
|
81
96
|
signal,
|
|
82
97
|
});
|
|
83
98
|
const result = unwrapResult(singleEnvelope.parse(json));
|
|
84
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
calls: [toRawCall(result)],
|
|
101
|
+
assetChanges: toAssetChanges([result]),
|
|
102
|
+
};
|
|
85
103
|
}
|
|
86
104
|
const json = await rpcRequest({
|
|
87
105
|
rpcUrl: config.rpcUrl,
|
|
@@ -89,8 +107,11 @@ export async function simulateTenderlyRpc(params) {
|
|
|
89
107
|
params: [transactions.map(buildCall), block, stateOverrides],
|
|
90
108
|
signal,
|
|
91
109
|
});
|
|
92
|
-
const
|
|
93
|
-
return {
|
|
110
|
+
const results = unwrapResult(bundleEnvelope.parse(json));
|
|
111
|
+
return {
|
|
112
|
+
calls: results.map(toRawCall),
|
|
113
|
+
assetChanges: toAssetChanges(results),
|
|
114
|
+
};
|
|
94
115
|
}
|
|
95
116
|
catch (error) {
|
|
96
117
|
if (error instanceof SimulationRevertedError)
|
|
@@ -168,6 +189,39 @@ function toRawCall(data) {
|
|
|
168
189
|
status: true,
|
|
169
190
|
returnData: data.trace?.[0]?.output ?? "0x",
|
|
170
191
|
gasUsed: BigInt(data.gasUsed),
|
|
171
|
-
assetChanges: data.assetChanges,
|
|
172
192
|
};
|
|
173
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Reduce Tenderly's per-transfer asset changes to net per-token balance changes
|
|
196
|
+
* grouped by account. Both endpoints of each transfer are accounted (sender,
|
|
197
|
+
* counterparties, and the zero address for mints/burns).
|
|
198
|
+
*/
|
|
199
|
+
function toAssetChanges(results) {
|
|
200
|
+
const entries = [];
|
|
201
|
+
for (const result of results) {
|
|
202
|
+
for (const change of result.assetChanges ?? []) {
|
|
203
|
+
const amount = BigInt(change.rawAmount);
|
|
204
|
+
const token = change.assetInfo.contractAddress
|
|
205
|
+
? getAddress(change.assetInfo.contractAddress)
|
|
206
|
+
: ethAddress;
|
|
207
|
+
const { symbol, decimals } = change.assetInfo;
|
|
208
|
+
if (change.to)
|
|
209
|
+
entries.push({
|
|
210
|
+
account: change.to,
|
|
211
|
+
token,
|
|
212
|
+
diff: amount,
|
|
213
|
+
symbol,
|
|
214
|
+
decimals,
|
|
215
|
+
});
|
|
216
|
+
if (change.from)
|
|
217
|
+
entries.push({
|
|
218
|
+
account: change.from,
|
|
219
|
+
token,
|
|
220
|
+
diff: -amount,
|
|
221
|
+
symbol,
|
|
222
|
+
decimals,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return groupAssetChanges(entries);
|
|
227
|
+
}
|
|
@@ -9,11 +9,11 @@ interface AssertNoBundlerRetentionParams {
|
|
|
9
9
|
*
|
|
10
10
|
* Uses net flow (inbound minus outbound) per (bundler address, token) pair.
|
|
11
11
|
* Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
|
|
12
|
-
* vault), so gross inbound would fire false positives. We only
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* vault), so gross inbound would fire false positives. We only block positive
|
|
13
|
+
* net flow above `DUST_THRESHOLD` — tokens stuck in a bundler address. Negative
|
|
14
|
+
* net flow means the simulated bundle sweeps a pre-existing or state-overridden
|
|
15
|
+
* bundler balance. That is useful telemetry, but not current-bundle retention,
|
|
16
|
+
* so it is warned instead of raising a blacklist violation.
|
|
17
17
|
*/
|
|
18
18
|
export declare function assertNoBundlerRetention(params: AssertNoBundlerRetentionParams): void;
|
|
19
19
|
export {};
|
|
@@ -44,11 +44,11 @@ function getBundlerAddresses(chainId, logger) {
|
|
|
44
44
|
*
|
|
45
45
|
* Uses net flow (inbound minus outbound) per (bundler address, token) pair.
|
|
46
46
|
* Bundler3 legitimately receives tokens as an intermediary (user → bundler3 →
|
|
47
|
-
* vault), so gross inbound would fire false positives. We only
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
47
|
+
* vault), so gross inbound would fire false positives. We only block positive
|
|
48
|
+
* net flow above `DUST_THRESHOLD` — tokens stuck in a bundler address. Negative
|
|
49
|
+
* net flow means the simulated bundle sweeps a pre-existing or state-overridden
|
|
50
|
+
* bundler balance. That is useful telemetry, but not current-bundle retention,
|
|
51
|
+
* so it is warned instead of raising a blacklist violation.
|
|
52
52
|
*/
|
|
53
53
|
export function assertNoBundlerRetention(params) {
|
|
54
54
|
const { chainId, transfers, logger } = params;
|
|
@@ -74,15 +74,23 @@ export function assertNoBundlerRetention(params) {
|
|
|
74
74
|
if (bundlerAddresses.has(t.from))
|
|
75
75
|
recordFlow(t.from, t.token, -t.amount);
|
|
76
76
|
}
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
const entries = [...flow.values()];
|
|
78
|
+
const drained = entries.filter((e) => e.netChange < -DUST_THRESHOLD);
|
|
79
|
+
if (drained.length > 0) {
|
|
80
|
+
logger?.warn("Simulation detected pre-existing bundler balance being swept", {
|
|
81
|
+
changes: drained.map((e) => ({
|
|
82
|
+
address: e.address,
|
|
83
|
+
token: e.token,
|
|
84
|
+
netSwept: (-e.netChange).toString(),
|
|
85
|
+
})),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const retained = entries.filter((e) => e.netChange > DUST_THRESHOLD);
|
|
89
|
+
if (retained.length > 0) {
|
|
90
|
+
throw new BlacklistViolationError("Simulation detected asset transfers retained by restricted bundler contracts", retained.map((e) => ({
|
|
80
91
|
address: e.address,
|
|
81
92
|
token: e.token,
|
|
82
93
|
netRetained: e.netChange.toString(),
|
|
83
94
|
})));
|
|
84
95
|
}
|
|
85
96
|
}
|
|
86
|
-
function absBigInt(x) {
|
|
87
|
-
return x < 0n ? -x : x;
|
|
88
|
-
}
|
|
@@ -9,8 +9,10 @@ import type { SimulateParams, SimulationConfig, SimulationResult } from "../type
|
|
|
9
9
|
*
|
|
10
10
|
* - `transfers` → user-facing preview / server-side verification.
|
|
11
11
|
* - `simulationTxs` + `transfers` → server-side verification before broadcast.
|
|
12
|
-
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed
|
|
13
|
-
*
|
|
12
|
+
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed`).
|
|
13
|
+
* Aligned 1:1 with `simulationTxs[i]`.
|
|
14
|
+
* - `assetChanges` → net per-asset balance changes grouped by account (sender and
|
|
15
|
+
* counterparties) over the whole bundle, normalized to the same shape across backends.
|
|
14
16
|
* - `transfers[k].txIdx` → index into `simulationTxs` of the tx that emitted the
|
|
15
17
|
* underlying log; consumers map back via `simulationTxs[transfer.txIdx]`.
|
|
16
18
|
*
|
|
@@ -27,15 +29,15 @@ import type { SimulateParams, SimulationConfig, SimulationResult } from "../type
|
|
|
27
29
|
* empty transactions, malformed authorizations).
|
|
28
30
|
* @throws {UnsupportedChainError} when the chain is not configured for any backend.
|
|
29
31
|
* @throws {SimulationRevertedError} when the bundle reverts on either backend.
|
|
30
|
-
* @throws {BlacklistViolationError} when the simulation leaves value retained by
|
|
31
|
-
*
|
|
32
|
+
* @throws {BlacklistViolationError} when the simulation leaves value retained by
|
|
33
|
+
* a `bundler3` address beyond the dust threshold.
|
|
32
34
|
* @throws {ExternalServiceError} (a) when both backends are unavailable within the
|
|
33
35
|
* timeout budget, or (b) when a backend returns a `calls` array whose length does
|
|
34
36
|
* not match the resolved `simulationTxs` — refusing to map transfers with mismatched
|
|
35
37
|
* per-tx output.
|
|
36
38
|
* @returns A {@link SimulationResult} carrying the resolved `simulationTxs`, per-tx
|
|
37
|
-
* `calls` (aligned 1:1 with `simulationTxs`),
|
|
38
|
-
* with `txIdx`)
|
|
39
|
+
* `calls` (aligned 1:1 with `simulationTxs`), parsed `transfers` (each stamped
|
|
40
|
+
* with `txIdx`), and per-account net `assetChanges`.
|
|
39
41
|
* @example
|
|
40
42
|
* ```ts
|
|
41
43
|
* import { simulate } from "@morpho-org/evm-simulation";
|
|
@@ -11,8 +11,10 @@ import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, valida
|
|
|
11
11
|
*
|
|
12
12
|
* - `transfers` → user-facing preview / server-side verification.
|
|
13
13
|
* - `simulationTxs` + `transfers` → server-side verification before broadcast.
|
|
14
|
-
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed
|
|
15
|
-
*
|
|
14
|
+
* - `calls[i]` → per-tx raw backend output (`logs`, `status`, `returnData`, `gasUsed`).
|
|
15
|
+
* Aligned 1:1 with `simulationTxs[i]`.
|
|
16
|
+
* - `assetChanges` → net per-asset balance changes grouped by account (sender and
|
|
17
|
+
* counterparties) over the whole bundle, normalized to the same shape across backends.
|
|
16
18
|
* - `transfers[k].txIdx` → index into `simulationTxs` of the tx that emitted the
|
|
17
19
|
* underlying log; consumers map back via `simulationTxs[transfer.txIdx]`.
|
|
18
20
|
*
|
|
@@ -29,15 +31,15 @@ import { assertNoBundlerRetention, buildSimulationTxs, executeSimulation, valida
|
|
|
29
31
|
* empty transactions, malformed authorizations).
|
|
30
32
|
* @throws {UnsupportedChainError} when the chain is not configured for any backend.
|
|
31
33
|
* @throws {SimulationRevertedError} when the bundle reverts on either backend.
|
|
32
|
-
* @throws {BlacklistViolationError} when the simulation leaves value retained by
|
|
33
|
-
*
|
|
34
|
+
* @throws {BlacklistViolationError} when the simulation leaves value retained by
|
|
35
|
+
* a `bundler3` address beyond the dust threshold.
|
|
34
36
|
* @throws {ExternalServiceError} (a) when both backends are unavailable within the
|
|
35
37
|
* timeout budget, or (b) when a backend returns a `calls` array whose length does
|
|
36
38
|
* not match the resolved `simulationTxs` — refusing to map transfers with mismatched
|
|
37
39
|
* per-tx output.
|
|
38
40
|
* @returns A {@link SimulationResult} carrying the resolved `simulationTxs`, per-tx
|
|
39
|
-
* `calls` (aligned 1:1 with `simulationTxs`),
|
|
40
|
-
* with `txIdx`)
|
|
41
|
+
* `calls` (aligned 1:1 with `simulationTxs`), parsed `transfers` (each stamped
|
|
42
|
+
* with `txIdx`), and per-account net `assetChanges`.
|
|
41
43
|
* @example
|
|
42
44
|
* ```ts
|
|
43
45
|
* import { simulate } from "@morpho-org/evm-simulation";
|
|
@@ -81,5 +83,6 @@ export async function simulate(config, params) {
|
|
|
81
83
|
simulationTxs,
|
|
82
84
|
calls: result.calls,
|
|
83
85
|
transfers,
|
|
86
|
+
assetChanges: result.assetChanges,
|
|
84
87
|
};
|
|
85
88
|
}
|
package/lib/esm/types.d.ts
CHANGED
|
@@ -64,6 +64,33 @@ export type SimulationAuthorization = {
|
|
|
64
64
|
/** Defaults to maxUint256. Caller can set explicit amount for tokens that don't support max approval. */
|
|
65
65
|
amount?: bigint;
|
|
66
66
|
};
|
|
67
|
+
/**
|
|
68
|
+
* Net balance change for a single asset (one token) within an account's entry.
|
|
69
|
+
* Native ETH uses viem's `ethAddress` sentinel as `token`. `symbol`/`decimals`
|
|
70
|
+
* are best-effort and may be absent, notably on the `eth_simulateV1` fallback.
|
|
71
|
+
*/
|
|
72
|
+
export interface AssetChange {
|
|
73
|
+
readonly token: Address;
|
|
74
|
+
readonly symbol?: string;
|
|
75
|
+
readonly decimals?: number;
|
|
76
|
+
/** Signed net change of the account's balance, in raw token units. */
|
|
77
|
+
readonly diff: bigint;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Net per-token balance changes for one account over the whole bundle. Returned
|
|
81
|
+
* for every account that nets a non-zero change, the sender and counterparties
|
|
82
|
+
* alike (the zero address is kept for mints/burns). Accounts and their `changes`
|
|
83
|
+
* are sorted by address for deterministic, cross-backend output.
|
|
84
|
+
*
|
|
85
|
+
* Native-ETH coverage differs by backend: Tenderly reports the full net ETH
|
|
86
|
+
* delta (including ETH moved via internal calls), while the `eth_simulateV1`
|
|
87
|
+
* fallback derives native ETH only from the top-level transaction `value`s —
|
|
88
|
+
* ETH moved internally (e.g. a `WETH.withdraw` refund) is not captured there.
|
|
89
|
+
*/
|
|
90
|
+
export interface AccountAssetChanges {
|
|
91
|
+
readonly account: Address;
|
|
92
|
+
readonly changes: readonly AssetChange[];
|
|
93
|
+
}
|
|
67
94
|
/**
|
|
68
95
|
* A parsed ERC20 / WETH9 transfer extracted from simulation logs. Returned in
|
|
69
96
|
* `SimulationResult.transfers`.
|
|
@@ -87,7 +114,10 @@ export interface Transfer {
|
|
|
87
114
|
* - `simulationTxs` is the full resolved transaction list (including
|
|
88
115
|
* prepended authorization txs).
|
|
89
116
|
* - `calls[i]` corresponds 1:1 with `simulationTxs[i]` — read raw logs,
|
|
90
|
-
* status, returnData/gasUsed
|
|
117
|
+
* status, returnData/gasUsed.
|
|
118
|
+
* - `assetChanges` is the net per-asset balance change over the whole bundle,
|
|
119
|
+
* grouped by account (sender and counterparties), normalized to the same
|
|
120
|
+
* shape across backends — see `AccountAssetChanges`.
|
|
91
121
|
* - `transfers[k].txIdx` indexes into `simulationTxs` to attribute each
|
|
92
122
|
* transfer to its emitting transaction.
|
|
93
123
|
*/
|
|
@@ -96,12 +126,13 @@ export interface SimulationResult {
|
|
|
96
126
|
readonly simulationTxs: readonly SimulationTransaction[];
|
|
97
127
|
/**
|
|
98
128
|
* Per-transaction normalized output. `calls[i]` corresponds 1:1 with
|
|
99
|
-
* `simulationTxs[i]`. Use this to read raw logs, status, return data, gas
|
|
100
|
-
* used, and (Tenderly only) asset changes per transaction.
|
|
129
|
+
* `simulationTxs[i]`. Use this to read raw logs, status, return data, gas used.
|
|
101
130
|
*/
|
|
102
131
|
readonly calls: readonly SimulationCall[];
|
|
103
132
|
/** Parsed ERC-20 / WETH9 transfers from the simulation. */
|
|
104
133
|
readonly transfers: readonly Transfer[];
|
|
134
|
+
/** Net per-asset balance changes, grouped by account, over the whole bundle. */
|
|
135
|
+
readonly assetChanges: readonly AccountAssetChanges[];
|
|
105
136
|
}
|
|
106
137
|
/** Minimal structured logger the package calls for warnings and info. */
|
|
107
138
|
export interface SimulationLogger {
|
|
@@ -122,10 +153,11 @@ export interface SimulateParams {
|
|
|
122
153
|
/**
|
|
123
154
|
* Internal raw result from a simulation adapter before normalization.
|
|
124
155
|
* `calls[i]` corresponds 1:1 with the i-th transaction passed to the
|
|
125
|
-
* backend.
|
|
156
|
+
* backend; `assetChanges` is the bundle-level aggregate grouped by account.
|
|
126
157
|
*/
|
|
127
158
|
export interface RawSimulationResult {
|
|
128
159
|
calls: RawCall[];
|
|
160
|
+
assetChanges: AccountAssetChanges[];
|
|
129
161
|
}
|
|
130
162
|
/**
|
|
131
163
|
* Normalized EVM log emitted by a single simulated call. The shape is the
|
|
@@ -149,15 +181,14 @@ export interface RawCall {
|
|
|
149
181
|
status: boolean;
|
|
150
182
|
returnData: Hex;
|
|
151
183
|
gasUsed: bigint;
|
|
152
|
-
/** Tenderly-only `assetChanges` payload. Absent on `eth_simulateV1`. */
|
|
153
|
-
assetChanges?: unknown;
|
|
154
184
|
}
|
|
155
185
|
/**
|
|
156
186
|
* Per-transaction normalized output from the simulation backend.
|
|
157
187
|
*
|
|
158
188
|
* `SimulationResult.calls[i]` corresponds 1:1 with
|
|
159
189
|
* `SimulationResult.simulationTxs[i]`. Use this to read raw logs, status,
|
|
160
|
-
* return data, gas used
|
|
190
|
+
* return data, and gas used. Net asset changes are reported at the bundle
|
|
191
|
+
* level — see `SimulationResult.assetChanges`.
|
|
161
192
|
*/
|
|
162
193
|
export interface SimulationCall {
|
|
163
194
|
readonly logs: readonly RawLog[];
|
|
@@ -172,9 +203,4 @@ export interface SimulationCall {
|
|
|
172
203
|
readonly returnData: Hex;
|
|
173
204
|
/** Gas used by this call (root frame). */
|
|
174
205
|
readonly gasUsed: bigint;
|
|
175
|
-
/**
|
|
176
|
-
* Tenderly-only `assetChanges` payload for this tx. Opaque `unknown` —
|
|
177
|
-
* do not destructure without validation. Absent on `eth_simulateV1`.
|
|
178
|
-
*/
|
|
179
|
-
readonly assetChanges?: unknown;
|
|
180
206
|
}
|
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": "4.0.1",
|
|
5
5
|
"author": "Morpho Association <contact@morpho.org>",
|
|
6
6
|
"contributors": [
|
|
7
7
|
"Rubilmax <rmilon@gmail.com>"
|
|
@@ -25,23 +25,25 @@
|
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"zod": "^4.4.3",
|
|
28
|
-
"@morpho-org/
|
|
29
|
-
"@morpho-org/
|
|
28
|
+
"@morpho-org/morpho-ts": "^2.6.0",
|
|
29
|
+
"@morpho-org/blue-sdk": "^6.2.0"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"viem": "^2.0.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"typescript": "^6.0.3",
|
|
36
|
-
"viem": "
|
|
37
|
-
"vitest": "^4.1.
|
|
36
|
+
"viem": "2.52.2",
|
|
37
|
+
"vitest": "^4.1.8",
|
|
38
|
+
"@morpho-org/test": "^2.8.1"
|
|
38
39
|
},
|
|
39
40
|
"scripts": {
|
|
40
41
|
"prepublish": "$npm_execpath build",
|
|
41
42
|
"build": "tsc --noEmit && $npm_execpath build:cjs && $npm_execpath build:esm",
|
|
42
43
|
"build:cjs": "tsc --build tsconfig.build.cjs.json && echo '{\"type\":\"commonjs\"}' > lib/cjs/package.json",
|
|
43
44
|
"build:esm": "tsc --build tsconfig.build.esm.json && echo '{\"type\":\"module\"}' > lib/esm/package.json",
|
|
44
|
-
"test": "vitest --root ../.. --project evm-simulation"
|
|
45
|
+
"test": "vitest --root ../.. --project evm-simulation",
|
|
46
|
+
"test:fork": "dotenv -- vitest --root ../.. --project evm-simulation-fork"
|
|
45
47
|
},
|
|
46
48
|
"exports": {
|
|
47
49
|
".": {
|