Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion yarn-project/aztec-node/src/aztec-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -752,7 +752,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
watchers.push(dataWithholdingWatcher);
}

if (config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n) {
if (
config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n ||
config.slashAttestInvalidCheckpointProposalPenalty > 0n
) {
broadcastedInvalidCheckpointProposalWatcher = new BroadcastedInvalidCheckpointProposalWatcher(
p2pClient,
epochCache,
Expand Down
4 changes: 2 additions & 2 deletions yarn-project/slasher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,8 @@ List of all slashable offenses in the system:
**Time Unit**: Slot-based offense.

### ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL
**Description**: A committee member attested to a checkpoint proposal in a slot where this node detected a slashable invalid block proposal.
**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.
**Description**: A committee member attested to a checkpoint proposal in a slot where this node detected a slashable invalid block or checkpoint proposal.
**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.
**Target**: Committee members who attested in the invalid proposal slot.
**Time Unit**: Slot-based offense.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { OffenseType } from '@aztec/stdlib/slashing';
import {
makeBlockHeader,
makeBlockProposal,
makeCheckpointAttestation,
makeCheckpointHeader,
makeCheckpointProposal,
} from '@aztec/stdlib/testing';
Expand All @@ -21,14 +22,15 @@ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs } from '../watcher.js';
import { BroadcastedInvalidCheckpointProposalWatcher } from './broadcasted_invalid_checkpoint_proposal_watcher.js';

describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
let p2pClient: MockProxy<Pick<P2PClient, 'getProposalsForSlot'>>;
let p2pClient: MockProxy<Pick<P2PClient, 'getCheckpointAttestationsForSlot' | 'getProposalsForSlot'>>;
let epochCache: MockProxy<Pick<EpochCacheInterface, 'getCurrentAndNextSlot' | 'getL1Constants'>>;
let config: SlasherConfig;
let watcher: BroadcastedInvalidCheckpointProposalWatcher;
let handler: jest.MockedFunction<(args: WantToSlashArgs[]) => void>;

beforeEach(() => {
p2pClient = mock<Pick<P2PClient, 'getProposalsForSlot'>>();
p2pClient = mock<Pick<P2PClient, 'getCheckpointAttestationsForSlot' | 'getProposalsForSlot'>>();
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([]);
epochCache = mock<Pick<EpochCacheInterface, 'getCurrentAndNextSlot' | 'getL1Constants'>>();
epochCache.getCurrentAndNextSlot.mockReturnValue({ currentSlot: SlotNumber(12), nextSlot: SlotNumber(13) });
epochCache.getL1Constants.mockReturnValue({
Expand All @@ -39,6 +41,7 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
config = {
...DefaultSlasherConfig,
slashBroadcastedInvalidCheckpointProposalPenalty: 11n,
slashAttestInvalidCheckpointProposalPenalty: 13n,
};
watcher = new BroadcastedInvalidCheckpointProposalWatcher(p2pClient, epochCache, config, 4);
handler = jest.fn();
Expand Down Expand Up @@ -108,6 +111,154 @@ describe('BroadcastedInvalidCheckpointProposalWatcher', () => {
]);
});

it('slashes attesters when retained attestations exist for a truncated checkpoint proposal slot', async () => {
const signer = Secp256k1Signer.random();
const attester = Secp256k1Signer.random();
const slot = SlotNumber(10);
const blocks = await makeBlocks(signer, slot, 4);
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
const attestation = makeCheckpointAttestation({
header: makeCheckpointHeader(1, { slotNumber: slot }),
attesterSigner: attester,
});
mockProposals(slot, blocks, [checkpoint]);
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);

await watcher.scanSlot(slot);

expect(handler).toHaveBeenCalledWith([
{
validator: signer.address,
amount: 11n,
offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 10n,
},
{
validator: attester.address,
amount: config.slashAttestInvalidCheckpointProposalPenalty,
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 10n,
},
]);
});

it('slashes attesters when proposer checkpoint slashing is disabled', async () => {
watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n });
const signer = Secp256k1Signer.random();
const attester = Secp256k1Signer.random();
const slot = SlotNumber(10);
const blocks = await makeBlocks(signer, slot, 4);
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
const attestation = makeCheckpointAttestation({
header: makeCheckpointHeader(1, { slotNumber: slot }),
attesterSigner: attester,
});
mockProposals(slot, blocks, [checkpoint]);
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);

await watcher.scanSlot(slot);

expect(handler).toHaveBeenCalledWith([
{
validator: attester.address,
amount: config.slashAttestInvalidCheckpointProposalPenalty,
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 10n,
},
]);
});

