@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.
@@ -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 { calls: rawCalls };
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.unknown().optional(),
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 { calls: [toRawCall(result)] };
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 result = unwrapResult(bundleEnvelope.parse(json));
96
- return { calls: result.map(toRawCall) };
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 flag when
13
- * `|net| > DUST_THRESHOLD` — catching both **retention** (tokens stuck in
14
- * bundler, positive net) and **drain** (bundler sending more than it received
15
- * in this bundle, negative net — which implies a pre-existing or state-override
16
- * balance is being drawn down, a red flag for pre-broadcast integrity).
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 flag when
51
- * `|net| > DUST_THRESHOLD` — catching both **retention** (tokens stuck in
52
- * bundler, positive net) and **drain** (bundler sending more than it received
53
- * in this bundle, negative net — which implies a pre-existing or state-override
54
- * balance is being drawn down, a red flag for pre-broadcast integrity).
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 flagged = [...flow.values()].filter((e) => absBigInt(e.netChange) > DUST_THRESHOLD);
81
- if (flagged.length > 0) {
82
- throw new errors_js_1.BlacklistViolationError("Simulation detected asset transfers retained or drained by restricted bundler contracts", flagged.map((e) => ({
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
- * and Tenderly-only `assetChanges`). Aligned 1:1 with `simulationTxs[i]`.
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 or
31
- * drained from a `bundler3` address beyond the dust threshold.
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`), and parsed `transfers` (each stamped
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
- * and Tenderly-only `assetChanges`). Aligned 1:1 with `simulationTxs[i]`.
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 or
36
- * drained from a `bundler3` address beyond the dust threshold.
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`), and parsed `transfers` (each stamped
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
  }
@@ -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, and (Tenderly only) assetChanges per tx.
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, and (Tenderly only) asset changes per transaction.
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
  }
@@ -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 { calls: rawCalls };
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.unknown().optional(),
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 { calls: [toRawCall(result)] };
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 result = unwrapResult(bundleEnvelope.parse(json));
93
- return { calls: result.map(toRawCall) };
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 flag when
13
- * `|net| > DUST_THRESHOLD` — catching both **retention** (tokens stuck in
14
- * bundler, positive net) and **drain** (bundler sending more than it received
15
- * in this bundle, negative net — which implies a pre-existing or state-override
16
- * balance is being drawn down, a red flag for pre-broadcast integrity).
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 flag when
48
- * `|net| > DUST_THRESHOLD` — catching both **retention** (tokens stuck in
49
- * bundler, positive net) and **drain** (bundler sending more than it received
50
- * in this bundle, negative net — which implies a pre-existing or state-override
51
- * balance is being drawn down, a red flag for pre-broadcast integrity).
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 flagged = [...flow.values()].filter((e) => absBigInt(e.netChange) > DUST_THRESHOLD);
78
- if (flagged.length > 0) {
79
- throw new BlacklistViolationError("Simulation detected asset transfers retained or drained by restricted bundler contracts", flagged.map((e) => ({
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
- * and Tenderly-only `assetChanges`). Aligned 1:1 with `simulationTxs[i]`.
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 or
31
- * drained from a `bundler3` address beyond the dust threshold.
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`), and parsed `transfers` (each stamped
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
- * and Tenderly-only `assetChanges`). Aligned 1:1 with `simulationTxs[i]`.
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 or
33
- * drained from a `bundler3` address beyond the dust threshold.
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`), and parsed `transfers` (each stamped
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
  }
@@ -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, and (Tenderly only) assetChanges per tx.
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, and (Tenderly only) asset changes per transaction.
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": "3.0.0",
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/blue-sdk": "^6.1.0",
29
- "@morpho-org/morpho-ts": "^2.6.0"
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": "^2.50.4",
37
- "vitest": "^4.1.7"
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
  ".": {
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ export {
12
12
  export { simulate } from "./simulate/index.js";
13
13
  // Types
14
14
  export type {
15
+ AccountAssetChanges,
16
+ AssetChange,
15
17
  ChainSimulationConfig,
16
18
  RawLog,
17
19
  SimulateParams,