Skip to content

Commit 574d6cf

Browse files
committed
perf(fast-inbox): pack the inbox consumption counts into the slot-number slot (A-1384)
inboxMsgTotal and inboxConsumedBucket occupied a storage slot of their own in CompressedTempCheckpointLog, costing an extra zero-to-nonzero SSTORE on every propose and a cold SLOAD when reading the parent checkpoint's total. Declare them next to CompressedSlot slotNumber so all three share one slot (20 of 32 bytes), which propose already touches for the slot-progression check; the checkpoint record drops from nine slots to eight. Both counts keep their full uint64 width, and feeHeader stays at its previous slot offset. The pipelined-simulation state override writes that slot as a whole word, so it now packs the two counts alongside the slot number instead of silently zeroing them, and accepts them as override inputs.
1 parent 557fff1 commit 574d6cf

4 files changed

Lines changed: 85 additions & 27 deletions

File tree

l1-contracts/src/core/libraries/compressed-data/CheckpointLog.sol

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,16 @@ struct TempCheckpointLog {
3434
bytes32 attestationsHash;
3535
bytes32 payloadDigest;
3636
Slot slotNumber;
37-
FeeHeader feeHeader;
38-
// Streaming Inbox consumption record (AZIP-22 Fast Inbox). `inboxRollingHash` is the consensus rolling hash the
39-
// checkpoint header committed to; `inboxMsgTotal` is the cumulative Inbox message count consumed as of this
40-
// checkpoint (the child's parent-total origin); `inboxConsumedBucket` is the bucket sequence number the header's
41-
// rolling hash corresponds to. The two counts pack into a single storage slot.
42-
bytes32 inboxRollingHash;
37+
// Streaming Inbox consumption counts (AZIP-22 Fast Inbox). `inboxMsgTotal` is the cumulative Inbox message count
38+
// consumed as of this checkpoint (the child's parent-total origin); `inboxConsumedBucket` is the bucket sequence
39+
// number the header's rolling hash corresponds to. Declared next to the slot number so the three share one storage
40+
// slot (4 + 8 + 8 of 32 bytes): propose writes and reads that slot for the slot-progression check anyway.
4341
uint64 inboxMsgTotal;
4442
uint64 inboxConsumedBucket;
43+
FeeHeader feeHeader;
44+
// The consensus Inbox rolling hash the checkpoint header committed to, in a slot of its own: epoch proofs anchor
45+
// both ends of their consumed chain segment against it.
46+
bytes32 inboxRollingHash;
4547
}
4648

4749
struct CompressedTempCheckpointLog {
@@ -51,10 +53,10 @@ struct CompressedTempCheckpointLog {
5153
bytes32 attestationsHash;
5254
bytes32 payloadDigest;
5355
CompressedSlot slotNumber;
54-
CompressedFeeHeader feeHeader;
55-
bytes32 inboxRollingHash;
5656
uint64 inboxMsgTotal;
5757
uint64 inboxConsumedBucket;
58+
CompressedFeeHeader feeHeader;
59+
bytes32 inboxRollingHash;
5860
}
5961

6062
library CompressedTempCheckpointLogLib {
@@ -71,10 +73,10 @@ library CompressedTempCheckpointLogLib {
7173
attestationsHash: _checkpoint.attestationsHash,
7274
payloadDigest: _checkpoint.payloadDigest,
7375
slotNumber: _checkpoint.slotNumber.compress(),
74-
feeHeader: _checkpoint.feeHeader.compress(),
75-
inboxRollingHash: _checkpoint.inboxRollingHash,
7676
inboxMsgTotal: _checkpoint.inboxMsgTotal,
77-
inboxConsumedBucket: _checkpoint.inboxConsumedBucket
77+
inboxConsumedBucket: _checkpoint.inboxConsumedBucket,
78+
feeHeader: _checkpoint.feeHeader.compress(),
79+
inboxRollingHash: _checkpoint.inboxRollingHash
7880
});
7981
}
8082

@@ -90,10 +92,10 @@ library CompressedTempCheckpointLogLib {
9092
attestationsHash: _compressedCheckpoint.attestationsHash,
9193
payloadDigest: _compressedCheckpoint.payloadDigest,
9294
slotNumber: _compressedCheckpoint.slotNumber.decompress(),
93-
feeHeader: _compressedCheckpoint.feeHeader.decompress(),
94-
inboxRollingHash: _compressedCheckpoint.inboxRollingHash,
9595
inboxMsgTotal: _compressedCheckpoint.inboxMsgTotal,
96-
inboxConsumedBucket: _compressedCheckpoint.inboxConsumedBucket
96+
inboxConsumedBucket: _compressedCheckpoint.inboxConsumedBucket,
97+
feeHeader: _compressedCheckpoint.feeHeader.decompress(),
98+
inboxRollingHash: _compressedCheckpoint.inboxRollingHash
9799
});
98100
}
99101
}

