|
| 1 | +import type { EpochCache } from '@aztec/epoch-cache'; |
| 2 | +import { |
| 3 | + MULTI_CALL_3_ADDRESS, |
| 4 | + Multicall3, |
| 5 | + type RollupContract, |
| 6 | + buildSimulationOverridesStateOverride, |
| 7 | +} from '@aztec/ethereum/contracts'; |
| 8 | +import { type L1TxUtils, MAX_L1_TX_LIMIT } from '@aztec/ethereum/l1-tx-utils'; |
| 9 | +import { formatViemError } from '@aztec/ethereum/utils'; |
| 10 | +import type { SlotNumber } from '@aztec/foundation/branded-types'; |
| 11 | +import { type Logger, createLogger } from '@aztec/foundation/log'; |
| 12 | +import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; |
| 13 | + |
| 14 | +import type { Hex, StateOverride } from 'viem'; |
| 15 | + |
| 16 | +import type { RequestWithExpiry } from './sequencer-publisher.js'; |
| 17 | + |
| 18 | +/** |
| 19 | + * Result of {@link SequencerBundleSimulator.simulate}. |
| 20 | + * |
| 21 | + * - `success`: simulation succeeded. `requests` is the filtered survivor list, `gasLimit` is |
| 22 | + * the bumped gas limit derived from `gasUsed` (plus blob evaluation gas). `droppedRequests` |
| 23 | + * lists the entries that were observed to revert in simulation. |
| 24 | + * - `fallback`: the node does not support eth_simulateV1 (or the simulate call threw). No |
| 25 | + * per-entry filtering was possible. The caller should send the bundle as-is with a safe gas |
| 26 | + * limit (e.g. {@link MAX_L1_TX_LIMIT}). |
| 27 | + * - `aborted`: the bundle cannot be sent. `droppedRequests` contains only entries that were |
| 28 | + * actually observed to revert (so they can be reported as simulation failures); it is empty |
| 29 | + * when the abort was caused by infrastructure issues like a missing Multicall3 contract or a |
| 30 | + * second-pass eth_simulateV1 fallback. |
| 31 | + */ |
| 32 | +export type BundleSimulateResult = |
| 33 | + | { kind: 'success'; requests: RequestWithExpiry[]; gasLimit: bigint; droppedRequests: RequestWithExpiry[] } |
| 34 | + | { kind: 'fallback' } |
| 35 | + | { kind: 'aborted'; reason: AbortReason; droppedRequests: RequestWithExpiry[] }; |
| 36 | + |
| 37 | +export type AbortReason = |
| 38 | + | 'multicall-missing' |
| 39 | + | 'empty-bundle' |
| 40 | + | 'all-reverted' |
| 41 | + | 'second-pass-reverts' |
| 42 | + | 'second-pass-fallback'; |
| 43 | + |
| 44 | +type SimulatePassResult = |
| 45 | + | { kind: 'decoded'; survivors: RequestWithExpiry[]; droppedRequests: RequestWithExpiry[]; gasUsed: bigint } |
| 46 | + | { kind: 'fallback' }; |
| 47 | + |
| 48 | +/** |
| 49 | + * Bundle-level simulator for the aggregate3 payload that `SequencerPublisher` is about to send. |
| 50 | + * |
| 51 | + * Runs `eth_simulateV1` against `Multicall3.aggregate3`, drops entries that revert, and returns |
| 52 | + * a gasLimit for the survivors. When `eth_simulateV1` is unavailable, signals fallback to the |
| 53 | + * caller so it can send the bundle as-is with a conservative gas limit. |
| 54 | + */ |
| 55 | +export class SequencerBundleSimulator { |
| 56 | + private readonly log: Logger; |
| 57 | + /** Cached only when true; a missing Multicall3 should be re-checked on subsequent calls. */ |
| 58 | + private multicall3HasCode = false; |
| 59 | + |
| 60 | + constructor( |
| 61 | + private readonly deps: { |
| 62 | + getL1TxUtils: () => L1TxUtils; |
| 63 | + rollupContract: RollupContract; |
| 64 | + epochCache: EpochCache; |
| 65 | + log?: Logger; |
| 66 | + }, |
| 67 | + ) { |
| 68 | + this.log = deps.log ?? createLogger('sequencer:publisher:bundle-simulator'); |
| 69 | + } |
| 70 | + |
| 71 | + /** |
| 72 | + * Simulates the given bundle at the target slot's start timestamp and filters out entries |
| 73 | + * that revert. |
| 74 | + * |
| 75 | + * - If all entries pass on the first pass, returns `success` with the gasLimit. |
| 76 | + * - If some entries revert, re-simulates the survivors. If the second pass is clean, returns |
| 77 | + * `success` with the survivors and dropped entries. If the second pass surfaces any revert, |
| 78 | + * returns `aborted` — we refuse to send a bundle whose composition still has internal |
| 79 | + * reverts after one round of filtering. |
| 80 | + * - If eth_simulateV1 is unavailable, returns `fallback`. The caller is expected to send the |
| 81 | + * bundle as-is with a safe gas limit. |
| 82 | + * |
| 83 | + * The simulation `block.timestamp` is always the target L2 slot's start timestamp, since |
| 84 | + * propose's `validateHeader` and EIP-712 signature checks both derive a slot from |
| 85 | + * `block.timestamp` and compare against the slot the validator signed for. |
| 86 | + * |
| 87 | + * Known limitation: on networks where L1 is mining behind cadence (missed L1 slots, anvil with |
| 88 | + * overridden timestamps), the actual `block.timestamp` at send time can land in the prior L2 |
| 89 | + * slot. In that case `propose` would revert silently inside the multicall. The simulator does |
| 90 | + * not detect this case because it simulates AT the target timestamp — the prior implementation |
| 91 | + * used `min(predictedNextL1Ts, targetTimestamp)` to surface this failure mode at simulate time. |
| 92 | + */ |
| 93 | + public async simulate(validRequests: RequestWithExpiry[], targetSlot: SlotNumber): Promise<BundleSimulateResult> { |
| 94 | + if (validRequests.length === 0) { |
| 95 | + return { kind: 'aborted', reason: 'empty-bundle', droppedRequests: [] }; |
| 96 | + } |
| 97 | + // Pin the publisher we'll use across the whole simulate call so that the publisher's rotation |
| 98 | + // can't change l1TxUtils mid-flight. |
| 99 | + const l1TxUtils = this.deps.getL1TxUtils(); |
| 100 | + if (!(await this.ensureMulticall3Deployed(l1TxUtils))) { |
| 101 | + return { kind: 'aborted', reason: 'multicall-missing', droppedRequests: [] }; |
| 102 | + } |
| 103 | + |
| 104 | + const proposeRequest = validRequests.find(r => r.action === 'propose'); |
| 105 | + const simulateTimestamp = getTimestampForSlot(targetSlot, this.deps.epochCache.getL1Constants()); |
| 106 | + const firstPassOverrides = await this.buildStateOverrides(!!proposeRequest); |
| 107 | + |
| 108 | + const firstPass = await this.simulateAndDecode(l1TxUtils, validRequests, simulateTimestamp, firstPassOverrides); |
| 109 | + |
| 110 | + if (firstPass.kind === 'fallback') { |
| 111 | + this.log.warn('Bundle simulate fallback (eth_simulateV1 unavailable); caller will send bundle as-is', { |
| 112 | + actions: validRequests.map(r => r.action), |
| 113 | + }); |
| 114 | + return { kind: 'fallback' }; |
| 115 | + } |
| 116 | + |
| 117 | + if (firstPass.survivors.length === 0) { |
| 118 | + this.log.warn('All bundle entries dropped in sim; aborting send', { |
| 119 | + actions: validRequests.map(r => r.action), |
| 120 | + }); |
| 121 | + return { kind: 'aborted', reason: 'all-reverted', droppedRequests: validRequests }; |
| 122 | + } |
| 123 | + |
| 124 | + if (firstPass.droppedRequests.length === 0) { |
| 125 | + return this.buildSuccessResult(l1TxUtils, firstPass.survivors, [], firstPass.gasUsed, proposeRequest); |
| 126 | + } |
| 127 | + |
| 128 | + this.log.warn('Some bundle entries reverted; re-simulating reduced bundle', { |
| 129 | + droppedActions: firstPass.droppedRequests.map(r => r.action), |
| 130 | + remainingActions: firstPass.survivors.map(r => r.action), |
| 131 | + }); |
| 132 | + // Rebuild overrides for the reduced bundle: if propose was dropped, we no longer need the |
| 133 | + // blob-check override. |
| 134 | + const proposeSurvived = proposeRequest !== undefined && firstPass.survivors.includes(proposeRequest); |
| 135 | + const secondPassOverrides = proposeSurvived ? firstPassOverrides : await this.buildStateOverrides(false); |
| 136 | + const secondPass = await this.simulateAndDecode( |
| 137 | + l1TxUtils, |
| 138 | + firstPass.survivors, |
| 139 | + simulateTimestamp, |
| 140 | + secondPassOverrides, |
| 141 | + ); |
| 142 | + |
| 143 | + // We refuse to chase reverts through repeated trimming: anything other than a clean second |
| 144 | + // pass aborts the whole send. |
| 145 | + if (secondPass.kind === 'fallback') { |
| 146 | + this.log.error('Re-simulate returned fallback; aborting send', { |
| 147 | + survivingActions: firstPass.survivors.map(r => r.action), |
| 148 | + }); |
| 149 | + return { kind: 'aborted', reason: 'second-pass-fallback', droppedRequests: firstPass.droppedRequests }; |
| 150 | + } |
| 151 | + if (secondPass.droppedRequests.length > 0) { |
| 152 | + this.log.error('Re-simulate surfaced reverts; aborting send', { |
| 153 | + secondPassDroppedActions: secondPass.droppedRequests.map(r => r.action), |
| 154 | + }); |
| 155 | + return { |
| 156 | + kind: 'aborted', |
| 157 | + reason: 'second-pass-reverts', |
| 158 | + droppedRequests: [...firstPass.droppedRequests, ...secondPass.droppedRequests], |
| 159 | + }; |
| 160 | + } |
| 161 | + |
| 162 | + return this.buildSuccessResult( |
| 163 | + l1TxUtils, |
| 164 | + secondPass.survivors, |
| 165 | + firstPass.droppedRequests, |
| 166 | + secondPass.gasUsed, |
| 167 | + proposeRequest, |
| 168 | + ); |
| 169 | + } |
| 170 | + |
| 171 | + private buildSuccessResult( |
| 172 | + l1TxUtils: L1TxUtils, |
| 173 | + survivors: RequestWithExpiry[], |
| 174 | + droppedRequests: RequestWithExpiry[], |
| 175 | + bundleGasUsed: bigint, |
| 176 | + proposeRequest: RequestWithExpiry | undefined, |
| 177 | + ): BundleSimulateResult { |
| 178 | + const proposeSurvived = proposeRequest !== undefined && survivors.includes(proposeRequest); |
| 179 | + const blobEvaluationGas = proposeSurvived ? (proposeRequest?.blobEvaluationGas ?? 0n) : 0n; |
| 180 | + const gasLimit = this.computeGasLimit(l1TxUtils, bundleGasUsed, blobEvaluationGas); |
| 181 | + this.log.debug('Bundle simulate complete', { |
| 182 | + survivingRequests: survivors.length, |
| 183 | + bundleGasUsed, |
| 184 | + gasLimit, |
| 185 | + actions: survivors.map(r => r.action), |
| 186 | + }); |
| 187 | + return { kind: 'success', requests: survivors, gasLimit, droppedRequests }; |
| 188 | + } |
| 189 | + |
| 190 | + /** |
| 191 | + * `gasLimit = bumpGasLimit(ceil(gasUsed * 64 / 63))`, plus blob evaluation gas if a propose |
| 192 | + * survived, capped at the L1 block gas limit. |
| 193 | + */ |
| 194 | + private computeGasLimit(l1TxUtils: L1TxUtils, bundleGasUsed: bigint, blobEvaluationGas: bigint): bigint { |
| 195 | + const gasUsedWithEip150 = (bundleGasUsed * 64n + 62n) / 63n; |
| 196 | + const gasLimit = l1TxUtils.bumpGasLimit(gasUsedWithEip150) + blobEvaluationGas; |
| 197 | + return gasLimit > MAX_L1_TX_LIMIT ? MAX_L1_TX_LIMIT : gasLimit; |
| 198 | + } |
| 199 | + |
| 200 | + /** |
| 201 | + * eth_simulateV1 cannot carry blob sidecar data, so disable the rollup's on-chain blob check |
| 202 | + * when a propose is in the bundle. |
| 203 | + */ |
| 204 | + private buildStateOverrides(hasProposeAction: boolean): Promise<StateOverride> { |
| 205 | + return buildSimulationOverridesStateOverride( |
| 206 | + this.deps.rollupContract, |
| 207 | + hasProposeAction ? { disableBlobCheck: true } : undefined, |
| 208 | + ); |
| 209 | + } |
| 210 | + |
| 211 | + private async ensureMulticall3Deployed(l1TxUtils: L1TxUtils): Promise<boolean> { |
| 212 | + if (this.multicall3HasCode) { |
| 213 | + return true; |
| 214 | + } |
| 215 | + this.multicall3HasCode = await Multicall3.hasCode(l1TxUtils); |
| 216 | + if (!this.multicall3HasCode) { |
| 217 | + this.log.error(`Multicall3 bytecode missing at ${MULTI_CALL_3_ADDRESS}; cannot send bundled tx`); |
| 218 | + } |
| 219 | + return this.multicall3HasCode; |
| 220 | + } |
| 221 | + |
| 222 | + private async simulateAndDecode( |
| 223 | + l1TxUtils: L1TxUtils, |
| 224 | + requests: RequestWithExpiry[], |
| 225 | + simulateTimestamp: bigint, |
| 226 | + stateOverrides: StateOverride, |
| 227 | + ): Promise<SimulatePassResult> { |
| 228 | + let simResult: Awaited<ReturnType<typeof Multicall3.simulateAggregate3>>; |
| 229 | + try { |
| 230 | + simResult = await Multicall3.simulateAggregate3( |
| 231 | + requests.map(r => ({ to: r.request.to! as Hex, data: r.request.data! as Hex, abi: r.request.abi })), |
| 232 | + l1TxUtils, |
| 233 | + { |
| 234 | + blockOverrides: { time: simulateTimestamp, gasLimit: MAX_L1_TX_LIMIT * 2n }, |
| 235 | + stateOverrides, |
| 236 | + gas: MAX_L1_TX_LIMIT, |
| 237 | + fallbackGasEstimate: MAX_L1_TX_LIMIT, |
| 238 | + }, |
| 239 | + ); |
| 240 | + } catch (err) { |
| 241 | + this.log.warn('Bundle simulate threw; treating as fallback', { |
| 242 | + err: formatViemError(err), |
| 243 | + actions: requests.map(r => r.action), |
| 244 | + }); |
| 245 | + return { kind: 'fallback' }; |
| 246 | + } |
| 247 | + |
| 248 | + if (simResult.kind === 'fallback') { |
| 249 | + return { kind: 'fallback' }; |
| 250 | + } |
| 251 | + |
| 252 | + const survivors: RequestWithExpiry[] = []; |
| 253 | + const droppedRequests: RequestWithExpiry[] = []; |
| 254 | + for (let i = 0; i < requests.length; i++) { |
| 255 | + const entry = simResult.entries[i]; |
| 256 | + if (entry.success) { |
| 257 | + survivors.push(requests[i]); |
| 258 | + continue; |
| 259 | + } |
| 260 | + droppedRequests.push(requests[i]); |
| 261 | + this.log.warn('Bundle entry dropped: action reverted in sim', { |
| 262 | + action: requests[i].action, |
| 263 | + revertReason: entry.revertReason ?? entry.returnData, |
| 264 | + returnData: entry.returnData, |
| 265 | + }); |
| 266 | + } |
| 267 | + return { kind: 'decoded', survivors, droppedRequests, gasUsed: simResult.gasUsed }; |
| 268 | + } |
| 269 | +} |
0 commit comments