Skip to content
Open
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
9 changes: 9 additions & 0 deletions yarn-project/archiver/src/modules/l1_synchronizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,15 @@ export class ArchiverL1Synchronizer implements Traceable {
calldataArchiveRoot: calldataCheckpoint.archiveRoot.toString(),
},
);
// Both the locally-proposed checkpoint and the L1-confirmed one are signed by the
// slot proposer; emit a divergence event so the slasher can attribute equivocation.
this.events.emit(L2BlockSourceEvents.CheckpointEquivocationDetected, {
type: L2BlockSourceEvents.CheckpointEquivocationDetected,
slotNumber: calldataCheckpoint.header.slotNumber,
checkpointNumber: calldataCheckpoint.checkpointNumber,
l1ArchiveRoot: calldataCheckpoint.archiveRoot,
proposedArchiveRoot: proposed.archive.root,
});
Comment on lines +1079 to +1087
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just defensively, I wouldn't trigger this if the checkpoints' slots are not equal. I think it can't happen, since we prune uncheckpointed checkpoints before getting here, but just in case.

// Return a divergence signal so the caller can evict pending >= this number
return { diverged: true, fromCheckpointNumber: proposed.checkpointNumber };
}
Expand Down
11 changes: 11 additions & 0 deletions yarn-project/aztec-node/src/aztec-node/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { PublicContractsDB, PublicProcessorFactory } from '@aztec/simulator/serv
import {
AttestationsBlockWatcher,
BroadcastedInvalidCheckpointProposalWatcher,
CheckpointEquivocationWatcher,
DataWithholdingWatcher,
type SlasherClientInterface,
type Watcher,
Expand Down Expand Up @@ -733,6 +734,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
let dataWithholdingWatcher: DataWithholdingWatcher | undefined;
let attestationsBlockWatcher: AttestationsBlockWatcher | undefined;
let broadcastedInvalidCheckpointProposalWatcher: BroadcastedInvalidCheckpointProposalWatcher | undefined;
let checkpointEquivocationWatcher: CheckpointEquivocationWatcher | undefined;

if (!proverOnly) {
validatorsSentinel = await createSentinel(epochCache, archiver, p2pClient, reexecutionTracker, config);
Expand Down Expand Up @@ -763,6 +765,11 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
watchers.push(broadcastedInvalidCheckpointProposalWatcher);
}

if (config.slashDuplicateProposalPenalty > 0n) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking we should remove all these conditionals for the codebase, so the offenses get created even if they are not penalized, just for tracking purposes. But that's for another PR.

checkpointEquivocationWatcher = new CheckpointEquivocationWatcher(archiver, epochCache, config);
watchers.push(checkpointEquivocationWatcher);
}

// We assume we want to slash for invalid attestations unless all max penalties are set to 0
if (config.slashProposeInvalidAttestationsPenalty > 0n || config.slashAttestDescendantOfInvalidPenalty > 0n) {
attestationsBlockWatcher = new AttestationsBlockWatcher(archiver, epochCache, config);
Expand Down Expand Up @@ -790,6 +797,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb
await broadcastedInvalidCheckpointProposalWatcher.start();
started.push(broadcastedInvalidCheckpointProposalWatcher);
}
if (checkpointEquivocationWatcher) {
await checkpointEquivocationWatcher.start();
started.push(checkpointEquivocationWatcher);
}
log.info(`All p2p services started`);
})
.catch(err => log.error('Failed to start p2p services after archiver sync', err));
Expand Down
49 changes: 45 additions & 4 deletions yarn-project/end-to-end/src/e2e_epochs/epochs_equivocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { retryUntil } from '@aztec/foundation/retry';
import { bufferToHex } from '@aztec/foundation/string';
import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers';
import { tryStop } from '@aztec/stdlib/interfaces/server';
import { OffenseType } from '@aztec/stdlib/slashing';

