diff --git a/.test_patterns.yml b/.test_patterns.yml index e4800e12b11c..57d696c06877 100644 --- a/.test_patterns.yml +++ b/.test_patterns.yml @@ -418,7 +418,7 @@ tests: # http://ci.aztec-labs.com/930ddc9ede87059f # Pipelining race: slasher doesn't record the duplicate proposal offense before # the test's wait timeout — same family as #23501. - - regex: "src/multi-node/slashing/duplicate_proposal.test.ts.*duplicate proposals" + - regex: "src/multi-node/slashing/equivocation_offenses.test.ts.*duplicate proposals" error_regex: "TimeoutError: Timeout awaiting duplicate proposal offense" owners: - *palla diff --git a/yarn-project/end-to-end/src/multi-node/README.md b/yarn-project/end-to-end/src/multi-node/README.md index 0e11b755de28..9751a9856240 100644 --- a/yarn-project/end-to-end/src/multi-node/README.md +++ b/yarn-project/end-to-end/src/multi-node/README.md @@ -56,7 +56,7 @@ suffix marks files with more than one top-level `it`; CI splits each `it` into i | `recovery/` | `MultiNodeTestContext` + wide-slot / block-production timing | The chain detects a bad/withheld/conflicting proposal and recovers: `proposal_failure_recovery.parallel` (all nodes prune and recover when a proposer fails to publish to L1), `pipeline_prune` (an uncheckpointed-blocks prune under pipelined MBPS, then recovery to a multi-block checkpoint), `equivocation_recovery` (an L1-confirmed checkpoint overrides a gossip-only equivocating proposal, the chain heals, and observers record the offense). | | `invalid-attestations/` | `MultiNodeTestContext` (slasher on) | Invalid checkpoints are detected, invalidated on L1, and the chain progresses: `invalidate_block.parallel`, a six-validator suite injecting insufficient/fake/high-s/unrecoverable/shuffled attestations and withheld blobs. | | `high-availability/` | `MultiNodeTestContext` + `setupHaPairs` | HA-pair sync and handoff between nodes that share validator keys: `ha_sync` (a peer that did not build a block syncs to the proposed chain tip over P2P), `ha_checkpoint_handoff` (a peer records and takes over a pipelined checkpoint when its partner proposes the previous slot). | -| `slashing/` | `MultiNodeTestContext` + `SLASHER_ENABLED_MULTI_VALIDATOR_OPTS` (`setup.ts`) | Offense detection and the sentinel that drives it. Equivocation (`duplicate_proposal`, `duplicate_attestation`), broadcast of an invalid block or checkpoint proposal (`broadcasted_invalid_block_proposal_slash`, `broadcasted_invalid_checkpoint_proposal_slash.parallel`), attesting to an invalid checkpoint (`attested_invalid_proposal.parallel`), data withholding (`data_withholding_slash`), inactivity (`inactivity_slash`, `inactivity_slash_with_consecutive_epochs`, sharing the `InactivityTest` fixture in `inactivity_setup.ts`), the slasher veto path (`slash_veto_demo`), and sentinel observability (`validators_sentinel.parallel`, `multiple_validators_sentinel.parallel`, `sentinel_status_slash.parallel`). `setup.ts` holds the offense-detection config and the shared slashing waiters (`awaitCommitteeExists`, `awaitOffenseDetected`, `advanceToEpochBeforeProposer`, `findUpcomingProposerSlot`, `awaitCommitteeKicked`, `awaitProposalExecution`). | +| `slashing/` | `MultiNodeTestContext` + `SLASHER_ENABLED_MULTI_VALIDATOR_OPTS` (`setup.ts`) | Offense detection and the sentinel that drives it. Equivocation — both duplicate-proposal and duplicate-attestation — (`equivocation_offenses`), broadcast of an invalid block or checkpoint proposal (`broadcasted_invalid_block_proposal_slash`, `broadcasted_invalid_checkpoint_proposal_slash.parallel`), attesting to an invalid checkpoint (`attested_invalid_proposal.parallel`), data withholding (`data_withholding_slash`), inactivity (`inactivity_slash`, `inactivity_slash_with_consecutive_epochs`, sharing the `InactivityTest` fixture in `inactivity_setup.ts`), the slasher veto path (`slash_veto_demo`), and sentinel observability (`validators_sentinel.parallel`, `multiple_validators_sentinel.parallel`, `sentinel_status_slash.parallel`). `setup.ts` holds the two timing profiles (`baseSlashingOpts`, `SENTINEL_TIMING`), the shared offense lookup (`findSlashOffense`), and the slashing waiters (`awaitCommitteeExists`, `awaitOffenseDetected`, `advanceToEpochBeforeProposer`, `findUpcomingProposerSlot`, `awaitCommitteeKicked`, `awaitProposalExecution`). | | `governance/` | `MultiNodeTestContext` + `MOCK_GOSSIP_MULTI_VALIDATOR_OPTS` + `GOVERNANCE_TIMING` (`setup.ts`) | A validator committee drives an L1 governance upgrade. `upgrade_governance_proposer` (the committee signals a new governance-proposer payload, reaches quorum, and the proposal executes) and `add_rollup` (a new rollup version is registered and the nodes migrate to it, exercising L1↔L2 bridging on both rollups). | ## Helper surface diff --git a/yarn-project/end-to-end/src/multi-node/slashing/attested_invalid_proposal.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/attested_invalid_proposal.parallel.test.ts index b20424935ce8..cacd2fa10fe1 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/attested_invalid_proposal.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/attested_invalid_proposal.parallel.test.ts @@ -25,7 +25,7 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { awaitCommitteeExists } from './setup.js'; +import { SENTINEL_TIMING, awaitCommitteeExists, findSlashOffense } from './setup.js'; const TEST_TIMEOUT = 1_000_000; @@ -33,7 +33,8 @@ jest.setTimeout(TEST_TIMEOUT); const NUM_VALIDATORS = 3; const COMMITTEE_SIZE = NUM_VALIDATORS; -const ETHEREUM_SLOT_DURATION = 4; +// Long 36s L2 slots (vs SENTINEL_TIMING's 8s): the bad proposer serializes an AVM-heavy 3-block +// checkpoint, which must fit inside a single slot for honest receivers to accept and attest to it. const AZTEC_SLOT_DURATION = 36; const BLOCK_DURATION_MS = 8_000; const BLOCKS_PER_CHECKPOINT = 3; @@ -44,16 +45,6 @@ const OFFENSE_DETECTION_TIMEOUT = AZTEC_SLOT_DURATION * 3; const INVALID_BLOCK_REMOVAL_TIMEOUT = AZTEC_SLOT_DURATION * 3; type BlockProposedEvent = Parameters[0]; -type SlashOffense = Awaited>[number]; - -function findSlashOffense(offenses: SlashOffense[], validator: EthAddress, offenseType: OffenseType, slot: SlotNumber) { - return offenses.find( - offense => - offense.validator.equals(validator) && - offense.offenseType === offenseType && - offense.epochOrSlot === BigInt(slot), - ); -} // Validators are keyed from `getPrivateKeyFromIndex(i + 3)` (the `buildMockGossipValidators` convention), // so the signer for validator `index` is derived from the same key its node signs proposals with. @@ -160,11 +151,9 @@ describe('multi-node/slashing/attested_invalid_proposal', () => { beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - listenAddress: '127.0.0.1', - aztecEpochDuration: 2, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - aztecSlotDuration: AZTEC_SLOT_DURATION, + ...SENTINEL_TIMING, + sentinelEnabled: false, // reuse only the fast base timing; this test does not use the sentinel + aztecSlotDuration: AZTEC_SLOT_DURATION, // override SENTINEL_TIMING's 8s slot with 36s (see const above) aztecTargetCommitteeSize: COMMITTEE_SIZE, aztecProofSubmissionEpochs: 1024, slashInactivityConsecutiveEpochThreshold: 32, diff --git a/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_block_proposal_slash.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_block_proposal_slash.test.ts index 1667279cfe5c..d1876afe573e 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_block_proposal_slash.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_block_proposal_slash.test.ts @@ -11,18 +11,15 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { advanceToEpochBeforeProposer, awaitCommitteeExists } from './setup.js'; +import { SENTINEL_TIMING, advanceToEpochBeforeProposer, awaitCommitteeExists } from './setup.js'; const NUM_VALIDATORS = 4; const COMMITTEE_SIZE = NUM_VALIDATORS; -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = ETHEREUM_SLOT_DURATION * 2; -const BLOCK_DURATION_MS = 2000; +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.aztecSlotDuration; const slashingUnit = BigInt(1e18); const slashingQuorum = 3; const slashingRoundSize = 4; -const aztecEpochDuration = 2; /** * Test that slashing occurs when a validator broadcasts an invalid block proposal. @@ -48,19 +45,16 @@ describe('multi-node/slashing/broadcasted_invalid_block_proposal_slash', () => { beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - listenAddress: '127.0.0.1', - aztecEpochDuration, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - aztecSlotDuration: AZTEC_SLOT_DURATION, - blockDurationMs: BLOCK_DURATION_MS, + ...SENTINEL_TIMING, + sentinelEnabled: false, // reuse only the fast 8s-slot timing; this test does not use the sentinel + blockDurationMs: 2000, aztecTargetCommitteeSize: COMMITTEE_SIZE, inboxLag: 2, aztecProofSubmissionEpochs: 1024, // effectively do not reorg slashInactivityConsecutiveEpochThreshold: 32, // effectively do not slash for inactivity minTxsPerBlock: 0, // always be building slashingQuorum, - slashingRoundSizeInEpochs: slashingRoundSize / aztecEpochDuration, + slashingRoundSizeInEpochs: slashingRoundSize / SENTINEL_TIMING.aztecEpochDuration, slashAmountSmall: slashingUnit, slashAmountMedium: slashingUnit * 2n, slashAmountLarge: slashingUnit * 3n, diff --git a/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash.parallel.test.ts index 21acb78f0d7f..b207200e5a0a 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash.parallel.test.ts @@ -1,5 +1,6 @@ import type { AztecNodeService } from '@aztec/aztec-node'; import type { TestAztecNodeService } from '@aztec/aztec-node/test'; +import type { EthAddress } from '@aztec/aztec.js/addresses'; import { Fr } from '@aztec/aztec.js/fields'; import { BlockNumber, EpochNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; @@ -24,7 +25,13 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { advanceToEpochBeforeProposer, awaitCommitteeExists } from './setup.js'; +import { + SENTINEL_TIMING, + type SlashOffense, + advanceToEpochBeforeProposer, + awaitCommitteeExists, + findSlashOffense, +} from './setup.js'; const TEST_TIMEOUT = 1_000_000; @@ -32,17 +39,12 @@ jest.setTimeout(TEST_TIMEOUT); const NUM_VALIDATORS = 2; const COMMITTEE_SIZE = NUM_VALIDATORS; -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_EPOCH_DURATION = 2; -const AZTEC_SLOT_DURATION = ETHEREUM_SLOT_DURATION * AZTEC_EPOCH_DURATION; -const BLOCK_DURATION_MS = 2000; +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.aztecSlotDuration; const SLASHING_QUORUM = 5; const SLASHING_ROUND_SIZE = 8; const TERMINAL_BLOCK_INDEX = IndexWithinCheckpoint(1); const HIGHER_BLOCK_INDEX = IndexWithinCheckpoint(2); -type SlashOffense = Awaited>[number]; - // Validators are keyed from `getPrivateKeyFromIndex(i + 3)` (the `buildMockGossipValidators` convention), // so the signer for validator `index` is derived from the same key its node signs proposals with. function getAttesterSigner(validatorIndex: number) { @@ -52,15 +54,10 @@ function getAttesterSigner(validatorIndex: number) { function findBroadcastedInvalidCheckpointOffense( offenses: SlashOffense[], - validator: string, + validator: EthAddress, slot: SlotNumber, ): SlashOffense | undefined { - return offenses.find( - offense => - offense.validator.toString() === validator && - offense.offenseType === OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL && - offense.epochOrSlot === BigInt(slot), - ); + return findSlashOffense(offenses, validator, OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL, slot); } async function awaitBroadcastedInvalidCheckpointOffense({ @@ -69,7 +66,7 @@ async function awaitBroadcastedInvalidCheckpointOffense({ slot, }: { node: AztecNodeService; - validator: string; + validator: EthAddress; slot: SlotNumber; }) { return await retryUntil( @@ -88,14 +85,14 @@ async function awaitAnyBroadcastedInvalidCheckpointOffense({ validator, }: { nodes: AztecNodeService[]; - validator: string; + validator: EthAddress; }) { return await retryUntil( async () => { const offenses = (await Promise.all(nodes.map(node => node.getSlashOffenses('all')))).flat(); const matchingOffenses = offenses.filter( offense => - offense.validator.toString() === validator && + offense.validator.equals(validator) && offense.offenseType === OffenseType.BROADCASTED_INVALID_CHECKPOINT_PROPOSAL, ); return matchingOffenses.length > 0 ? matchingOffenses : undefined; @@ -112,7 +109,7 @@ async function expectNoBroadcastedInvalidCheckpointOffense({ slot, }: { node: AztecNodeService; - validator: string; + validator: EthAddress; slot: SlotNumber; }) { // The watcher polls every second with this test's slot timing; wait long enough @@ -237,18 +234,15 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - listenAddress: '127.0.0.1', - aztecEpochDuration: AZTEC_EPOCH_DURATION, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - aztecSlotDuration: AZTEC_SLOT_DURATION, - blockDurationMs: BLOCK_DURATION_MS, + ...SENTINEL_TIMING, + sentinelEnabled: false, // reuse only the fast 8s-slot timing; this test does not use the sentinel + blockDurationMs: 2000, aztecTargetCommitteeSize: COMMITTEE_SIZE, aztecProofSubmissionEpochs: 1024, minTxsPerBlock: 0, inboxLag: 2, slashingQuorum: SLASHING_QUORUM, - slashingRoundSizeInEpochs: SLASHING_ROUND_SIZE / AZTEC_EPOCH_DURATION, + slashingRoundSizeInEpochs: SLASHING_ROUND_SIZE / SENTINEL_TIMING.aztecEpochDuration, slashAmountSmall: slashingUnit, slashAmountMedium: slashingUnit * 2n, slashAmountLarge: slashingUnit * 3n, @@ -292,7 +286,7 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () expect(currentSlot).toBeGreaterThan(2); const signer = getAttesterSigner(0); - const validator = test.addressAt(0).toString(); + const validator = test.addressAt(0); const signatureContext: CoordinationSignatureContext = { chainId: test.context.config.l1ChainId, rollupAddress: test.context.deployL1ContractsValues.l1ContractAddresses.rollupAddress, @@ -327,11 +321,11 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () checkpointCount: 1, }); expect(firstProposals.blockProposals.map(proposal => proposal.getSender()?.toString())).toEqual([ - validator, - validator, - validator, + validator.toString(), + validator.toString(), + validator.toString(), ]); - expect(firstProposals.checkpointProposals[0].getSender()?.toString()).toEqual(validator); + expect(firstProposals.checkpointProposals[0].getSender()?.toString()).toEqual(validator.toString()); const firstOffense = await awaitBroadcastedInvalidCheckpointOffense({ node, @@ -387,7 +381,7 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () const offenses = await awaitAnyBroadcastedInvalidCheckpointOffense({ nodes: honestNodes, - validator: invalidProposer.toString(), + validator: invalidProposer, }); test.logger.warn(`Collected broadcasted invalid checkpoint proposal offenses`, { offenses }); @@ -424,15 +418,15 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () checkpointCount: 1, }); expect(validProposals.blockProposals.map(proposal => proposal.getSender()?.toString())).toEqual([ - validator, - validator, + validator.toString(), + validator.toString(), ]); const terminalProposal = validProposals.blockProposals.find( proposal => proposal.indexWithinCheckpoint === TERMINAL_BLOCK_INDEX, ); expect(terminalProposal?.archive.toString()).toEqual(lateHigherBlockProposals.terminalBlock.archive.toString()); - expect(terminalProposal?.getSender()?.toString()).toEqual(validator); - expect(validProposals.checkpointProposals[0].getSender()?.toString()).toEqual(validator); + expect(terminalProposal?.getSender()?.toString()).toEqual(validator.toString()); + expect(validProposals.checkpointProposals[0].getSender()?.toString()).toEqual(validator.toString()); await expectNoBroadcastedInvalidCheckpointOffense({ node, validator, slot: targetSlot }); await node.getP2P().broadcastProposal(lateHigherBlockProposals.higherBlock); @@ -444,9 +438,9 @@ describe('multi-node/slashing/broadcasted_invalid_checkpoint_proposal_slash', () checkpointCount: 1, }); expect(invalidProposals.blockProposals.map(proposal => proposal.getSender()?.toString())).toEqual([ - validator, - validator, - validator, + validator.toString(), + validator.toString(), + validator.toString(), ]); const offense = await awaitBroadcastedInvalidCheckpointOffense({ diff --git a/yarn-project/end-to-end/src/multi-node/slashing/data_withholding_slash.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/data_withholding_slash.test.ts index edfbd4a37c1a..ea146ea02982 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/data_withholding_slash.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/data_withholding_slash.test.ts @@ -12,15 +12,22 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { awaitCommitteeExists, awaitOffenseDetected, getMinedSlot, submitTxsThroughNode } from './setup.js'; +import { + SENTINEL_TIMING, + awaitCommitteeExists, + awaitOffenseDetected, + getMinedSlot, + submitTxsThroughNode, +} from './setup.js'; const TEST_TIMEOUT = 1_000_000; jest.setTimeout(TEST_TIMEOUT); const NUM_VALIDATORS = 4; const COMMITTEE_SIZE = NUM_VALIDATORS; -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = ETHEREUM_SLOT_DURATION * 3; +// Longer 12s L2 slots (3 eth-slots) than SENTINEL_TIMING's default 8s, giving the data-withholding +// tolerance window room to settle before the watchers probe their mempools. +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.ethereumSlotDuration * 3; const TOLERANCE_SLOTS = 3; /** @@ -62,17 +69,15 @@ describe('multi-node/slashing/data_withholding_slash', () => { // (~23% probability). Extending slashOffenseExpirationRounds gives us several rounds to // hit quorum before the offense expires. const slashingRoundSize = 4; - const aztecEpochDuration = 2; + const aztecEpochDuration = SENTINEL_TIMING.aztecEpochDuration; const slashingAmount = slashingUnit * 3n; beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - listenAddress: '127.0.0.1', - aztecEpochDuration, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - aztecSlotDuration: AZTEC_SLOT_DURATION, + ...SENTINEL_TIMING, + sentinelEnabled: false, // reuse only the fast base timing; this test does not use the sentinel + aztecSlotDuration: AZTEC_SLOT_DURATION, // override SENTINEL_TIMING's 8s slot with 12s (see const above) aztecTargetCommitteeSize: COMMITTEE_SIZE, // Long proof submission window so the legacy L1-prune path is irrelevant. aztecProofSubmissionEpochs: 1024, diff --git a/yarn-project/end-to-end/src/multi-node/slashing/duplicate_attestation.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/duplicate_attestation.test.ts deleted file mode 100644 index 3bc75c05ee37..000000000000 --- a/yarn-project/end-to-end/src/multi-node/slashing/duplicate_attestation.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import type { AztecNodeService } from '@aztec/aztec-node'; -import type { TestAztecNodeService } from '@aztec/aztec-node/test'; -import { EthAddress } from '@aztec/aztec.js/addresses'; -import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { OffenseType } from '@aztec/slasher'; - -import { - MultiNodeTestContext, - SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - buildMockGossipValidators, -} from '../multi_node_test_context.js'; -import { - AZTEC_SLOT_DURATION, - NUM_VALIDATORS, - advanceToEpochBeforeProposer, - awaitCommitteeExists, - awaitOffenseDetected, - aztecEpochDuration, - baseSlashingOpts, - slashingRoundSize, - slashingUnit, -} from './setup.js'; - -/** - * Test that slashing occurs when a validator sends duplicate attestations (equivocation). - * - * The setup of the test is as follows: - * 1. Create 4 validator nodes total: - * - 2 honest validators with unique keys - * - 2 "malicious proposer" validators that share the SAME validator key but have DIFFERENT coinbase addresses - * (these will create duplicate proposals for the same slot) - * - The malicious proposer validators also have `attestToEquivocatedProposals: true` which makes them attest - * to BOTH proposals when they receive them - this is the attestation equivocation we want to detect - * 2. The two nodes with the same proposer key will both detect they are proposers for the same slot and race to propose - * 3. Since they have different coinbase addresses, their proposals will have different archives (different content) - * 4. The malicious attester nodes (with attestToEquivocatedProposals enabled) will attest to BOTH proposals - * 5. Honest validators will detect the duplicate attestations and emit a slash event - * - * NOTE: This test triggers BOTH duplicate proposal (from malicious proposers sharing a key) AND duplicate attestation - * (from the malicious proposers attesting to multiple proposals). We verify specifically that the duplicate - * attestation offense is recorded. - * - * Setup: MultiNodeTestContext on the in-memory mock-gossip bus (no real libp2p). 4 validators, ethSlot=8s, - * aztecSlot=24s, epoch=2, proofSubEpochs=1024, minTxsPerBlock=0, inboxLag=2 (v5 always enforces the timetable). - */ -describe('multi-node/slashing/duplicate_attestation', () => { - let test: MultiNodeTestContext; - let nodes: AztecNodeService[]; - - beforeEach(async () => { - test = await MultiNodeTestContext.setup({ - ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - ...baseSlashingOpts, - slashDuplicateAttestationPenalty: slashingUnit, - initialValidators: buildMockGossipValidators(NUM_VALIDATORS), - }); - }); - - afterEach(async () => { - await test.teardown(); - }); - - const cheatCodes = () => test.context.cheatCodes; - - const debugRollup = async () => { - await cheatCodes().rollup.debugRollup(); - }; - - // Two malicious nodes share a validator key and both attest to each other's proposals - // (attestToEquivocatedProposals:true). Honest nodes detect the DUPLICATE_ATTESTATION offense and verify - // the offending attester is the shared key's address. Also exercises DUPLICATE_PROPOSAL as a side effect - // but asserts specifically that DUPLICATE_ATTESTATION is recorded. - it('slashes validator who sends duplicate attestations', async () => { - const { rollup } = await test.getSlashingContracts(); - - // Jump forward to an epoch in the future such that the validator set is not empty - await cheatCodes().rollup.advanceToEpoch(EpochNumber(4)); - await debugRollup(); - - test.logger.warn('Creating nodes'); - - // Use validator index 0 for the "malicious" proposer validator key - const maliciousProposerIndex = 0; - const maliciousProposerAddress = test.addressAt(maliciousProposerIndex); - - test.logger.warn(`Malicious proposer address: ${maliciousProposerAddress.toString()}`); - - // Create two nodes with the SAME validator key but DIFFERENT coinbase addresses - // This will cause them to create proposals with different content for the same slot - // Additionally, enable attestToEquivocatedProposals so they will attest to BOTH proposals - const coinbase1 = EthAddress.random(); - const coinbase2 = EthAddress.random(); - - test.logger.warn(`Creating malicious proposer node 1 with coinbase ${coinbase1.toString()}`); - const maliciousNode1 = await test.createValidatorNodeAt(maliciousProposerIndex, { - coinbase: coinbase1, - attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations - broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals - dontStartSequencer: true, - // Prevent HA peer proposals from being added to the archiver, so both - // malicious nodes build their own blocks instead of one yielding to the other. - skipPushProposedBlocksToArchiver: true, - }); - - test.logger.warn(`Creating malicious proposer node 2 with coinbase ${coinbase2.toString()}`); - const maliciousNode2 = await test.createValidatorNodeAt(maliciousProposerIndex, { - coinbase: coinbase2, - attestToEquivocatedProposals: true, // Attest to all proposals - creates duplicate attestations - broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals - dontStartSequencer: true, - // Prevent HA peer proposals from being added to the archiver, so both - // malicious nodes build their own blocks instead of one yielding to the other. - skipPushProposedBlocksToArchiver: true, - }); - - // Create honest nodes with unique validator keys (indices 1 and 2) - test.logger.warn('Creating honest nodes'); - const honestNode1 = await test.createValidatorNodeAt(1, { dontStartSequencer: true }); - const honestNode2 = await test.createValidatorNodeAt(2, { dontStartSequencer: true }); - - nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - - await awaitCommitteeExists({ rollup, logger: test.logger }); - - // Find an epoch where the malicious proposer is selected, stopping one epoch before - // so we have time to start sequencers before the target epoch arrives - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; - const { targetEpoch, targetSlot } = await advanceToEpochBeforeProposer({ - epochCache, - cheatCodes: cheatCodes().rollup, - targetProposer: maliciousProposerAddress, - logger: test.logger, - }); - - // Start all sequencers while still one epoch before the target - test.logger.warn('Starting all sequencers'); - await Promise.all(nodes.map(n => n.getSequencer()!.start())); - - // Now warp to one slot before the target epoch — sequencers are already running. The helper - // picks a target slot at least one slot into the epoch, so warping here (rather than to the - // epoch start) leaves the freshly-started sequencers a full warm-up slot before the pipelined - // build for the malicious slot begins. Without that margin the duplicate proposals serialize - // past the slot boundary and receivers reject them as late, so the malicious nodes never get to - // attest to both and no duplicate attestation is produced. - test.logger.warn(`Advancing to one slot before target epoch ${targetEpoch} (target slot ${targetSlot})`); - await cheatCodes().rollup.advanceToEpoch(targetEpoch, { offset: -AZTEC_SLOT_DURATION }); - - // Wait for offenses to be detected - // We expect BOTH duplicate proposal AND duplicate attestation offenses - // The malicious proposer nodes create duplicate proposals (same key, different coinbase) - // The malicious proposer nodes also create duplicate attestations (attestToEquivocatedProposals enabled) - test.logger.warn('Waiting for duplicate attestation offense to be detected...'); - const offenses = await awaitOffenseDetected({ - epochDuration: aztecEpochDuration, - logger: test.logger, - nodeAdmin: honestNode1, // Use honest node to check for offenses - slashingRoundSize, - waitUntilOffenseCount: 2, // Wait for both duplicate proposal and duplicate attestation - timeoutSeconds: AZTEC_SLOT_DURATION * 16, - }); - - test.logger.warn(`Collected offenses`, { offenses }); - - // Verify we have detected the duplicate attestation offense - const duplicateAttestationOffenses = offenses.filter( - offense => offense.offenseType === OffenseType.DUPLICATE_ATTESTATION, - ); - const duplicateProposalOffenses = offenses.filter( - offense => offense.offenseType === OffenseType.DUPLICATE_PROPOSAL, - ); - - test.logger.info(`Found ${duplicateAttestationOffenses.length} duplicate attestation offenses`); - test.logger.info(`Found ${duplicateProposalOffenses.length} duplicate proposal offenses`); - - // We should have at least one duplicate attestation offense - expect(duplicateAttestationOffenses.length).toBeGreaterThan(0); - - // Verify the duplicate attestation offense is from the malicious proposer address - // (since they are the ones with attestToEquivocatedProposals enabled) - for (const offense of duplicateAttestationOffenses) { - expect(offense.offenseType).toEqual(OffenseType.DUPLICATE_ATTESTATION); - expect(offense.validator.toString()).toEqual(maliciousProposerAddress.toString()); - } - - // Verify that for each duplicate attestation offense, the attester for that slot is the malicious validator - for (const offense of duplicateAttestationOffenses) { - const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); - const committeeInfo = await epochCache.getCommittee(offenseSlot); - test.logger.info( - `Offense slot ${offenseSlot}: committee includes attester ${maliciousProposerAddress.toString()}`, - ); - expect(committeeInfo.committee?.map(addr => addr.toString())).toContain(maliciousProposerAddress.toString()); - } - - test.logger.warn('Duplicate attestation offense correctly detected and recorded'); - }); -}); diff --git a/yarn-project/end-to-end/src/multi-node/slashing/duplicate_proposal.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/duplicate_proposal.test.ts deleted file mode 100644 index 00def7f1eec0..000000000000 --- a/yarn-project/end-to-end/src/multi-node/slashing/duplicate_proposal.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import type { AztecNodeService } from '@aztec/aztec-node'; -import type { TestAztecNodeService } from '@aztec/aztec-node/test'; -import { EthAddress } from '@aztec/aztec.js/addresses'; -import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { OffenseType } from '@aztec/slasher'; - -import { - MultiNodeTestContext, - SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - buildMockGossipValidators, -} from '../multi_node_test_context.js'; -import { - AZTEC_SLOT_DURATION, - NUM_VALIDATORS, - advanceToEpochBeforeProposer, - awaitCommitteeExists, - awaitOffenseDetected, - aztecEpochDuration, - baseSlashingOpts, - slashingRoundSize, -} from './setup.js'; - -/** - * Test that slashing occurs when a validator sends duplicate proposals (equivocation). - * - * The setup of the test is as follows: - * 1. Create 4 validator nodes total: - * - 2 honest validators with unique keys - * - 2 "malicious" validators that share the SAME validator key but have DIFFERENT coinbase addresses - * 2. The two nodes with the same key will both detect they are proposers for the same slot and naturally race to propose - * 3. Since they have different coinbase addresses, their proposals will have different archives (different content) - * 4. Other validators will detect the duplicate and emit a slash event - * - * Setup: MultiNodeTestContext on the in-memory mock-gossip bus (no real libp2p). 4 validators, ethSlot=8s, - * aztecSlot=24s, epoch=2, proofSubEpochs=1024, minTxsPerBlock=0, inboxLag=2 (v5 always enforces the timetable). - */ -describe('multi-node/slashing/duplicate_proposal', () => { - let test: MultiNodeTestContext; - let nodes: AztecNodeService[]; - - beforeEach(async () => { - test = await MultiNodeTestContext.setup({ - ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - ...baseSlashingOpts, - initialValidators: buildMockGossipValidators(NUM_VALIDATORS), - }); - }); - - afterEach(async () => { - await test.teardown(); - }); - - const cheatCodes = () => test.context.cheatCodes; - - const debugRollup = async () => { - await cheatCodes().rollup.debugRollup(); - }; - - // Two malicious nodes share a validator key but have different coinbase addresses so their proposals - // differ. Honest nodes receive both proposals via mock gossip, detect the equivocation, and record a - // DUPLICATE_PROPOSAL offense. The test collects offenses from all nodes (equivocation may only be - // observed by whichever node processed both proposals before the slot closed) and asserts the offense - // is attributed to the shared key's address. - it('slashes validator who sends duplicate proposals', async () => { - const { rollup } = await test.getSlashingContracts(); - - // Jump forward to an epoch in the future such that the validator set is not empty - await cheatCodes().rollup.advanceToEpoch(EpochNumber(4)); - await debugRollup(); - - test.logger.warn('Creating nodes'); - - // Use validator index 0 for the "malicious" validator key - const maliciousValidatorIndex = 0; - const maliciousValidatorAddress = test.addressAt(maliciousValidatorIndex); - - test.logger.warn(`Malicious proposer address: ${maliciousValidatorAddress.toString()}`); - - // Create two nodes with the SAME validator key but DIFFERENT coinbase addresses - // This will cause them to create proposals with different content for the same slot - const coinbase1 = EthAddress.random(); - const coinbase2 = EthAddress.random(); - - test.logger.warn(`Creating malicious node 1 with coinbase ${coinbase1.toString()}`); - const maliciousNode1 = await test.createValidatorNodeAt(maliciousValidatorIndex, { - coinbase: coinbase1, - broadcastEquivocatedProposals: true, - dontStartSequencer: true, - // Prevent HA peer proposals from being added to the archiver, so both - // malicious nodes build their own blocks instead of one yielding to the other. - skipPushProposedBlocksToArchiver: true, - }); - - test.logger.warn(`Creating malicious node 2 with coinbase ${coinbase2.toString()}`); - const maliciousNode2 = await test.createValidatorNodeAt(maliciousValidatorIndex, { - coinbase: coinbase2, - broadcastEquivocatedProposals: true, - dontStartSequencer: true, - // Prevent HA peer proposals from being added to the archiver, so both - // malicious nodes build their own blocks instead of one yielding to the other. - skipPushProposedBlocksToArchiver: true, - }); - - // Create honest nodes with unique validator keys (indices 1 and 2) - test.logger.warn('Creating honest nodes'); - const honestNode1 = await test.createValidatorNodeAt(1, { dontStartSequencer: true }); - const honestNode2 = await test.createValidatorNodeAt(2, { dontStartSequencer: true }); - - nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; - - await awaitCommitteeExists({ rollup, logger: test.logger }); - - // Find an epoch where the malicious proposer is selected, stopping one epoch before - // so we have time to start sequencers before the target epoch arrives - const epochCache = (honestNode1 as TestAztecNodeService).epochCache; - const { targetEpoch, targetSlot } = await advanceToEpochBeforeProposer({ - epochCache, - cheatCodes: cheatCodes().rollup, - targetProposer: maliciousValidatorAddress, - logger: test.logger, - }); - - // Start all sequencers while still one epoch before the target - test.logger.warn('Starting all sequencers'); - await Promise.all(nodes.map(n => n.getSequencer()!.start())); - - // Now warp to one slot before the target epoch — sequencers are already running. - // Under proposer pipelining, the malicious proposers begin building for their slot one slot - // earlier; warping to the start of the epoch would force both AVM-heavy duplicate proposals to - // serialize past the slot boundary, after which honest receivers reject them as late. The helper - // picks a target slot at least one slot into the epoch, so warping here leaves a full warm-up - // slot before the build begins rather than starting it at the exact instant of the warp. - test.logger.warn(`Advancing to one slot before target epoch ${targetEpoch} (target slot ${targetSlot})`); - await cheatCodes().rollup.advanceToEpoch(targetEpoch, { offset: -AZTEC_SLOT_DURATION }); - - // Wait for offense to be detected. Under proposer pipelining, checkpoint proposals are broadcast - // at the slot boundary while the receivers' wall clocks may have already advanced past the build - // slot — when that happens, honest nodes reject the gossip with "invalid slot number" before - // duplicate detection runs, so DUPLICATE_PROPOSAL is only observed by whichever node managed to - // process both proposals while still in the build slot (often the other malicious node, since - // they receive each other's broadcasts immediately). We therefore collect offenses from every - // node in the network and assert that at least one of them recorded the duplicate proposal. - test.logger.warn('Waiting for duplicate proposal offense to be detected...'); - await awaitOffenseDetected({ - epochDuration: aztecEpochDuration, - logger: test.logger, - nodeAdmin: honestNode1, - slashingRoundSize, - waitUntilOffenseCount: 1, - timeoutSeconds: AZTEC_SLOT_DURATION * 16, - }); - - // Poll every node for DUPLICATE_PROPOSAL offenses, retrying briefly so any node that detected - // the duplicate after the initial offense was collected has time to flush it through the - // slasher's offenses-collector. - const proposalOffenses = await test.waitForOffenseOnNodes( - nodes, - o => o.offenseType === OffenseType.DUPLICATE_PROPOSAL, - { mode: 'any', timeout: AZTEC_SLOT_DURATION * 4 }, - ); - - test.logger.warn(`Collected duplicate proposal offenses`, { proposalOffenses }); - expect(proposalOffenses.length).toBeGreaterThan(0); - for (const offense of proposalOffenses) { - expect(offense.validator.toString()).toEqual(maliciousValidatorAddress.toString()); - } - - // Verify that for each offense, the proposer for that slot is the malicious validator - for (const offense of proposalOffenses) { - const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); - const proposerForSlot = await epochCache.getProposerAttesterAddressInSlot(offenseSlot); - test.logger.info(`Offense slot ${offenseSlot}: proposer is ${proposerForSlot?.toString()}`); - expect(proposerForSlot?.toString()).toEqual(maliciousValidatorAddress.toString()); - } - - test.logger.warn('Duplicate proposal offense correctly detected and recorded'); - }); -}); diff --git a/yarn-project/end-to-end/src/multi-node/slashing/equivocation_offenses.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/equivocation_offenses.test.ts new file mode 100644 index 000000000000..150ed960ae2d --- /dev/null +++ b/yarn-project/end-to-end/src/multi-node/slashing/equivocation_offenses.test.ts @@ -0,0 +1,247 @@ +import type { AztecNodeService } from '@aztec/aztec-node'; +import type { TestAztecNodeService } from '@aztec/aztec-node/test'; +import { EthAddress } from '@aztec/aztec.js/addresses'; +import type { EpochCacheInterface } from '@aztec/epoch-cache'; +import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { OffenseType } from '@aztec/slasher'; + +import { + MultiNodeTestContext, + SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, + buildMockGossipValidators, +} from '../multi_node_test_context.js'; +import { + AZTEC_SLOT_DURATION, + NUM_VALIDATORS, + advanceToEpochBeforeProposer, + awaitCommitteeExists, + awaitOffenseDetected, + aztecEpochDuration, + baseSlashingOpts, + slashingRoundSize, + slashingUnit, +} from './setup.js'; + +/** + * Slashing on validator equivocation. Two malicious nodes share the same validator key but use + * DIFFERENT coinbase addresses, so both detect they are proposers for the same slot, race to propose, + * and produce proposals with different archives. Honest validators receive both proposals over the + * mock-gossip bus and record the equivocation. + * + * The two suites differ only in whether the malicious nodes also attest to both proposals: + * - `duplicate proposal`: the malicious nodes equivocate but do not double-attest. Honest nodes + * record a DUPLICATE_PROPOSAL offense for the shared key. + * - `duplicate attestation`: the malicious nodes additionally run with `attestToEquivocatedProposals`, + * so they attest to BOTH proposals they see. This exercises DUPLICATE_PROPOSAL as a side effect but + * the suite asserts specifically that DUPLICATE_ATTESTATION is recorded. + * + * The two node-setup configs (the extra `slashDuplicateAttestationPenalty` and the + * `attestToEquivocatedProposals` flag) differ per case, so each runs in its own describe with its own + * `beforeEach`/`afterEach` rather than sharing one setup. + * + * Setup: MultiNodeTestContext on the in-memory mock-gossip bus (no real libp2p). 4 validators, ethSlot=8s, + * aztecSlot=24s, epoch=2, proofSubEpochs=1024, minTxsPerBlock=0, inboxLag=2 (v5 always enforces the timetable). + */ + +/** + * Drives an equivocation scenario to the point where at least `waitUntilOffenseCount` offenses are + * detected on the first honest node, and returns the actors + collected offenses for the caller to + * assert on. Creates two malicious nodes sharing validator index 0 (distinct coinbases) plus two honest + * nodes, finds the malicious proposer's slot, starts sequencers, and warps to it. + */ +async function runEquivocationScenario( + test: MultiNodeTestContext, + { + attestToEquivocatedProposals, + waitUntilOffenseCount, + }: { attestToEquivocatedProposals: boolean; waitUntilOffenseCount: number }, +): Promise<{ + nodes: AztecNodeService[]; + epochCache: EpochCacheInterface; + maliciousAddress: EthAddress; + honestNode: AztecNodeService; +}> { + const cheatCodes = test.context.cheatCodes.rollup; + const { rollup } = await test.getSlashingContracts(); + + // Jump forward to an epoch in the future such that the validator set is not empty + await cheatCodes.advanceToEpoch(EpochNumber(4)); + await cheatCodes.debugRollup(); + + test.logger.warn('Creating nodes'); + + // Use validator index 0 for the "malicious" proposer validator key + const maliciousProposerIndex = 0; + const maliciousAddress = test.addressAt(maliciousProposerIndex); + test.logger.warn(`Malicious proposer address: ${maliciousAddress.toString()}`); + + // Create two nodes with the SAME validator key but DIFFERENT coinbase addresses so their proposals + // have different content for the same slot. With `attestToEquivocatedProposals` they also attest to + // both proposals, producing duplicate attestations. + const maliciousConfig = (coinbase: EthAddress) => ({ + coinbase, + broadcastEquivocatedProposals: true, // Don't abort checkpoint building on duplicate block proposals + dontStartSequencer: true, + // Prevent HA peer proposals from being added to the archiver, so both malicious nodes build their + // own blocks instead of one yielding to the other. + skipPushProposedBlocksToArchiver: true, + ...(attestToEquivocatedProposals ? { attestToEquivocatedProposals: true } : {}), + }); + + const maliciousNode1 = await test.createValidatorNodeAt(maliciousProposerIndex, maliciousConfig(EthAddress.random())); + const maliciousNode2 = await test.createValidatorNodeAt(maliciousProposerIndex, maliciousConfig(EthAddress.random())); + + // Create honest nodes with unique validator keys (indices 1 and 2) + test.logger.warn('Creating honest nodes'); + const honestNode1 = await test.createValidatorNodeAt(1, { dontStartSequencer: true }); + const honestNode2 = await test.createValidatorNodeAt(2, { dontStartSequencer: true }); + + const nodes = [maliciousNode1, maliciousNode2, honestNode1, honestNode2]; + + await awaitCommitteeExists({ rollup, logger: test.logger }); + + // Find an epoch where the malicious proposer is selected, stopping one epoch before so we have time + // to start sequencers before the target epoch arrives. + const epochCache = (honestNode1 as TestAztecNodeService).epochCache; + const { targetEpoch, targetSlot } = await advanceToEpochBeforeProposer({ + epochCache, + cheatCodes, + targetProposer: maliciousAddress, + logger: test.logger, + }); + + // Start all sequencers while still one epoch before the target + test.logger.warn('Starting all sequencers'); + await Promise.all(nodes.map(n => n.getSequencer()!.start())); + + // Now warp to one slot before the target epoch — sequencers are already running. The helper picks a + // target slot at least one slot into the epoch, so warping here (rather than to the epoch start) + // leaves the freshly-started sequencers a full warm-up slot before the pipelined build for the + // malicious slot begins. Without that margin the duplicate proposals serialize past the slot + // boundary and receivers reject them as late, so no equivocation is produced. + test.logger.warn(`Advancing to one slot before target epoch ${targetEpoch} (target slot ${targetSlot})`); + await cheatCodes.advanceToEpoch(targetEpoch, { offset: -AZTEC_SLOT_DURATION }); + + test.logger.warn('Waiting for offenses to be detected...'); + await awaitOffenseDetected({ + epochDuration: aztecEpochDuration, + logger: test.logger, + nodeAdmin: honestNode1, + slashingRoundSize, + waitUntilOffenseCount, + timeoutSeconds: AZTEC_SLOT_DURATION * 16, + }); + + return { nodes, epochCache, maliciousAddress, honestNode: honestNode1 }; +} + +describe('multi-node/slashing/duplicate_attestation', () => { + let test: MultiNodeTestContext; + + beforeEach(async () => { + test = await MultiNodeTestContext.setup({ + ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, + ...baseSlashingOpts, + slashDuplicateAttestationPenalty: slashingUnit, + initialValidators: buildMockGossipValidators(NUM_VALIDATORS), + }); + }); + + afterEach(async () => { + await test.teardown(); + }); + + // Two malicious nodes share a validator key and both attest to each other's proposals + // (attestToEquivocatedProposals). Honest nodes detect the DUPLICATE_ATTESTATION offense and verify the + // offending attester is the shared key's address, and that the address is in that slot's committee. + // Also exercises DUPLICATE_PROPOSAL as a side effect but asserts specifically on DUPLICATE_ATTESTATION. + it('slashes validator who sends duplicate attestations', async () => { + // Wait for both the duplicate proposal and the duplicate attestation offense. + const { epochCache, maliciousAddress, honestNode } = await runEquivocationScenario(test, { + attestToEquivocatedProposals: true, + waitUntilOffenseCount: 2, + }); + + const offenses = await honestNode.getSlashOffenses('all'); + test.logger.warn(`Collected offenses`, { offenses }); + + const duplicateAttestationOffenses = offenses.filter( + offense => offense.offenseType === OffenseType.DUPLICATE_ATTESTATION, + ); + test.logger.info(`Found ${duplicateAttestationOffenses.length} duplicate attestation offenses`); + + // We should have at least one duplicate attestation offense, all from the malicious proposer address + // (they are the ones with attestToEquivocatedProposals enabled). + expect(duplicateAttestationOffenses.length).toBeGreaterThan(0); + for (const offense of duplicateAttestationOffenses) { + expect(offense.offenseType).toEqual(OffenseType.DUPLICATE_ATTESTATION); + expect(offense.validator.toString()).toEqual(maliciousAddress.toString()); + } + + // For each duplicate attestation offense, the attester for that slot is the malicious validator. + for (const offense of duplicateAttestationOffenses) { + const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); + const committeeInfo = await epochCache.getCommittee(offenseSlot); + test.logger.info(`Offense slot ${offenseSlot}: committee includes attester ${maliciousAddress.toString()}`); + expect(committeeInfo.committee?.map(addr => addr.toString())).toContain(maliciousAddress.toString()); + } + + test.logger.warn('Duplicate attestation offense correctly detected and recorded'); + }); +}); + +describe('multi-node/slashing/duplicate_proposal', () => { + let test: MultiNodeTestContext; + + beforeEach(async () => { + test = await MultiNodeTestContext.setup({ + ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, + ...baseSlashingOpts, + initialValidators: buildMockGossipValidators(NUM_VALIDATORS), + }); + }); + + afterEach(async () => { + await test.teardown(); + }); + + // Two malicious nodes share a validator key but have different coinbase addresses so their proposals + // differ. Honest nodes receive both proposals via mock gossip, detect the equivocation, and record a + // DUPLICATE_PROPOSAL offense. The test collects offenses from all nodes (equivocation may only be + // observed by whichever node processed both proposals before the slot closed) and asserts the offense + // is attributed to the shared key's address and that the address is the slot's proposer. + it('slashes validator who sends duplicate proposals', async () => { + const { nodes, epochCache, maliciousAddress } = await runEquivocationScenario(test, { + attestToEquivocatedProposals: false, + waitUntilOffenseCount: 1, + }); + + // Poll every node for DUPLICATE_PROPOSAL offenses, retrying briefly so any node that detected the + // duplicate after the initial offense was collected has time to flush it through the slasher's + // offenses-collector. Under proposer pipelining, checkpoint proposals are broadcast at the slot + // boundary while receivers' wall clocks may have advanced past the build slot — when that happens, + // honest nodes reject the gossip with "invalid slot number" before duplicate detection runs, so + // DUPLICATE_PROPOSAL is only observed by whichever node processed both proposals in time. + const proposalOffenses = await test.waitForOffenseOnNodes( + nodes, + o => o.offenseType === OffenseType.DUPLICATE_PROPOSAL, + { mode: 'any', timeout: AZTEC_SLOT_DURATION * 4 }, + ); + + test.logger.warn(`Collected duplicate proposal offenses`, { proposalOffenses }); + expect(proposalOffenses.length).toBeGreaterThan(0); + for (const offense of proposalOffenses) { + expect(offense.validator.toString()).toEqual(maliciousAddress.toString()); + } + + // Verify that for each offense, the proposer for that slot is the malicious validator. + for (const offense of proposalOffenses) { + const offenseSlot = SlotNumber(Number(offense.epochOrSlot)); + const proposerForSlot = await epochCache.getProposerAttesterAddressInSlot(offenseSlot); + test.logger.info(`Offense slot ${offenseSlot}: proposer is ${proposerForSlot?.toString()}`); + expect(proposerForSlot?.toString()).toEqual(maliciousAddress.toString()); + } + + test.logger.warn('Duplicate proposal offense correctly detected and recorded'); + }); +}); diff --git a/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash.test.ts index 8cd4b4ea0ba9..50399e3a8521 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash.test.ts @@ -1,4 +1,4 @@ -import { EthAddress } from '@aztec/aztec.js/addresses'; +import type { EthAddress } from '@aztec/aztec.js/addresses'; import { promiseWithResolvers } from '@aztec/foundation/promise'; import { jest } from '@jest/globals'; @@ -8,18 +8,14 @@ import { InactivityTest } from './inactivity_setup.js'; jest.setTimeout(1000 * 60 * 10); -const SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD = 1; - -// Verifies the basic inactivity slash path: one of 6 validators has its sequencer stopped; after -// slashInactivityConsecutiveEpochThreshold=1 epoch of inactivity the sentinel detects the offense and -// the validator is slashed on L1. Uses MultiNodeTestContext on the mock-gossip bus (6 nodes, fake -// prover, ethSlot varies by CI env, epoch=2, proofSubEpochs=1024, sentinelEnabled). +// Inactivity slashing on the shared `InactivityTest` fixture (mock-gossip bus, 6 nodes, fake prover, +// epoch=2, proofSubEpochs=1024, sentinelEnabled). describe('multi-node/slashing/inactivity_slash', () => { let test: InactivityTest; beforeAll(async () => { test = await InactivityTest.setup({ - slashInactivityConsecutiveEpochThreshold: SLASH_INACTIVITY_CONSECUTIVE_EPOCH_THRESHOLD, + slashInactivityConsecutiveEpochThreshold: 1, inactiveNodeCount: 1, }); }); @@ -28,8 +24,9 @@ describe('multi-node/slashing/inactivity_slash', () => { await test?.teardown(); }); - // Waits for a Slash event on the rollup contract and asserts it targets the offline validator with - // the expected slashing amount. Simple event-driven assertion; no polling inside the test body. + // Basic inactivity slash path: one of 6 validators has its sequencer stopped; after one epoch of + // inactivity (threshold=1) the sentinel detects the offense and the validator is slashed on L1. + // Simple event-driven assertion; no polling inside the test body. it('slashes inactive validator', async () => { const slashPromise = promiseWithResolvers<{ amount: bigint; attester: EthAddress }>(); test.rollup.listenToSlash(args => { diff --git a/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts index 6055411da53e..1d70fbd846a8 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts @@ -12,9 +12,8 @@ import { InactivityTest } from './inactivity_setup.js'; jest.setTimeout(1000 * 60 * 10); -// Verifies that consecutive-epoch threshold is respected: 2 validators are offline, but one is re-enabled -// after the first epoch. Only the permanently-offline validator should be slashed. Uses MultiNodeTestContext -// on the mock-gossip bus (6 nodes, fake prover, epoch=2, proofSubEpochs=1024, threshold=3 consecutive epochs). +// Inactivity slashing on the shared `InactivityTest` fixture (mock-gossip bus, 6 nodes, fake prover, +// epoch=2, proofSubEpochs=1024, sentinelEnabled). describe('multi-node/slashing/inactivity_slash_with_consecutive_epochs', () => { let test: InactivityTest; @@ -31,9 +30,9 @@ describe('multi-node/slashing/inactivity_slash_with_consecutive_epochs', () => { await test?.teardown(); }); - // Re-enables one of the two offline validators after the first epoch, then waits for INACTIVITY - // offenses to appear. Asserts that offenses are only emitted for the permanently-offline validator - // and that the re-enabled validator is never included in the slash. + // Consecutive-epoch threshold is respected: 2 validators start offline, but one is re-enabled after + // the first epoch. Asserts that offenses and slash events target only the permanently-offline + // validator (never the re-enabled one). it('only slashes validator inactive for N consecutive epochs', async () => { const [offlineValidator, reenabledValidator] = test.offlineValidators; diff --git a/yarn-project/end-to-end/src/multi-node/slashing/multiple_validators_sentinel.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/multiple_validators_sentinel.parallel.test.ts index 94d673e4750e..8bfb047e9d5a 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/multiple_validators_sentinel.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/multiple_validators_sentinel.parallel.test.ts @@ -12,18 +12,17 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; +import { SENTINEL_TIMING } from './setup.js'; const NUM_NODES = 2; const VALIDATORS_PER_NODE = 3; const NUM_VALIDATORS = NUM_NODES * VALIDATORS_PER_NODE; const SLOT_COUNT = 3; -const EPOCH_DURATION = 2; // The body advances through SLOT_COUNT real L2 slots at wall-clock pace, so the slot duration directly -// sets body time. At eth<8 the sequencer uses the fast (mocked-p2p) operational budgets, which fit a -// checkpoint comfortably in an 8s slot even with six co-hosted validators; larger durations only add +// sets body time. SENTINEL_TIMING's 8s slot (eth 4s) uses the fast (mocked-p2p) operational budgets, +// which fit a checkpoint comfortably even with six co-hosted validators; larger durations only add // dead wall-clock without exercising new behavior. -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = 8; +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.aztecSlotDuration; jest.setTimeout(1000 * 60 * 10); @@ -41,17 +40,12 @@ describe('multi-node/slashing/multiple_validators_sentinel', () => { beforeAll(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, + ...SENTINEL_TIMING, aztecTargetCommitteeSize: NUM_VALIDATORS, - aztecSlotDuration: AZTEC_SLOT_DURATION, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, blockDurationMs: 2000, aztecProofSubmissionEpochs: 1024, // effectively do not reorg - listenAddress: '127.0.0.1', minTxsPerBlock: 0, - aztecEpochDuration: EPOCH_DURATION, slashingRoundSizeInEpochs: 2, - sentinelEnabled: true, slashInactivityPenalty: 0n, // Set to 0 to disable inboxLag: 2, initialValidators: buildMockGossipValidators(NUM_VALIDATORS), diff --git a/yarn-project/end-to-end/src/multi-node/slashing/sentinel_status_slash.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/sentinel_status_slash.parallel.test.ts index 30dd83c1de96..47b64bf660f9 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/sentinel_status_slash.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/sentinel_status_slash.parallel.test.ts @@ -17,7 +17,7 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; -import { awaitCommitteeExists, findUpcomingProposerSlot } from './setup.js'; +import { SENTINEL_TIMING, awaitCommitteeExists, findUpcomingProposerSlot } from './setup.js'; /** * Exercises the sentinel's six-case proposer-status taxonomy end-to-end by driving each of the @@ -50,14 +50,11 @@ jest.setTimeout(TEST_TIMEOUT); const NUM_VALIDATORS = 6; const COMMITTEE_SIZE = NUM_VALIDATORS; // The body advances through real L2 slots at wall-clock pace, so the slot duration directly sets body -// time. At eth<8 the sequencer uses the fast (mocked-p2p) operational budgets, which fit a checkpoint -// comfortably in an 8s slot. proofSubEpochs=1024 disables reorg/proving deadlines and the only assertions +// time. SENTINEL_TIMING's 8s slot (eth 4s) uses the fast (mocked-p2p) operational budgets, which fit a +// checkpoint comfortably. proofSubEpochs=1024 disables reorg/proving deadlines and the only assertions // are sentinel attestation/status records (no proving-window timing), so the fast profile is safe here; // larger durations only add dead wall-clock without exercising new behavior. -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = ETHEREUM_SLOT_DURATION * 2; -const BLOCK_DURATION_MS = ETHEREUM_SLOT_DURATION * 500; -const AZTEC_EPOCH_DURATION = 2; +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.aztecSlotDuration; const SLASHING_UNIT = BigInt(1e18); const SLASHING_AMOUNT = SLASHING_UNIT * 3n; const SLASHING_QUORUM = 3; @@ -71,17 +68,12 @@ describe('multi-node/slashing/sentinel_status_slash', () => { beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - listenAddress: '127.0.0.1', + ...SENTINEL_TIMING, aztecTargetCommitteeSize: COMMITTEE_SIZE, - aztecSlotDuration: AZTEC_SLOT_DURATION, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - blockDurationMs: BLOCK_DURATION_MS, - aztecEpochDuration: AZTEC_EPOCH_DURATION, + blockDurationMs: 2000, aztecProofSubmissionEpochs: 1024, minTxsPerBlock: 0, inboxLag: 2, - sentinelEnabled: true, // A single proposer-fault slot in an epoch gives missed/total = 1/6 ≈ 0.167; threshold // 0.1 lets that single fault trip inactivity. slashInactivityTargetPercentage: 0.1, @@ -126,49 +118,27 @@ describe('multi-node/slashing/sentinel_status_slash', () => { await test.teardown(); }); - // Spawns one malicious node with broadcastInvalidBlockProposal:true; honest observers reject via - // re-execution state_mismatch and record `checkpoint-unvalidated` for that proposer slot. The sentinel - // then emits an INACTIVITY offense. Asserts all honest observers agree on the fault slot and status. - it('slashes the proposer with INACTIVITY when checkpoint validation records unvalidated', async () => { - // One malicious node broadcasts invalid block proposals; honest observers reject them via - // re-execution state_mismatch and therefore never push to their archivers, so the malicious - // node's checkpoint proposals can't find their last block and observers record `unvalidated`. - const targetAddress = await spawnMaliciousAndHonestNodes({ broadcastInvalidBlockProposal: true }); - // Warp near the malicious node's proposer slot to keep wall-clock down. We discover the slot at - // which the fault is actually recorded rather than assuming it is the warped block-proposer - // slot: the re-execution outcome is keyed by the checkpoint proposal's slot, and a proposer - // only emits a checkpoint proposal when its slot closes a checkpoint, which does not always - // coincide with the block-proposer slot we warp to. - await warpToSlotBeforeTargetProposer(targetAddress); - // nodes[0] is the malicious node; honest observers are nodes[1..]. - const honestObservers = nodes.slice(1); - const faultSlot = await findObservedStatusSlot(honestObservers, targetAddress, 'checkpoint-unvalidated'); - await assertAllObserversSentinelStatus(honestObservers, targetAddress, faultSlot, 'checkpoint-unvalidated'); - // The malicious node self-records `checkpoint-valid` for that slot using the locally computed - // archive (broadcastInvalidBlockProposal only corrupts the broadcast archive, not the - // proposer's local state). - await assertAllObserversSentinelStatus([nodes[0]], targetAddress, faultSlot, 'checkpoint-valid'); - await assertInactivityOffenseFor(targetAddress, nodes[1]); - }); + // Cases 1 and 2 share the same body (spawn 1 malicious + 5 honest, warp near the malicious proposer + // slot, assert observers agree on the fault status, malicious self-records `checkpoint-valid`, and an + // INACTIVITY offense follows). They differ only in the malicious-node config flag and the fault status + // observers record. Kept as unrolled its (no it.each) so each stays its own CI job under the parallel + // convention. See {@link runProposerFaultScenario}. - // Spawns one malicious node with broadcastInvalidCheckpointProposalOnly:true; block proposals are - // valid (land in archivers) but checkpoint proposals carry a random archive. Observers detect - // header_mismatch and record `checkpoint-invalid`. The sentinel emits INACTIVITY. Asserts all - // observers agree and the malicious node self-records `checkpoint-valid`. - it('slashes the proposer with INACTIVITY when checkpoint validation records invalid', async () => { - // One malicious node broadcasts invalid CHECKPOINT proposals while keeping the underlying - // block proposals valid; observers accept the blocks (so they land in the archiver) but - // reject the checkpoint via header_mismatch, recording `invalid`. - const targetAddress = await spawnMaliciousAndHonestNodes({ broadcastInvalidCheckpointProposalOnly: true }); - await warpToSlotBeforeTargetProposer(targetAddress); - const honestObservers = nodes.slice(1); - const faultSlot = await findObservedStatusSlot(honestObservers, targetAddress, 'checkpoint-invalid'); - await assertAllObserversSentinelStatus(honestObservers, targetAddress, faultSlot, 'checkpoint-invalid'); - // Malicious self-records `checkpoint-valid` for that slot — proposers always consider their - // own freshly-built proposal valid from their local-state perspective. - await assertAllObserversSentinelStatus([nodes[0]], targetAddress, faultSlot, 'checkpoint-valid'); - await assertInactivityOffenseFor(targetAddress, nodes[1]); - }); + // broadcastInvalidBlockProposal: observers reject the block via re-execution state_mismatch and never + // push it to their archivers, so the checkpoint proposal can't find its last block → `unvalidated`. + it('slashes the proposer with INACTIVITY when checkpoint validation records unvalidated', () => + runProposerFaultScenario({ + maliciousOverride: { broadcastInvalidBlockProposal: true }, + expectedStatus: 'checkpoint-unvalidated', + })); + + // broadcastInvalidCheckpointProposalOnly: block proposals stay valid (land in archivers) but the + // checkpoint proposal carries a random archive → observers detect header_mismatch and record `invalid`. + it('slashes the proposer with INACTIVITY when checkpoint validation records invalid', () => + runProposerFaultScenario({ + maliciousOverride: { broadcastInvalidCheckpointProposalOnly: true }, + expectedStatus: 'checkpoint-invalid', + })); // Starts 6 honest validators, waits for the committee, then stops the last validator. Asserts that // all remaining observers record `attestation-missed` for the stopped node and that an INACTIVITY @@ -193,6 +163,33 @@ describe('multi-node/slashing/sentinel_status_slash', () => { // -- helpers ------------------------------------------------------------------------------ + /** + * Runs a single-malicious-proposer fault scenario: spawns the malicious node (with the given config + * override) and 5 honest observers, warps near the malicious proposer's slot, and asserts every honest + * observer records `expectedStatus` at the discovered fault slot while the malicious node self-records + * `checkpoint-valid`, then that an INACTIVITY offense follows. We discover the fault slot rather than + * assuming it is the warped block-proposer slot: the re-execution outcome is keyed by the checkpoint + * proposal's slot, which only closes a checkpoint (and so records the fault) on some proposer slots. + */ + async function runProposerFaultScenario({ + maliciousOverride, + expectedStatus, + }: { + maliciousOverride: Partial; + expectedStatus: ValidatorStatusInSlot; + }): Promise { + const targetAddress = await spawnMaliciousAndHonestNodes(maliciousOverride); + await warpToSlotBeforeTargetProposer(targetAddress); + // nodes[0] is the malicious node; honest observers are nodes[1..]. + const honestObservers = nodes.slice(1); + const faultSlot = await findObservedStatusSlot(honestObservers, targetAddress, expectedStatus); + await assertAllObserversSentinelStatus(honestObservers, targetAddress, faultSlot, expectedStatus); + // The malicious node self-records `checkpoint-valid` for that slot using its locally computed + // archive (the invalid-broadcast flags corrupt only the broadcast, not the proposer's local state). + await assertAllObserversSentinelStatus([nodes[0]], targetAddress, faultSlot, 'checkpoint-valid'); + await assertInactivityOffenseFor(targetAddress, nodes[1]); + } + /** * Spawns 1 malicious node at index 0 with the given config override, then `NUM_VALIDATORS - 1` * honest nodes at indices 1..N-1. Returns the malicious node's validator address. diff --git a/yarn-project/end-to-end/src/multi-node/slashing/setup.ts b/yarn-project/end-to-end/src/multi-node/slashing/setup.ts index 46661b510961..00f2c9dc4522 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/setup.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/setup.ts @@ -8,7 +8,7 @@ import { unique } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { retryUntil } from '@aztec/foundation/retry'; import { pluralize } from '@aztec/foundation/string'; -import { getRoundForOffense } from '@aztec/slasher'; +import { type OffenseType, getRoundForOffense } from '@aztec/slasher'; import type { AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; import type { TxHash } from '@aztec/stdlib/tx'; @@ -60,6 +60,42 @@ export const baseSlashingOpts = { slashingOffsetInRounds: 1, }; +/** + * The fast slot timing shared by the sentinel-observation suites (`sentinel_status_slash`, + * `validators_sentinel`, `multiple_validators_sentinel`, `slash_veto_demo`). Slots are 8s (eth 4s × + * epoch 2), which fit a checkpoint comfortably at the fast mocked-p2p operational budgets used at + * eth<8, keeping wall-clock down for tests whose bodies advance through real L2 slots. Spread into a + * {@link MultiNodeTestContext.setup} call alongside {@link SLASHER_ENABLED_MULTI_VALIDATOR_OPTS}, + * `initialValidators`, and the per-test slash/penalty config. Non-sentinel offense-detection files + * that only need the same fast timing spread this and override `sentinelEnabled: false`. + */ +export const SENTINEL_TIMING = { + anvilSlotsInAnEpoch: 4, + listenAddress: '127.0.0.1', + ethereumSlotDuration: 4, + aztecSlotDuration: 8, + aztecEpochDuration: 2, + sentinelEnabled: true, +} as const; + +/** A single detected slash offense as returned by {@link AztecNodeService.getSlashOffenses}. */ +export type SlashOffense = Awaited>[number]; + +/** Looks up the offense recorded for `validator` of `offenseType` at `slot`, if any. */ +export function findSlashOffense( + offenses: SlashOffense[], + validator: EthAddress, + offenseType: OffenseType, + slot: SlotNumber, +): SlashOffense | undefined { + return offenses.find( + offense => + offense.validator.equals(validator) && + offense.offenseType === offenseType && + offense.epochOrSlot === BigInt(slot), + ); +} + export function awaitProposalExecution( slashingProposer: SlashingProposerContract, timeoutSeconds: number, diff --git a/yarn-project/end-to-end/src/multi-node/slashing/slash_veto_demo.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/slash_veto_demo.test.ts index 62cc053cdcbf..6ab8d440b5cf 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/slash_veto_demo.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/slash_veto_demo.test.ts @@ -21,16 +21,13 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; +import { SENTINEL_TIMING } from './setup.js'; const debugLogger = createLogger('e2e:multi-node:slash-veto-demo'); const VETOER_PRIVATE_KEY_INDEX = 18; // This should be after all keys used by validators const NUM_NODES = 3; const NUM_VALIDATORS = NUM_NODES + 1; // We create an extra validator, who will not have a running node -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = 8; -const BLOCK_DURATION_MS = 2000; -const EPOCH_DURATION = 2; // how many l2 slots make up a slashing round const SLASHING_ROUND_SIZE = 4; // how many block builders must signal for a single payload in a single round for it to be executable @@ -70,23 +67,18 @@ describe('veto slash', () => { beforeEach(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, - aztecSlotDuration: AZTEC_SLOT_DURATION, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - blockDurationMs: BLOCK_DURATION_MS, + ...SENTINEL_TIMING, + blockDurationMs: 2000, aztecProofSubmissionEpochs: 1024, // effectively do not reorg - listenAddress: '127.0.0.1', minTxsPerBlock: 0, inboxLag: 2, aztecTargetCommitteeSize: NUM_VALIDATORS, - aztecEpochDuration: EPOCH_DURATION, - sentinelEnabled: true, slashSelfAllowed: true, slashingOffsetInRounds: SLASH_OFFSET_IN_ROUNDS, slashAmountSmall: SLASHING_UNIT, slashAmountMedium: SLASHING_UNIT * 2n, slashAmountLarge: SLASHING_UNIT * 3n, - slashingRoundSizeInEpochs: SLASHING_ROUND_SIZE / EPOCH_DURATION, + slashingRoundSizeInEpochs: SLASHING_ROUND_SIZE / SENTINEL_TIMING.aztecEpochDuration, slashingQuorum: SLASHING_QUORUM, slashingLifetimeInRounds: LIFETIME_IN_ROUNDS, slashingExecutionDelayInRounds: EXECUTION_DELAY_IN_ROUNDS, @@ -152,12 +144,11 @@ describe('veto slash', () => { } // Waits for the inactive validator to accumulate inactivity offenses reaching quorum, then the vetoer - // calls vetoPayload on the Slasher contract. Asserts the payload either expires (lifetime exceeded) - // or a later round is executed, and that the inactive validator's GSE balance is unchanged. - // Currently parameterised as shouldVeto=true only (the non-veto branch is present but not exercised). - it.each([[true]] as const)( - 'vetoes %s a slashing payload', - async (shouldVeto: boolean) => { + // calls vetoPayload on the Slasher contract. Asserts the vetoed payload never executes — its lifetime + // expires or a later (non-vetoed) round executes instead. + it( + 'vetoes a slashing payload', + async () => { //#####################################// // // // Verify the initial slasher's vetoer // @@ -230,18 +221,15 @@ describe('veto slash', () => { // // //##############################// - if (shouldVeto) { - const slasherAddress = await rollup.getSlasherAddress(); - const { receipt } = await vetoerL1TxUtils.sendAndMonitorTransaction({ - to: slasherAddress.toString() as `0x${string}`, - data: encodeFunctionData({ - abi: SlasherAbi, - functionName: 'vetoPayload', - args: [submittableRound.payload], - }), - }); - debugLogger.info(`\n\nvetoPayload tx receipt: ${receipt.status}\n\n`); - } + const { receipt } = await vetoerL1TxUtils.sendAndMonitorTransaction({ + to: slasherAddress.toString() as `0x${string}`, + data: encodeFunctionData({ + abi: SlasherAbi, + functionName: 'vetoPayload', + args: [submittableRound.payload], + }), + }); + debugLogger.info(`\n\nvetoPayload tx receipt: ${receipt.status}\n\n`); //###################################// // // @@ -266,18 +254,12 @@ describe('veto slash', () => { }); const payloadExecutedOrExpired = await Promise.race([awaitPayloadSubmitted.promise, awaitPayloadExpiredPromise]); - const badAttesterFinalBalance = await gse.read.effectiveBalanceOf([rollup.address, attester.address]); - if (shouldVeto) { - // If we vetoed, then either the payload expired, or another more recent payload was executed - if (typeof payloadExecutedOrExpired === 'boolean') { - expect(payloadExecutedOrExpired).toBe(true); - } else { - expect(payloadExecutedOrExpired.round).toBeGreaterThan(submittableRound.round); - } + // The vetoed payload must never execute: either its lifetime expired, or a later (non-vetoed) + // round executed instead. + if (typeof payloadExecutedOrExpired === 'boolean') { + expect(payloadExecutedOrExpired).toBe(true); } else { - // If we didn't veto, the attester should have their balance decreased by the slashing amount. - expect((payloadExecutedOrExpired as { round: bigint }).round).toBe(submittableRound.round); - expect(badAttesterFinalBalance).toBe(badAttesterInitialBalance - slashingAmount); + expect(payloadExecutedOrExpired.round).toBeGreaterThan(submittableRound.round); } }, 1000 * 60 * 10, diff --git a/yarn-project/end-to-end/src/multi-node/slashing/validators_sentinel.parallel.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/validators_sentinel.parallel.test.ts index a6e0732c5bc5..09c9a6b1870e 100644 --- a/yarn-project/end-to-end/src/multi-node/slashing/validators_sentinel.parallel.test.ts +++ b/yarn-project/end-to-end/src/multi-node/slashing/validators_sentinel.parallel.test.ts @@ -12,14 +12,12 @@ import { SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, buildMockGossipValidators, } from '../multi_node_test_context.js'; +import { SENTINEL_TIMING } from './setup.js'; const NUM_NODES = 5; const NUM_VALIDATORS = NUM_NODES + 1; // We create an extra validator, who will not have a running node const BLOCK_COUNT = 3; -const EPOCH_DURATION = 2; -const ETHEREUM_SLOT_DURATION = 4; -const AZTEC_SLOT_DURATION = 8; -const BLOCK_DURATION_MS = 2000; +const AZTEC_SLOT_DURATION = SENTINEL_TIMING.aztecSlotDuration; jest.setTimeout(1000 * 60 * 10); @@ -37,17 +35,12 @@ describe('multi-node/slashing/validators_sentinel', () => { beforeAll(async () => { test = await MultiNodeTestContext.setup({ ...SLASHER_ENABLED_MULTI_VALIDATOR_OPTS, - anvilSlotsInAnEpoch: 4, + ...SENTINEL_TIMING, aztecTargetCommitteeSize: NUM_VALIDATORS, - aztecSlotDuration: AZTEC_SLOT_DURATION, - ethereumSlotDuration: ETHEREUM_SLOT_DURATION, - blockDurationMs: BLOCK_DURATION_MS, + blockDurationMs: 2000, aztecProofSubmissionEpochs: 1024, // effectively do not reorg - listenAddress: '127.0.0.1', minTxsPerBlock: 0, - aztecEpochDuration: EPOCH_DURATION, slashingRoundSizeInEpochs: 2, - sentinelEnabled: true, slashInactivityPenalty: 0n, // Set to 0 to disable inboxLag: 2, initialValidators: buildMockGossipValidators(NUM_VALIDATORS),