it('does not slash attesters when bad attestation slashing is disabled', async () => {
watcher.updateConfig({ slashAttestInvalidCheckpointProposalPenalty: 0n });
const signer = Secp256k1Signer.random();
const attester = Secp256k1Signer.random();
const slot = SlotNumber(10);
const blocks = await makeBlocks(signer, slot, 4);
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
const attestation = makeCheckpointAttestation({
header: makeCheckpointHeader(1, { slotNumber: slot }),
attesterSigner: attester,
});
mockProposals(slot, blocks, [checkpoint]);
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);

await watcher.scanSlot(slot);

expect(handler).toHaveBeenCalledWith([
{
validator: signer.address,
amount: 11n,
offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: 10n,
},
]);
});

it('does not emit duplicate bad attestation offenses on repeated scans', async () => {
watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n });
const signer = Secp256k1Signer.random();
const attester = Secp256k1Signer.random();
const slot = SlotNumber(10);
const blocks = await makeBlocks(signer, slot, 4);
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
const attestation = makeCheckpointAttestation({
header: makeCheckpointHeader(1, { slotNumber: slot }),
attesterSigner: attester,
});
mockProposals(slot, blocks, [checkpoint]);
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);

await watcher.scanSlot(slot);
await watcher.scanSlot(slot);

expect(handler).toHaveBeenCalledTimes(1);
});

it('does not emit bad attestation offenses for equivocated checkpoint proposal slots', async () => {
watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n });
const signer = Secp256k1Signer.random();
const attester = Secp256k1Signer.random();
const slot = SlotNumber(10);
const blocks = await makeBlocks(signer, slot, 4);
const truncatedCheckpoint = await makeCheckpointCore(signer, slot, blocks[1]);
const otherCheckpoint = await makeCheckpointCore(signer, slot, blocks[3]);
const attestation = makeCheckpointAttestation({
header: makeCheckpointHeader(1, { slotNumber: slot }),
attesterSigner: attester,
});
mockProposals(slot, blocks, [truncatedCheckpoint, otherCheckpoint]);
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);

await watcher.scanSlot(slot);

expect(handler).not.toHaveBeenCalled();
});

it('does not emit bad attestation offenses for equivocated block proposal slots', async () => {
watcher.updateConfig({ slashBroadcastedInvalidCheckpointProposalPenalty: 0n });
const signer = Secp256k1Signer.random();
const attester = Secp256k1Signer.random();
const slot = SlotNumber(10);
const blocks = await makeBlocks(signer, slot, 4);
const equivocatedBlock = await makeBlockProposal({
signer,
blockHeader: makeBlockHeader(99, { slotNumber: slot }),
archiveRoot: Fr.random(),
indexWithinCheckpoint: IndexWithinCheckpoint(2),
});
const checkpoint = await makeCheckpointCore(signer, slot, blocks[1]);
const attestation = makeCheckpointAttestation({
header: makeCheckpointHeader(1, { slotNumber: slot }),
attesterSigner: attester,
});
mockProposals(slot, [...blocks, equivocatedBlock], [checkpoint]);
p2pClient.getCheckpointAttestationsForSlot.mockResolvedValue([attestation]);

await watcher.scanSlot(slot);

expect(handler).not.toHaveBeenCalled();
});