import { jest } from '@jest/globals';
import { privateKeyToAccount } from 'viem/accounts';
Expand Down Expand Up @@ -58,6 +59,7 @@ describe('e2e_epochs/epochs_equivocation', () => {
// - checkpointFinalization = 0.5s (assemble) + 0 (p2p in test) + 2s (L1 publish) = 2.5s
// - finalBlockDuration = 8s (re-execution)
// - Total: 0.5 + 24 + 8 + 2.5 = 35s => use 36s
const slashingUnit = BigInt(1e14);
test = await EpochsTestContext.setup({
numberOfAccounts: 0,
initialValidators: validators,
Expand All @@ -76,6 +78,28 @@ describe('e2e_epochs/epochs_equivocation', () => {
l1PublishingTime: 2,
aztecTargetCommitteeSize: 4,
skipInitialSequencer: true,
// Enable the slasher so we can assert the equivocating proposer is detected for slashing.
// Round size is aztecEpochDuration * slashingRoundSizeInEpochs = 4 slots; the L1 contract
// requires QUORUM > ROUND_SIZE / 2, so quorum must be at least 3.
slasherEnabled: true,
slashingQuorum: 3,
slashingRoundSizeInEpochs: 1,
slashingOffsetInRounds: 1,
slashAmountSmall: slashingUnit,
slashAmountMedium: slashingUnit * 2n,
slashAmountLarge: slashingUnit * 3n,
slashSelfAllowed: true,
slashDuplicateProposalPenalty: slashingUnit,
// Disable other offense penalties so we only see the equivocation offense.
slashInactivityPenalty: 0n,
slashDataWithholdingPenalty: 0n,
slashBroadcastedInvalidBlockPenalty: 0n,
slashBroadcastedInvalidCheckpointProposalPenalty: 0n,
slashDuplicateAttestationPenalty: 0n,
slashProposeInvalidAttestationsPenalty: 0n,
slashAttestDescendantOfInvalidPenalty: 0n,
slashAttestInvalidCheckpointProposalPenalty: 0n,
slashUnknownPenalty: 0n,
});

logger = test.logger;
Expand Down Expand Up @@ -271,9 +295,26 @@ describe('e2e_epochs/epochs_equivocation', () => {
),
);

// TODO(A-980): assert the equivocating proposer of the first slot is eventually slashed
// for the DUPLICATE_PROPOSAL offense. Slasher is currently disabled in the harness
// (slasherEnabled: false) and enabling it requires plumbing offense submission and
// waiting for the slasher's offense window.
// Every observing validator should have recorded the equivocation offense. A has been stopped
// above and D is a non-validator (no slasher), so we poll only B and C.
logger.warn(`Waiting for DUPLICATE_PROPOSAL offense on every observing node`, {
proposerAttester,
submissionSlot,
});
const matchesOffense = (o: { offenseType: OffenseType; validator: { toString(): string }; epochOrSlot: bigint }) =>
o.offenseType === OffenseType.DUPLICATE_PROPOSAL &&
o.validator.toString() === proposerAttester.toString() &&
o.epochOrSlot === BigInt(submissionSlot);
await retryUntil(
async () => {
const found = await Promise.all(
[nodeB, nodeC].map(async n => (await n.getSlashOffenses('all')).some(matchesOffense)),
);
return found.every(Boolean);
},
`DUPLICATE_PROPOSAL offense on every observing node`,
test.L2_SLOT_DURATION_IN_S * 4,
0.5,
);
});
});
4 changes: 2 additions & 2 deletions yarn-project/slasher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ List of all slashable offenses in the system:
**Time Unit**: Slot-based offense.

