Skip to content

Commit d6bbeea

Browse files
committed
fix: extend slashing of bad attestations
1 parent 5031497 commit d6bbeea

7 files changed

Lines changed: 397 additions & 43 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: 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: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ 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,
@@ -34,7 +36,7 @@ type SignedBlockProposal = {
3436
signer: EthAddress;
3537
};
3638

37-
/** Detects truncated-checkpoint proposal offenses from retained signed P2P proposals. */
39+
/** Detects A-520 truncated-checkpoint proposer offenses 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);
@@ -134,7 +133,10 @@ export class BroadcastedInvalidCheckpointProposalWatcher
134133

135134
private getSlashArgsForProposals(slot: SlotNumber, proposals: ProposalsForSlot): WantToSlashArgs[] {
136135
const offenders = this.findOffenders(proposals.blockProposals, proposals.checkpointProposals);
137-
// we expect one proposer per slot today.
136+
if (offenders.size === 0) {
137+
return [];
138+
}
139+
138140
return [...offenders.values()].map(validator => ({
139141
validator,
140142
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,

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)