yarn-project/ethereum/src/contracts/chain_state_override.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export type PendingCheckpointOverrideState = {
2121
outHash?: Fr;
2222
payloadDigest?: Buffer32;
2323
slotNumber?: SlotNumber;
24+
inboxMsgTotal?: bigint;
25+
inboxConsumedBucket?: bigint;
2426
};
2527

2628
export type ChainTipsOverride = {
@@ -93,11 +95,12 @@ export class SimulationOverridesBuilder {
9395

9496
/**
9597
* Overrides one or more `tempCheckpointLogs` cell fields for the configured pending checkpoint.
96-
* Fields are independent: any subset can be provided. The translator (`makeTempCheckpointLogOverride`)
97-
* emits a stateDiff entry per field actually set, so unspecified fields stay at their on-chain
98-
* values.
98+
* Any subset can be provided. The translator (`makeTempCheckpointLogOverride`) emits a stateDiff
99+
* entry per storage word touched, so fields in untouched words stay at their on-chain values;
100+
* `slotNumber`, `inboxMsgTotal` and `inboxConsumedBucket` share a word, so setting any of them
101+
* zeroes the others unless they are supplied too.
99102
*
100-
* `slotNumber` is load-bearing for `STFLib.canPruneAtTime`: when the simulation overrides `pending`
103+
* `slotNumber` is required for `STFLib.canPruneAtTime`: when the simulation overrides `pending`
101104
* to a checkpoint that has no on-chain `tempCheckpointLogs` entry yet, the missing slotNumber falls
102105
* back to 0 and the contract treats the pending tip as belonging to epoch 0, triggering a phantom
103106
* prune that silently undoes the `pending` override.
@@ -107,6 +110,8 @@ export class SimulationOverridesBuilder {
107110
outHash?: Fr;
108111
payloadDigest?: Buffer32;
109112
slotNumber?: SlotNumber;
113+
inboxMsgTotal?: bigint;
114+
inboxConsumedBucket?: bigint;
110115
}): this {
111116
this.assertPendingCheckpointNumber();
112117
this.pendingCheckpointState = { ...(this.pendingCheckpointState ?? {}), ...fields };
@@ -174,6 +179,8 @@ export async function buildSimulationOverridesStateOverride(
174179
outHash: plan.pendingCheckpointState.outHash,
175180
payloadDigest: plan.pendingCheckpointState.payloadDigest,
176181
slotNumber: plan.pendingCheckpointState.slotNumber,
182+
inboxMsgTotal: plan.pendingCheckpointState.inboxMsgTotal,
183+
inboxConsumedBucket: plan.pendingCheckpointState.inboxConsumedBucket,
177184
feeHeader: plan.pendingCheckpointState.feeHeader,
178185
}),
179186
),

yarn-project/ethereum/src/contracts/rollup.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,26 @@ describe('Rollup', () => {
487487
);
488488
});
489489

490+
it('packs the inbox consumption counts into the slot-number word', async () => {
491+
const checkpointNumber = CheckpointNumber(13);
492+
const override = await rollup.makeTempCheckpointLogOverride(checkpointNumber, {
493+
slotNumber: SlotNumber(7),
494+
inboxMsgTotal: 300n,
495+
inboxConsumedBucket: 5n,
496+
});
497+
const { map, slotFor } = getDiffMap(checkpointNumber, override);
498+
expect(override[0].stateDiff).toHaveLength(1);
499+
expect(map.get(await slotFor(TempCheckpointLogField.SlotNumber))).toBe(
500+
`0x${(7n | (300n << 32n) | (5n << 96n)).toString(16).padStart(64, '0')}`.toLowerCase(),
501+
);
502+
});
503+
504+
it('throws when an inbox consumption count overflows uint64', async () => {
505+
await expect(
506+
rollup.makeTempCheckpointLogOverride(CheckpointNumber(3), { inboxMsgTotal: 1n << 64n }),
507+
).rejects.toThrow(/does not fit in uint64/);
508+
});
509+
490510
it('partial override returns an empty array when no fields are supplied', async () => {
491511
const override = await rollup.makeTempCheckpointLogOverride(CheckpointNumber(13), {});
492512
expect(override).toEqual([]);

yarn-project/ethereum/src/contracts/rollup.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,10 @@ export type L1FeeData = {
139139
blobFee: bigint;
140140
};
141141

142-
/** Field offsets within the CompressedTempCheckpointLog struct in Solidity storage. */
142+
/**
143+
* Field offsets within the CompressedTempCheckpointLog struct in Solidity storage. The `SlotNumber`
144+
* word also packs the inbox consumption counts, which Solidity places in the same slot.
145+
*/
143146
export enum TempCheckpointLogField {
144147
HeaderHash = 0,
145148
BlobCommitmentsHash = 1,
@@ -148,19 +151,27 @@ export enum TempCheckpointLogField {
148151
PayloadDigest = 4,
149152
SlotNumber = 5,
150153
FeeHeader = 6,
154+
InboxRollingHash = 7,
151155
}
152156

153157
/**
154158
* Field-level override input for `tempCheckpointLogs[checkpointNumber]`. Covers the fields the
155159
* `propose()` path actually reads back. `payloadDigest` is `Buffer32` because it carries an
156160
* arbitrary `bytes32` value rather than a BN254 scalar. `slotNumber` carries the uint32 portion
157161
* of the on-chain `CompressedSlot`.
162+
*
163+
* `slotNumber`, `inboxMsgTotal` and `inboxConsumedBucket` share a single storage word, so supplying
164+
* any one of them rewrites all three; the ones left out land as zero.
158165
*/
159166
export type TempCheckpointLogOverrideFields = {
160167
headerHash?: Fr;
161168
outHash?: Fr;
162169
payloadDigest?: Buffer32;
163170
slotNumber?: SlotNumber;
171+
/** Cumulative Inbox message count consumed as of this checkpoint. */
172+
inboxMsgTotal?: bigint;
173+
/** Inbox bucket sequence number this checkpoint's rolling hash corresponds to. */
174+
inboxConsumedBucket?: bigint;
164175
feeHeader?: FeeHeader;
165176
};
166177

@@ -266,6 +277,13 @@ function isHexString(value: unknown): value is Hex {
266277
return typeof value === 'string' && value.startsWith('0x');
267278
}
268279

280+
function requireUintFits(value: bigint, bits: number, name: string): bigint {
281+
if (value < 0n || value >= 1n << BigInt(bits)) {
282+
throw new Error(`${name} ${value} does not fit in uint${bits}`);
283+
}
284+
return value;
285+
}
286+
269287
export class RollupContract {
270288
private readonly rollup: GetContractReturnType<typeof RollupAbi, ViemClient>;
271289
private readonly logger = createLogger('ethereum:rollup');
@@ -936,6 +954,9 @@ export class RollupContract {
936954
*
937955
* `blobCommitmentsHash` and `attestationsHash` are intentionally not exposed here — the propose path
938956
* never asserts against them, so leaving them at storage zero is harmless.
957+
*
958+
* One diff entry is emitted per storage word touched, so words left out keep their on-chain values.
959+
* `slotNumber` and the two inbox consumption counts share a word: any of them rewrites all three.
939960
*/
940961
public async makeTempCheckpointLogOverride(
941962
checkpointNumber: CheckpointNumber,
@@ -960,14 +981,22 @@ export class RollupContract {
960981
value: fields.payloadDigest.toString() as `0x${string}`,
961982
});
962983
}
963-
if (fields.slotNumber !== undefined) {
964-
// CompressedSlot is uint32 on L1 (SafeCast.toUint32 reverts on overflow). Match that behavior here
965-
// so a malformed override surfaces immediately rather than silently truncating into a wrong slot.
966-
const slotNumber = BigInt(fields.slotNumber);
967-
if (slotNumber < 0n || slotNumber > 0xffffffffn) {
968-
throw new Error(`slotNumber ${slotNumber} does not fit in uint32`);
969-
}
970-
stateDiff.push({ slot: slotAt(TempCheckpointLogField.SlotNumber), value: word(slotNumber) });
984+
if (
985+
fields.slotNumber !== undefined ||
986+
fields.inboxMsgTotal !== undefined ||
987+
fields.inboxConsumedBucket !== undefined
988+
) {
989+
// The L1 struct packs the slot number and the two inbox consumption counts into one word, so this
990+
// diff always writes all three. Widths are enforced here because the L1 writers cast through
991+
// SafeCast and revert on overflow; a malformed override must surface rather than silently truncate
992+
// into a neighbouring field.
993+
const slotNumber = requireUintFits(BigInt(fields.slotNumber ?? 0), 32, 'slotNumber');
994+
const inboxMsgTotal = requireUintFits(fields.inboxMsgTotal ?? 0n, 64, 'inboxMsgTotal');
995+
const inboxConsumedBucket = requireUintFits(fields.inboxConsumedBucket ?? 0n, 64, 'inboxConsumedBucket');
996+
stateDiff.push({
997+
slot: slotAt(TempCheckpointLogField.SlotNumber),
998+
value: word(slotNumber | (inboxMsgTotal << 32n) | (inboxConsumedBucket << 96n)),
999+
});
9711000
}
9721001
if (fields.feeHeader) {
9731002
stateDiff.push({

0 commit comments

Comments
 (0)