### DUPLICATE_PROPOSAL
**Description**: A proposer sent multiple block or checkpoint proposals for the same position (slot and indexWithinCheckpoint for blocks, or slot for checkpoints) with different content. Since each slot has exactly one designated proposer, sending conflicting proposals is equivocation.
**Detection**: Detected in the P2P layer when proposals are received. The AttestationPool tracks proposals by position; when a second proposal arrives for the same position with a different archive, it flags the duplicate. The first duplicate is propagated (Accept) so other validators can witness the offense.
**Description**: A proposer sent multiple block or checkpoint proposals for the same position (slot and indexWithinCheckpoint for blocks, or slot for checkpoints) with different content. Since each slot has exactly one designated proposer, sending conflicting proposals is equivocation. This also covers the case where a proposer broadcasts one checkpoint proposal via P2P but submits a different checkpoint to L1 for the same slot.
**Detection**: Detected in two places. (1) The P2P layer flags duplicates when a second proposal arrives for the same position with a different archive; the AttestationPool tracks proposals by position and the first duplicate is propagated (Accept) so other validators can witness the offense. (2) CheckpointEquivocationWatcher compares the archive root of each L1-confirmed checkpoint against retained signed P2P checkpoint proposals from the same slot's proposer and flags any mismatch.
**Target**: Proposer who broadcast the duplicate proposal.
**Time Unit**: Slot-based offense.

Expand Down
1 change: 1 addition & 0 deletions yarn-project/slasher/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './config.js';
export * from './watchers/data_withholding_watcher.js';
export * from './watchers/attestations_block_watcher.js';
export * from './watchers/broadcasted_invalid_checkpoint_proposal_watcher.js';
export * from './watchers/checkpoint_equivocation_watcher.js';
export * from './slasher_client.js';
export * from './slash_offenses_collector.js';
export * from './slasher_client_interface.js';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import type { EpochCacheInterface } from '@aztec/epoch-cache';
import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types';
import { Fr } from '@aztec/foundation/curves/bn254';
import { EthAddress } from '@aztec/foundation/eth-address';
import {
type ArchiverEmitter,
type CheckpointEquivocationDetectedEvent,
type L2BlockSourceEventEmitter,
L2BlockSourceEvents,
} from '@aztec/stdlib/block';
import { OffenseType } from '@aztec/stdlib/slashing';

import { jest } from '@jest/globals';
import { type MockProxy, mock } from 'jest-mock-extended';
import EventEmitter from 'node:events';

import { DefaultSlasherConfig, type SlasherConfig } from '../config.js';
import { WANT_TO_SLASH_EVENT, type WantToSlashArgs } from '../watcher.js';
import { CheckpointEquivocationWatcher } from './checkpoint_equivocation_watcher.js';

