@morpho-org/evm-simulation 5.0.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,11 +46,11 @@ try {
46
46
  }
47
47
  ```
48
48
 
49
- Every chain entry requires `simulateV1Url`, pointing to a JSON-RPC endpoint that supports `eth_simulateV1`. Execution uses the full `timeoutMs` budget (default 5000 ms), with no retries or provider fallback. RPC failures, timeouts and reverts throw typed errors. The optional logger still reports parsing and retention warnings.
49
+ Every chain entry requires `simulateV1Url`, pointing to a JSON-RPC endpoint that supports `eth_simulateV1`. Requests send `validation: false` so gas is not charged. Monad (chain 143) is handled internally: its nodes reject `false`, so it sends `true` (its simulated block charges no gas either way), and it pins to the `finalized` block by default because its `latest` block is not final. On Monad, `gasUsed` reports the call's gas limit, not the gas consumed. Set `blockOverrides.gasLimit` on a chain entry to send that gas limit as the simulated block's `blockOverrides.gasLimit`; without it, no block override is sent. Set `parentHashCheck` on a chain entry to turn on or off the check that the simulated block's `parentHash` is the pinned block hash. It defaults to off on Stable (chain 988), whose nodes report a `parentHash` that never matches the pinned block, and on for every other chain. The block number, timestamp and reorg checks always apply. Execution uses the full `timeoutMs` budget (default 5000 ms), with no retries or provider fallback. RPC failures, timeouts and reverts throw typed errors. The optional logger still reports parsing and retention warnings.
50
50
 
51
51
  Native-ETH movements are observed through `traceTransfers` logs on the simulated calls — no `stateOverrides` or helper contracts are injected.
52
52
 
53
- This is the unreleased v5 integration stack. See the [v4 → v5 migration guide](../../docs/migrations/evm-simulation-v4-to-v5.md) for the backend cutover and remaining release gates.
53
+ Upgrading from v4? See the [v4 → v5 migration guide](../../docs/migrations/evm-simulation-v4-to-v5.md) for the backend cutover.
54
54
 
55
55
  ### API surface
56
56
 
@@ -17,7 +17,8 @@ export interface SimulateParams {
17
17
  readonly mode?: SimulationMode;
18
18
  /** Pending wallet requests, in order. Preview only; rejected in final. */
19
19
  readonly authorizations?: readonly SimulationAuthorization[];
20
- /** Resolved once; defaults to "latest". Only canonical (mined) blocks can be
20
+ /** Resolved once; defaults to "latest" ("finalized" on Monad, whose
21
+ * "latest" block is not final). Only canonical (mined) blocks can be
21
22
  * pinned; `pending` has no stable hash and is rejected at runtime. */
22
23
  readonly blockNumber?: bigint | Exclude<BlockTag, "pending">;
23
24
  /** Caller-selected quotes and percentage tolerances. Omitted limits are unchecked. */
@@ -26,9 +26,13 @@ export declare function assertEndpointChain(params: {
26
26
  *
27
27
  * 1. **`eth_simulateV1`** — one `blockStateCalls` entry carrying the planned
28
28
  * calls with their per-call `from`, `traceTransfers: true` so the node
29
- * synthesizes native-ETH moves as transfer logs, and
30
- * `validation: false`. Validation-off means gas is not charged, which is
31
- * how gas is separated from economic effects. **No balance override is
29
+ * synthesizes native-ETH moves as transfer logs, and `validation: false`
30
+ * (`true` on Monad, whose nodes reject `false` and charge no gas in the
31
+ * simulated block). Validation-off means gas is not charged, which is how
32
+ * gas is separated from economic effects. When `blockGasLimit` is set,
33
+ * it is sent as the block's `blockOverrides.gasLimit`; otherwise no block
34
+ * override is sent.
35
+ * **No balance override is
32
36
  * applied** — `value` transfers are funded by the sender's real native
33
37
  * balance.
34
38
  * 2. **Response validation** — the response is parsed before the reorg
@@ -45,8 +49,9 @@ export declare function assertEndpointChain(params: {
45
49
  * The endpoint must support `eth_simulateV1` with per-call `from`; there is
46
50
  * no fallback backend.
47
51
  *
48
- * @param params - Shared simulation client, execution plan, and already-pinned
49
- * state block.
52
+ * @param params - Shared simulation client, execution plan, already-pinned
53
+ * state block, the `validation` flag to send, the optional simulated
54
+ * block gas limit, and whether to check the successor `parentHash`.
50
55
  * @returns Deep-frozen {@link SimulationExecution} — per-transaction call
51
56
  * results and the resolved {@link ExecutionBlock}.
52
57
  * @throws {ExternalServiceError} For transport failures, timeouts,
@@ -66,5 +71,8 @@ export declare function executePlan(params: {
66
71
  client: Client & Pick<PublicClient, "getBlock">;
67
72
  plan: ExecutionPlan;
68
73
  stateBlock: PinnedBlock;
74
+ validation: boolean;
75
+ blockGasLimit?: bigint | undefined;
76
+ parentHashCheck: boolean;
69
77
  }): Promise<SimulationExecution>;
70
78
  export {};
@@ -89,9 +89,13 @@ async function assertEndpointChain(params) {
89
89
  *
90
90
  * 1. **`eth_simulateV1`** — one `blockStateCalls` entry carrying the planned
91
91
  * calls with their per-call `from`, `traceTransfers: true` so the node
92
- * synthesizes native-ETH moves as transfer logs, and
93
- * `validation: false`. Validation-off means gas is not charged, which is
94
- * how gas is separated from economic effects. **No balance override is
92
+ * synthesizes native-ETH moves as transfer logs, and `validation: false`
93
+ * (`true` on Monad, whose nodes reject `false` and charge no gas in the
94
+ * simulated block). Validation-off means gas is not charged, which is how
95
+ * gas is separated from economic effects. When `blockGasLimit` is set,
96
+ * it is sent as the block's `blockOverrides.gasLimit`; otherwise no block
97
+ * override is sent.
98
+ * **No balance override is
95
99
  * applied** — `value` transfers are funded by the sender's real native
96
100
  * balance.
97
101
  * 2. **Response validation** — the response is parsed before the reorg
@@ -108,8 +112,9 @@ async function assertEndpointChain(params) {
108
112
  * The endpoint must support `eth_simulateV1` with per-call `from`; there is
109
113
  * no fallback backend.
110
114
  *
111
- * @param params - Shared simulation client, execution plan, and already-pinned
112
- * state block.
115
+ * @param params - Shared simulation client, execution plan, already-pinned
116
+ * state block, the `validation` flag to send, the optional simulated
117
+ * block gas limit, and whether to check the successor `parentHash`.
113
118
  * @returns Deep-frozen {@link SimulationExecution} — per-transaction call
114
119
  * results and the resolved {@link ExecutionBlock}.
115
120
  * @throws {ExternalServiceError} For transport failures, timeouts,
@@ -126,13 +131,16 @@ async function assertEndpointChain(params) {
126
131
  * @internal
127
132
  */
128
133
  async function executePlan(params) {
129
- const { client, plan, stateBlock } = params;
134
+ const { client, plan, stateBlock, validation, blockGasLimit, parentHashCheck, } = params;
130
135
  const response = await (0, exports.rpc)("eth_simulateV1", () => client.request({
131
136
  method: "eth_simulateV1",
132
137
  params: [
133
138
  {
134
139
  blockStateCalls: [
135
140
  {
141
+ ...(blockGasLimit !== undefined && {
142
+ blockOverrides: { gasLimit: (0, viem_1.numberToHex)(blockGasLimit) },
143
+ }),
136
144
  calls: plan.calls.map((call) => ({
137
145
  from: call.transaction.from,
138
146
  to: call.transaction.to,
@@ -142,7 +150,7 @@ async function executePlan(params) {
142
150
  },
143
151
  ],
144
152
  traceTransfers: true,
145
- validation: false,
153
+ validation,
146
154
  },
147
155
  (0, viem_1.numberToHex)(stateBlock.number),
148
156
  ],
@@ -158,6 +166,7 @@ async function executePlan(params) {
158
166
  stateBlockNumber: stateBlock.number,
159
167
  stateBlockHash: stateBlock.hash,
160
168
  stateBlockTimestamp: stateBlock.timestamp,
169
+ parentHashCheck,
161
170
  });
162
171
  // Reorg window: the pinned state block must still carry the same hash
163
172
  // after simulation, or the result may describe a different chain tip.
@@ -65,5 +65,6 @@ export declare function parseSimulationResponse(params: {
65
65
  readonly stateBlockNumber: bigint;
66
66
  readonly stateBlockHash: Hex;
67
67
  readonly stateBlockTimestamp: bigint;
68
+ readonly parentHashCheck: boolean;
68
69
  }): SimulationExecution;
69
70
  export {};
@@ -57,8 +57,10 @@ function parseSimulationResponse(params) {
57
57
  }
58
58
  // Anvil re-hashes the pinned block, so only the geth-style successor can be
59
59
  // pinned by hash: it must report a parentHash (geth always does) equal to
60
- // the pinned state block hash.
61
- if (blockNumber === params.stateBlockNumber + 1n &&
60
+ // the pinned state block hash. With `parentHashCheck` off (Stable by
61
+ // default) the block number, timestamp and reorg checks still apply.
62
+ if (params.parentHashCheck &&
63
+ blockNumber === params.stateBlockNumber + 1n &&
62
64
  block.parentHash !== params.stateBlockHash) {
63
65
  throw new errors_js_1.InvalidSimulationResponseError(`eth_simulateV1 reported block ${blockNumber} whose parent ${String(block.parentHash)} is not the pinned state block hash ${params.stateBlockHash}; the node did not simulate on top of the pinned block.`, { context: errorContext });
64
66
  }
@@ -4,6 +4,10 @@ import type { ChainSimulationConfig, SimulationConfig } from "../../types.js";
4
4
  *
5
5
  * Looks up the per-chain `ChainSimulationConfig`. Throws `UnsupportedChainError`
6
6
  * when the chain is absent from the map or has no `simulateV1Url` (defensive —
7
- * the type already requires it at construction).
7
+ * the type already requires it at construction). Throws
8
+ * `SimulationValidationError` when `blockOverrides.gasLimit` is set but is
9
+ * not a positive bigint, or `parentHashCheck` is set but is not a boolean, so a
10
+ * misconfiguration never surfaces as a bypassable
11
+ * `ExternalServiceError`.
8
12
  */
9
13
  export declare function resolveChain(config: SimulationConfig, chainId: number): ChainSimulationConfig;
@@ -7,11 +7,22 @@ const errors_js_1 = require("../../errors.js");
7
7
  *
8
8
  * Looks up the per-chain `ChainSimulationConfig`. Throws `UnsupportedChainError`
9
9
  * when the chain is absent from the map or has no `simulateV1Url` (defensive —
10
- * the type already requires it at construction).
10
+ * the type already requires it at construction). Throws
11
+ * `SimulationValidationError` when `blockOverrides.gasLimit` is set but is
12
+ * not a positive bigint, or `parentHashCheck` is set but is not a boolean, so a
13
+ * misconfiguration never surfaces as a bypassable
14
+ * `ExternalServiceError`.
11
15
  */
12
16
  function resolveChain(config, chainId) {
13
17
  const entry = config.chains.get(chainId);
14
18
  if (!entry?.simulateV1Url)
15
19
  throw new errors_js_1.UnsupportedChainError(chainId);
20
+ const gasLimit = entry.blockOverrides?.gasLimit;
21
+ if (gasLimit !== undefined &&
22
+ (typeof gasLimit !== "bigint" || gasLimit <= 0n))
23
+ throw new errors_js_1.SimulationValidationError(`Chain ${chainId} blockOverrides.gasLimit must be a positive bigint, got "${String(gasLimit)}" (${typeof gasLimit}). Pass a bigint greater than 0n or omit gasLimit.`, ["blockOverrides.gasLimit: must be a positive bigint"]);
24
+ const parentHashCheck = entry.parentHashCheck;
25
+ if (parentHashCheck !== undefined && typeof parentHashCheck !== "boolean")
26
+ throw new errors_js_1.SimulationValidationError(`Chain ${chainId} parentHashCheck must be a boolean, got "${String(parentHashCheck)}" (${typeof parentHashCheck}). Pass true or false, or omit parentHashCheck.`, ["parentHashCheck: must be a boolean"]);
16
27
  return entry;
17
28
  }
@@ -51,9 +51,13 @@ async function runSimulation(params) {
51
51
  mode: request.mode,
52
52
  blockNumber: typeof request.blockNumber === "bigint" ? request.blockNumber : undefined,
53
53
  });
54
+ // Monad nodes reject `validation: false` and charge no gas in the simulated
55
+ // block either way. Its `latest` block is not final: simulating at its number
56
+ // runs on another parent, so default to `finalized`.
57
+ const monad = request.chainId === blue_sdk_1.ChainId.MonadMainnet;
54
58
  const pinnedBlock = await (0, resolve_pinned_block_js_1.resolvePinnedBlock)({
55
59
  client,
56
- blockNumber: request.blockNumber,
60
+ blockNumber: request.blockNumber ?? (monad ? "finalized" : undefined),
57
61
  signal,
58
62
  });
59
63
  // With no calldata decoding, the caller's declared `limits.operations`
@@ -86,6 +90,9 @@ async function runSimulation(params) {
86
90
  client,
87
91
  plan,
88
92
  stateBlock: pinnedBlock,
93
+ validation: monad,
94
+ blockGasLimit: chain.blockOverrides?.gasLimit,
95
+ parentHashCheck: chain.parentHashCheck ?? request.chainId !== blue_sdk_1.ChainId.StableMainnet,
89
96
  });
90
97
  const userCalls = execution.calls
91
98
  .filter((call) => call.planned.type === "transaction")
@@ -34,10 +34,12 @@ import type { SimulationConfig } from "../types.js";
34
34
  * **Funding.** `value` transfers are funded by the sender's real native
35
35
  * balance — no balance inflation — so under-funded bundles revert exactly as
36
36
  * they would on-chain. `validation: false` keeps gas from being charged,
37
- * separating gas from economic effects.
37
+ * separating gas from economic effects. Monad nodes reject `false`, so Monad
38
+ * simulations send `true`; its simulated block charges no gas either way.
38
39
  *
39
- * @param config - Required per-chain `eth_simulateV1` URL, optional logger, and
40
- * the overall timeout budget.
40
+ * @param config - Required per-chain `eth_simulateV1` URL, optional per-chain
41
+ * `blockOverrides.gasLimit` and `parentHashCheck`, optional logger, and the
42
+ * overall timeout budget.
41
43
  * @param params - Per-call simulation input.
42
44
  * @param params.chainId - Chain id the bundle targets; must match the endpoint.
43
45
  * @param params.transactions - The bundle's transactions, in execution order.
@@ -47,10 +49,13 @@ import type { SimulationConfig } from "../types.js";
47
49
  * @param params.limits - Optional quotes and percentage tolerances; unquoted amounts require no reads.
48
50
  * @param params.blockNumber - Optional pinned block number or `BlockTag` other
49
51
  * than `"pending"`.
50
- * Defaults to `latest`, resolved exactly once.
52
+ * Defaults to `latest` (`finalized` on Monad, whose `latest` block is not
53
+ * final), resolved exactly once.
51
54
  * @throws {SimulationValidationError} for invalid input (mixed senders, bad
52
55
  * addresses, empty transactions, malformed authorizations, final-mode
53
- * authorizations, malformed limits, unknown fields, a `"pending"` block tag, or share quotes for
56
+ * authorizations, malformed limits, unknown fields, a `"pending"` block tag, a
57
+ * `blockOverrides.gasLimit` that is not a positive bigint, a `parentHashCheck`
58
+ * that is not a boolean, or share quotes for
54
59
  * `blueSupplyCollateral` / `blueWithdrawCollateral`).
55
60
  * @throws {ConsumerLimitViolationError} when a declared `limits` bound is
56
61
  * violated by the observed effects.
@@ -64,7 +69,7 @@ import type { SimulationConfig } from "../types.js";
64
69
  * @throws {InvalidSimulationResponseError} when the node response cannot be
65
70
  * trusted (bad shape, call-count mismatch, block that is neither the pinned
66
71
  * state block nor its immediate successor, a successor with a mismatched
67
- * `parentHash`, a block timestamp earlier than the pinned block's, a
72
+ * `parentHash` (unless `parentHashCheck` is off; off by default on Stable, chain 988), a block timestamp earlier than the pinned block's, a
68
73
  * malformed per-call result, a quoted balance/position read whose non-empty
69
74
  * return data cannot be decoded, a state-block hash that changed or a pinned
70
75
  * block that vanished mid-flight, or an
@@ -36,10 +36,12 @@ const run_simulation_js_1 = require("./run-simulation.js");
36
36
  * **Funding.** `value` transfers are funded by the sender's real native
37
37
  * balance — no balance inflation — so under-funded bundles revert exactly as
38
38
  * they would on-chain. `validation: false` keeps gas from being charged,
39
- * separating gas from economic effects.
39
+ * separating gas from economic effects. Monad nodes reject `false`, so Monad
40
+ * simulations send `true`; its simulated block charges no gas either way.
40
41
  *
41
- * @param config - Required per-chain `eth_simulateV1` URL, optional logger, and
42
- * the overall timeout budget.
42
+ * @param config - Required per-chain `eth_simulateV1` URL, optional per-chain
43
+ * `blockOverrides.gasLimit` and `parentHashCheck`, optional logger, and the
44
+ * overall timeout budget.
43
45
  * @param params - Per-call simulation input.
44
46
  * @param params.chainId - Chain id the bundle targets; must match the endpoint.
45
47
  * @param params.transactions - The bundle's transactions, in execution order.
@@ -49,10 +51,13 @@ const run_simulation_js_1 = require("./run-simulation.js");
49
51
  * @param params.limits - Optional quotes and percentage tolerances; unquoted amounts require no reads.
50
52
  * @param params.blockNumber - Optional pinned block number or `BlockTag` other
51
53
  * than `"pending"`.
52
- * Defaults to `latest`, resolved exactly once.
54
+ * Defaults to `latest` (`finalized` on Monad, whose `latest` block is not
55
+ * final), resolved exactly once.
53
56
  * @throws {SimulationValidationError} for invalid input (mixed senders, bad
54
57
  * addresses, empty transactions, malformed authorizations, final-mode
55
- * authorizations, malformed limits, unknown fields, a `"pending"` block tag, or share quotes for
58
+ * authorizations, malformed limits, unknown fields, a `"pending"` block tag, a
59
+ * `blockOverrides.gasLimit` that is not a positive bigint, a `parentHashCheck`
60
+ * that is not a boolean, or share quotes for
56
61
  * `blueSupplyCollateral` / `blueWithdrawCollateral`).
57
62
  * @throws {ConsumerLimitViolationError} when a declared `limits` bound is
58
63
  * violated by the observed effects.
@@ -66,7 +71,7 @@ const run_simulation_js_1 = require("./run-simulation.js");
66
71
  * @throws {InvalidSimulationResponseError} when the node response cannot be
67
72
  * trusted (bad shape, call-count mismatch, block that is neither the pinned
68
73
  * state block nor its immediate successor, a successor with a mismatched
69
- * `parentHash`, a block timestamp earlier than the pinned block's, a
74
+ * `parentHash` (unless `parentHashCheck` is off; off by default on Stable, chain 988), a block timestamp earlier than the pinned block's, a
70
75
  * malformed per-call result, a quoted balance/position read whose non-empty
71
76
  * return data cannot be decoded, a state-block hash that changed or a pinned
72
77
  * block that vanished mid-flight, or an
@@ -3,6 +3,21 @@ import type { Address, Hex } from "viem";
3
3
  export interface ChainSimulationConfig {
4
4
  /** JSON-RPC URL supporting `eth_simulateV1`. Required for every configured chain. */
5
5
  readonly simulateV1Url: string;
6
+ /**
7
+ * Block overrides forwarded to `eth_simulateV1` as the simulated block's
8
+ * `blockOverrides`. Omitted fields leave the node's defaults untouched.
9
+ */
10
+ readonly blockOverrides?: {
11
+ /** Gas limit of the simulated block. Omitted: no gas limit override is sent. */
12
+ readonly gasLimit?: bigint;
13
+ };
14
+ /**
15
+ * Whether a simulated successor block's `parentHash` must equal the pinned
16
+ * state block hash. Block number, timestamp and reorg checks always run.
17
+ * Omitted: on everywhere except Stable (988), whose nodes report a
18
+ * `parentHash` that never matches the pinned block.
19
+ */
20
+ readonly parentHashCheck?: boolean;
6
21
  }
7
22
  /**
8
23
  * Top-level configuration for `simulate`.
@@ -145,7 +160,8 @@ export interface SimulationCall {
145
160
  * Gas consumed by this call's root frame, not a safe gas limit. This is
146
161
  * post-refund consumption and does not account for EIP-150's 63/64 rule in
147
162
  * nested calls, so consumers deriving a limit must add their own headroom,
148
- * larger than headroom derived from `eth_estimateGas`.
163
+ * larger than headroom derived from `eth_estimateGas`. On Monad it reports the
164
+ * call's gas limit (150M by default) instead of gas consumed.
149
165
  */
150
166
  readonly gasUsed: bigint;
151
167
  }
@@ -17,7 +17,8 @@ export interface SimulateParams {
17
17
  readonly mode?: SimulationMode;
18
18
  /** Pending wallet requests, in order. Preview only; rejected in final. */
19
19
  readonly authorizations?: readonly SimulationAuthorization[];
20
- /** Resolved once; defaults to "latest". Only canonical (mined) blocks can be
20
+ /** Resolved once; defaults to "latest" ("finalized" on Monad, whose
21
+ * "latest" block is not final). Only canonical (mined) blocks can be
21
22
  * pinned; `pending` has no stable hash and is rejected at runtime. */
22
23
  readonly blockNumber?: bigint | Exclude<BlockTag, "pending">;
23
24
  /** Caller-selected quotes and percentage tolerances. Omitted limits are unchecked. */
@@ -26,9 +26,13 @@ export declare function assertEndpointChain(params: {
26
26
  *
27
27
  * 1. **`eth_simulateV1`** — one `blockStateCalls` entry carrying the planned
28
28
  * calls with their per-call `from`, `traceTransfers: true` so the node
29
- * synthesizes native-ETH moves as transfer logs, and
30
- * `validation: false`. Validation-off means gas is not charged, which is
31
- * how gas is separated from economic effects. **No balance override is
29
+ * synthesizes native-ETH moves as transfer logs, and `validation: false`
30
+ * (`true` on Monad, whose nodes reject `false` and charge no gas in the
31
+ * simulated block). Validation-off means gas is not charged, which is how
32
+ * gas is separated from economic effects. When `blockGasLimit` is set,
33
+ * it is sent as the block's `blockOverrides.gasLimit`; otherwise no block
34
+ * override is sent.
35
+ * **No balance override is
32
36
  * applied** — `value` transfers are funded by the sender's real native
33
37
  * balance.
34
38
  * 2. **Response validation** — the response is parsed before the reorg
@@ -45,8 +49,9 @@ export declare function assertEndpointChain(params: {
45
49
  * The endpoint must support `eth_simulateV1` with per-call `from`; there is
46
50
  * no fallback backend.
47
51
  *
48
- * @param params - Shared simulation client, execution plan, and already-pinned
49
- * state block.
52
+ * @param params - Shared simulation client, execution plan, already-pinned
53
+ * state block, the `validation` flag to send, the optional simulated
54
+ * block gas limit, and whether to check the successor `parentHash`.
50
55
  * @returns Deep-frozen {@link SimulationExecution} — per-transaction call
51
56
  * results and the resolved {@link ExecutionBlock}.
52
57
  * @throws {ExternalServiceError} For transport failures, timeouts,
@@ -66,5 +71,8 @@ export declare function executePlan(params: {
66
71
  client: Client & Pick<PublicClient, "getBlock">;
67
72
  plan: ExecutionPlan;
68
73
  stateBlock: PinnedBlock;
74
+ validation: boolean;
75
+ blockGasLimit?: bigint | undefined;
76
+ parentHashCheck: boolean;
69
77
  }): Promise<SimulationExecution>;
70
78
  export {};
@@ -83,9 +83,13 @@ export async function assertEndpointChain(params) {
83
83
  *
84
84
  * 1. **`eth_simulateV1`** — one `blockStateCalls` entry carrying the planned
85
85
  * calls with their per-call `from`, `traceTransfers: true` so the node
86
- * synthesizes native-ETH moves as transfer logs, and
87
- * `validation: false`. Validation-off means gas is not charged, which is
88
- * how gas is separated from economic effects. **No balance override is
86
+ * synthesizes native-ETH moves as transfer logs, and `validation: false`
87
+ * (`true` on Monad, whose nodes reject `false` and charge no gas in the
88
+ * simulated block). Validation-off means gas is not charged, which is how
89
+ * gas is separated from economic effects. When `blockGasLimit` is set,
90
+ * it is sent as the block's `blockOverrides.gasLimit`; otherwise no block
91
+ * override is sent.
92
+ * **No balance override is
89
93
  * applied** — `value` transfers are funded by the sender's real native
90
94
  * balance.
91
95
  * 2. **Response validation** — the response is parsed before the reorg
@@ -102,8 +106,9 @@ export async function assertEndpointChain(params) {
102
106
  * The endpoint must support `eth_simulateV1` with per-call `from`; there is
103
107
  * no fallback backend.
104
108
  *
105
- * @param params - Shared simulation client, execution plan, and already-pinned
106
- * state block.
109
+ * @param params - Shared simulation client, execution plan, already-pinned
110
+ * state block, the `validation` flag to send, the optional simulated
111
+ * block gas limit, and whether to check the successor `parentHash`.
107
112
  * @returns Deep-frozen {@link SimulationExecution} — per-transaction call
108
113
  * results and the resolved {@link ExecutionBlock}.
109
114
  * @throws {ExternalServiceError} For transport failures, timeouts,
@@ -120,13 +125,16 @@ export async function assertEndpointChain(params) {
120
125
  * @internal
121
126
  */
122
127
  export async function executePlan(params) {
123
- const { client, plan, stateBlock } = params;
128
+ const { client, plan, stateBlock, validation, blockGasLimit, parentHashCheck, } = params;
124
129
  const response = await rpc("eth_simulateV1", () => client.request({
125
130
  method: "eth_simulateV1",
126
131
  params: [
127
132
  {
128
133
  blockStateCalls: [
129
134
  {
135
+ ...(blockGasLimit !== undefined && {
136
+ blockOverrides: { gasLimit: numberToHex(blockGasLimit) },
137
+ }),
130
138
  calls: plan.calls.map((call) => ({
131
139
  from: call.transaction.from,
132
140
  to: call.transaction.to,
@@ -136,7 +144,7 @@ export async function executePlan(params) {
136
144
  },
137
145
  ],
138
146
  traceTransfers: true,
139
- validation: false,
147
+ validation,
140
148
  },
141
149
  numberToHex(stateBlock.number),
142
150
  ],
@@ -152,6 +160,7 @@ export async function executePlan(params) {
152
160
  stateBlockNumber: stateBlock.number,
153
161
  stateBlockHash: stateBlock.hash,
154
162
  stateBlockTimestamp: stateBlock.timestamp,
163
+ parentHashCheck,
155
164
  });
156
165
  // Reorg window: the pinned state block must still carry the same hash
157
166
  // after simulation, or the result may describe a different chain tip.
@@ -65,5 +65,6 @@ export declare function parseSimulationResponse(params: {
65
65
  readonly stateBlockNumber: bigint;
66
66
  readonly stateBlockHash: Hex;
67
67
  readonly stateBlockTimestamp: bigint;
68
+ readonly parentHashCheck: boolean;
68
69
  }): SimulationExecution;
69
70
  export {};
@@ -54,8 +54,10 @@ export function parseSimulationResponse(params) {
54
54
  }
55
55
  // Anvil re-hashes the pinned block, so only the geth-style successor can be
56
56
  // pinned by hash: it must report a parentHash (geth always does) equal to
57
- // the pinned state block hash.
58
- if (blockNumber === params.stateBlockNumber + 1n &&
57
+ // the pinned state block hash. With `parentHashCheck` off (Stable by
58
+ // default) the block number, timestamp and reorg checks still apply.
59
+ if (params.parentHashCheck &&
60
+ blockNumber === params.stateBlockNumber + 1n &&
59
61
  block.parentHash !== params.stateBlockHash) {
60
62
  throw new InvalidSimulationResponseError(`eth_simulateV1 reported block ${blockNumber} whose parent ${String(block.parentHash)} is not the pinned state block hash ${params.stateBlockHash}; the node did not simulate on top of the pinned block.`, { context: errorContext });
61
63
  }
@@ -4,6 +4,10 @@ import type { ChainSimulationConfig, SimulationConfig } from "../../types.js";
4
4
  *
5
5
  * Looks up the per-chain `ChainSimulationConfig`. Throws `UnsupportedChainError`
6
6
  * when the chain is absent from the map or has no `simulateV1Url` (defensive —
7
- * the type already requires it at construction).
7
+ * the type already requires it at construction). Throws
8
+ * `SimulationValidationError` when `blockOverrides.gasLimit` is set but is
9
+ * not a positive bigint, or `parentHashCheck` is set but is not a boolean, so a
10
+ * misconfiguration never surfaces as a bypassable
11
+ * `ExternalServiceError`.
8
12
  */
9
13
  export declare function resolveChain(config: SimulationConfig, chainId: number): ChainSimulationConfig;
@@ -1,14 +1,25 @@
1
- import { UnsupportedChainError } from "../../errors.js";
1
+ import { SimulationValidationError, UnsupportedChainError, } from "../../errors.js";
2
2
  /**
3
3
  * Stage 2 of the simulate() pipeline.
4
4
  *
5
5
  * Looks up the per-chain `ChainSimulationConfig`. Throws `UnsupportedChainError`
6
6
  * when the chain is absent from the map or has no `simulateV1Url` (defensive —
7
- * the type already requires it at construction).
7
+ * the type already requires it at construction). Throws
8
+ * `SimulationValidationError` when `blockOverrides.gasLimit` is set but is
9
+ * not a positive bigint, or `parentHashCheck` is set but is not a boolean, so a
10
+ * misconfiguration never surfaces as a bypassable
11
+ * `ExternalServiceError`.
8
12
  */
9
13
  export function resolveChain(config, chainId) {
10
14
  const entry = config.chains.get(chainId);
11
15
  if (!entry?.simulateV1Url)
12
16
  throw new UnsupportedChainError(chainId);
17
+ const gasLimit = entry.blockOverrides?.gasLimit;
18
+ if (gasLimit !== undefined &&
19
+ (typeof gasLimit !== "bigint" || gasLimit <= 0n))
20
+ throw new SimulationValidationError(`Chain ${chainId} blockOverrides.gasLimit must be a positive bigint, got "${String(gasLimit)}" (${typeof gasLimit}). Pass a bigint greater than 0n or omit gasLimit.`, ["blockOverrides.gasLimit: must be a positive bigint"]);
21
+ const parentHashCheck = entry.parentHashCheck;
22
+ if (parentHashCheck !== undefined && typeof parentHashCheck !== "boolean")
23
+ throw new SimulationValidationError(`Chain ${chainId} parentHashCheck must be a boolean, got "${String(parentHashCheck)}" (${typeof parentHashCheck}). Pass true or false, or omit parentHashCheck.`, ["parentHashCheck: must be a boolean"]);
13
24
  return entry;
14
25
  }
@@ -1,4 +1,4 @@
1
- import { getChainAddresses, UnsupportedChainIdError, } from "@morpho-org/blue-sdk";
1
+ import { ChainId, getChainAddresses, UnsupportedChainIdError, } from "@morpho-org/blue-sdk";
2
2
  import { _try } from "@morpho-org/morpho-ts";
3
3
  import { zeroAddress } from "viem";
4
4
  import { InvalidSimulationResponseError, UnsupportedChainError, } from "../errors.js";
@@ -48,9 +48,13 @@ export async function runSimulation(params) {
48
48
  mode: request.mode,
49
49
  blockNumber: typeof request.blockNumber === "bigint" ? request.blockNumber : undefined,
50
50
  });
51
+ // Monad nodes reject `validation: false` and charge no gas in the simulated
52
+ // block either way. Its `latest` block is not final: simulating at its number
53
+ // runs on another parent, so default to `finalized`.
54
+ const monad = request.chainId === ChainId.MonadMainnet;
51
55
  const pinnedBlock = await resolvePinnedBlock({
52
56
  client,
53
- blockNumber: request.blockNumber,
57
+ blockNumber: request.blockNumber ?? (monad ? "finalized" : undefined),
54
58
  signal,
55
59
  });
56
60
  // With no calldata decoding, the caller's declared `limits.operations`
@@ -83,6 +87,9 @@ export async function runSimulation(params) {
83
87
  client,
84
88
  plan,
85
89
  stateBlock: pinnedBlock,
90
+ validation: monad,
91
+ blockGasLimit: chain.blockOverrides?.gasLimit,
92
+ parentHashCheck: chain.parentHashCheck ?? request.chainId !== ChainId.StableMainnet,
86
93
  });
87
94
  const userCalls = execution.calls
88
95
  .filter((call) => call.planned.type === "transaction")
@@ -34,10 +34,12 @@ import type { SimulationConfig } from "../types.js";
34
34
  * **Funding.** `value` transfers are funded by the sender's real native
35
35
  * balance — no balance inflation — so under-funded bundles revert exactly as
36
36
  * they would on-chain. `validation: false` keeps gas from being charged,
37
- * separating gas from economic effects.
37
+ * separating gas from economic effects. Monad nodes reject `false`, so Monad
38
+ * simulations send `true`; its simulated block charges no gas either way.
38
39
  *
39
- * @param config - Required per-chain `eth_simulateV1` URL, optional logger, and
40
- * the overall timeout budget.
40
+ * @param config - Required per-chain `eth_simulateV1` URL, optional per-chain
41
+ * `blockOverrides.gasLimit` and `parentHashCheck`, optional logger, and the
42
+ * overall timeout budget.
41
43
  * @param params - Per-call simulation input.
42
44
  * @param params.chainId - Chain id the bundle targets; must match the endpoint.
43
45
  * @param params.transactions - The bundle's transactions, in execution order.
@@ -47,10 +49,13 @@ import type { SimulationConfig } from "../types.js";
47
49
  * @param params.limits - Optional quotes and percentage tolerances; unquoted amounts require no reads.
48
50
  * @param params.blockNumber - Optional pinned block number or `BlockTag` other
49
51
  * than `"pending"`.
50
- * Defaults to `latest`, resolved exactly once.
52
+ * Defaults to `latest` (`finalized` on Monad, whose `latest` block is not
53
+ * final), resolved exactly once.
51
54
  * @throws {SimulationValidationError} for invalid input (mixed senders, bad
52
55
  * addresses, empty transactions, malformed authorizations, final-mode
53
- * authorizations, malformed limits, unknown fields, a `"pending"` block tag, or share quotes for
56
+ * authorizations, malformed limits, unknown fields, a `"pending"` block tag, a
57
+ * `blockOverrides.gasLimit` that is not a positive bigint, a `parentHashCheck`
58
+ * that is not a boolean, or share quotes for
54
59
  * `blueSupplyCollateral` / `blueWithdrawCollateral`).
55
60
  * @throws {ConsumerLimitViolationError} when a declared `limits` bound is
56
61
  * violated by the observed effects.
@@ -64,7 +69,7 @@ import type { SimulationConfig } from "../types.js";
64
69
  * @throws {InvalidSimulationResponseError} when the node response cannot be
65
70
  * trusted (bad shape, call-count mismatch, block that is neither the pinned
66
71
  * state block nor its immediate successor, a successor with a mismatched
67
- * `parentHash`, a block timestamp earlier than the pinned block's, a
72
+ * `parentHash` (unless `parentHashCheck` is off; off by default on Stable, chain 988), a block timestamp earlier than the pinned block's, a
68
73
  * malformed per-call result, a quoted balance/position read whose non-empty
69
74
  * return data cannot be decoded, a state-block hash that changed or a pinned
70
75
  * block that vanished mid-flight, or an
@@ -33,10 +33,12 @@ import { runSimulation } from "./run-simulation.js";
33
33
  * **Funding.** `value` transfers are funded by the sender's real native
34
34
  * balance — no balance inflation — so under-funded bundles revert exactly as
35
35
  * they would on-chain. `validation: false` keeps gas from being charged,
36
- * separating gas from economic effects.
36
+ * separating gas from economic effects. Monad nodes reject `false`, so Monad
37
+ * simulations send `true`; its simulated block charges no gas either way.
37
38
  *
38
- * @param config - Required per-chain `eth_simulateV1` URL, optional logger, and
39
- * the overall timeout budget.
39
+ * @param config - Required per-chain `eth_simulateV1` URL, optional per-chain
40
+ * `blockOverrides.gasLimit` and `parentHashCheck`, optional logger, and the
41
+ * overall timeout budget.
40
42
  * @param params - Per-call simulation input.
41
43
  * @param params.chainId - Chain id the bundle targets; must match the endpoint.
42
44
  * @param params.transactions - The bundle's transactions, in execution order.
@@ -46,10 +48,13 @@ import { runSimulation } from "./run-simulation.js";
46
48
  * @param params.limits - Optional quotes and percentage tolerances; unquoted amounts require no reads.
47
49
  * @param params.blockNumber - Optional pinned block number or `BlockTag` other
48
50
  * than `"pending"`.
49
- * Defaults to `latest`, resolved exactly once.
51
+ * Defaults to `latest` (`finalized` on Monad, whose `latest` block is not
52
+ * final), resolved exactly once.
50
53
  * @throws {SimulationValidationError} for invalid input (mixed senders, bad
51
54
  * addresses, empty transactions, malformed authorizations, final-mode
52
- * authorizations, malformed limits, unknown fields, a `"pending"` block tag, or share quotes for
55
+ * authorizations, malformed limits, unknown fields, a `"pending"` block tag, a
56
+ * `blockOverrides.gasLimit` that is not a positive bigint, a `parentHashCheck`
57
+ * that is not a boolean, or share quotes for
53
58
  * `blueSupplyCollateral` / `blueWithdrawCollateral`).
54
59
  * @throws {ConsumerLimitViolationError} when a declared `limits` bound is
55
60
  * violated by the observed effects.
@@ -63,7 +68,7 @@ import { runSimulation } from "./run-simulation.js";
63
68
  * @throws {InvalidSimulationResponseError} when the node response cannot be
64
69
  * trusted (bad shape, call-count mismatch, block that is neither the pinned
65
70
  * state block nor its immediate successor, a successor with a mismatched
66
- * `parentHash`, a block timestamp earlier than the pinned block's, a
71
+ * `parentHash` (unless `parentHashCheck` is off; off by default on Stable, chain 988), a block timestamp earlier than the pinned block's, a
67
72
  * malformed per-call result, a quoted balance/position read whose non-empty
68
73
  * return data cannot be decoded, a state-block hash that changed or a pinned
69
74
  * block that vanished mid-flight, or an
@@ -3,6 +3,21 @@ import type { Address, Hex } from "viem";
3
3
  export interface ChainSimulationConfig {
4
4
  /** JSON-RPC URL supporting `eth_simulateV1`. Required for every configured chain. */
5
5
  readonly simulateV1Url: string;
6
+ /**
7
+ * Block overrides forwarded to `eth_simulateV1` as the simulated block's
8
+ * `blockOverrides`. Omitted fields leave the node's defaults untouched.
9
+ */
10
+ readonly blockOverrides?: {
11
+ /** Gas limit of the simulated block. Omitted: no gas limit override is sent. */
12
+ readonly gasLimit?: bigint;
13
+ };
14
+ /**
15
+ * Whether a simulated successor block's `parentHash` must equal the pinned
16
+ * state block hash. Block number, timestamp and reorg checks always run.
17
+ * Omitted: on everywhere except Stable (988), whose nodes report a
18
+ * `parentHash` that never matches the pinned block.
19
+ */
20
+ readonly parentHashCheck?: boolean;
6
21
  }
7
22
  /**
8
23
  * Top-level configuration for `simulate`.
@@ -145,7 +160,8 @@ export interface SimulationCall {
145
160
  * Gas consumed by this call's root frame, not a safe gas limit. This is
146
161
  * post-refund consumption and does not account for EIP-150's 63/64 rule in
147
162
  * nested calls, so consumers deriving a limit must add their own headroom,
148
- * larger than headroom derived from `eth_estimateGas`.
163
+ * larger than headroom derived from `eth_estimateGas`. On Monad it reports the
164
+ * call's gas limit (150M by default) instead of gas consumed.
149
165
  */
150
166
  readonly gasUsed: bigint;
151
167
  }
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": "5.0.0",
4
+ "version": "5.1.0",
5
5
  "author": "Morpho Association <contact@morpho.org>",
6
6
  "repository": {
7
7
  "type": "git",
@@ -22,14 +22,14 @@
22
22
  ],
23
23
  "dependencies": {
24
24
  "@morpho-org/blue-sdk": "^7.2.0",
25
- "@morpho-org/morpho-sdk": "^6.5.0",
26
- "@morpho-org/morpho-ts": "^3.2.0"
25
+ "@morpho-org/morpho-sdk": "^6.6.0",
26
+ "@morpho-org/morpho-ts": "^3.2.1"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "viem": "^2.0.0"
30
30
  },
31
31
  "devDependencies": {
32
- "@morpho-org/test": "^2.10.0",
32
+ "@morpho-org/test": "^2.10.1",
33
33
  "fast-check": "^4.9.0",
34
34
  "typescript": "7.0.2",
35
35
  "viem": "^2.55.10",