Skip to content
Merged
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
20 changes: 20 additions & 0 deletions docs/docs-developers/docs/resources/migration_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
76 changes: 48 additions & 28 deletions yarn-project/aztec-node/src/sentinel/sentinel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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();
Expand All @@ -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' },
]);
Expand All @@ -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' },
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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' },
],
});
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand All @@ -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
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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
Expand All @@ -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 },
],
};
Expand Down
58 changes: 41 additions & 17 deletions yarn-project/aztec-node/src/sentinel/sentinel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
),
);
Expand All @@ -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],
Expand All @@ -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)) {
Expand Down Expand Up @@ -472,25 +492,29 @@ 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,
};
}

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)),
Expand Down
Loading
Loading