Skip to content

Commit 618be98

Browse files
committed
fix: extend slashing of bad attestations
1 parent d8f44c3 commit 618be98

7 files changed

Lines changed: 479 additions & 32 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
@@ -753,7 +753,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
753753
watchers.push(dataWithholdingWatcher);
754754
}
755755

756-
if (config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n) {
756+
if (
757+
config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n ||
758+
config.slashAttestInvalidCheckpointProposalPenalty > 0n
759+
) {
757760
broadcastedInvalidCheckpointProposalWatcher = new BroadcastedInvalidCheckpointProposalWatcher(
758761
p2pClient,
759762
epochCache,

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 A-520 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: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { OffenseType } from '@aztec/stdlib/slashing';
99
import {
1010
makeBlockHeader,
1111
makeBlockProposal,
12+
makeCheckpointAttestation,
1213
makeCheckpointHeader,
1314
makeCheckpointProposal,
1415
} from '@aztec/stdlib/testing';
@@ -21,14 +22,15 @@ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs } from '../watcher.js';
2122
import { BroadcastedInvalidCheckpointProposalWatcher } from './broadcasted_invalid_checkpoint_proposal_watcher.js';
2223

2324
describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
24-
let p2pClient: MockProxy<Pick<P2PClient, 'getProposalsForSlot'>>;
25+
let p2pClient: MockProxy<Pick<P2PClient, 'getCheckpointAttestationsForSlot' | 'getProposalsForSlot'>>;
2526
let epochCache: MockProxy<Pick<EpochCacheInterface, 'getCurrentAndNextSlot' | 'getL1Constants'>>;
2627
let config: SlasherConfig;
2728
let watcher: BroadcastedInvalidCheckpointProposalWatcher;
2829
let handler: jest.MockedFunction<(args: WantToSlashArgs[]) => void>;
2930

3031
beforeEach(() => {
31-
p2pClient = mock<Pick<P2PClient, 'getProposalsForSlot'>>();
32+
p2pClient = mock<Pick<P2PClient, 'getCheckpointAttestationsForSlot' | 'getProposalsForSlot'>>();
33+
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([]);
3234
epochCache = mock<Pick<EpochCacheInterface, 'getCurrentAndNextSlot' | 'getL1Constants'>>();
3335
epochCache.getCurrentAndNextSlot.mockReturnValue({ currentSlot: SlotNumber(12), nextSlot: SlotNumber(13) });
3436
epochCache.getL1Constants.mockReturnValue({
@@ -39,6 +41,7 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
3941
config = {
4042
...DefaultSlasherConfig,
4143
slashBroadcastedInvalidCheckpointProposalPenalty: 11n,
44+
slashAttestInvalidCheckpointProposalPenalty: 13n,
4245
};
4346
watcher = new BroadcastedInvalidCheckpointProposalWatcher(p2pClient, epochCache, config, 4);
4447
handler = jest.fn();
@@ -108,6 +111,154 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
108111
]);
109112
});
110113

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

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

Lines changed: 88 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,27 @@ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEm
1515

1616
const BroadcastedInvalidCheckpointProposalWatcherConfigKeys = [
1717
'slashBroadcastedInvalidCheckpointProposalPenalty',
18+
'slashAttestInvalidCheckpointProposalPenalty',
1819
] as const;
1920

2021
const SCAN_SLOT_LAG = 1;
2122
const DEFAULT_SCAN_SLOT_LOOKBACK = 4;
23+
const MAX_TRACKED_OFFENSES_PER_SLOT = 2048;
2224

2325
type BroadcastedInvalidCheckpointProposalWatcherConfig = Pick<
2426
SlasherConfig,
2527
(typeof BroadcastedInvalidCheckpointProposalWatcherConfigKeys)[number]
2628
>;
2729

2830
type ProposalsForSlot = Awaited<ReturnType<P2PClient['getProposalsForSlot']>>;
29-
type P2PProposalsForSlotSource = Pick<P2PClient, 'getProposalsForSlot'>;
31+
type P2PProposalsForSlotSource = Pick<P2PClient, 'getCheckpointAttestationsForSlot' | 'getProposalsForSlot'>;
3032

3133
type SignedBlockProposal = {
3234
proposal: BlockProposal;
3335
signer: EthAddress;
3436
};
3537

36-
/** Detects truncated-checkpoint proposal offenses from retained signed P2P proposals. */
38+
/** Detects A-520 truncated-checkpoint proposal offenses and associated bad attestations from retained P2P evidence. */
3739
export class BroadcastedInvalidCheckpointProposalWatcher
3840
extends (EventEmitter as new () => WatcherEmitter)
3941
implements Watcher
@@ -56,10 +58,7 @@ export class BroadcastedInvalidCheckpointProposalWatcher
5658
this.config = pick(config, ...BroadcastedInvalidCheckpointProposalWatcherConfigKeys);
5759
this.scanSlotLookback = Math.max(1, scanSlotLookback);
5860

59-
// Bound emitted offenses to the number of slots we rescan. This watcher currently tracks one offense type,
60-
// and at most one offense of that type can be emitted per slot.
61-
const offenseTypes = 1;
62-
this.emittedOffenses = FifoSet.withLimit<string>(offenseTypes * this.scanSlotLookback);
61+
this.emittedOffenses = FifoSet.withLimit<string>(MAX_TRACKED_OFFENSES_PER_SLOT * this.scanSlotLookback);
6362

6463
const intervalMs = Math.max(1000, (constants.ethereumSlotDuration * 1000) / 4);
6564
this.runningPromise = new RunningPromise(() => this.scan(), this.log, intervalMs);
@@ -84,7 +83,10 @@ export class BroadcastedInvalidCheckpointProposalWatcher
8483

8584
/** Scans newly closed slots, plus a small lookback for late-arriving proposals. */
8685
public async scan(): Promise<void> {
87-
if (this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n) {
86+
if (
87+
this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n &&
88+
this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n
89+
) {
8890
return;
8991
}
9092

@@ -106,12 +108,17 @@ export class BroadcastedInvalidCheckpointProposalWatcher
106108

107109
/** Scans a single slot. Public for tests. */
108110
public async scanSlot(slot: SlotNumber): Promise<void> {
109-
if (this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n) {
111+
if (
112+
this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n &&
113+
this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n
114+
) {
110115
return;
111116
}
112117

113118
const proposals = await this.p2pClient.getProposalsForSlot(slot);
114-
const slashArgs = this.getSlashArgsForProposals(slot, proposals).filter(args => this.markAsNewOffense(args));
119+
const slashArgs = (await this.getSlashArgsForProposals(slot, proposals)).filter(args =>
120+
this.markAsNewOffense(args),
121+
);
115122
if (slashArgs.length === 0) {
116123
return;
117124
}
@@ -127,15 +134,79 @@ export class BroadcastedInvalidCheckpointProposalWatcher
127134
this.emit(WANT_TO_SLASH_EVENT, slashArgs);
128135
}
129136

130-
private getSlashArgsForProposals(slot: SlotNumber, proposals: ProposalsForSlot): WantToSlashArgs[] {
137+
private async getSlashArgsForProposals(slot: SlotNumber, proposals: ProposalsForSlot): Promise<WantToSlashArgs[]> {
131138
const offenders = this.findOffenders(proposals.blockProposals, proposals.checkpointProposals);
132-
// we expect one proposer per slot today.
133-
return [...offenders.values()].map(validator => ({
134-
validator,
135-
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
136-
offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL,
137-
epochOrSlot: BigInt(slot),
138-
}));
139+
if (offenders.size === 0) {
140+
return [];
141+
}
142+
143+
const proposerArgs =
144+
this.config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n
145+
? [...offenders.values()].map(validator => ({
146+
validator,
147+
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
148+
offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL,
149+
epochOrSlot: BigInt(slot),
150+
}))
151+
: [];
152+
153+
return [...proposerArgs, ...(await this.getBadAttestationSlashArgsForProposals(slot, proposals))];
154+
}
155+
156+
private async getBadAttestationSlashArgsForProposals(
157+
slot: SlotNumber,
158+
proposals: ProposalsForSlot,
159+
): Promise<WantToSlashArgs[]> {
160+
if (this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n || this.hasProposalEquivocation(proposals)) {
161+
return [];
162+
}
163+
164+
let attestations: Awaited<ReturnType<P2PClient['getCheckpointAttestationsForSlot']>>;
165+
try {
166+
attestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot);
167+
} catch (err) {
168+
this.log.warn(`Failed to fetch checkpoint attestations for invalid checkpoint proposal slot`, {
169+
slot,
170+
err,
171+
});
172+
return [];
173+
}
174+
175+
const args: WantToSlashArgs[] = [];
176+
for (const attestation of attestations) {
177+
const attester = attestation.getSender();
178+
if (!attester) {
179+
continue;
180+
}
181+
182+
args.push({
183+
validator: attester,
184+
amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
185+
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
186+
epochOrSlot: BigInt(slot),
187+
});
188+
}
189+
return args;
190+
}
191+
192+
private hasProposalEquivocation(proposals: ProposalsForSlot): boolean {
193+
const checkpointProposalHashes = new Set(proposals.checkpointProposals.map(proposal => proposal.getPayloadHash()));
194+
if (checkpointProposalHashes.size > 1) {
195+
return true;
196+
}
197+
198+
const blockProposalHashesByPosition = new Map<string, string>();
199+
for (const proposal of proposals.blockProposals) {
200+
const positionKey = `${proposal.slotNumber}:${proposal.indexWithinCheckpoint}`;
201+
const payloadHash = proposal.getPayloadHash();
202+
const previousPayloadHash = blockProposalHashesByPosition.get(positionKey);
203+
if (previousPayloadHash !== undefined && previousPayloadHash !== payloadHash) {
204+
return true;
205+
}
206+
blockProposalHashesByPosition.set(positionKey, payloadHash);
207+
}
208+
209+
return false;
139210
}
140211

141212
private findOffenders(blockProposals: BlockProposal[], checkpointProposals: CheckpointProposalCore[]) {

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)