Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion yarn-project/validator-client/src/checkpoint_builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NUM_CHECKPOINT_END_MARKER_FIELDS, getNumBlockEndBlobFields } from '@azt
import {
BLOBS_PER_CHECKPOINT,
CONTRACT_CLASS_LOG_SIZE_IN_FIELDS,
DA_BYTES_PER_FIELD,
DA_GAS_PER_FIELD,
FIELDS_PER_BLOB,
MAX_PROCESSABLE_DA_GAS_PER_CHECKPOINT,
Expand Down Expand Up @@ -37,7 +38,7 @@ import {
import type { TelemetryClient } from '@aztec/telemetry-client';
import { NativeWorldStateService } from '@aztec/world-state/native';

import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { afterEach, beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals';
import { type MockProxy, mock } from 'jest-mock-extended';

import { CheckpointBuilder, FullNodeCheckpointsBuilder } from './checkpoint_builder.js';
Expand Down Expand Up @@ -754,6 +755,131 @@ describe('CheckpointBuilder', () => {
});
});

describe('transaction-less tail block blob reservation', () => {
const totalBlobCapacity = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS;
const blockEndOverhead = getNumBlockEndBlobFields();
/** Blob fields left for txs in the current block once its own end fields and a tail's are held back. */
const txRoomBeyondTail = 10;
const nearCapacityUsed = totalBlobCapacity - 2 * blockEndOverhead - txRoomBeyondTail;

/** Measured size of a real transaction-less block, rather than the helper's constant. */
let tailBlockFields: number;

beforeAll(async () => {
const tailBlock = await L2Block.random(BlockNumber(1), { txsPerBlock: 0 });
tailBlockFields = tailBlock.toBlobFields().length;
});

/** Fills the checkpoint with a single prior block of the given blob size. */
function withPriorBlockOfSize(blockBlobFieldCount: number) {
lightweightCheckpointBuilder.getBlocks.mockReturnValue([
createMockBlock({ manaUsed: 0, txBlobFields: [], blockBlobFieldCount }),
]);
}

/** Proposer opts where the fair share across remaining blocks is not the binding cap. */
function unsharedProposerOpts(maxBlocksPerCheckpoint: number, existingBlocks: number) {
const remainingBlocks = Math.max(1, maxBlocksPerCheckpoint - existingBlocks);
return proposerOpts({
maxBlocksPerCheckpoint,
perBlockAllocationMultiplier: remainingBlocks,
perBlockDAAllocationMultiplier: remainingBlocks,
});
}

it('serializes a transaction-less block into exactly the shared block-end field count', () => {
expect(tailBlockFields).toBe(blockEndOverhead);
expect(tailBlockFields).toBe(7);
expect(tailBlockFields * DA_BYTES_PER_FIELD).toBe(224);
});

it('leaves room for a tail block while another block can still follow', () => {
setupBuilder();
withPriorBlockOfSize(nearCapacityUsed);

const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(unsharedProposerOpts(3, 1));

expect(capped.maxBlobFields).toBe(txRoomBeyondTail);
// A block that packs the full allowance still ends the checkpoint with exactly a tail block's fields free.
const usedAfterThisBlock = nearCapacityUsed + capped.maxBlobFields! + blockEndOverhead;
expect(totalBlobCapacity - usedAfterThisBlock).toBe(tailBlockFields);
});

it('packs no txs when only the tail block fits', () => {
setupBuilder();
withPriorBlockOfSize(totalBlobCapacity - 2 * blockEndOverhead);

const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(unsharedProposerOpts(3, 1));

expect(capped.maxBlobFields).toBe(0);
});

it('packs no txs when the checkpoint is one field short of the tail block', () => {
setupBuilder();
withPriorBlockOfSize(totalBlobCapacity - 2 * blockEndOverhead + 1);

const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(unsharedProposerOpts(3, 1));

expect(capped.maxBlobFields).toBe(0);
});

it('releases the reservation on the last block the checkpoint can hold', () => {
setupBuilder();
withPriorBlockOfSize(nearCapacityUsed);

const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(unsharedProposerOpts(2, 1));

expect(capped.maxBlobFields).toBe(txRoomBeyondTail + blockEndOverhead);
});

it('reserves the tail block alone, not a second checkpoint end marker', () => {
setupBuilder();
withPriorBlockOfSize(nearCapacityUsed);

const reserved = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(unsharedProposerOpts(3, 1));
const released = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(unsharedProposerOpts(2, 1));

expect(released.maxBlobFields! - reserved.maxBlobFields!).toBe(tailBlockFields);
});

it('fits the tail block within the checkpoint after packing an ordinary block to its allowance', async () => {
setupBuilder();
withPriorBlockOfSize(nearCapacityUsed);

const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(unsharedProposerOpts(3, 1));
const ordinaryBlock = createMockBlock({
manaUsed: 0,
txBlobFields: [capped.maxBlobFields!],
blockBlobFieldCount: capped.maxBlobFields! + blockEndOverhead,
});
lightweightCheckpointBuilder.getBlocks.mockReturnValue([
createMockBlock({ manaUsed: 0, txBlobFields: [], blockBlobFieldCount: nearCapacityUsed }),
ordinaryBlock,
]);

// The tail consumes the reservation: its own end fields are the only ones it adds, and the checkpoint end
// marker is charged once for the whole checkpoint.
const tailBlock = await L2Block.random(BlockNumber(2), { txsPerBlock: 0 });
const usedWithTail = nearCapacityUsed + ordinaryBlock.toBlobFields().length + tailBlock.toBlobFields().length;
expect(usedWithTail + NUM_CHECKPOINT_END_MARKER_FIELDS).toBeLessThanOrEqual(
BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB,
);

// Building the tail itself reports no room for txs rather than charging its overhead twice.
const tailLimits = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(unsharedProposerOpts(3, 2));
expect(tailLimits.maxBlobFields).toBe(0);
});

it('does not reserve a tail block when re-executing a peer proposal', () => {
setupBuilder();
withPriorBlockOfSize(nearCapacityUsed);

const capped = (checkpointBuilder as TestCheckpointBuilder).testCapLimits(validatorOpts());

expect(capped.maxBlobFields).toBe(txRoomBeyondTail + blockEndOverhead);
});
});

describe('per-block DA allocation multiplier (largest deploy fit under v5 mainnet geometry)', () => {
// v5 mainnet: 72s slots / 6s blocks -> 10 blocks per checkpoint.
const mainnetBlocks = 10;
Expand Down
20 changes: 17 additions & 3 deletions yarn-project/validator-client/src/checkpoint_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,9 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder {
/**
* Caps per-block gas and blob field limits by remaining checkpoint-level budgets.
* When building a proposal (isBuildingProposal=true), computes a fair share of remaining budget
* across remaining blocks scaled by the multiplier. When validating, only caps by per-block limit
* and remaining checkpoint budget (no redistribution or multiplier).
* across remaining blocks scaled by the multiplier, and holds back blob space for a transaction-less block that
* may still be needed to end the checkpoint at a live L1 Inbox bucket end. When validating, only caps by per-block
* limit and remaining checkpoint budget (no redistribution, multiplier or reservation).
*/
protected capLimitsByCheckpointBudgets(
opts: BlockBuilderOptions,
Expand All @@ -212,7 +213,20 @@ export class CheckpointBuilder implements ICheckpointBlockBuilder {
const usedBlobFields = sum(existingBlocks.map(b => b.toBlobFields().length));
const totalBlobCapacity = BLOBS_PER_CHECKPOINT * FIELDS_PER_BLOB - NUM_CHECKPOINT_END_MARKER_FIELDS;
const blockEndOverhead = getNumBlockEndBlobFields();
const maxBlobFieldsForTxs = totalBlobCapacity - usedBlobFields - blockEndOverhead;

// A proposer whose sub-slots run out while the consumption cursor sits at a prefix that is not a live L1 Inbox
// bucket end appends one transaction-less block to reach one, so the checkpoint can be published at all. That
// block still writes its own block-end fields, so hold them back from transaction packing while such a block can
// still follow; the last block the checkpoint can hold releases them, since nothing can follow it. Only the
// proposer packs against this: re-executing a peer's proposal must not reject a block over it. The checkpoint end
// marker is already deducted from the total capacity, so the reservation is a block's end fields alone.
// Reserving blob space does not reserve build time, nor guarantee that the extra block can be built.
const rescueTailReservation =
opts.isBuildingProposal && opts.maxBlocksPerCheckpoint - existingBlocks.length > 1 ? blockEndOverhead : 0;
const maxBlobFieldsForTxs = Math.max(
0,
totalBlobCapacity - usedBlobFields - blockEndOverhead - rescueTailReservation,
);

// Remaining txs
const usedTxs = sum(existingBlocks.map(b => b.body.txEffects.length));
Expand Down
Loading