Skip to content

Commit e935225

Browse files
committed
fix: extend slashing of bad attestations
1 parent 3f0436b commit e935225

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
archiver,

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
@@ -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,154 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
112115
]);
113116
});
114117

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

1717
const BroadcastedInvalidCheckpointProposalWatcherConfigKeys = [
1818
'slashBroadcastedInvalidCheckpointProposalPenalty',
19+
'slashAttestInvalidCheckpointProposalPenalty',
1920
] as const;
2021

2122
const SCAN_SLOT_LAG = 1;
2223
const DEFAULT_SCAN_SLOT_LOOKBACK = 4;
24+
const MAX_TRACKED_OFFENSES_PER_SLOT = 2048;
2325

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

2931
type ProposalsForSlot = Awaited<ReturnType<P2PClient['getProposalsForSlot']>>;
30-
type P2PProposalsForSlotSource = Pick<P2PClient, 'getProposalsForSlot'>;
32+
type P2PProposalsForSlotSource = Pick<P2PClient, 'getCheckpointAttestationsForSlot' | 'getProposalsForSlot'>;
3133

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

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

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);
63+
this.emittedOffenses = FifoSet.withLimit<string>(MAX_TRACKED_OFFENSES_PER_SLOT * this.scanSlotLookback);
6564

6665
const intervalMs = Math.max(1000, (constants.ethereumSlotDuration * 1000) / 4);
6766
this.runningPromise = new RunningPromise(() => this.scan(), this.log, intervalMs);
@@ -89,7 +88,10 @@ export class BroadcastedInvalidCheckpointProposalWatcher
8988
* `currentSlot` at the archiver's last synced L2 slot.
9089
*/
9190
public async scan(): Promise<void> {
92-
if (this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n) {
91+
if (
92+
this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n &&
93+
this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n
94+
) {
9395
return;
9496
}
9597

@@ -111,12 +113,17 @@ export class BroadcastedInvalidCheckpointProposalWatcher
111113

112114
/** Scans a single slot. Public for tests. */
113115
public async scanSlot(slot: SlotNumber): Promise<void> {
114-
if (this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n) {
116+
if (
117+
this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n &&
118+
this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n
119+
) {
115120
return;
116121
}
117122

118123
const proposals = await this.p2pClient.getProposalsForSlot(slot);
119-
const slashArgs = this.getSlashArgsForProposals(slot, proposals).filter(args => this.markAsNewOffense(args));
124+
const slashArgs = (await this.getSlashArgsForProposals(slot, proposals)).filter(args =>
125+
this.markAsNewOffense(args),
126+
);
120127
if (slashArgs.length === 0) {
121128
return;
122129
}
@@ -132,15 +139,79 @@ export class BroadcastedInvalidCheckpointProposalWatcher
132139
this.emit(WANT_TO_SLASH_EVENT, slashArgs);
133140
}
134141

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

146217
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)