Skip to content

Commit fe6118c

Browse files
committed
fix: extend slashing of bad attestations
1 parent 855df97 commit fe6118c

9 files changed

Lines changed: 431 additions & 61 deletions

File tree

yarn-project/aztec-node/src/aztec-node/server.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -757,7 +757,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
757757
watchers.push(dataWithholdingWatcher);
758758
}
759759

760-
if (config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n) {
760+
if (
761+
config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n ||
762+
config.slashAttestInvalidCheckpointProposalPenalty > 0n
763+
) {
761764
broadcastedInvalidCheckpointProposalWatcher = new BroadcastedInvalidCheckpointProposalWatcher(
762765
p2pClient,
763766
archiver,

yarn-project/end-to-end/src/e2e_slashing/attested_invalid_proposal.test.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -394,11 +394,6 @@ describe('e2e_slashing_attested_invalid_proposal', () => {
394394
validator: badProposer,
395395
offenseType: OffenseType.BROADCASTED_INVALID_BLOCK_PROPOSAL,
396396
},
397-
{
398-
description: 'lazy validator attested to invalid checkpoint proposal',
399-
validator: lazyValidator,
400-
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
401-
},
402397
];
403398

404399
const offensesWithExpectedSlashes = await retryUntil(
@@ -415,7 +410,7 @@ describe('e2e_slashing_attested_invalid_proposal', () => {
415410
? currentOffenses
416411
: undefined;
417412
},
418-
'honest validator slash offenses for invalid proposal attestation',
413+
'honest validator slash offenses for invalid block proposal',
419414
OFFENSE_DETECTION_TIMEOUT,
420415
1,
421416
);
@@ -425,6 +420,14 @@ describe('e2e_slashing_attested_invalid_proposal', () => {
425420
expect(offense.amount).toBeGreaterThan(0n);
426421
t.logger.warn(`Observed expected slash offense: ${description}`, { offense });
427422
}
423+
expect(
424+
findSlashOffense(
425+
offensesWithExpectedSlashes,
426+
lazyValidator,
427+
OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
428+
targetSlot,
429+
),
430+
).toBeUndefined();
428431

429432
return {
430433
rollup,
@@ -439,7 +442,7 @@ describe('e2e_slashing_attested_invalid_proposal', () => {
439442
};
440443
}
441444

442-
it('slashes a lazy attester for an invalid checkpoint and clears it on delayed equivocation', async () => {
445+
it('does not slash a lazy checkpoint attester for invalid block evidence', async () => {
443446
const {
444447
rollup,
445448
badProposerNode,
@@ -531,7 +534,7 @@ describe('e2e_slashing_attested_invalid_proposal', () => {
531534
const offensesAfterClear = await retryUntil(
532535
async () => {
533536
const currentOffenses = await honestValidatorNode.getSlashOffenses('all');
534-
const badAttestationOffense = findSlashOffense(
537+
const lazyAttestationOffense = findSlashOffense(
535538
currentOffenses,
536539
lazyValidator,
537540
OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
@@ -544,16 +547,16 @@ describe('e2e_slashing_attested_invalid_proposal', () => {
544547
targetSlot,
545548
);
546549

547-
t.logger.warn('Waiting for delayed equivocation to clear bad attestation slash', {
550+
t.logger.warn('Waiting for delayed equivocation without a lazy attestation slash', {
548551
targetSlot,
549-
badAttestationOffense,
552+
lazyAttestationOffense,
550553
duplicateProposalOffense,
551554
currentOffenses,
552555
});
553556

554-
return !badAttestationOffense && duplicateProposalOffense ? currentOffenses : undefined;
557+
return !lazyAttestationOffense && duplicateProposalOffense ? currentOffenses : undefined;
555558
},
556-
'bad attestation slash cleared after delayed block proposal equivocation',
559+
'delayed block proposal equivocation recorded without lazy attester slash',
557560
OFFENSE_DETECTION_TIMEOUT,
558561
1,
559562
);

yarn-project/end-to-end/src/e2e_slashing/broadcasted_invalid_checkpoint_proposal_slash.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ async function awaitBroadcastedInvalidCheckpointOffense({
7777
const offenses = await node.getSlashOffenses('all');
7878
return findBroadcastedInvalidCheckpointOffense(offenses, validator, slot);
7979
},
80-
`A-520 offense for slot ${slot}`,
80+
`broadcasted invalid checkpoint proposal offense for slot ${slot}`,
8181
AZTEC_SLOT_DURATION * 3,
8282
1,
8383
);

yarn-project/slasher/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ List of all slashable offenses in the system:
123123
**Time Unit**: Slot-based offense.
124124

125125
### ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL
126-
**Description**: A committee member attested to a checkpoint proposal in a slot where this node detected a slashable invalid block proposal.
127-
**Detection**: ValidatorClient marks slots with invalid block proposals detected via reexecution and slashes checkpoint attesters seen for that slot. If proposal equivocation is later detected for the slot, pending bad-attestation offenses are cleared.
126+
**Description**: A committee member attested to a checkpoint proposal in a slot where this node detected a slashable invalid block or checkpoint proposal.
127+
**Detection**: ValidatorClient marks slots with invalid block proposals detected via reexecution and invalid checkpoint proposals detected via deterministic validation, then slashes checkpoint attesters seen for that slot. BroadcastedInvalidCheckpointProposalWatcher also scans retained truncated-checkpoint evidence and retained attestations for the same slot. If proposal equivocation is later detected for the slot, pending bad-attestation offenses are cleared.
128128
**Target**: Committee members who attested in the invalid proposal slot.
129129
**Time Unit**: Slot-based offense.
130130

yarn-project/slasher/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.test.ts

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { OffenseType } from '@aztec/stdlib/slashing';
1010
import {
1111
makeBlockHeader,
1212
makeBlockProposal,
13+
makeCheckpointAttestation,
1314
makeCheckpointHeader,
1415
makeCheckpointProposal,
1516
} from '@aztec/stdlib/testing';
@@ -22,15 +23,16 @@ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs } from '../watcher.js';
2223
import { BroadcastedInvalidCheckpointProposalWatcher } from './broadcasted_invalid_checkpoint_proposal_watcher.js';
2324

2425
describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
25-
let p2pClient: MockProxy<Pick<P2PClient, 'getProposalsForSlot'>>;
26+
let p2pClient: MockProxy<Pick<P2PClient, 'getCheckpointAttestationsForSlot' | 'getProposalsForSlot'>>;
2627
let l2BlockSource: MockProxy<Pick<L2BlockSource, 'getSyncedL2SlotNumber'>>;
2728
let epochCache: MockProxy<Pick<EpochCacheInterface, 'getSlotNow' | 'getL1Constants'>>;
2829
let config: SlasherConfig;
2930
let watcher: BroadcastedInvalidCheckpointProposalWatcher;
3031
let handler: jest.MockedFunction<(args: WantToSlashArgs[]) => void>;
3132

3233
beforeEach(() => {
33-
p2pClient = mock<Pick<P2PClient, 'getProposalsForSlot'>>();
34+
p2pClient = mock<Pick<P2PClient, 'getCheckpointAttestationsForSlot' | 'getProposalsForSlot'>>();
35+
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([]);
3436
l2BlockSource = mock<Pick<L2BlockSource, 'getSyncedL2SlotNumber'>>();
3537
l2BlockSource.getSyncedL2SlotNumber.mockResolvedValue(SlotNumber(12));
3638
epochCache = mock<Pick<EpochCacheInterface, 'getSlotNow' | 'getL1Constants'>>();
@@ -43,6 +45,7 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
4345
config = {
4446
...DefaultSlasherConfig,
4547
slashBroadcastedInvalidCheckpointProposalPenalty: 11n,
48+
slashAttestInvalidCheckpointProposalPenalty: 13n,
4649
};
4750
watcher = new BroadcastedInvalidCheckpointProposalWatcher(p2pClient, l2BlockSource, epochCache, config, 4);
4851
handler = jest.fn();
@@ -112,6 +115,121 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
112115
]);
113116
});
114117

118+
it('does not slash attesters from retained truncated-checkpoint evidence', async () => {
119+
const signer = Secp256k1Signer.random();
120+
const attester = Secp256k1Signer.random();
121+
const slot = SlotNumber(10);
122+
const blocks = await makeBlocks(signer, slot, 4);
123+
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
124+
const attestation = makeCheckpointAttestation({
125+
header: makeCheckpointHeader(1, { slotNumber: slot }),
126+
attesterSigner: attester,
127+
});
128+
mockProposals(slot, blocks, [checkpoint]);
129+
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);
130+
131+
await watcher.scanSlot(slot);
132+
133+
expect(handler).toHaveBeenCalledWith([
134+
{
135+
validator: signer.address,
136+
amount: 11n,
137+
offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL,
138+
epochOrSlot: 10n,
139+
},
140+
]);
141+
});
142+
143+
it('does not emit attester offenses when proposer checkpoint slashing is disabled', async () => {
144+
watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n });
145+
const signer = Secp256k1Signer.random();
146+
const attester = Secp256k1Signer.random();
147+
const slot = SlotNumber(10);
148+
const blocks = await makeBlocks(signer, slot, 4);
149+
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
150+
const attestation = makeCheckpointAttestation({
151+
header: makeCheckpointHeader(1, { slotNumber: slot }),
152+
attesterSigner: attester,
153+
});
154+
mockProposals(slot, blocks, [checkpoint]);
155+
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);
156+
157+
await watcher.scanSlot(slot);
158+
159+
expect(handler).not.toHaveBeenCalled();
160+
});
161+
162+
it('does not slash attesters when bad attestation slashing is disabled', async () => {
163+
watcher.updateConfig({ slashAttestInvalidCheckpointProposalPenalty: 0n });
164+
const signer = Secp256k1Signer.random();
165+
const attester = Secp256k1Signer.random();
166+
const slot = SlotNumber(10);
167+
const blocks = await makeBlocks(signer, slot, 4);
168+
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
169+
const attestation = makeCheckpointAttestation({
170+
header: makeCheckpointHeader(1, { slotNumber: slot }),
171+
attesterSigner: attester,
172+
});
173+
mockProposals(slot, blocks, [checkpoint]);
174+
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);
175+
176+
await watcher.scanSlot(slot);
177+
178+
expect(handler).toHaveBeenCalledWith([
179+
{
180+
validator: signer.address,
181+
amount: 11n,
182+
offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL,
183+
epochOrSlot: 10n,
184+
},
185+
]);
186+
});
187+
188+
it('does not emit bad attestation offenses for equivocated checkpoint proposal slots', async () => {
189+
watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n });
190+
const signer = Secp256k1Signer.random();
191+
const attester = Secp256k1Signer.random();
192+
const slot = SlotNumber(10);
193+
const blocks = await makeBlocks(signer, slot, 4);
194+
const truncatedCheckpoint = await makeCheckpointCore(signer, slot, blocks[1]);
195+
const otherCheckpoint = await makeCheckpointCore(signer, slot, blocks[3]);
196+
const attestation = makeCheckpointAttestation({
197+
header: makeCheckpointHeader(1, { slotNumber: slot }),
198+
attesterSigner: attester,
199+
});
200+
mockProposals(slot, blocks, [truncatedCheckpoint, otherCheckpoint]);
201+
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);
202+
203+
await watcher.scanSlot(slot);
204+
205+
expect(handler).not.toHaveBeenCalled();
206+
});
207+
208+
it('does not emit bad attestation offenses for equivocated block proposal slots', async () => {
209+
watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n });
210+
const signer = Secp256k1Signer.random();
211+
const attester = Secp256k1Signer.random();
212+
const slot = SlotNumber(10);
213+
const blocks = await makeBlocks(signer, slot, 4);
214+
const equivocatedBlock = await makeBlockProposal({
215+
signer,
216+
blockHeader: makeBlockHeader(99, { slotNumber: slot }),
217+
archiveRoot: Fr.random(),
218+
indexWithinCheckpoint: IndexWithinCheckpoint(2),
219+
});
220+
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
221+
const attestation = makeCheckpointAttestation({
222+
header: makeCheckpointHeader(1, { slotNumber: slot }),
223+
attesterSigner: attester,
224+
});
225+
mockProposals(slot, [...blocks, equivocatedBlock], [checkpoint]);
226+
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);
227+
228+
await watcher.scanSlot(slot);
229+
230+
expect(handler).not.toHaveBeenCalled();
231+
});
232+
115233
it('slashes when a higher-index proposal arrives after an earlier non-slashing scan', async () => {
116234
const signer = Secp256k1Signer.random();
117235
const slot = SlotNumber(10);

yarn-project/slasher/src/watchers/broadcasted_invalid_checkpoint_proposal_watcher.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type { EpochCacheInterface } from '@aztec/epoch-cache';
22
import { SlotNumber } from '@aztec/foundation/branded-types';
33
import { merge, pick } from '@aztec/foundation/collection';
44
import type { EthAddress } from '@aztec/foundation/eth-address';
5-
import { FifoSet } from '@aztec/foundation/fifo-set';
65
import { type Logger, createLogger } from '@aztec/foundation/log';
76
import { RunningPromise } from '@aztec/foundation/running-promise';
87
import type { L2BlockSource } from '@aztec/stdlib/block';
@@ -16,6 +15,7 @@ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEm
1615

1716
const BroadcastedInvalidCheckpointProposalWatcherConfigKeys = [
1817
'slashBroadcastedInvalidCheckpointProposalPenalty',
18+
'slashAttestInvalidCheckpointProposalPenalty',
1919
] as const;
2020

2121
const SCAN_SLOT_LAG = 1;
@@ -34,14 +34,14 @@ type SignedBlockProposal = {
3434
signer: EthAddress;
3535
};
3636

37-
/** Detects truncated-checkpoint proposal offenses from retained signed P2P proposals. */
37+
/** Detects truncated-checkpoint proposer offenses from retained P2P evidence. */
3838
export class BroadcastedInvalidCheckpointProposalWatcher
3939
extends (EventEmitter as new () => WatcherEmitter)
4040
implements Watcher
4141
{
4242
private readonly log: Logger = createLogger('broadcasted-invalid-checkpoint-proposal-watcher');
4343
private readonly runningPromise: RunningPromise;
44-
private readonly emittedOffenses: FifoSet<string>;
44+
private readonly emittedOffensesBySlot = new Map<bigint, Set<string>>();
4545
private readonly scanSlotLookback: number;
4646
private config: BroadcastedInvalidCheckpointProposalWatcherConfig;
4747
private lastScannedSlot: SlotNumber | undefined;
@@ -58,11 +58,6 @@ export class BroadcastedInvalidCheckpointProposalWatcher
5858
this.config = pick(config, ...BroadcastedInvalidCheckpointProposalWatcherConfigKeys);
5959
this.scanSlotLookback = Math.max(1, scanSlotLookback);
6060

61-
// Bound emitted offenses to the number of slots we rescan. This watcher currently tracks one offense type,
62-
// and at most one offense of that type can be emitted per slot.
63-
const offenseTypes = 1;
64-
this.emittedOffenses = FifoSet.withLimit<string>(offenseTypes * this.scanSlotLookback);
65-
6661
const intervalMs = Math.max(1000, (constants.ethereumSlotDuration * 1000) / 4);
6762
this.runningPromise = new RunningPromise(() => this.scan(), this.log, intervalMs);
6863
this.log.info('BroadcastedInvalidCheckpointProposalWatcher initialized', {
@@ -107,6 +102,7 @@ export class BroadcastedInvalidCheckpointProposalWatcher
107102
await this.scanSlot(SlotNumber(slot));
108103
}
109104
this.lastScannedSlot = newestSlotToConsider;
105+
this.pruneEmittedOffensesBefore(oldestLookbackSlot);
110106
}
111107

112108
/** Scans a single slot. Public for tests. */
@@ -134,7 +130,10 @@ export class BroadcastedInvalidCheckpointProposalWatcher
134130

135131
private getSlashArgsForProposals(slot: SlotNumber, proposals: ProposalsForSlot): WantToSlashArgs[] {
136132
const offenders = this.findOffenders(proposals.blockProposals, proposals.checkpointProposals);
137-
// we expect one proposer per slot today.
133+
if (offenders.size === 0) {
134+
return [];
135+
}
136+
138137
return [...offenders.values()].map(validator => ({
139138
validator,
140139
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
@@ -193,7 +192,23 @@ export class BroadcastedInvalidCheckpointProposalWatcher
193192
}
194193

195194
private markAsNewOffense(args: WantToSlashArgs): boolean {
196-
const key = `${args.validator.toString()}-${args.offenseType}-${args.epochOrSlot}`;
197-
return this.emittedOffenses.addIfAbsent(key);
195+
const key = `${args.validator.toString()}-${args.offenseType}`;
196+
const slotOffenses = this.emittedOffensesBySlot.get(args.epochOrSlot) ?? new Set<string>();
197+
if (slotOffenses.has(key)) {
198+
return false;
199+
}
200+
201+
slotOffenses.add(key);
202+
this.emittedOffensesBySlot.set(args.epochOrSlot, slotOffenses);
203+
return true;
204+
}
205+
206+
private pruneEmittedOffensesBefore(slot: SlotNumber): void {
207+
const oldestRetainedSlot = BigInt(slot);
208+
for (const emittedSlot of this.emittedOffensesBySlot.keys()) {
209+
if (emittedSlot < oldestRetainedSlot) {
210+
this.emittedOffensesBySlot.delete(emittedSlot);
211+
}
212+
}
198213
}
199214
}

yarn-project/stdlib/src/slashing/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export enum OffenseType {
2222
DUPLICATE_PROPOSAL = 8,
2323
/** A validator signed attestations for different proposals at the same slot (equivocation) */
2424
DUPLICATE_ATTESTATION = 9,
25-
/** A committee member attested to a checkpoint proposal in a slot with an invalid block proposal */
25+
/** A committee member attested to a checkpoint proposal in a slot with an invalid block or checkpoint proposal */
2626
ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL = 10,
2727
/** A proposer broadcast an invalid checkpoint proposal, detected by retained evidence or deterministic recomputation */
2828
BROADCASTED_INVALID_CHECKPOINT_PROPOSAL = 11,

0 commit comments

Comments
 (0)