@aztec/aztec-node 5.3.0-nightly.20260909 → 5.3.0-nightly.20260910

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.
Files changed (37) hide show
  1. package/dest/aztec-node/next_block/index.d.ts +4 -0
  2. package/dest/aztec-node/next_block/index.d.ts.map +1 -0
  3. package/dest/aztec-node/next_block/index.js +3 -0
  4. package/dest/aztec-node/next_block/next_block_fee_cache.d.ts +110 -0
  5. package/dest/aztec-node/next_block/next_block_fee_cache.d.ts.map +1 -0
  6. package/dest/aztec-node/next_block/next_block_fee_cache.js +201 -0
  7. package/dest/aztec-node/next_block/next_block_planner.d.ts +78 -0
  8. package/dest/aztec-node/next_block/next_block_planner.d.ts.map +1 -0
  9. package/dest/aztec-node/next_block/next_block_planner.js +109 -0
  10. package/dest/aztec-node/next_block/next_block_predictor.d.ts +91 -0
  11. package/dest/aztec-node/next_block/next_block_predictor.d.ts.map +1 -0
  12. package/dest/aztec-node/next_block/next_block_predictor.js +146 -0
  13. package/dest/aztec-node/next_block/test_helpers.d.ts +40 -0
  14. package/dest/aztec-node/next_block/test_helpers.d.ts.map +1 -0
  15. package/dest/aztec-node/next_block/test_helpers.js +100 -0
  16. package/dest/aztec-node/node_public_calls_simulator.d.ts +10 -48
  17. package/dest/aztec-node/node_public_calls_simulator.d.ts.map +1 -1
  18. package/dest/aztec-node/node_public_calls_simulator.js +56 -164
  19. package/dest/aztec-node/server.d.ts +18 -2
  20. package/dest/aztec-node/server.d.ts.map +1 -1
  21. package/dest/aztec-node/server.js +34 -12
  22. package/dest/factory.d.ts +1 -1
  23. package/dest/factory.d.ts.map +1 -1
  24. package/dest/factory.js +17 -1
  25. package/dest/index.d.ts +2 -1
  26. package/dest/index.d.ts.map +1 -1
  27. package/dest/index.js +1 -0
  28. package/package.json +28 -28
  29. package/src/aztec-node/next_block/index.ts +3 -0
  30. package/src/aztec-node/next_block/next_block_fee_cache.ts +279 -0
  31. package/src/aztec-node/next_block/next_block_planner.ts +198 -0
  32. package/src/aztec-node/next_block/next_block_predictor.ts +158 -0
  33. package/src/aztec-node/next_block/test_helpers.ts +115 -0
  34. package/src/aztec-node/node_public_calls_simulator.ts +78 -218
  35. package/src/aztec-node/server.ts +32 -9
  36. package/src/factory.ts +15 -1
  37. package/src/index.ts +1 -0
package/dest/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './aztec-node/config.js';
2
+ export { NextBlockPredictor } from './aztec-node/next_block/index.js';
2
3
  export * from './aztec-node/register_node_rpc_handlers.js';
3
4
  export * from './aztec-node/server.js';
4
5
  export * from './factory.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aztec/aztec-node",
3
- "version": "5.3.0-nightly.20260909",
3
+ "version": "5.3.0-nightly.20260910",
4
4
  "main": "dest/index.js",
5
5
  "type": "module",
6
6
  "exports": {
@@ -65,33 +65,33 @@
65
65
  ]
66
66
  },
