Skip to content
Open
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
11 changes: 11 additions & 0 deletions yarn-project/epoch-cache/src/epoch_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export interface EpochCacheInterface {
isInCommittee(slot: SlotTag, validator: EthAddress): Promise<boolean>;
filterInCommittee(slot: SlotTag, validators: EthAddress[]): Promise<EthAddress[]>;
getL1Constants(): L1RollupConstants;
getLagInEpochsForValidatorSet(): number;
}

/**
Expand Down Expand Up @@ -180,6 +181,16 @@ export class EpochCache implements EpochCacheInterface {
return this.l1constants;
}

/**
* The number of epochs by which validator-set sampling lags. The committee for epoch `E` samples the
* validator set as of `lagInEpochsForValidatorSet` epochs before `E` (see
* `ValidatorSelectionLib.stableEpochToValidatorSetSampleTime` on L1), so a newly staked validator only
* becomes eligible for a committee this many epochs after staking.
*/
public getLagInEpochsForValidatorSet(): number {
return this.l1constants.lagInEpochsForValidatorSet;
}

public getSlotNow(): SlotNumber {
return this.getEpochAndSlotNow().slot;
}
Expand Down
10 changes: 10 additions & 0 deletions yarn-project/epoch-cache/src/test/test_epoch_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,17 @@ export class TestEpochCache implements EpochCacheInterface {
private seed: bigint = 0n;
private registeredValidators: EthAddress[] = [];
private l1Constants: L1RollupConstants;
private lagInEpochsForValidatorSet = 2;

constructor(l1Constants: Partial<L1RollupConstants> = {}) {
this.l1Constants = { ...DEFAULT_L1_CONSTANTS, ...l1Constants };
}

setLagInEpochsForValidatorSet(lag: number): this {
this.lagInEpochsForValidatorSet = lag;
return this;
}

/**
* Sets the committee members. Used in validation and attestation flows.
* @param committee - Array of committee member addresses.
Expand Down Expand Up @@ -115,6 +121,10 @@ export class TestEpochCache implements EpochCacheInterface {
return this.l1Constants;
}

getLagInEpochsForValidatorSet(): number {
return this.lagInEpochsForValidatorSet;
}

getCommittee(_slot?: SlotTag): Promise<EpochCommitteeInfo> {
const epoch = getEpochAtSlot(this.currentSlot, this.l1Constants);
return Promise.resolve({
Expand Down
12 changes: 12 additions & 0 deletions yarn-project/ethereum/src/contracts/gse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ export class GSEContract {
return this.gse.read.getAttestersFromIndicesAtTime([instance, ts, indices], options);
}

/** Number of attesters registered to `instance` as of the given L1 timestamp. */
public async getAttesterCountAtTime(
instance: Hex | EthAddress,
ts: bigint,
options?: { blockNumber?: bigint },
): Promise<number> {
if (instance instanceof EthAddress) {
instance = instance.toString();
}
return Number(await this.gse.read.getAttesterCountAtTime([instance, ts], options));
}

public async getRegistrationDigest(publicKey: ProjPointType<bigint>): Promise<ProjPointType<bigint>> {
const affinePublicKey = publicKey.toAffine();
const g1PointDigest = await this.gse.read.getRegistrationDigest([{ x: affinePublicKey.x, y: affinePublicKey.y }]);
Expand Down
10 changes: 10 additions & 0 deletions yarn-project/ethereum/src/contracts/rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,16 @@ export class RollupContract {
return Number(await this.rollup.read.getActiveAttesterCount(options));
}

/**
* Number of attesters that were staked at the given (historical) L1 timestamp. This is the count that
* validator-set sampling uses to decide whether an epoch gets a committee, and the historical counterpart
* of {@link getActiveAttesterCount} (which reads at the latest L1 block).
*/
async getAttesterCountAtTime(timestamp: bigint, options?: { blockNumber?: bigint }): Promise<number> {
const gse = new GSEContract(this.client, await this.getGSE());
return gse.getAttesterCountAtTime(this.address, timestamp, options);
}

public async getSlashingProposerAddress() {
const slasher = await this.getSlasherContract();
if (!slasher) {
Expand Down
18 changes: 17 additions & 1 deletion yarn-project/foundation/src/string/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { urlJoin, withHexPrefix, withoutHexPrefix } from './index.js';
import { formatSeconds, urlJoin, withHexPrefix, withoutHexPrefix } from './index.js';

describe('string', () => {
describe('urlJoin', () => {
Expand All @@ -23,6 +23,22 @@ describe('string', () => {
});
});

describe('formatSeconds', () => {
it.each([
[0, '0s'],
[45, '45s'],
[59, '59s'],
[60, '1m'],
[90, '2m'],
[3540, '59m'],
[3600, '1h'],
[3660, '1h 1m'],
[7500, '2h 5m'],
])('formats %d seconds as %s', (seconds, expected) => {
expect(formatSeconds(seconds)).toBe(expected);
});
});

describe('hex prefix helpers', () => {
it('adds 0x prefix when missing', () => {
expect(withHexPrefix('abc')).toBe('0xabc');
Expand Down
14 changes: 14 additions & 0 deletions yarn-project/foundation/src/string/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ export function truncate(str: string, length: number = 64): string {
return str.length > length ? str.slice(0, length) + '...' : str;
}

/** Formats a duration in seconds into a compact human-readable string (e.g. `45s`, `12m`, `2h 5m`). */
export function formatSeconds(seconds: number): string {
if (seconds < 60) {
return `${Math.round(seconds)}s`;
}
const minutes = Math.round(seconds / 60);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remMinutes = minutes % 60;
return remMinutes > 0 ? `${hours}h ${remMinutes}m` : `${hours}h`;
}

export function isoDate(date?: Date) {
return (date ?? new Date()).toISOString().replace(/[-:T]/g, '').replace(/\..+$/, '');
}
Expand Down
1 change: 1 addition & 0 deletions yarn-project/p2p/src/test-helpers/testbench-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ export function createMockEpochCache(): EpochCacheInterface {
targetCommitteeSize: 48,
rollupManaLimit: Number.MAX_SAFE_INTEGER,
}),
getLagInEpochsForValidatorSet: () => 2,
};
return cache;
}
Expand Down
207 changes: 207 additions & 0 deletions yarn-project/sequencer-client/src/sequencer/missing_committee.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types';
import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers';

import { jest } from '@jest/globals';

import {
type MissingCommitteeContext,
classifyMissingCommittee,
findFirstEpochWithCommittee,
logMissingCommittee,
} from './missing_committee.js';

describe('classifyMissingCommittee', () => {
const base = { targetCommitteeSize: 48, hasProducedBlocks: false };

it('reports awaiting-first-validators while bootstrapping with too few validators', () => {
const result = classifyMissingCommittee({ ...base, attesterCount: 0, hasProducedBlocks: false });
expect(result).toEqual({ cause: 'awaiting-first-validators', severity: 'info' });
});

it('still awaits first validators when partially filled and no blocks produced yet', () => {
const result = classifyMissingCommittee({ ...base, attesterCount: 47, hasProducedBlocks: false });
expect(result).toEqual({ cause: 'awaiting-first-validators', severity: 'info' });
});

it('warns about a shrunken validator set once the chain has produced blocks', () => {
const result = classifyMissingCommittee({ ...base, attesterCount: 10, hasProducedBlocks: true });
expect(result).toEqual({ cause: 'validator-set-shrank', severity: 'warn' });
});

it('reports awaiting-sampling-lag once enough validators are staked', () => {
const result = classifyMissingCommittee({ ...base, attesterCount: 48, hasProducedBlocks: false });
expect(result).toEqual({ cause: 'awaiting-sampling-lag', severity: 'info' });
});

it('prefers the sampling-lag cause over shrink even after blocks were produced', () => {
const result = classifyMissingCommittee({ ...base, attesterCount: 3462, hasProducedBlocks: true });
expect(result).toEqual({ cause: 'awaiting-sampling-lag', severity: 'info' });
});
});

describe('findFirstEpochWithCommittee', () => {
const targetCommitteeSize = 48;

it('returns the first past epoch whose sample already met the target', () => {
const result = findFirstEpochWithCommittee({
candidates: [
{ epoch: EpochNumber(601), sampledAttesterCount: 10 },
{ epoch: EpochNumber(602), sampledAttesterCount: 48 },
{ epoch: EpochNumber(603), sampledAttesterCount: undefined },
],
targetCommitteeSize,
});
expect(result).toEqual(EpochNumber(602));
});

it('projects the first future-sampled epoch when no past sample qualifies', () => {
const result = findFirstEpochWithCommittee({
candidates: [
{ epoch: EpochNumber(601), sampledAttesterCount: 10 },
{ epoch: EpochNumber(602), sampledAttesterCount: undefined },
],
targetCommitteeSize,
});
expect(result).toEqual(EpochNumber(602));
});

it('returns the earliest qualifying epoch, scanning ascending', () => {
// The predicate is not monotone, so a future sample time (undefined) that precedes a qualifying past
// sample still wins — the earliest qualifying epoch is the answer.
const result = findFirstEpochWithCommittee({
candidates: [
{ epoch: EpochNumber(601), sampledAttesterCount: undefined },
{ epoch: EpochNumber(602), sampledAttesterCount: 50 },
],
targetCommitteeSize,
});
expect(result).toEqual(EpochNumber(601));
});

it('returns undefined when nothing qualifies', () => {
const result = findFirstEpochWithCommittee({
candidates: [
{ epoch: EpochNumber(601), sampledAttesterCount: 10 },
{ epoch: EpochNumber(602), sampledAttesterCount: 20 },
],
targetCommitteeSize,
});
expect(result).toBeUndefined();
});
});

describe('logMissingCommittee', () => {
const targetSlot = SlotNumber(2);
const targetEpoch = EpochNumber(0);

// l1GenesisTime 0, slotDuration 8, epochDuration 16 → 128s/epoch, so epochStart(E) = E * 128.
const l1Constants: L1RollupConstants = {
l1StartBlock: 0n,
l1GenesisTime: 0n,
slotDuration: 8,
epochDuration: 16,
ethereumSlotDuration: 4,
proofSubmissionEpochs: 2,
targetCommitteeSize: 48,
rollupManaLimit: Number.MAX_SAFE_INTEGER,
};

function makeCtx(opts: {
attesterCount?: () => Promise<number>;
blockNumber?: BlockNumber;
attesterCountAtTime?: (ts: bigint) => Promise<number>;
nowSeconds?: number;
lag?: number;
}) {
const logger = { info: jest.fn(), warn: jest.fn(), debug: jest.fn() };
const ctx: MissingCommitteeContext = {
epochCache: {
getL1Constants: () => l1Constants,
getLagInEpochsForValidatorSet: () => opts.lag ?? 2,
},
rollupContract: {
getActiveAttesterCount: opts.attesterCount ?? (() => Promise.resolve(0)),
getAttesterCountAtTime: opts.attesterCountAtTime ?? (() => Promise.resolve(0)),
},
l2BlockSource: {
getBlockNumber: () => Promise.resolve(opts.blockNumber ?? BlockNumber.ZERO),
},
l1Constants,
dateProvider: { nowInSeconds: () => opts.nowSeconds ?? 10 },
logger,
};
return { ctx, logger };
}

it('logs a gentle info while bootstrapping with too few validators and no blocks yet', async () => {
const { ctx, logger } = makeCtx({ attesterCount: () => Promise.resolve(0), blockNumber: BlockNumber.ZERO });

await logMissingCommittee(targetSlot, targetEpoch, ctx);

expect(logger.warn).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('has not started producing blocks'),
expect.objectContaining({ cause: 'awaiting-first-validators', attesterCount: 0, targetCommitteeSize: 48 }),
);
});

it('warns when the validator set has shrunk on a live chain', async () => {
const { ctx, logger } = makeCtx({ attesterCount: () => Promise.resolve(10), blockNumber: BlockNumber(100) });

await logMissingCommittee(targetSlot, targetEpoch, ctx);

expect(logger.info).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('validator set has dropped'),
expect.objectContaining({ cause: 'validator-set-shrank', attesterCount: 10 }),
);
});

it('estimates a fixed earlier epoch by replaying the validator-set sampling rule', async () => {
// Committee for epoch E samples at epochStart(E) - 2 epochs. The set is below target at epoch 1's sample
// time but full by epoch 2's (== genesis here), so the first committee lands at epoch 2 — earlier than the
// pessimistic epoch 3 (targetEpoch + lag + 1) bound.
const { ctx, logger } = makeCtx({
attesterCount: () => Promise.resolve(3462),
blockNumber: BlockNumber.ZERO,
attesterCountAtTime: (ts: bigint) => Promise.resolve(ts >= 0n ? 3462 : 10),
nowSeconds: 10,
});

await logMissingCommittee(targetSlot, targetEpoch, ctx);

expect(logger.warn).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('first committee is expected at epoch 2'),
expect.objectContaining({ cause: 'awaiting-sampling-lag', firstCommitteeEpoch: EpochNumber(2) }),
);
});

it('reports a bounded epoch when the sampling-time reads fail', async () => {
const { ctx, logger } = makeCtx({
attesterCount: () => Promise.resolve(3462),
blockNumber: BlockNumber.ZERO,
attesterCountAtTime: () => Promise.reject(new Error('gse down')),
nowSeconds: 10,
});

await logMissingCommittee(targetSlot, targetEpoch, ctx);

expect(logger.warn).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('no later than epoch 3'),
expect.objectContaining({ cause: 'awaiting-sampling-lag', firstCommitteeEpoch: EpochNumber(3) }),
);
});

it('falls back to a neutral warning when the attester count query fails', async () => {
const { ctx, logger } = makeCtx({ attesterCount: () => Promise.reject(new Error('rpc down')) });

await logMissingCommittee(targetSlot, targetEpoch, ctx);

expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('could not determine validator set size'),
expect.objectContaining({ targetSlot, targetEpoch }),
);
});
});
Loading
Loading