describe('CheckpointEquivocationWatcher', () => {
let archiverEmitter: ArchiverEmitter;
let l2BlockSource: Pick<L2BlockSourceEventEmitter, 'events'>;
let epochCache: MockProxy<Pick<EpochCacheInterface, 'getProposerAttesterAddressInSlot'>>;
let config: SlasherConfig;
let watcher: CheckpointEquivocationWatcher;
let handler: jest.MockedFunction<(args: WantToSlashArgs[]) => void>;

const makeEvent = (
overrides: Partial<CheckpointEquivocationDetectedEvent> = {},
): CheckpointEquivocationDetectedEvent => ({
type: L2BlockSourceEvents.CheckpointEquivocationDetected,
slotNumber: SlotNumber(10),
checkpointNumber: CheckpointNumber(2),
l1ArchiveRoot: Fr.random(),
proposedArchiveRoot: Fr.random(),
...overrides,
});

beforeEach(async () => {
archiverEmitter = new EventEmitter() as unknown as ArchiverEmitter;
l2BlockSource = { events: archiverEmitter };
epochCache = mock<Pick<EpochCacheInterface, 'getProposerAttesterAddressInSlot'>>();
epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(EthAddress.random());
config = {
...DefaultSlasherConfig,
slashDuplicateProposalPenalty: 23n,
};
watcher = new CheckpointEquivocationWatcher(l2BlockSource, epochCache, config);
handler = jest.fn();
watcher.on(WANT_TO_SLASH_EVENT, handler);
await watcher.start();
});

afterEach(async () => {
await watcher.stop();
});

const emitAndFlush = async (event: CheckpointEquivocationDetectedEvent) => {
archiverEmitter.emit(L2BlockSourceEvents.CheckpointEquivocationDetected, event);
// Allow the async handler to settle.
await new Promise(resolve => setImmediate(resolve));
};

it('emits a DUPLICATE_PROPOSAL slash for the slot proposer when divergence is detected', async () => {
const proposer = EthAddress.random();
epochCache.getProposerAttesterAddressInSlot.mockResolvedValueOnce(proposer);

await emitAndFlush(makeEvent());

expect(handler).toHaveBeenCalledWith([
{
validator: proposer,
amount: 23n,
offenseType: OffenseType.DUPLICATE_PROPOSAL,
epochOrSlot: 10n,
},
]);
});

it('does not emit when there is no proposer for the slot', async () => {
epochCache.getProposerAttesterAddressInSlot.mockResolvedValueOnce(undefined);

await emitAndFlush(makeEvent());

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

it('does not emit when the penalty is zero', async () => {
await watcher.stop();
config = { ...config, slashDuplicateProposalPenalty: 0n };
watcher = new CheckpointEquivocationWatcher(l2BlockSource, epochCache, config);
handler = jest.fn();
watcher.on(WANT_TO_SLASH_EVENT, handler);
await watcher.start();

await emitAndFlush(makeEvent());

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

it('deduplicates repeat events for the same (validator, slot)', async () => {
const proposer = EthAddress.random();
epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(proposer);

await emitAndFlush(makeEvent());
await emitAndFlush(makeEvent());

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

it('emits separately for distinct slots', async () => {
const proposer = EthAddress.random();
epochCache.getProposerAttesterAddressInSlot.mockResolvedValue(proposer);

await emitAndFlush(makeEvent({ slotNumber: SlotNumber(10) }));
await emitAndFlush(makeEvent({ slotNumber: SlotNumber(11) }));

expect(handler).toHaveBeenCalledTimes(2);
expect(handler.mock.calls[0][0][0].epochOrSlot).toBe(10n);
expect(handler.mock.calls[1][0][0].epochOrSlot).toBe(11n);
});

it('does not slash after stop()', async () => {
await watcher.stop();
await emitAndFlush(makeEvent());

expect(handler).not.toHaveBeenCalled();
});
});
112 changes: 112 additions & 0 deletions yarn-project/slasher/src/watchers/checkpoint_equivocation_watcher.ts
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just remove this in favor of adding the offense directly from the L1 synchronizer? Sometimes I wonder whether we're over-using events, and just calling into whatever class we want is simpler to reason about. I tend to go from one approach to the other, not sure what's best here.

Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { EpochCacheInterface } from '@aztec/epoch-cache';
import { merge, pick } from '@aztec/foundation/collection';
import { FifoSet } from '@aztec/foundation/fifo-set';
import { type Logger, createLogger } from '@aztec/foundation/log';
import {
type CheckpointEquivocationDetectedEvent,
type L2BlockSourceEventEmitter,
L2BlockSourceEvents,
} from '@aztec/stdlib/block';
import type { SlasherConfig } from '@aztec/stdlib/interfaces/server';
import { OffenseType } from '@aztec/stdlib/slashing';

import EventEmitter from 'node:events';

import { WANT_TO_SLASH_EVENT, type WantToSlashArgs, type Watcher, type WatcherEmitter } from '../watcher.js';

const CheckpointEquivocationWatcherConfigKeys = ['slashDuplicateProposalPenalty'] as const;

const DEFAULT_EMITTED_OFFENSES_LIMIT = 64;

type CheckpointEquivocationWatcherConfig = Pick<
SlasherConfig,
(typeof CheckpointEquivocationWatcherConfigKeys)[number]
>;

type EquivocationEventSource = Pick<L2BlockSourceEventEmitter, 'events'>;
type ProposerLookup = Pick<EpochCacheInterface, 'getProposerAttesterAddressInSlot'>;

/**
* Slashes the slot proposer for DUPLICATE_PROPOSAL when the archiver detects that a
* locally-stored proposed checkpoint disagrees with the L1-confirmed checkpoint at the
* same slot. Both are signed by the slot proposer (the proposed one by accepting it via
* P2P or building it locally; the L1 one by submission), so the proposer equivocated.
*/
export class CheckpointEquivocationWatcher extends (EventEmitter as new () => WatcherEmitter) implements Watcher {
private readonly log: Logger = createLogger('checkpoint-equivocation-watcher');
private readonly emittedOffenses: FifoSet<string>;
private readonly handler: (args: CheckpointEquivocationDetectedEvent) => void;
private config: CheckpointEquivocationWatcherConfig;

constructor(
private readonly l2BlockSource: EquivocationEventSource,
private readonly epochCache: ProposerLookup,
config: CheckpointEquivocationWatcherConfig,
emittedOffensesLimit = DEFAULT_EMITTED_OFFENSES_LIMIT,
) {
super();
this.config = pick(config, ...CheckpointEquivocationWatcherConfigKeys);
this.emittedOffenses = FifoSet.withLimit<string>(Math.max(1, emittedOffensesLimit));
this.handler = event => {
this.onEquivocationDetected(event).catch(err =>
this.log.error('Failed to handle checkpoint equivocation event', err),
);
};
this.log.info('CheckpointEquivocationWatcher initialized');
}

public updateConfig(config: Partial<CheckpointEquivocationWatcherConfig>): void {
this.config = merge(this.config, pick(config, ...CheckpointEquivocationWatcherConfigKeys));
this.log.verbose('CheckpointEquivocationWatcher config updated', this.config);
}

public start(): Promise<void> {
this.l2BlockSource.events.on(L2BlockSourceEvents.CheckpointEquivocationDetected, this.handler);
return Promise.resolve();
}

public stop(): Promise<void> {
this.l2BlockSource.events.off(L2BlockSourceEvents.CheckpointEquivocationDetected, this.handler);
return Promise.resolve();
}

/** Public for tests. */
public async onEquivocationDetected(event: CheckpointEquivocationDetectedEvent): Promise<void> {
if (this.config.slashDuplicateProposalPenalty <= 0n) {
return;
}

const proposer = await this.epochCache.getProposerAttesterAddressInSlot(event.slotNumber);
if (!proposer) {
this.log.warn(`Cannot attribute checkpoint equivocation: no proposer for slot ${event.slotNumber}`, {
slotNumber: event.slotNumber,
checkpointNumber: event.checkpointNumber,
});
return;
}

const slashArgs: WantToSlashArgs = {
validator: proposer,
amount: this.config.slashDuplicateProposalPenalty,
offenseType: OffenseType.DUPLICATE_PROPOSAL,
epochOrSlot: BigInt(event.slotNumber),
};
if (!this.markAsNewOffense(slashArgs)) {
return;
}

this.log.info(`Detected checkpoint equivocation offense`, {
slotNumber: event.slotNumber,
checkpointNumber: event.checkpointNumber,
l1ArchiveRoot: event.l1ArchiveRoot.toString(),
proposedArchiveRoot: event.proposedArchiveRoot.toString(),
validator: proposer.toString(),
});
this.emit(WANT_TO_SLASH_EVENT, [slashArgs]);
}

private markAsNewOffense(args: WantToSlashArgs): boolean {
const key = `${args.validator.toString()}-${args.offenseType}-${args.epochOrSlot}`;
return this.emittedOffenses.addIfAbsent(key);
}
Comment on lines +108 to +111
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've just checked and the offenses store already deduplicates based on this exact same criteria, so we don't need to track it in the watcher.

}
Loading
Loading