67
67
  "dependencies": {
68
- "@aztec/archiver": "5.3.0-nightly.20260909",
69
- "@aztec/bb-prover": "5.3.0-nightly.20260909",
70
- "@aztec/bb.js": "5.3.0-nightly.20260909",
71
- "@aztec/blob-client": "5.3.0-nightly.20260909",
72
- "@aztec/blob-lib": "5.3.0-nightly.20260909",
73
- "@aztec/constants": "5.3.0-nightly.20260909",
74
- "@aztec/epoch-cache": "5.3.0-nightly.20260909",
75
- "@aztec/ethereum": "5.3.0-nightly.20260909",
76
- "@aztec/foundation": "5.3.0-nightly.20260909",
77
- "@aztec/kv-store": "5.3.0-nightly.20260909",
78
- "@aztec/l1-artifacts": "5.3.0-nightly.20260909",
79
- "@aztec/node-keystore": "5.3.0-nightly.20260909",
80
- "@aztec/node-lib": "5.3.0-nightly.20260909",
81
- "@aztec/noir-protocol-circuits-types": "5.3.0-nightly.20260909",
82
- "@aztec/p2p": "5.3.0-nightly.20260909",
83
- "@aztec/protocol-contracts": "5.3.0-nightly.20260909",
84
- "@aztec/prover-client": "5.3.0-nightly.20260909",
85
- "@aztec/prover-node": "5.3.0-nightly.20260909",
86
- "@aztec/sequencer-client": "5.3.0-nightly.20260909",
87
- "@aztec/simulator": "5.3.0-nightly.20260909",
88
- "@aztec/slasher": "5.3.0-nightly.20260909",
89
- "@aztec/standard-contracts": "5.3.0-nightly.20260909",
90
- "@aztec/stdlib": "5.3.0-nightly.20260909",
91
- "@aztec/telemetry-client": "5.3.0-nightly.20260909",
92
- "@aztec/validator-client": "5.3.0-nightly.20260909",
93
- "@aztec/validator-ha-signer": "5.3.0-nightly.20260909",
94
- "@aztec/world-state": "5.3.0-nightly.20260909",
68
+ "@aztec/archiver": "5.3.0-nightly.20260910",
69
+ "@aztec/bb-prover": "5.3.0-nightly.20260910",
70
+ "@aztec/bb.js": "5.3.0-nightly.20260910",
71
+ "@aztec/blob-client": "5.3.0-nightly.20260910",
72
+ "@aztec/blob-lib": "5.3.0-nightly.20260910",
73
+ "@aztec/constants": "5.3.0-nightly.20260910",
74
+ "@aztec/epoch-cache": "5.3.0-nightly.20260910",
75
+ "@aztec/ethereum": "5.3.0-nightly.20260910",
76
+ "@aztec/foundation": "5.3.0-nightly.20260910",
77
+ "@aztec/kv-store": "5.3.0-nightly.20260910",
78
+ "@aztec/l1-artifacts": "5.3.0-nightly.20260910",
79
+ "@aztec/node-keystore": "5.3.0-nightly.20260910",
80
+ "@aztec/node-lib": "5.3.0-nightly.20260910",
81
+ "@aztec/noir-protocol-circuits-types": "5.3.0-nightly.20260910",
82
+ "@aztec/p2p": "5.3.0-nightly.20260910",
83
+ "@aztec/protocol-contracts": "5.3.0-nightly.20260910",
84
+ "@aztec/prover-client": "5.3.0-nightly.20260910",
85
+ "@aztec/prover-node": "5.3.0-nightly.20260910",
86
+ "@aztec/sequencer-client": "5.3.0-nightly.20260910",
87
+ "@aztec/simulator": "5.3.0-nightly.20260910",
88
+ "@aztec/slasher": "5.3.0-nightly.20260910",
89
+ "@aztec/standard-contracts": "5.3.0-nightly.20260910",
90
+ "@aztec/stdlib": "5.3.0-nightly.20260910",
91
+ "@aztec/telemetry-client": "5.3.0-nightly.20260910",
92
+ "@aztec/validator-client": "5.3.0-nightly.20260910",
93
+ "@aztec/validator-ha-signer": "5.3.0-nightly.20260910",
94
+ "@aztec/world-state": "5.3.0-nightly.20260910",
95
95
  "koa": "^2.16.1",
96
96
  "koa-router": "^13.1.1",
97
97
  "tslib": "^2.4.0",
@@ -0,0 +1,3 @@
1
+ export * from './next_block_fee_cache.js';
2
+ export * from './next_block_planner.js';
3
+ export * from './next_block_predictor.js';
@@ -0,0 +1,279 @@
1
+ import type { EpochCacheInterface } from '@aztec/epoch-cache';
2
+ import {
3
+ type RollupContract,
4
+ SimulationOverridesBuilder,
5
+ type SimulationOverridesPlan,
6
+ } from '@aztec/ethereum/contracts';
7
+ import { CheckpointNumber } from '@aztec/foundation/branded-types';
8
+ import { EthAddress } from '@aztec/foundation/eth-address';
9
+ import { type Logger, createLogger } from '@aztec/foundation/log';
10
+ import { RunningPromise } from '@aztec/foundation/promise';
11
+ import { type DateProvider, executeTimeout } from '@aztec/foundation/timer';
12
+ import { AztecAddress } from '@aztec/stdlib/aztec-address';
13
+ import type { L1SyncPoint, L2BlockSource, L2Frontier } from '@aztec/stdlib/block';
14
+ import { buildCheckpointSimulationOverridesPlan } from '@aztec/stdlib/checkpoint';
15
+ import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p';
16
+ import type { CheckpointGlobalVariables, GlobalVariableBuilder } from '@aztec/stdlib/tx';
17
+
18
+ import {
19
+ type BoundaryFeeKey,
20
+ type NewCheckpointPlan,
21
+ boundaryFeeKeyEquals,
22
+ computeBoundaryFeeKey,
23
+ getClockSlot,
24
+ planNextBlock,
25
+ } from './next_block_planner.js';
26
+
27
+ /** Default interval for the background refresh, well below L1's block time. */
28
+ export const DEFAULT_REFRESH_INTERVAL_MS = 1000;
29
+
30
+ /**
31
+ * A record older than this many refresh intervals is treated as missing: the refresh has been failing long
32
+ * enough that serving its value could underquote a fee that has since stepped. Ten intervals because one
33
+ * failed pass is a hiccup, and a ten-second-old boundary fee is almost always still right.
34
+ */
35
+ export const MAX_AGE_INTERVALS = 10;
36
+
37
+ /** Refresh passes an uncapped reader will wait through before giving up on its boundary. */
38
+ const MAX_REFRESH_ATTEMPTS = 2;
39
+
40
+ /** A priced checkpoint boundary, with the L1 block it was priced at kept as metadata. */
41
+ type BoundaryFeeRecord = {
42
+ key: BoundaryFeeKey;
43
+ l1SyncPoint: L1SyncPoint | undefined;
44
+ globals: CheckpointGlobalVariables;
45
+ refreshedAtMs: number;
46
+ };
47
+
48
+ /** Dependencies required to build a {@link NextBlockFeeCache}. */
49
+ export interface NextBlockFeeCacheDeps {
50
+ blockSource: L2BlockSource;
51
+ globalVariableBuilder: GlobalVariableBuilder;
52
+ /**
53
+ * Rollup contract used to build the fee-relevant L1 state overrides when opening a new checkpoint.
54
+ * Only needed when a proposed parent checkpoint exists (pipelining) or the pending chain is invalid;
55
+ * may be omitted in environments that never reach those states (e.g. TXE). When omitted, those paths
56
+ * degrade to a pinned-tips plan (non-pipelined fees) instead.
57
+ */
58
+ rollupContract?: RollupContract;
59
+ epochCache: EpochCacheInterface;
60
+ signatureContext: CoordinationSignatureContext;
61
+ dateProvider: DateProvider;
62
+ log?: Logger;
63
+ }
64
+
65
+ /**
66
+ * Caches the checkpoint globals, and so the mana min fee, that a block opening a fresh checkpoint would carry.
67
+ * This is the only value on the RPC path that has to be read from L1.
68
+ *
69
+ * A background loop prices the upcoming boundary on every pass, so requests are normally answered from memory.
70
+ * Records are looked up by {@link BoundaryFeeKey}: the target slot, the checkpointed tip, the block the plan
71
+ * builds on, and the proposed parent's fee-relevant fields or the pending chain's validity. The L1 block a record
72
+ * was priced at is stored with it but is not part of the key: the rollup transactions that move the fee also move
73
+ * the frontier and hence the key, so a miss means the chain moved (a slot rollover, a checkpoint landing or being
74
+ * proposed, a validity flip), not merely that L1 produced a block. Governance updates such as the mana target are
75
+ * the exception; the loop re-prices a matching record whenever the L1 anchor moves, so those lag by at most one
76
+ * refresh interval.
77
+ *
78
+ * On a miss a request does not call L1 itself. It joins the single refresh already in flight, or starts the one
79
+ * the loop would have started, so a burst of requests during a transition costs one L1 round trip. Simulations
80
+ * wait for as long as the refresh takes and surface its failure; fee quotes cap their wait and fall back to the
81
+ * last record they can trust.
82
+ *
83
+ * Before {@link start} (or after {@link stop}) requests still price inline through the same refresh path, which
84
+ * is what tests and TXE-like environments rely on.
85
+ */
86
+ export class NextBlockFeeCache {
87
+ private readonly blockSource: L2BlockSource;
88
+ private readonly globalVariableBuilder: GlobalVariableBuilder;
89
+ private readonly rollupContract: RollupContract | undefined;
90
+ private readonly epochCache: EpochCacheInterface;
91
+ private readonly signatureContext: CoordinationSignatureContext;
92
+ private readonly dateProvider: DateProvider;
93
+ private readonly log: Logger;
94
+
95
+ private current: BoundaryFeeRecord | undefined;
96
+ private previous: BoundaryFeeRecord | undefined;
97
+ private refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS;
98
+ private refreshLoop: RunningPromise | undefined;
99
+ private inFlightRefresh: Promise<void> | undefined;
100
+
101
+ constructor(deps: NextBlockFeeCacheDeps) {
102
+ this.blockSource = deps.blockSource;
103
+ this.globalVariableBuilder = deps.globalVariableBuilder;
104
+ this.rollupContract = deps.rollupContract;
105
+ this.epochCache = deps.epochCache;
106
+ this.signatureContext = deps.signatureContext;
107
+ this.dateProvider = deps.dateProvider;
108
+ this.log = deps.log ?? createLogger('node:next-block-fee-cache');
109
+ }
110
+
111
+ /**
112
+ * Starts the background refresh. The loop's first pass begins immediately and a request that arrives before it
113
+ * completes joins it rather than pricing on its own, so nothing waits on priming here: a node whose archiver or
114
+ * L1 client is not ready yet still starts, and the loop fills the cache once they are. A second call while
115
+ * running is a no-op, so it cannot orphan a loop that {@link stop} could then never reach.
116
+ */
117
+ public start(pollingIntervalMs = DEFAULT_REFRESH_INTERVAL_MS): void {
118
+ if (this.refreshLoop) {
119
+ return;
120
+ }
121
+ this.refreshIntervalMs = pollingIntervalMs;
122
+ this.refreshLoop = new RunningPromise(() => this.refresh(), this.log, pollingIntervalMs).start();
123
+ }
124
+
125
+ public async stop(): Promise<void> {
126
+ const loop = this.refreshLoop;
127
+ this.refreshLoop = undefined;
128
+ await loop?.stop();
129
+ // A refresh started by a request rather than by the loop may still be running; let it drain.
130
+ await this.inFlightRefresh?.catch(() => {});
131
+ }
132
+
133
+ /**
134
+ * The checkpoint globals for a boundary keyed by `key`, or undefined when they could not be produced in time.
135
+ *
136
+ * A matching record under the staleness cutoff is served straight away. Otherwise the caller joins the single
137
+ * shared refresh: a simulation waits for it and surfaces its failure, while a quote passes `maxWaitMs` and
138
+ * falls back to whatever the cache already holds rather than turning an L1 outage into a multi-second RPC.
139
+ */
140
+ public async getBoundaryGlobals(
141
+ key: BoundaryFeeKey,
142
+ frontier: L2Frontier,
143
+ opts?: { maxWaitMs?: number },
144
+ ): Promise<CheckpointGlobalVariables | undefined> {
145
+ const maxWaitMs = opts?.maxWaitMs;
146
+ // An uncapped reader gets more than one attempt because the refresh it joins may be one that started from an
147
+ // older frontier and therefore priced a different boundary; the next pass is its own. Still never concurrent.
148
+ const attempts = maxWaitMs === undefined ? MAX_REFRESH_ATTEMPTS : 1;
149
+ for (let attempt = 0; attempt < attempts; attempt++) {
150
+ const hit = this.findRecord(key);
151
+ if (hit) {
152
+ return hit.globals;
153
+ }
154
+ const refresh = this.refresh(frontier);
155
+ await (maxWaitMs === undefined ? refresh : this.waitFor(refresh, maxWaitMs));
156
+ }
157
+ return this.findRecord(key)?.globals;
158
+ }
159
+
160
+ /**
161
+ * Runs one refresh pass, or joins the one already running. Single-flight is what keeps a burst of requests
162
+ * during a transition down to a single L1 round trip, shared with the background loop.
163
+ * @param frontier - Snapshot to plan from; read fresh from the archiver when omitted, as the loop does.
164
+ */
165
+ public refresh(frontier?: L2Frontier): Promise<void> {
166
+ if (this.inFlightRefresh) {
167
+ return this.inFlightRefresh;
168
+ }
169
+ const refresh = this.runRefresh(frontier).finally(() => {
170
+ this.inFlightRefresh = undefined;
171
+ });
172
+ // Every caller awaits the returned promise and reports its failure, but the stored copy may only be awaited by
173
+ // stop() after it has settled. Mark it handled now so a failure can never surface as an unhandled rejection.
174
+ refresh.catch(() => {});
175
+ this.inFlightRefresh = refresh;
176
+ return refresh;
177
+ }
178
+
179
+ private async runRefresh(frontier?: L2Frontier): Promise<void> {
180
+ const snapshot = frontier ?? (await this.blockSource.getL2Frontier());
181
+ const plan = planNextBlock(snapshot, getClockSlot(this.epochCache));
182
+ const key = computeBoundaryFeeKey(plan, snapshot.pendingChainValidationStatus);
183
+ if (!key || !plan.newCheckpoint) {
184
+ // Mid-checkpoint: the fee is frozen in the in-progress checkpoint's header, so there is nothing to price.
185
+ return;
186
+ }
187
+
188
+ const current = this.current;
189
+ const sameKey = current !== undefined && boundaryFeeKeyEquals(current.key, key);
190
+ if (sameKey && l1SyncPointEquals(current.l1SyncPoint, snapshot.l1SyncPoint)) {
191
+ // This pass confirmed against a fresh snapshot that none of the fee's inputs moved, so the record is as
192
+ // good as one just priced. Re-stamping it is what makes the staleness cutoff mean "how long we have been
193
+ // unable to confirm", rather than expiring a value that is still exactly right.
194
+ this.current = { ...current, refreshedAtMs: this.dateProvider.now() };
195
+ return;
196
+ }
197
+
198
+ const overrides = await this.buildOverridesPlan(snapshot, plan.newCheckpoint);
199
+ // Pinned to the L1 block the frontier was read at, so the fee describes the same L1 state the plan derives
200
+ // from. Undefined before the archiver's first sync pass, where the read falls back to L1's head.
201
+ const globals = await this.globalVariableBuilder.buildCheckpointGlobalVariables(
202
+ EthAddress.ZERO,
203
+ AztecAddress.ZERO,
204
+ plan.newCheckpoint.targetSlot,
205
+ overrides,
206
+ { blockNumber: snapshot.l1SyncPoint?.blockNumber },
207
+ );
208
+
209
+ const record = { key, l1SyncPoint: snapshot.l1SyncPoint, globals, refreshedAtMs: this.dateProvider.now() };
210
+ if (!sameKey) {
211
+ // Keep the boundary we just left addressable: a request that planned from the previous frontier is still
212
+ // served while the new one settles.
213
+ this.previous = current;
214
+ }
215
+ this.current = record;
216
+ }
217
+
218
+ /** The freshest record matching `key`, or undefined when none is recent enough to trust. */
219
+ private findRecord(key: BoundaryFeeKey): BoundaryFeeRecord | undefined {
220
+ const maxAgeMs = MAX_AGE_INTERVALS * this.refreshIntervalMs;
221
+ const now = this.dateProvider.now();
222
+ return [this.current, this.previous].find(
223
+ record => record !== undefined && boundaryFeeKeyEquals(record.key, key) && now - record.refreshedAtMs < maxAgeMs,
224
+ );
225
+ }
226
+
227
+ /** Awaits `promise` for at most `maxWaitMs`, swallowing both a timeout and the promise's own failure. */
228
+ private waitFor(promise: Promise<void>, maxWaitMs: number): Promise<void> {
229
+ return executeTimeout(() => promise, maxWaitMs).catch(err =>
230
+ this.log.debug(`Refreshing the next-block boundary fee failed or timed out`, err),
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Builds the chain-state overrides plan passed to `buildCheckpointGlobalVariables`, mirroring the sequencer
236
+ * (which always pins tips to neutralize prunes). When pipelining, the plan carries the proposed parent's
237
+ * archive, temp-checkpoint-log cell, and locally-derived fee header; when the pending chain is invalid, it
238
+ * pins the tips to the last valid checkpoint instead.
239
+ *
240
+ * Both of those need a rollup contract for the L1 fee reads. Environments that omit it (e.g. TXE, which never
241
+ * has a proposed checkpoint and whose pending chain is always valid) fall back to pinning both pending and
242
+ * proven tips to the checkpointed tip, which neutralizes prunes in fee computation at the cost of
243
+ * non-pipelined fees.
244
+ */
245
+ private buildOverridesPlan(
246
+ frontier: L2Frontier,
247
+ newCheckpoint: NewCheckpointPlan,
248
+ ): Promise<SimulationOverridesPlan | undefined> {
249
+ const { targetCheckpoint, proposedCheckpointData, checkpointedCheckpointNumber } = newCheckpoint;
250
+ const rollup = this.rollupContract;
251
+ if (!rollup) {
252
+ return Promise.resolve(
253
+ new SimulationOverridesBuilder()
254
+ .withChainTips({ pending: checkpointedCheckpointNumber, proven: checkpointedCheckpointNumber })
255
+ .build(),
256
+ );
257
+ }
258
+
259
+ // The helper treats pipelining and invalidation as mutually exclusive; a proposed parent takes precedence.
260
+ const validationStatus = frontier.pendingChainValidationStatus;
261
+ const invalidateToPendingCheckpointNumber =
262
+ !proposedCheckpointData && !validationStatus.valid
263
+ ? CheckpointNumber(validationStatus.checkpoint.checkpointNumber - 1)
264
+ : undefined;
265
+ return buildCheckpointSimulationOverridesPlan({
266
+ checkpointNumber: targetCheckpoint,
267
+ proposedCheckpointData,
268
+ invalidateToPendingCheckpointNumber,
269
+ checkpointedCheckpointNumber,
270
+ rollup,
271
+ signatureContext: this.signatureContext,
272
+ log: this.log,
273
+ });
274
+ }
275
+ }
276
+
277
+ function l1SyncPointEquals(a: L1SyncPoint | undefined, b: L1SyncPoint | undefined): boolean {
278
+ return a === undefined || b === undefined ? a === b : a.blockHash.equals(b.blockHash);
279
+ }
@@ -0,0 +1,198 @@
1
+ import { PROPOSER_PIPELINING_SLOT_OFFSET } from '@aztec/epoch-cache';
2
+ import type { EpochCacheInterface } from '@aztec/epoch-cache';
3
+ import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
4
+ import { compactArray } from '@aztec/foundation/collection';
5
+ import { type L2Frontier, type ValidateCheckpointResult, getCheckpointedTipSlot } from '@aztec/stdlib/block';
6
+ import type { ProposedCheckpointData } from '@aztec/stdlib/checkpoint';
7
+
8
+ /** Slot, target checkpoint, and parent data for a next block that opens a fresh checkpoint. */
9
+ export type NewCheckpointPlan = {
10
+ /** Slot the next block will land in. */
11
+ targetSlot: SlotNumber;
12
+ /** Checkpoint whose L1-to-L2 messages the simulation fork needs. */
13
+ targetCheckpoint: CheckpointNumber;
14
+ /** The proposed (not yet L1-confirmed) parent checkpoint, when pipelining. */
15
+ proposedCheckpointData: ProposedCheckpointData | undefined;
16
+ /** Checkpointed tip at the time the plan's snapshot was taken. */
17
+ checkpointedCheckpointNumber: CheckpointNumber;
18
+ };
19
+
20
+ /**
21
+ * How the next block sits on the chain, derived from a single atomic archiver snapshot. Everything here comes
22
+ * from archiver reads only; turning it into globals is what may hit L1. `newCheckpoint` is set only when the
23
+ * next block opens a fresh checkpoint rather than continuing the in-progress one.
24
+ */
25
+ export type NextBlockPlan = {
26
+ latestBlockNumber: BlockNumber;
27
+ /** Hash of the latest proposed block, so the world-state fork can be checked against the plan's chain. */
28
+ latestBlockHash: string;
29
+ newCheckpoint?: NewCheckpointPlan;
30
+ };
31
+
32
+ /** Identity of the parent the boundary fee is computed on top of. */
33
+ export type BoundaryFeeParent =
34
+ | {
35
+ kind: 'proposed';
36
+ headerHash: string;
37
+ archiveRoot: string;
38
+ checkpointOutHash: string;
39
+ totalManaUsed: bigint;
40
+ feeAssetPriceModifier: bigint;
41
+ }
42
+ | { kind: 'checkpointed'; pendingChainValid: boolean; firstInvalidCheckpoint?: CheckpointNumber };
43
+
44
+ /**
45
+ * Every input the min fee of a checkpoint-opening block derives from, so two equal keys prove a cached fee is
46
+ * still what a fresh build would produce: the target slot, the checkpointed tip, the block the plan sits on,
47
+ * and either the proposed parent's fee-relevant fields or the pending-chain validity that selects the
48
+ * invalidation override.
49
+ *
50
+ * The L1 block the fee was read at is deliberately not part of the key. The min fee for a fixed slot and parent
51
+ * depends only on rollup storage, and the rollup transactions that move it (checkpoints landing, invalidations,
52
+ * prunes) all move the frontier and therefore this key, so a new L1 block on its own is a hit. The exception is a
53
+ * governance parameter update such as the mana target, which changes the fee without touching the frontier;
54
+ * the cache re-prices a matching record whenever the L1 anchor moves, so that lags by at most one refresh.
55
+ */
56
+ export type BoundaryFeeKey = {
57
+ targetSlot: SlotNumber;
58
+ checkpointedCheckpointNumber: CheckpointNumber;
59
+ latestBlockHash: string;
60
+ parent: BoundaryFeeParent;
61
+ };
62
+
63
+ /**
64
+ * Slot the next block will land in, the largest of three terms:
65
+ *
66
+ * - The sequencer's exact formula, `getEpochAndSlotInNextL1Slot().slot + PROPOSER_PIPELINING_SLOT_OFFSET`.
67
+ * - `proposedCheckpointSlot + 1`, an RPC-side approximation of the next build: when a proposed checkpoint
68
+ * is gossiped before its L1 slot starts, the next build (once its wall clock arrives) will target
69
+ * `parentSlot + 1`. The sequencer never advances its own target past wall clock — it just declines to
70
+ * build — so this is a prediction of inclusion globals, not literal sequencer behavior. The parent slot
71
+ * comes from the proposed checkpoint header so the slot and the overrides plan cannot derive from
72
+ * different snapshots.
73
+ * - `checkpointedTipSlot + 1`, a floor: the next block can never land in a slot already taken by a
74
+ * checkpointed checkpoint. The slot comes from the frontier's checkpointed checkpoint header, so it
75
+ * describes the same instant as the tips and the proposed checkpoint. This only binds when this node's
76
+ * clock is behind the chain, in which case the first term would otherwise price the next block in a slot
77
+ * L1 has already moved past — and the L1 gas oracle can step between the two, so wallet quotes and
78
+ * simulations would disagree on the fee.
79
+ */
80
+ function computeTargetSlot(
81
+ clockSlot: SlotNumber,
82
+ proposedCheckpointData: ProposedCheckpointData | undefined,
83
+ checkpointedTipSlot: SlotNumber | undefined,
84
+ ): SlotNumber {
85
+ const slotAfterProposedCheckpoint = proposedCheckpointData ? proposedCheckpointData.header.slotNumber + 1 : undefined;
86
+ const slotAfterCheckpointedTip = checkpointedTipSlot !== undefined ? checkpointedTipSlot + 1 : undefined;
87
+ return SlotNumber(Math.max(...compactArray([clockSlot, slotAfterProposedCheckpoint, slotAfterCheckpointedTip])));
88
+ }
89
+
90
+ /**
91
+ * The slot this node's clock says the next block would be built in, the first of the three terms
92
+ * {@link planNextBlock} maximizes over. Pure arithmetic over the epoch cache's in-memory view.
93
+ */
94
+ export function getClockSlot(epochCache: EpochCacheInterface): SlotNumber {
95
+ return SlotNumber(epochCache.getEpochAndSlotInNextL1Slot().slot + PROPOSER_PIPELINING_SLOT_OFFSET);
96
+ }
97
+
98
+ /**
99
+ * Works out how the next block sits on the chain from one atomic frontier snapshot: whether it continues the
100
+ * in-progress checkpoint or opens a fresh one, which slot it lands in, and which checkpoint's L1-to-L2
101
+ * messages a fork would need. Pure: the caller supplies both the snapshot and the clock slot, so the fee a
102
+ * wallet is quoted and the fee a simulation charges can never derive from different decisions.
103
+ */
104
+ export function planNextBlock(frontier: L2Frontier, clockSlot: SlotNumber): NextBlockPlan {
105
+ const { tips, proposedCheckpoint: proposedCheckpointData } = frontier;
106
+ const latestBlockNumber = tips.proposed.number;
107
+ const latestBlockHash = tips.proposed.hash;
108
+
109
+ // Terminating block of the proposed-checkpoint frontier: the leading proposed (not-yet-L1-confirmed)
110
+ // checkpoint's last block is `startBlock + blockCount - 1`; with no proposed checkpoint the frontier
111
+ // coincides with the checkpointed tip.
112
+ const proposedCheckpointLastBlock = proposedCheckpointData
113
+ ? BlockNumber.add(proposedCheckpointData.startBlock, proposedCheckpointData.blockCount - 1)
114
+ : tips.checkpointed.block.number;
115
+
116
+ // The next block continues the in-progress checkpoint when the latest proposed block is ahead of the
117
+ // proposed-checkpoint terminating block; it opens a new checkpoint when they coincide.
118
+ if (proposedCheckpointLastBlock !== latestBlockNumber) {
119
+ return { latestBlockNumber, latestBlockHash };
120
+ }
121
+
122
+ const checkpointedCheckpointNumber = tips.checkpointed.checkpoint.number;
123
+ // The new checkpoint sits on top of the proposed one when pipelining, otherwise on the checkpointed tip.
124
+ const parentCheckpointNumber = proposedCheckpointData?.checkpointNumber ?? checkpointedCheckpointNumber;
125
+ // Undefined before the first checkpoint lands: no slot is taken yet, so there is no floor.
126
+ const checkpointedTipSlot = frontier.checkpointedCheckpoint ? getCheckpointedTipSlot(frontier) : undefined;
127
+
128
+ return {
129
+ latestBlockNumber,
130
+ latestBlockHash,
131
+ newCheckpoint: {
132
+ targetSlot: computeTargetSlot(clockSlot, proposedCheckpointData, checkpointedTipSlot),
133
+ targetCheckpoint: CheckpointNumber(parentCheckpointNumber + 1),
134
+ proposedCheckpointData,
135
+ checkpointedCheckpointNumber,
136
+ },
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Keys the fee of a plan that opens a new checkpoint. Undefined mid-checkpoint, where the fee is copied from
142
+ * the in-progress checkpoint's header and nothing needs pricing.
143
+ * @param pendingChainValidationStatus - From the same frontier snapshot the plan was built from.
144
+ */
145
+ export function computeBoundaryFeeKey(
146
+ plan: NextBlockPlan,
147
+ pendingChainValidationStatus: ValidateCheckpointResult,
148
+ ): BoundaryFeeKey | undefined {
149
+ if (!plan.newCheckpoint) {
150
+ return undefined;
151
+ }
152
+ const { targetSlot, proposedCheckpointData, checkpointedCheckpointNumber } = plan.newCheckpoint;
153
+ const parent: BoundaryFeeParent = proposedCheckpointData
154
+ ? {
155
+ kind: 'proposed',
156
+ headerHash: proposedCheckpointData.header.hash().toString(),
157
+ archiveRoot: proposedCheckpointData.archive.root.toString(),
158
+ checkpointOutHash: proposedCheckpointData.checkpointOutHash.toString(),
159
+ totalManaUsed: proposedCheckpointData.totalManaUsed,
160
+ feeAssetPriceModifier: proposedCheckpointData.feeAssetPriceModifier,
161
+ }
162
+ : {
163
+ kind: 'checkpointed',
164
+ pendingChainValid: pendingChainValidationStatus.valid,
165
+ firstInvalidCheckpoint: pendingChainValidationStatus.valid
166
+ ? undefined
167
+ : pendingChainValidationStatus.checkpoint.checkpointNumber,
168
+ };
169
+ return { targetSlot, checkpointedCheckpointNumber, latestBlockHash: plan.latestBlockHash, parent };
170
+ }
171
+
172
+ /** Whether two boundary fee keys describe the same fee. */
173
+ export function boundaryFeeKeyEquals(a: BoundaryFeeKey, b: BoundaryFeeKey): boolean {
174
+ return (
175
+ a.targetSlot === b.targetSlot &&
176
+ a.checkpointedCheckpointNumber === b.checkpointedCheckpointNumber &&
177
+ a.latestBlockHash === b.latestBlockHash &&
178
+ boundaryFeeParentEquals(a.parent, b.parent)
179
+ );
180
+ }
181
+
182
+ function boundaryFeeParentEquals(a: BoundaryFeeParent, b: BoundaryFeeParent): boolean {
183
+ if (a.kind === 'proposed') {
184
+ return (
185
+ b.kind === 'proposed' &&
186
+ a.headerHash === b.headerHash &&
187
+ a.archiveRoot === b.archiveRoot &&
188
+ a.checkpointOutHash === b.checkpointOutHash &&
189
+ a.totalManaUsed === b.totalManaUsed &&
190
+ a.feeAssetPriceModifier === b.feeAssetPriceModifier
191
+ );
192
+ }
193
+ return (
194
+ b.kind === 'checkpointed' &&
195
+ a.pendingChainValid === b.pendingChainValid &&
196
+ a.firstInvalidCheckpoint === b.firstInvalidCheckpoint
197
+ );
198
+ }