Skip to content

Commit ef9d4ed

Browse files
committed
Adding MAXIMUM_GOSSIP_CLOCK_DISPARITY for p2p validation
1 parent ad55b40 commit ef9d4ed

13 files changed

Lines changed: 343 additions & 21 deletions

File tree

docs/docs-network/reference/changelog/v4.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ aztec add-l1-validator --withdrawer-address <address>
5757

5858
## New features
5959

60+
### P2P clock tolerance for slot validation
61+
62+
Added a 500ms tolerance window for P2P messages from the previous slot, following Ethereum's `MAXIMUM_GOSSIP_CLOCK_DISPARITY` approach. This prevents peers from being penalized for valid messages that arrive slightly after the slot boundary due to network latency.
63+
64+
The tolerance is hardcoded at 500ms (matching Ethereum's current value) and can be made configurable via environment variables in the future if needed.
65+
66+
**Impact**: Improved network stability with no action required from node operators.
67+
6068
## Changed defaults
6169

6270
## Troubleshooting

yarn-project/aztec-node/src/sentinel/sentinel.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ describe('sentinel', () => {
7777
proofSubmissionEpochs: 1,
7878
};
7979

80-
epochCache.getEpochAndSlotNow.mockReturnValue({ epoch, slot, ts, now: ts });
80+
epochCache.getEpochAndSlotNow.mockReturnValue({ epoch, slot, ts, nowMs: ts * 1000n });
8181
epochCache.getL1Constants.mockReturnValue(l1Constants);
8282

8383
sentinel = new TestSentinel(epochCache, archiver, p2p, store, config, blockStream);
@@ -566,7 +566,7 @@ describe('sentinel', () => {
566566
epoch: epochNumber,
567567
slot,
568568
ts,
569-
now: ts,
569+
nowMs: ts * 1000n,
570570
});
571571
archiver.getL2BlockNew.calledWith(blockNumber).mockResolvedValue(mockBlock);
572572
archiver.getL1Constants.mockResolvedValue(l1Constants);

yarn-project/epoch-cache/src/epoch_cache.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export type SlotTag = 'now' | 'next' | SlotNumber;
3636

3737
export interface EpochCacheInterface {
3838
getCommittee(slot: SlotTag | undefined): Promise<EpochCommitteeInfo>;
39-
getEpochAndSlotNow(): EpochAndSlot;
39+
getEpochAndSlotNow(): EpochAndSlot & { nowMs: bigint };
4040
getEpochAndSlotInNextL1Slot(): EpochAndSlot & { now: bigint };
4141
getProposerIndexEncoding(epoch: EpochNumber, slot: SlotNumber, seed: bigint): `0x${string}`;
4242
computeProposerIndex(slot: SlotNumber, epoch: EpochNumber, seed: bigint, size: bigint): bigint;
@@ -134,9 +134,10 @@ export class EpochCache implements EpochCacheInterface {
134134
return this.l1constants;
135135
}
136136

137-
public getEpochAndSlotNow(): EpochAndSlot & { now: bigint } {
138-
const now = this.nowInSeconds();
139-
return { ...this.getEpochAndSlotAtTimestamp(now), now };
137+
public getEpochAndSlotNow(): EpochAndSlot & { nowMs: bigint } {
138+
const nowMs = BigInt(this.dateProvider.now());
139+
const nowSeconds = nowMs / 1000n;
140+
return { ...this.getEpochAndSlotAtTimestamp(nowSeconds), nowMs };
140141
}
141142

142143
public nowInSeconds(): bigint {

yarn-project/p2p/src/msg_validators/attestation_validator/attestation_validator.test.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ describe('CheckpointAttestationValidator', () => {
2323
attester = Secp256k1Signer.random();
2424
});
2525

26-
it('returns high tolerance error if slot number is not current or next slot', async () => {
27-
// Create an attestation for slot 97
26+
it('returns high tolerance error if slot number is not current or next slot (outside clock tolerance)', async () => {
27+
// Create an attestation for slot 97 (previous slot)
2828
const header = CheckpointHeader.random({ slotNumber: SlotNumber(97) });
2929
const mockAttestation = makeCheckpointAttestation({
3030
header,
@@ -37,12 +37,47 @@ describe('CheckpointAttestationValidator', () => {
3737
currentSlot: SlotNumber(98),
3838
nextSlot: SlotNumber(99),
3939
});
40+
// Mock getEpochAndSlotNow to return time OUTSIDE clock tolerance (1000ms elapsed)
41+
(epochCache.getEpochAndSlotNow as jest.Mock).mockReturnValue({
42+
epoch: 1,
43+
slot: SlotNumber(98),
44+
ts: 1000n, // slot started at 1000 seconds
45+
nowMs: 1001000n, // 1000ms elapsed, outside 500ms tolerance
46+
});
4047
(epochCache.isInCommittee as jest.Mock).mockResolvedValue(true);
4148

4249
const result = await validator.validate(mockAttestation);
4350
expect(result).toBe(PeerErrorSeverity.HighToleranceError);
4451
});
4552

53+
it('returns undefined if previous slot attestation is within clock tolerance', async () => {
54+
// Create an attestation for slot 97 (previous slot)
55+
const header = CheckpointHeader.random({ slotNumber: SlotNumber(97) });
56+
const mockAttestation = makeCheckpointAttestation({
57+
header,
58+
attesterSigner: attester,
59+
proposerSigner: proposer,
60+
});
61+
62+
// Mock epoch cache - attestation is for previous slot (97) when current is 98
63+
(epochCache.getCurrentAndNextSlot as jest.Mock).mockReturnValue({
64+
currentSlot: SlotNumber(98),
65+
nextSlot: SlotNumber(99),
66+
});
67+
// Mock getEpochAndSlotNow to return time WITHIN clock tolerance (100ms elapsed)
68+
(epochCache.getEpochAndSlotNow as jest.Mock).mockReturnValue({
69+
epoch: 1,
70+
slot: SlotNumber(98),
71+
ts: 1000n, // slot started at 1000 seconds
72+
nowMs: 1000100n, // 100ms elapsed, within 500ms tolerance
73+
});
74+
(epochCache.isInCommittee as jest.Mock).mockResolvedValue(true);
75+
(epochCache.getProposerAttesterAddressInSlot as jest.Mock).mockResolvedValue(proposer.address);
76+
77+
const result = await validator.validate(mockAttestation);
78+
expect(result).toBeUndefined();
79+
});
80+
4681
it('returns high tolerance error if attester is not in committee', async () => {
4782
// The slot is correct, but the attester is not in the committee
4883
const header = CheckpointHeader.random({ slotNumber: SlotNumber(100) });

yarn-project/p2p/src/msg_validators/attestation_validator/attestation_validator.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { NoCommitteeError } from '@aztec/ethereum/contracts';
33
import { type Logger, createLogger } from '@aztec/foundation/log';
44
import { type CheckpointAttestation, type P2PValidator, PeerErrorSeverity } from '@aztec/stdlib/p2p';
55

6+
import { isWithinClockTolerance } from '../clock_tolerance.js';
7+
68
export class CheckpointAttestationValidator implements P2PValidator<CheckpointAttestation> {
79
protected epochCache: EpochCacheInterface;
810
protected logger: Logger;
@@ -19,10 +21,14 @@ export class CheckpointAttestationValidator implements P2PValidator<CheckpointAt
1921
const { currentSlot, nextSlot } = this.epochCache.getCurrentAndNextSlot();
2022

2123
if (slotNumber !== currentSlot && slotNumber !== nextSlot) {
22-
this.logger.warn(
23-
`Checkpoint attestation slot ${slotNumber} is not current (${currentSlot}) or next (${nextSlot}) slot`,
24-
);
25-
return PeerErrorSeverity.HighToleranceError;
24+
// Check if message is for previous slot and within clock tolerance
25+
if (!isWithinClockTolerance(slotNumber, currentSlot, this.epochCache)) {
26+
this.logger.warn(
27+
`Checkpoint attestation slot ${slotNumber} is not current (${currentSlot}) or next (${nextSlot}) slot`,
28+
);
29+
return PeerErrorSeverity.HighToleranceError;
30+
}
31+
this.logger.debug(`Accepting checkpoint attestation for previous slot ${slotNumber} within clock tolerance`);
2632
}
2733

2834
// Verify the signature is valid
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import type { EpochCacheInterface } from '@aztec/epoch-cache';
2+
import { SlotNumber } from '@aztec/foundation/branded-types';
3+
4+
import { mock } from 'jest-mock-extended';
5+
6+
import { MAXIMUM_GOSSIP_CLOCK_DISPARITY_MS, isWithinClockTolerance } from './clock_tolerance.js';
7+
8+
describe('clock_tolerance', () => {
9+
describe('MAXIMUM_GOSSIP_CLOCK_DISPARITY_MS', () => {
10+
it('is set to 500ms', () => {
11+
expect(MAXIMUM_GOSSIP_CLOCK_DISPARITY_MS).toBe(500);
12+
});
13+
});
14+
15+
describe('isWithinClockTolerance', () => {
16+
let epochCache: ReturnType<typeof mock<EpochCacheInterface>>;
17+
18+
beforeEach(() => {
19+
epochCache = mock<EpochCacheInterface>();
20+
});
21+
22+
it('returns true for previous slot message within tolerance window (100ms elapsed)', () => {
23+
const currentSlot = SlotNumber(100);
24+
const messageSlot = SlotNumber(99); // previous slot
25+
26+
// Slot started at 1000 seconds (1000000ms), now is 1000100ms (100ms elapsed)
27+
epochCache.getEpochAndSlotNow.mockReturnValue({
28+
epoch: 1 as any,
29+
slot: currentSlot,
30+
ts: 1000n, // seconds
31+
nowMs: 1000100n, // 100ms after slot start
32+
});
33+
34+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(true);
35+
});
36+
37+
it('returns true at exactly 499ms elapsed (just under tolerance)', () => {
38+
const currentSlot = SlotNumber(100);
39+
const messageSlot = SlotNumber(99);
40+
41+
// 499ms elapsed - should be within tolerance (499 < 500)
42+
epochCache.getEpochAndSlotNow.mockReturnValue({
43+
epoch: 1 as any,
44+
slot: currentSlot,
45+
ts: 1000n,
46+
nowMs: 1000499n,
47+
});
48+
49+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(true);
50+
});
51+
52+
it('returns false at exactly 500ms elapsed (at boundary)', () => {
53+
const currentSlot = SlotNumber(100);
54+
const messageSlot = SlotNumber(99);
55+
56+
// 500ms elapsed - should be outside tolerance (500 is NOT < 500)
57+
epochCache.getEpochAndSlotNow.mockReturnValue({
58+
epoch: 1 as any,
59+
slot: currentSlot,
60+
ts: 1000n,
61+
nowMs: 1000500n,
62+
});
63+
64+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(false);
65+
});
66+
67+
it('returns false at 501ms elapsed (just over tolerance)', () => {
68+
const currentSlot = SlotNumber(100);
69+
const messageSlot = SlotNumber(99);
70+
71+
// 501ms elapsed - clearly outside tolerance
72+
epochCache.getEpochAndSlotNow.mockReturnValue({
73+
epoch: 1 as any,
74+
slot: currentSlot,
75+
ts: 1000n,
76+
nowMs: 1000501n,
77+
});
78+
79+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(false);
80+
});
81+
82+
it('returns false for previous slot message well outside tolerance (1000ms elapsed)', () => {
83+
const currentSlot = SlotNumber(100);
84+
const messageSlot = SlotNumber(99);
85+
86+
// 1000ms elapsed - outside 500ms tolerance
87+
epochCache.getEpochAndSlotNow.mockReturnValue({
88+
epoch: 1 as any,
89+
slot: currentSlot,
90+
ts: 1000n,
91+
nowMs: 1001000n,
92+
});
93+
94+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(false);
95+
});
96+
97+
it('returns false for message two slots behind (currentSlot - 2)', () => {
98+
const currentSlot = SlotNumber(100);
99+
const messageSlot = SlotNumber(98); // two slots behind
100+
101+
// Even within time tolerance, should reject slots older than previous
102+
epochCache.getEpochAndSlotNow.mockReturnValue({
103+
epoch: 1 as any,
104+
slot: currentSlot,
105+
ts: 1000n,
106+
nowMs: 1000000n, // 0ms elapsed
107+
});
108+
109+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(false);
110+
});
111+
112+
it('returns false for current slot message (handled by main validation)', () => {
113+
const currentSlot = SlotNumber(100);
114+
const messageSlot = SlotNumber(100); // current slot
115+
116+
epochCache.getEpochAndSlotNow.mockReturnValue({
117+
epoch: 1 as any,
118+
slot: currentSlot,
119+
ts: 1000n,
120+
nowMs: 1000000n,
121+
});
122+
123+
// Current slot messages are handled by the main validation, not clock tolerance
124+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(false);
125+
});
126+
127+
it('returns false for next slot message (handled by main validation)', () => {
128+
const currentSlot = SlotNumber(100);
129+
const messageSlot = SlotNumber(101); // next slot
130+
131+
epochCache.getEpochAndSlotNow.mockReturnValue({
132+
epoch: 1 as any,
133+
slot: currentSlot,
134+
ts: 1000n,
135+
nowMs: 1000000n,
136+
});
137+
138+
// Next slot messages are handled by the main validation, not clock tolerance
139+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(false);
140+
});
141+
142+
it('returns false when current slot is 0 (genesis edge case)', () => {
143+
const currentSlot = SlotNumber(0);
144+
const messageSlot = SlotNumber(0);
145+
146+
epochCache.getEpochAndSlotNow.mockReturnValue({
147+
epoch: 0 as any,
148+
slot: currentSlot,
149+
ts: 0n,
150+
nowMs: 0n,
151+
});
152+
153+
// Cannot have a previous slot when at genesis
154+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(false);
155+
});
156+
157+
it('returns false for future slot message', () => {
158+
const currentSlot = SlotNumber(100);
159+
const messageSlot = SlotNumber(105); // future slot
160+
161+
epochCache.getEpochAndSlotNow.mockReturnValue({
162+
epoch: 1 as any,
163+
slot: currentSlot,
164+
ts: 1000n,
165+
nowMs: 1000000n,
166+
});
167+
168+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(false);
169+
});
170+
171+
it('returns true at 0ms elapsed (exactly at slot boundary)', () => {
172+
const currentSlot = SlotNumber(100);
173+
const messageSlot = SlotNumber(99);
174+
175+
// 0ms elapsed - definitely within tolerance
176+
epochCache.getEpochAndSlotNow.mockReturnValue({
177+
epoch: 1 as any,
178+
slot: currentSlot,
179+
ts: 1000n,
180+
nowMs: 1000000n, // exactly at slot start
181+
});
182+
183+
expect(isWithinClockTolerance(messageSlot, currentSlot, epochCache)).toBe(true);
184+
});
185+
});
186+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { EpochCacheInterface } from '@aztec/epoch-cache';
2+
import { SlotNumber } from '@aztec/foundation/branded-types';
3+
4+
/**
5+
* Maximum clock disparity tolerance for P2P message validation (in milliseconds).
6+
* Messages for the previous slot are accepted if we're within this many milliseconds
7+
* of the current slot start. This prevents penalizing peers for messages that
8+
* were valid when sent but arrived slightly late due to network latency.
9+
*
10+
* This follows Ethereum's MAXIMUM_GOSSIP_CLOCK_DISPARITY approach.
11+
*/
12+
export const MAXIMUM_GOSSIP_CLOCK_DISPARITY_MS = 500;
13+
14+
/**
15+
* Checks if a message for the previous slot should be accepted due to clock tolerance.
16+
*
17+
* @param messageSlot - The slot number from the received message
18+
* @param currentSlot - The current slot number
19+
* @param epochCache - EpochCache to get timing information
20+
* @returns true if the message is for the previous slot AND we're within the clock tolerance window
21+
*/
22+
export function isWithinClockTolerance(
23+
messageSlot: SlotNumber,
24+
currentSlot: SlotNumber,
25+
epochCache: EpochCacheInterface,
26+
): boolean {
27+
// Guard against slot 0 edge case (genesis)
28+
if (currentSlot === SlotNumber(0)) {
29+
return false;
30+
}
31+
32+
// Only apply tolerance to messages for the previous slot
33+
const previousSlot = SlotNumber(currentSlot - 1);
34+
if (messageSlot !== previousSlot) {
35+
return false;
36+
}
37+
38+
// Check how far we are into the current slot (in milliseconds)
39+
const { ts: slotStartTs, nowMs } = epochCache.getEpochAndSlotNow();
40+
// ts is in seconds, convert to ms; nowMs is already in milliseconds
41+
const slotStartMs = slotStartTs * 1000n;
42+
const elapsedMs = Number(nowMs - slotStartMs);
43+
44+
return elapsedMs < MAXIMUM_GOSSIP_CLOCK_DISPARITY_MS;
45+
}

yarn-project/p2p/src/msg_validators/proposal_validator/proposal_validator.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { NoCommitteeError } from '@aztec/ethereum/contracts';
33
import { type Logger, createLogger } from '@aztec/foundation/log';
44
import { BlockProposal, CheckpointProposal, PeerErrorSeverity } from '@aztec/stdlib/p2p';
55

6+
import { isWithinClockTolerance } from '../clock_tolerance.js';
7+
68
export abstract class ProposalValidator<TProposal extends BlockProposal | CheckpointProposal> {
79
protected epochCache: EpochCacheInterface;
810
protected logger: Logger;
@@ -20,8 +22,12 @@ export abstract class ProposalValidator<TProposal extends BlockProposal | Checkp
2022
const { currentSlot, nextSlot } = this.epochCache.getCurrentAndNextSlot();
2123
const slotNumber = proposal.slotNumber;
2224
if (slotNumber !== currentSlot && slotNumber !== nextSlot) {
23-
this.logger.debug(`Penalizing peer for invalid slot number ${slotNumber}`, { currentSlot, nextSlot });
24-
return PeerErrorSeverity.HighToleranceError;
25+
// Check if message is for previous slot and within clock tolerance
26+
if (!isWithinClockTolerance(slotNumber, currentSlot, this.epochCache)) {
27+
this.logger.debug(`Penalizing peer for invalid slot number ${slotNumber}`, { currentSlot, nextSlot });
28+
return PeerErrorSeverity.HighToleranceError;
29+
}
30+
this.logger.debug(`Accepting proposal for previous slot ${slotNumber} within clock tolerance`);
2531
}
2632

2733
// Signature validity

0 commit comments

Comments
 (0)