it('slashes when a higher-index proposal arrives after an earlier non-slashing scan', async () => {
const signer = Secp256k1Signer.random();
const slot = SlotNumber(10);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,27 @@ import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEm

const BroadcastedInvalidCheckpointProposalWatcherConfigKeys = [
'slashBroadcastedInvalidCheckpointProposalPenalty',
'slashAttestInvalidCheckpointProposalPenalty',
] as const;

const SCAN_SLOT_LAG = 1;
const DEFAULT_SCAN_SLOT_LOOKBACK = 4;
const MAX_TRACKED_OFFENSES_PER_SLOT = 2048;

type BroadcastedInvalidCheckpointProposalWatcherConfig = Pick<
SlasherConfig,
(typeof BroadcastedInvalidCheckpointProposalWatcherConfigKeys)[number]
>;

type ProposalsForSlot = Awaited<ReturnType<P2PClient['getProposalsForSlot']>>;
type P2PProposalsForSlotSource = Pick<P2PClient, 'getProposalsForSlot'>;
type P2PProposalsForSlotSource = Pick<P2PClient, 'getCheckpointAttestationsForSlot' | 'getProposalsForSlot'>;

type SignedBlockProposal = {
proposal: BlockProposal;
signer: EthAddress;
};

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

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

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

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

Expand All @@ -106,12 +108,17 @@ export class BroadcastedInvalidCheckpointProposalWatcher

/** Scans a single slot. Public for tests. */
public async scanSlot(slot: SlotNumber): Promise<void> {
if (this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n) {
if (
this.config.slashBroadcastedInvalidCheckpointProposalPenalty <= 0n &&
this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n
) {
return;
}

const proposals = await this.p2pClient.getProposalsForSlot(slot);
const slashArgs = this.getSlashArgsForProposals(slot, proposals).filter(args => this.markAsNewOffense(args));
const slashArgs = (await this.getSlashArgsForProposals(slot, proposals)).filter(args =>
this.markAsNewOffense(args),
);
if (slashArgs.length === 0) {
return;
}
Expand All @@ -127,15 +134,79 @@ export class BroadcastedInvalidCheckpointProposalWatcher
this.emit(WANT_TO_SLASH_EVENT, slashArgs);
}

private getSlashArgsForProposals(slot: SlotNumber, proposals: ProposalsForSlot): WantToSlashArgs[] {
private async getSlashArgsForProposals(slot: SlotNumber, proposals: ProposalsForSlot): Promise<WantToSlashArgs[]> {
const offenders = this.findOffenders(proposals.blockProposals, proposals.checkpointProposals);
// we expect one proposer per slot today.
return [...offenders.values()].map(validator => ({
validator,
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: BigInt(slot),
}));
if (offenders.size === 0) {
return [];
}

const proposerArgs =
this.config.slashBroadcastedInvalidCheckpointProposalPenalty > 0n
? [...offenders.values()].map(validator => ({
validator,
amount: this.config.slashBroadcastedInvalidCheckpointProposalPenalty,
offenseType: OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: BigInt(slot),
}))
: [];

return [...proposerArgs, ...(await this.getBadAttestationSlashArgsForProposals(slot, proposals))];
}

private async getBadAttestationSlashArgsForProposals(
slot: SlotNumber,
proposals: ProposalsForSlot,
): Promise<WantToSlashArgs[]> {
if (this.config.slashAttestInvalidCheckpointProposalPenalty <= 0n || this.hasProposalEquivocation(proposals)) {
return [];
}

let attestations: Awaited<ReturnType<P2PClient['getCheckpointAttestationsForSlot']>>;
try {
attestations = await this.p2pClient.getCheckpointAttestationsForSlot(slot);
} catch (err) {
this.log.warn(`Failed to fetch checkpoint attestations for invalid checkpoint proposal slot`, {
slot,
err,
});
return [];
}

const args: WantToSlashArgs[] = [];
for (const attestation of attestations) {
const attester = attestation.getSender();
if (!attester) {
continue;
}

args.push({
validator: attester,
amount: this.config.slashAttestInvalidCheckpointProposalPenalty,
offenseType: OffenseType.ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL,
epochOrSlot: BigInt(slot),
});
}
return args;
}

private hasProposalEquivocation(proposals: ProposalsForSlot): boolean {
const checkpointProposalHashes = new Set(proposals.checkpointProposals.map(proposal => proposal.getPayloadHash()));
if (checkpointProposalHashes.size > 1) {
return true;
}

const blockProposalHashesByPosition = new Map<string, string>();
for (const proposal of proposals.blockProposals) {
const positionKey = `${proposal.slotNumber}:${proposal.indexWithinCheckpoint}`;
const payloadHash = proposal.getPayloadHash();
const previousPayloadHash = blockProposalHashesByPosition.get(positionKey);
if (previousPayloadHash !== undefined && previousPayloadHash !== payloadHash) {
return true;
}
blockProposalHashesByPosition.set(positionKey, payloadHash);
}

return false;
}

private findOffenders(blockProposals: BlockProposal[], checkpointProposals: CheckpointProposalCore[]) {
Expand Down
2 changes: 1 addition & 1 deletion yarn-project/stdlib/src/slashing/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export enum OffenseType {
DUPLICATE_PROPOSAL = 8,
/** A validator signed attestations for different proposals at the same slot (equivocation) */
DUPLICATE_ATTESTATION = 9,
/** A committee member attested to a checkpoint proposal in a slot with an invalid block proposal */
/** A committee member attested to a checkpoint proposal in a slot with an invalid block or checkpoint proposal */
ATTESTED_TO_INVALID_CHECKPOINT_PROPOSAL = 10,
/** A proposer broadcast an invalid checkpoint proposal, detected by retained evidence or deterministic recomputation */
BROADCASTED_INVALID_CHECKPOINT_PROPOSAL = 11,
Expand Down
Loading
Loading