diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index 4a6d3c80880b..bb08c152b6f8 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -62,6 +62,26 @@ Additionally, `debug_log_format_slice` has been removed. Use `debug_log_format` This has been done as usage of Noir slices is discouraged and the function was unused in the aztec codebase. +### [AztecNode] Sentinel validator status values renamed + +The `ValidatorStatusInSlot` values returned by `getValidatorsStats` and `getValidatorStats` have been updated to reflect the multi-block-per-slot model, where blocks and checkpoints are distinct concepts: + +```diff +- 'block-mined' ++ 'checkpoint-mined' + +- 'block-proposed' ++ 'checkpoint-proposed' + +- 'block-missed' ++ 'checkpoint-missed' // blocks were proposed but checkpoint was not attested ++ 'blocks-missed' // no block proposals were sent at all +``` + +The `attestation-sent` and `attestation-missed` values are unchanged but now explicitly refer to checkpoint attestations. + +The `ValidatorStatusType` used for categorizing statuses has also changed from `'block' | 'attestation'` to `'proposer' | 'attestation'`. + ### [aztec.js] `getDecodedPublicEvents` renamed to `getPublicEvents` with new signature The `getDecodedPublicEvents` function has been renamed to `getPublicEvents` and now uses a filter object instead of positional parameters: diff --git a/yarn-project/aztec-node/src/sentinel/sentinel.test.ts b/yarn-project/aztec-node/src/sentinel/sentinel.test.ts index 9a939a74b2b8..23bd50f032cd 100644 --- a/yarn-project/aztec-node/src/sentinel/sentinel.test.ts +++ b/yarn-project/aztec-node/src/sentinel/sentinel.test.ts @@ -117,25 +117,33 @@ describe('sentinel', () => { p2p.getCheckpointAttestationsForSlot.mockResolvedValue(attestations); }); - it('flags block as mined', async () => { + it('flags checkpoint as mined', async () => { // Create a checkpoint with a block at the target slot and emit chain-checkpointed event const checkpoint = await Checkpoint.random(CheckpointNumber(1), { numBlocks: 1, slotNumber: slot }); await emitCheckpointEvent(checkpoint); const activity = await sentinel.getSlotActivity(slot, epoch, proposer, committee); - expect(activity[proposer.toString()]).toEqual('block-mined'); + expect(activity[proposer.toString()]).toEqual('checkpoint-mined'); }); - it('flags block as proposed when it is not mined but there are attestations', async () => { + it('flags checkpoint as proposed when it is not mined but there are attestations', async () => { p2p.getCheckpointAttestationsForSlot.mockResolvedValue(attestations); const activity = await sentinel.getSlotActivity(slot, epoch, proposer, committee); - expect(activity[proposer.toString()]).toEqual('block-proposed'); + expect(activity[proposer.toString()]).toEqual('checkpoint-proposed'); }); - it('flags block as missed when there are no attestations', async () => { + it('flags as blocks-missed when there are no attestations and no block proposals', async () => { p2p.getCheckpointAttestationsForSlot.mockResolvedValue([]); + p2p.hasBlockProposalsForSlot.mockResolvedValue(false); const activity = await sentinel.getSlotActivity(slot, epoch, proposer, committee); - expect(activity[proposer.toString()]).toEqual('block-missed'); + expect(activity[proposer.toString()]).toEqual('blocks-missed'); + }); + + it('flags as checkpoint-missed when there are no attestations but block proposals exist', async () => { + p2p.getCheckpointAttestationsForSlot.mockResolvedValue([]); + p2p.hasBlockProposalsForSlot.mockResolvedValue(true); + const activity = await sentinel.getSlotActivity(slot, epoch, proposer, committee); + expect(activity[proposer.toString()]).toEqual('checkpoint-missed'); }); it('identifies attestors from p2p and archiver', async () => { @@ -208,8 +216,8 @@ describe('sentinel', () => { const activity = await sentinel.getSlotActivity(slot, epoch, proposer, committee); - // Validators 0 and 1 should be marked as having sent attestations (proposer is validator 0, so block-mined) - expect(activity[committee[0].toString()]).toEqual('block-mined'); + // Validators 0 and 1 should be marked as having sent attestations (proposer is validator 0, so checkpoint-mined) + expect(activity[committee[0].toString()]).toEqual('checkpoint-mined'); expect(activity[committee[1].toString()]).toEqual('attestation-sent'); // Validators 2 and 3 should be marked as having missed attestations (not counted as sent despite placeholders) @@ -242,9 +250,21 @@ describe('sentinel', () => { it('does not tag attestors as missed if there was no block and no attestations', async () => { p2p.getCheckpointAttestationsForSlot.mockResolvedValue([]); + p2p.hasBlockProposalsForSlot.mockResolvedValue(false); + + const activity = await sentinel.getSlotActivity(slot, epoch, proposer, committee); + expect(activity[proposer.toString()]).toEqual('blocks-missed'); + expect(activity[committee[1].toString()]).not.toBeDefined(); + expect(activity[committee[2].toString()]).not.toBeDefined(); + expect(activity[committee[3].toString()]).not.toBeDefined(); + }); + + it('does not tag attestors as missed if blocks were proposed but checkpoint was missed', async () => { + p2p.getCheckpointAttestationsForSlot.mockResolvedValue([]); + p2p.hasBlockProposalsForSlot.mockResolvedValue(true); const activity = await sentinel.getSlotActivity(slot, epoch, proposer, committee); - expect(activity[proposer.toString()]).toEqual('block-missed'); + expect(activity[proposer.toString()]).toEqual('checkpoint-missed'); expect(activity[committee[1].toString()]).not.toBeDefined(); expect(activity[committee[2].toString()]).not.toBeDefined(); expect(activity[committee[3].toString()]).not.toBeDefined(); @@ -260,10 +280,10 @@ describe('sentinel', () => { it('computes stats correctly', () => { const stats = sentinel.computeStatsForValidator(validator, [ - { slot: SlotNumber(1), status: 'block-mined' }, - { slot: SlotNumber(2), status: 'block-proposed' }, - { slot: SlotNumber(3), status: 'block-missed' }, - { slot: SlotNumber(4), status: 'block-missed' }, + { slot: SlotNumber(1), status: 'checkpoint-mined' }, + { slot: SlotNumber(2), status: 'checkpoint-proposed' }, + { slot: SlotNumber(3), status: 'checkpoint-missed' }, + { slot: SlotNumber(4), status: 'blocks-missed' }, { slot: SlotNumber(5), status: 'attestation-sent' }, { slot: SlotNumber(6), status: 'attestation-missed' }, ]); @@ -282,10 +302,10 @@ describe('sentinel', () => { it('resets streaks correctly', () => { const stats = sentinel.computeStatsForValidator(validator, [ - { slot: SlotNumber(1), status: 'block-mined' }, - { slot: SlotNumber(2), status: 'block-missed' }, - { slot: SlotNumber(3), status: 'block-mined' }, - { slot: SlotNumber(4), status: 'block-missed' }, + { slot: SlotNumber(1), status: 'checkpoint-mined' }, + { slot: SlotNumber(2), status: 'blocks-missed' }, + { slot: SlotNumber(3), status: 'checkpoint-mined' }, + { slot: SlotNumber(4), status: 'blocks-missed' }, { slot: SlotNumber(5), status: 'attestation-sent' }, { slot: SlotNumber(6), status: 'attestation-missed' }, { slot: SlotNumber(7), status: 'attestation-sent' }, @@ -303,7 +323,7 @@ describe('sentinel', () => { }); it('considers only latest slots', () => { - const history = times(20, i => ({ slot: SlotNumber(i), status: 'block-missed' }) as const); + const history = times(20, i => ({ slot: SlotNumber(i), status: 'blocks-missed' }) as const); const stats = sentinel.computeStatsForValidator(validator, history, SlotNumber(15)); expect(stats.address.toString()).toEqual(validator); @@ -312,7 +332,7 @@ describe('sentinel', () => { }); it('filters history by toSlot parameter', () => { - const history = times(20, i => ({ slot: SlotNumber(i), status: 'block-missed' }) as const); + const history = times(20, i => ({ slot: SlotNumber(i), status: 'blocks-missed' }) as const); const stats = sentinel.computeStatsForValidator(validator, history, SlotNumber(5), SlotNumber(10)); expect(stats.address.toString()).toEqual(validator); @@ -329,12 +349,12 @@ describe('sentinel', () => { validator = EthAddress.random(); jest.spyOn(store, 'getHistoryLength').mockReturnValue(10); jest.spyOn(store, 'getHistory').mockResolvedValue([ - { slot: SlotNumber(1), status: 'block-mined' }, + { slot: SlotNumber(1), status: 'checkpoint-mined' }, { slot: SlotNumber(2), status: 'attestation-sent' }, ]); jest.spyOn(store, 'getHistories').mockResolvedValue({ [validator.toString()]: [ - { slot: SlotNumber(1), status: 'block-mined' }, + { slot: SlotNumber(1), status: 'checkpoint-mined' }, { slot: SlotNumber(2), status: 'attestation-sent' }, ], }); @@ -369,7 +389,7 @@ describe('sentinel', () => { it('should return expected mocked data structure', async () => { const mockHistory: ValidatorStatusHistory = [ - { slot: SlotNumber(1), status: 'block-mined' }, + { slot: SlotNumber(1), status: 'checkpoint-mined' }, { slot: SlotNumber(2), status: 'attestation-sent' }, ]; const mockProvenPerformance = [ @@ -405,7 +425,7 @@ describe('sentinel', () => { }); it('should call computeStatsForValidator with correct parameters', async () => { - const mockHistory: ValidatorStatusHistory = [{ slot: SlotNumber(5), status: 'block-mined' }]; + const mockHistory: ValidatorStatusHistory = [{ slot: SlotNumber(5), status: 'checkpoint-mined' }]; jest.spyOn(store, 'getHistory').mockResolvedValue(mockHistory); jest.spyOn(store, 'getProvenPerformance').mockResolvedValue([]); const computeStatsSpy = jest.spyOn(sentinel, 'computeStatsForValidator').mockReturnValue({ @@ -422,7 +442,7 @@ describe('sentinel', () => { }); it('should use default slot range when not provided', async () => { - const mockHistory: ValidatorStatusHistory = [{ slot: SlotNumber(5), status: 'block-mined' }]; + const mockHistory: ValidatorStatusHistory = [{ slot: SlotNumber(5), status: 'checkpoint-mined' }]; jest.spyOn(store, 'getHistory').mockResolvedValue(mockHistory); jest.spyOn(store, 'getProvenPerformance').mockResolvedValue([]); const computeStatsSpy = jest.spyOn(sentinel, 'computeStatsForValidator').mockReturnValue({ @@ -444,7 +464,7 @@ describe('sentinel', () => { }); it('should not produce negative slot numbers when historyLength exceeds lastProcessedSlot', async () => { - const mockHistory: ValidatorStatusHistory = [{ slot: SlotNumber(2), status: 'block-mined' }]; + const mockHistory: ValidatorStatusHistory = [{ slot: SlotNumber(2), status: 'checkpoint-mined' }]; jest.spyOn(store, 'getHistory').mockResolvedValue(mockHistory); jest.spyOn(store, 'getProvenPerformance').mockResolvedValue([]); jest.spyOn(store, 'getHistoryLength').mockReturnValue(1000); // Large history length @@ -472,7 +492,7 @@ describe('sentinel', () => { }); it('should return proven performance data from store', async () => { - const mockHistory: ValidatorStatusHistory = [{ slot: SlotNumber(1), status: 'block-mined' }]; + const mockHistory: ValidatorStatusHistory = [{ slot: SlotNumber(1), status: 'checkpoint-mined' }]; const mockProvenPerformance = [ { epoch: EpochNumber(5), missed: 3, total: 12 }, { epoch: EpochNumber(6), missed: 0, total: 15 }, @@ -505,7 +525,7 @@ describe('sentinel', () => { it('should not produce negative slot numbers when historyLength exceeds lastProcessedSlot', async () => { const validator = EthAddress.random(); const mockHistories = { - [validator.toString()]: [{ slot: SlotNumber(2), status: 'block-mined' as const }], + [validator.toString()]: [{ slot: SlotNumber(2), status: 'checkpoint-mined' as const }], }; jest.spyOn(store, 'getHistories').mockResolvedValue(mockHistories); jest.spyOn(store, 'getHistoryLength').mockReturnValue(1000); // Large history length @@ -528,7 +548,7 @@ describe('sentinel', () => { const validator = EthAddress.random(); const mockHistories = { [validator.toString()]: [ - { slot: SlotNumber(95), status: 'block-mined' as const }, + { slot: SlotNumber(95), status: 'checkpoint-mined' as const }, { slot: SlotNumber(100), status: 'attestation-sent' as const }, ], }; diff --git a/yarn-project/aztec-node/src/sentinel/sentinel.ts b/yarn-project/aztec-node/src/sentinel/sentinel.ts index 18c6be46eb62..44e4dc80d022 100644 --- a/yarn-project/aztec-node/src/sentinel/sentinel.ts +++ b/yarn-project/aztec-node/src/sentinel/sentinel.ts @@ -36,6 +36,17 @@ import EventEmitter from 'node:events'; import { SentinelStore } from './store.js'; +/** Maps a validator status to its category: proposer or attestation. */ +function statusToCategory(status: ValidatorStatusInSlot): ValidatorStatusType { + switch (status) { + case 'attestation-sent': + case 'attestation-missed': + return 'attestation'; + default: + return 'proposer'; + } +} + export class Sentinel extends (EventEmitter as new () => WatcherEmitter) implements L2BlockStreamEventHandler, Watcher { protected runningPromise: RunningPromise; protected blockStream!: L2BlockStream; @@ -336,16 +347,16 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme // Check if there is an L2 block in L1 for this L2 slot - // Here we get all attestations for the block mined at the given slot, - // or all attestations for all proposals in the slot if no block was mined. + // Here we get all checkpoint attestations for the checkpoint at the given slot, + // or all checkpoint attestations for all proposals in the slot if no checkpoint was mined. // We gather from both p2p (contains the ones seen on the p2p layer) and archiver - // (contains the ones synced from mined blocks, which we may have missed from p2p). - const block = this.slotNumberToCheckpoint.get(slot); - const p2pAttested = await this.p2p.getCheckpointAttestationsForSlot(slot, block?.archive); + // (contains the ones synced from mined checkpoints, which we may have missed from p2p). + const checkpoint = this.slotNumberToCheckpoint.get(slot); + const p2pAttested = await this.p2p.getCheckpointAttestationsForSlot(slot, checkpoint?.archive); // Filter out attestations with invalid signatures const p2pAttestors = p2pAttested.map(a => a.getSender()).filter((s): s is EthAddress => s !== undefined); const attestors = new Set( - [...p2pAttestors.map(a => a.toString()), ...(block?.attestors.map(a => a.toString()) ?? [])].filter( + [...p2pAttestors.map(a => a.toString()), ...(checkpoint?.attestors.map(a => a.toString()) ?? [])].filter( addr => proposer.toString() !== addr, // Exclude the proposer from the attestors ), ); @@ -356,20 +367,29 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme // But we'll leave that corner case out to reduce pressure on the node. // TODO(palla/slash): This breaks if a given node has more than one validator in the current committee, // since they will attest to their own proposal it even if it's not re-executable. - const blockStatus = block ? 'mined' : attestors.size > 0 ? 'proposed' : 'missed'; - this.logger.debug(`Block for slot ${slot} was ${blockStatus}`, { ...block, slot }); + let status: 'checkpoint-mined' | 'checkpoint-proposed' | 'checkpoint-missed' | 'blocks-missed'; + if (checkpoint) { + status = 'checkpoint-mined'; + } else if (attestors.size > 0) { + status = 'checkpoint-proposed'; + } else { + // No checkpoint on L1 and no checkpoint attestations seen. Check if block proposals were sent for this slot. + const hasBlockProposals = await this.p2p.hasBlockProposalsForSlot(slot); + status = hasBlockProposals ? 'checkpoint-missed' : 'blocks-missed'; + } + this.logger.debug(`Checkpoint status for slot ${slot}: ${status}`, { ...checkpoint, slot }); - // Get attestors that failed their duties for this block, but only if there was a block proposed + // Get attestors that failed their checkpoint attestation duties, but only if there was a checkpoint proposed or mined const missedAttestors = new Set( - blockStatus === 'missed' + status === 'blocks-missed' || status === 'checkpoint-missed' ? [] : committee.filter(v => !attestors.has(v.toString()) && !proposer.equals(v)).map(v => v.toString()), ); this.logger.debug(`Retrieved ${attestors.size} attestors out of ${committee.length} for slot ${slot}`, { - blockStatus, + status, proposer: proposer.toString(), - ...block, + ...checkpoint, slot, attestors: [...attestors], missedAttestors: [...missedAttestors], @@ -379,7 +399,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme // Compute the status for each validator in the committee const statusFor = (who: `0x${string}`): ValidatorStatusInSlot | undefined => { if (who === proposer.toString()) { - return `block-${blockStatus}`; + return status; } else if (attestors.has(who)) { return 'attestation-sent'; } else if (missedAttestors.has(who)) { @@ -472,14 +492,16 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme ): ValidatorStats { let history = fromSlot ? allHistory.filter(h => BigInt(h.slot) >= fromSlot) : allHistory; history = toSlot ? history.filter(h => BigInt(h.slot) <= toSlot) : history; - const lastProposal = history.filter(h => h.status === 'block-proposed' || h.status === 'block-mined').at(-1); + const lastProposal = history + .filter(h => h.status === 'checkpoint-proposed' || h.status === 'checkpoint-mined') + .at(-1); const lastAttestation = history.filter(h => h.status === 'attestation-sent').at(-1); return { address: EthAddress.fromString(address), lastProposal: this.computeFromSlot(lastProposal?.slot), lastAttestation: this.computeFromSlot(lastAttestation?.slot), totalSlots: history.length, - missedProposals: this.computeMissed(history, 'block', ['block-missed']), + missedProposals: this.computeMissed(history, 'proposer', ['checkpoint-missed', 'blocks-missed']), missedAttestations: this.computeMissed(history, 'attestation', ['attestation-missed']), history, }; @@ -487,10 +509,12 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme protected computeMissed( history: ValidatorStatusHistory, - computeOverPrefix: ValidatorStatusType | undefined, + computeOverCategory: ValidatorStatusType | undefined, filter: ValidatorStatusInSlot[], ) { - const relevantHistory = history.filter(h => !computeOverPrefix || h.status.startsWith(computeOverPrefix)); + const relevantHistory = history.filter( + h => !computeOverCategory || statusToCategory(h.status) === computeOverCategory, + ); const filteredHistory = relevantHistory.filter(h => filter.includes(h.status)); return { currentStreak: countWhile([...relevantHistory].reverse(), h => filter.includes(h.status)), diff --git a/yarn-project/aztec-node/src/sentinel/store.test.ts b/yarn-project/aztec-node/src/sentinel/store.test.ts index 8525b428b23d..0c7babb7ce99 100644 --- a/yarn-project/aztec-node/src/sentinel/store.test.ts +++ b/yarn-project/aztec-node/src/sentinel/store.test.ts @@ -27,11 +27,12 @@ describe('sentinel-store', () => { it('inserts new validators with all statuses', async () => { const slot = SlotNumber(1); - const validators: `0x${string}`[] = times(5, () => EthAddress.random().toString()); + const validators: `0x${string}`[] = times(6, () => EthAddress.random().toString()); const statuses: ValidatorStatusInSlot[] = [ - 'block-mined', - 'block-proposed', - 'block-missed', + 'checkpoint-mined', + 'checkpoint-proposed', + 'checkpoint-missed', + 'blocks-missed', 'attestation-sent', 'attestation-missed', ]; @@ -58,15 +59,15 @@ describe('sentinel-store', () => { // Insert existing validators with initial statuses await store.updateValidators( SlotNumber(1), - Object.fromEntries(existingValidators.map(v => [v, 'block-mined'] as const)), + Object.fromEntries(existingValidators.map(v => [v, 'checkpoint-mined'] as const)), ); // Insert new validators with their statuses, and append history to existing ones await store.updateValidators( SlotNumber(2), Object.fromEntries([ - ...newValidators.map(v => [v, 'block-proposed'] as const), - ...existingValidators.map(v => [v, 'block-missed'] as const), + ...newValidators.map(v => [v, 'checkpoint-proposed'] as const), + ...existingValidators.map(v => [v, 'checkpoint-missed'] as const), ]), ); @@ -74,17 +75,17 @@ describe('sentinel-store', () => { expect(Object.keys(histories)).toHaveLength(4); expect(histories[existingValidators[0]]).toEqual([ - { slot: SlotNumber(1), status: 'block-mined' }, - { slot: SlotNumber(2), status: 'block-missed' }, + { slot: SlotNumber(1), status: 'checkpoint-mined' }, + { slot: SlotNumber(2), status: 'checkpoint-missed' }, ]); expect(histories[existingValidators[1]]).toEqual([ - { slot: SlotNumber(1), status: 'block-mined' }, - { slot: SlotNumber(2), status: 'block-missed' }, + { slot: SlotNumber(1), status: 'checkpoint-mined' }, + { slot: SlotNumber(2), status: 'checkpoint-missed' }, ]); - expect(histories[newValidators[0]]).toEqual([{ slot: SlotNumber(2), status: 'block-proposed' }]); - expect(histories[newValidators[1]]).toEqual([{ slot: SlotNumber(2), status: 'block-proposed' }]); + expect(histories[newValidators[0]]).toEqual([{ slot: SlotNumber(2), status: 'checkpoint-proposed' }]); + expect(histories[newValidators[1]]).toEqual([{ slot: SlotNumber(2), status: 'checkpoint-proposed' }]); }); it('trims history to the specified length', async () => { @@ -92,16 +93,16 @@ describe('sentinel-store', () => { const validator = EthAddress.random().toString(); for (let i = 0; i < 10; i++) { - await store.updateValidators(SlotNumber(slot + i), { [validator]: 'block-mined' }); + await store.updateValidators(SlotNumber(slot + i), { [validator]: 'checkpoint-mined' }); } const histories = await store.getHistories(); expect(histories[validator]).toHaveLength(historyLength); expect(histories[validator]).toEqual([ - { slot: SlotNumber(7), status: 'block-mined' }, - { slot: SlotNumber(8), status: 'block-mined' }, - { slot: SlotNumber(9), status: 'block-mined' }, - { slot: SlotNumber(10), status: 'block-mined' }, + { slot: SlotNumber(7), status: 'checkpoint-mined' }, + { slot: SlotNumber(8), status: 'checkpoint-mined' }, + { slot: SlotNumber(9), status: 'checkpoint-mined' }, + { slot: SlotNumber(10), status: 'checkpoint-mined' }, ]); }); @@ -207,6 +208,6 @@ describe('sentinel-store', () => { await expect( store.updateProvenPerformance(EpochNumber(1), { [validator]: { missed: 2, total: 10 } }), ).rejects.toThrow(); - await expect(store.updateValidators(SlotNumber(1), { [validator]: 'block-mined' })).rejects.toThrow(); + await expect(store.updateValidators(SlotNumber(1), { [validator]: 'checkpoint-mined' })).rejects.toThrow(); }); }); diff --git a/yarn-project/aztec-node/src/sentinel/store.ts b/yarn-project/aztec-node/src/sentinel/store.ts index 7d2c83138f88..c06be27cb179 100644 --- a/yarn-project/aztec-node/src/sentinel/store.ts +++ b/yarn-project/aztec-node/src/sentinel/store.ts @@ -9,7 +9,7 @@ import type { } from '@aztec/stdlib/validators'; export class SentinelStore { - public static readonly SCHEMA_VERSION = 2; + public static readonly SCHEMA_VERSION = 3; // a map from validator address to their ValidatorStatusHistory private readonly historyMap: AztecAsyncMap<`0x${string}`, Buffer>; @@ -86,11 +86,7 @@ export class SentinelStore { }); } - private async pushValidatorStatusForSlot( - who: EthAddress, - slot: SlotNumber, - status: 'block-mined' | 'block-proposed' | 'block-missed' | 'attestation-sent' | 'attestation-missed', - ) { + private async pushValidatorStatusForSlot(who: EthAddress, slot: SlotNumber, status: ValidatorStatusInSlot) { await this.store.transactionAsync(async () => { const currentHistory = (await this.getHistory(who)) ?? []; const newHistory = [...currentHistory, { slot, status }].slice(-this.config.historyLength); @@ -149,16 +145,18 @@ export class SentinelStore { private statusToNumber(status: ValidatorStatusInSlot): number { switch (status) { - case 'block-mined': + case 'checkpoint-mined': return 1; - case 'block-proposed': + case 'checkpoint-proposed': return 2; - case 'block-missed': + case 'checkpoint-missed': return 3; case 'attestation-sent': return 4; case 'attestation-missed': return 5; + case 'blocks-missed': + return 6; default: { const _exhaustive: never = status; throw new Error(`Unknown status: ${status}`); @@ -169,15 +167,17 @@ export class SentinelStore { private statusFromNumber(status: number): ValidatorStatusInSlot { switch (status) { case 1: - return 'block-mined'; + return 'checkpoint-mined'; case 2: - return 'block-proposed'; + return 'checkpoint-proposed'; case 3: - return 'block-missed'; + return 'checkpoint-missed'; case 4: return 'attestation-sent'; case 5: return 'attestation-missed'; + case 6: + return 'blocks-missed'; default: throw new Error(`Unknown status: ${status}`); } diff --git a/yarn-project/end-to-end/src/e2e_bot.test.ts b/yarn-project/end-to-end/src/e2e_bot.test.ts index 08d546dbb133..8628a7ebdeb9 100644 --- a/yarn-project/end-to-end/src/e2e_bot.test.ts +++ b/yarn-project/end-to-end/src/e2e_bot.test.ts @@ -219,7 +219,7 @@ describe('e2e_bot', () => { beforeAll(() => { config = { ...getBotDefaultConfig(), - followChain: 'CHECKPOINTED', + followChain: 'PROPOSED', ammTxs: false, senderPrivateKey: new SecretValue(Fr.random()), l1PrivateKey: new SecretValue(bufferToHex(getPrivateKeyFromIndex(8)!)), diff --git a/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts b/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts index 94e33fc0d53e..7dc521775561 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts @@ -186,14 +186,18 @@ describe('e2e_p2p_multiple_validators_sentinel', () => { const validatorStats = stats.stats[validator.toString().toLowerCase()]; const history = validatorStats?.history.filter(h => h.slot > initialSlot && h.slot <= slotForSentinel) ?? []; t.logger.info(`Asserting stats for online validator ${validator}`, { history }); - expect(history.filter(h => h.status === 'attestation-missed' || h.status === 'block-missed')).toBeEmpty(); + expect( + history.filter( + h => h.status === 'attestation-missed' || h.status === 'blocks-missed' || h.status === 'checkpoint-missed', + ), + ).toBeEmpty(); } // At least one of the first node validators must have been seen as proposer const firstNodeBlockProposedHistory = firstNodeValidators .flatMap(v => stats.stats[v.toString().toLowerCase()].history) .filter(h => h.slot > initialSlot && h.slot <= slotForSentinel) - .filter(h => h.status === 'block-proposed'); + .filter(h => h.status === 'checkpoint-proposed'); expect(firstNodeBlockProposedHistory).not.toBeEmpty(); // And all of the proposers for the offline node must be seen as missed attestation or proposal @@ -201,7 +205,11 @@ describe('e2e_p2p_multiple_validators_sentinel', () => { const validatorStats = stats.stats[validator.toString().toLowerCase()]; const history = validatorStats.history?.filter(h => h.slot > initialSlot && h.slot <= slotForSentinel) ?? []; t.logger.info(`Asserting stats for offline validator ${validator}`, { history }); - expect(history.filter(h => h.status === 'attestation-missed' || h.status === 'block-missed')).not.toBeEmpty(); + expect( + history.filter( + h => h.status === 'attestation-missed' || h.status === 'blocks-missed' || h.status === 'checkpoint-missed', + ), + ).not.toBeEmpty(); } }); }); diff --git a/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts b/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts index 0b62ad197753..3a4e47afe387 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts @@ -103,7 +103,7 @@ describe('e2e_p2p_validators_sentinel', () => { initialSlot && lastProcessedSlot && lastProcessedSlot - initialSlot >= blockCount - 1 && - Object.values(stats).some(stat => stat.history.some(h => h.status === 'block-mined')) && + Object.values(stats).some(stat => stat.history.some(h => h.status === 'checkpoint-mined')) && Object.values(stats).some(stat => stat.history.some(h => h.status === 'attestation-sent')) && stats[offlineValidator.toString().toLowerCase()] && stats[offlineValidator.toString().toLowerCase()].history.length > 0 && @@ -132,7 +132,7 @@ describe('e2e_p2p_validators_sentinel', () => { it('collects stats on a block builder', () => { const [proposerValidator, proposerStats] = Object.entries(stats.stats).find(([_, v]) => - v?.history?.some(h => h.status === 'block-mined'), + v?.history?.some(h => h.status === 'checkpoint-mined'), )!; t.logger.info(`Asserting stats for proposer validator ${proposerValidator}`); expect(proposerStats).toBeDefined(); diff --git a/yarn-project/p2p/src/client/interface.ts b/yarn-project/p2p/src/client/interface.ts index 350fc90c1ffb..3712e4132ee9 100644 --- a/yarn-project/p2p/src/client/interface.ts +++ b/yarn-project/p2p/src/client/interface.ts @@ -233,6 +233,9 @@ export type P2P = P2PApiFull & handleAuthRequestFromPeer(authRequest: AuthRequest, peerId: PeerId): Promise; + /** Checks if any block proposals exist for the given slot. */ + hasBlockProposalsForSlot(slot: SlotNumber): Promise; + /** If node running this P2P stack is validator, passes in validator address to P2P layer */ registerThisValidatorAddresses(address: EthAddress[]): void; }; diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index 5ef8c4db63ed..b77de7b84d0f 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -404,6 +404,10 @@ export class P2PClient return this.attestationPool.addOwnCheckpointAttestations(attestations); } + public hasBlockProposalsForSlot(slot: SlotNumber): Promise { + return this.attestationPool.hasBlockProposalsForSlot(slot); + } + // REVIEW: https://github.com/AztecProtocol/aztec-packages/issues/7963 // ^ This pattern is not my favorite (md) public registerBlockProposalHandler(handler: P2PBlockReceivedCallback): void { diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts index 7f4626a035c7..827d91ce84ea 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool.ts @@ -43,6 +43,7 @@ export type AttestationPoolApi = Pick< | 'deleteOlderThan' | 'getCheckpointAttestationsForSlot' | 'getCheckpointAttestationsForSlotAndProposal' + | 'hasBlockProposalsForSlot' | 'isEmpty' >; @@ -254,6 +255,13 @@ export class AttestationPool { return undefined; } + /** Checks if any block proposals exist for a given slot (at index 0). */ + public async hasBlockProposalsForSlot(slot: SlotNumber): Promise { + const positionKey = this.getBlockPositionKey(slot, 0); + const count = await this.blockProposalsForSlotAndIndex.getValueCountAsync(positionKey); + return count > 0; + } + /** * Attempts to add a checkpoint proposal to the pool. * diff --git a/yarn-project/p2p/src/test-helpers/testbench-utils.ts b/yarn-project/p2p/src/test-helpers/testbench-utils.ts index 2afb25e9f37f..38eb8d46c91a 100644 --- a/yarn-project/p2p/src/test-helpers/testbench-utils.ts +++ b/yarn-project/p2p/src/test-helpers/testbench-utils.ts @@ -252,6 +252,10 @@ export class InMemoryAttestationPool { return Promise.resolve({ added: true, alreadyExists: false, count: 1 }); } + hasBlockProposalsForSlot(_slot: SlotNumber): Promise { + return Promise.resolve(false); + } + isEmpty(): Promise { return Promise.resolve(this.proposals.size === 0); } diff --git a/yarn-project/slasher/README.md b/yarn-project/slasher/README.md index 3de956862489..dbd4454cf1eb 100644 --- a/yarn-project/slasher/README.md +++ b/yarn-project/slasher/README.md @@ -202,21 +202,24 @@ Details about specific offenses in the system: Inactivity slashing is one of the most critical, since it allows purging validators that are not fulfilling their duties, which could potentially bring the chain to a halt. This slashing must be aggressive enough to balance out the rate of the entry queue, in case the queue is filled with inactive validators. Furthermore, if enough inactive validators join the system, it may become impossible to gather enough quorum to pass any governance proposal. -Inactivity slashing is handled by the `Sentinel` which monitors performance of all validators slot-by-slot. After each slot, the sentinel assigns one of the following to the block proposer for the slot: -- `block-mined` if the block was added to L1 -- `block-proposed` if the block received at least one attestation, but didn't make it to L1 -- `block-missed` if the block received no attestations (note that we cannot rely on the P2P proposal alone since it may be invalid, unless we reexecute it) +Inactivity slashing is handled by the `Sentinel` which monitors performance of all validators slot-by-slot. With the multiple-blocks-per-slot model, block proposals and checkpoints are distinct concepts: proposers build multiple blocks per slot, but attestations are only for checkpoints. After each slot, the sentinel assigns one of the following to the proposer for the slot: +- `checkpoint-mined` if the checkpoint was added to L1 +- `checkpoint-proposed` if the checkpoint received at least one attestation, but didn't make it to L1 +- `checkpoint-missed` if blocks were proposed but the checkpoint received no attestations +- `blocks-missed` if no block proposals were sent for this slot at all -And assigns one of the following to each validator: -- `attestation-sent` if there was a `block-proposed` or `block-mined` and an attestation from this validator was seen on either on L1 or on the P2P network -- `attestation-missed` if there was a `block-proposed` or `block-mined` but no attestation was seen -- none if the slot was a `block-missed` +And assigns one of the following to each validator (these refer to checkpoint attestations): +- `attestation-sent` if there was a `checkpoint-proposed` or `checkpoint-mined` and a checkpoint attestation from this validator was seen on either on L1 or on the P2P network +- `attestation-missed` if there was a `checkpoint-proposed` or `checkpoint-mined` but no checkpoint attestation was seen +- none if the slot was a `blocks-missed` + +Both `blocks-missed` and `checkpoint-missed` count as proposer inactivity. Once an epoch is proven, the sentinel computes the _proven performance_ for the epoch for each validator. Note that we wait until the epoch is proven so we know that the data for all blocks in the epoch was available, and validators who did not attest were effectively inactive. Then, for each validator such that: ``` -total_failures = count(block-missed) + count(attestation-missed) -total = count(block-*) + count(attestation-*) +total_failures = count(blocks-missed) + count(checkpoint-missed) + count(attestation-missed) +total = count(checkpoint-*) + count(blocks-*) + count(attestation-*) total_failures / total >= slash_inactivity_target_percentage ``` diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts index 139b195a95d5..2243efd963dc 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts @@ -360,7 +360,7 @@ describe('AztecNodeApiSchema', () => { count: 1, total: 1, }, - history: [{ slot: SlotNumber(1), status: 'block-mined' }], + history: [{ slot: SlotNumber(1), status: 'checkpoint-mined' }], }, }, lastProcessedSlot: SlotNumber(20), @@ -407,7 +407,7 @@ describe('AztecNodeApiSchema', () => { totalSlots: 5, missedAttestations: { currentStreak: 0, count: 0, total: 1 }, missedProposals: { currentStreak: 0, count: 0, total: 1 }, - history: [{ slot: SlotNumber(1), status: 'block-mined' }], + history: [{ slot: SlotNumber(1), status: 'checkpoint-mined' }], }, allTimeProvenPerformance: [], lastProcessedSlot: SlotNumber(10), diff --git a/yarn-project/stdlib/src/validators/schemas.ts b/yarn-project/stdlib/src/validators/schemas.ts index 6deb8c61fde4..1201aecfd05b 100644 --- a/yarn-project/stdlib/src/validators/schemas.ts +++ b/yarn-project/stdlib/src/validators/schemas.ts @@ -12,7 +12,14 @@ import type { } from './types.js'; export const ValidatorStatusInSlotSchema = zodFor()( - z.enum(['block-mined', 'block-proposed', 'block-missed', 'attestation-sent', 'attestation-missed']), + z.enum([ + 'checkpoint-mined', + 'checkpoint-proposed', + 'checkpoint-missed', + 'blocks-missed', + 'attestation-sent', + 'attestation-missed', + ]), ); export const ValidatorStatusHistorySchema = zodFor()( diff --git a/yarn-project/stdlib/src/validators/types.ts b/yarn-project/stdlib/src/validators/types.ts index 4e4309dc2866..bf005e4537d1 100644 --- a/yarn-project/stdlib/src/validators/types.ts +++ b/yarn-project/stdlib/src/validators/types.ts @@ -1,12 +1,13 @@ import type { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import type { EthAddress } from '@aztec/foundation/eth-address'; -export type ValidatorStatusType = 'block' | 'attestation'; +export type ValidatorStatusType = 'proposer' | 'attestation'; export type ValidatorStatusInSlot = - | 'block-mined' - | 'block-proposed' - | 'block-missed' + | 'checkpoint-mined' + | 'checkpoint-proposed' + | 'checkpoint-missed' + | 'blocks-missed' | 'attestation-sent' | 'attestation-missed'; diff --git a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts index f1b02767d255..4a7e940ef18b 100644 --- a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts +++ b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts @@ -212,4 +212,8 @@ export class DummyP2P implements P2P { public registerDuplicateAttestationCallback(_callback: P2PDuplicateAttestationCallback): void { throw new Error('DummyP2P does not implement "registerDuplicateAttestationCallback"'); } + + public hasBlockProposalsForSlot(_slot: SlotNumber): Promise { + throw new Error('DummyP2P does not implement "hasBlockProposalsForSlot"'); + } }