From 20a9f7096fde33d0ac6868930891430338ecf39b Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 3 Jul 2026 04:18:05 -0300 Subject: [PATCH 1/2] test(e2e): unify slashing timing profiles and merge duplicate suites Round-2 consolidation of multi-node/slashing: - Add a SENTINEL_TIMING profile to setup.ts and route every test file's slot timing through it or baseSlashingOpts, removing the per-file redeclared ETHEREUM_SLOT_DURATION/AZTEC_SLOT_DURATION/AZTEC_EPOCH_DURATION constants. - Merge duplicate_attestation + duplicate_proposal into equivocation_offenses with a shared scenario runner and two describes. - Fold inactivity_slash_with_consecutive_epochs into inactivity_slash (2 describes). - Centralize the findSlashOffense offense lookup (and SlashOffense type) in setup.ts. - Remove the dead shouldVeto:false branch in slash_veto_demo. - Hoist sentinel_status_slash its 1-2 onto a shared runProposerFaultScenario helper. --- .test_patterns.yml | 2 +- yarn-project/end-to-end/bootstrap.sh | 5 + .../end-to-end/src/multi-node/README.md | 2 +- ...attested_invalid_proposal.parallel.test.ts | 23 +- ...asted_invalid_block_proposal_slash.test.ts | 18 +- ...checkpoint_proposal_slash.parallel.test.ts | 70 +++-- .../slashing/data_withholding_slash.test.ts | 23 +- .../slashing/duplicate_attestation.test.ts | 197 -------------- .../slashing/duplicate_proposal.test.ts | 178 ------------- .../slashing/equivocation_offenses.test.ts | 247 ++++++++++++++++++ .../slashing/inactivity_slash.test.ts | 104 +++++++- ...vity_slash_with_consecutive_epochs.test.ts | 96 ------- ...tiple_validators_sentinel.parallel.test.ts | 16 +- .../sentinel_status_slash.parallel.test.ts | 109 ++++---- .../src/multi-node/slashing/setup.ts | 38 ++- .../slashing/slash_veto_demo.test.ts | 64 ++--- .../validators_sentinel.parallel.test.ts | 15 +- 17 files changed, 528 insertions(+), 679 deletions(-) delete mode 100644 yarn-project/end-to-end/src/multi-node/slashing/duplicate_attestation.test.ts delete mode 100644 yarn-project/end-to-end/src/multi-node/slashing/duplicate_proposal.test.ts create mode 100644 yarn-project/end-to-end/src/multi-node/slashing/equivocation_offenses.test.ts delete mode 100644 yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts 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/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index 428646156342..79d496fa2954 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -85,6 +85,11 @@ function test_cmds { # The long-proving-time scenario waits out a multi-epoch prover delay. test_prefix="$prefix:TIMEOUT=15m" ;; + multi-node/slashing/inactivity_slash) + # Two multi-epoch inactivity scenarios (single- and consecutive-epoch) share one container, + # each waiting out several epochs for a slash to execute; give the pair headroom over the default. + test_prefix="$prefix:TIMEOUT=25m" + ;; esac # Check if this is a .parallel.test.ts file 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..18b55c2e4187 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 (both the single-epoch and consecutive-epoch cases in `inactivity_slash`, 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..2f8aed2f8b13 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,5 +1,10 @@ -import { EthAddress } from '@aztec/aztec.js/addresses'; +import type { EthAddress } from '@aztec/aztec.js/addresses'; +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { unique } from '@aztec/foundation/collection'; import { promiseWithResolvers } from '@aztec/foundation/promise'; +import { retryUntil } from '@aztec/foundation/retry'; +import { sleep } from '@aztec/foundation/sleep'; +import { OffenseType } from '@aztec/slasher'; import { jest } from '@jest/globals'; import 'jest-extended'; @@ -8,18 +13,16 @@ 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). Each suite spins up its own fixture instance because +// the fixture is stateful (it starts nodes and advances epochs during setup), so the two scenarios +// cannot share one beforeAll. 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 +31,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 => { @@ -41,3 +45,83 @@ describe('multi-node/slashing/inactivity_slash', () => { expect(amount).toEqual(test.slashingAmount); }); }); + +describe('multi-node/slashing/inactivity_slash_with_consecutive_epochs', () => { + let test: InactivityTest; + + const slashInactivityConsecutiveEpochThreshold = 3; + + beforeAll(async () => { + test = await InactivityTest.setup({ + slashInactivityConsecutiveEpochThreshold, + inactiveNodeCount: 2, + }); + }); + + afterAll(async () => { + await test?.teardown(); + }); + + // 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; + + const { + aztecEpochDuration, + slashingExecutionDelayInRounds, + slashingOffsetInRounds, + slashingRoundSizeInEpochs, + aztecSlotDuration, + } = test.config; + + const initialEpoch = Number(test.monitor.l2EpochNumber) + 1; + test.logger.warn(`Waiting until end of epoch ${initialEpoch} to reenable validator ${reenabledValidator}`); + await test.monitor.waitUntilL2Slot(SlotNumber(initialEpoch * aztecEpochDuration)); + + test.logger.warn(`Re-enabling offline validator ${reenabledValidator}`); + const reenabledNode = test.nodes.at(-1)!; + expect(reenabledNode.getSequencer()!.validatorAddresses![0].toString()).toEqual(reenabledValidator.toString()); + await reenabledNode.getSequencer()!.start(); + + test.logger.warn(`Waiting until offenses are created for ${offlineValidator}`); + const offenses = await retryUntil( + async () => { + const offenses = await test.activeNodes[0].getSlashOffenses('all'); + return offenses.length > 0 ? offenses : undefined; + }, + 'slash offenses', + slashInactivityConsecutiveEpochThreshold * aztecEpochDuration * aztecSlotDuration * 4, + ); + expect(unique(offenses.map(o => o.validator.toString()))).toEqual([offlineValidator.toString()]); + expect(unique(offenses.map(o => o.offenseType))).toEqual([OffenseType.INACTIVITY]); + + test.logger.warn(`Expecting offline validator ${offlineValidator} to be slashed but not ${reenabledValidator}`); + const slashed: EthAddress[] = []; + test.rollup.listenToSlash(args => { + test.logger.warn(`Slashed ${args.attester.toString()}`); + slashed.push(args.attester); + }); + + // Wait until after the slashing would have executed for inactivity + // Note that this may take some time if the offline validator is elected as proposer enough times + // that we never get to collect enough votes in a round to trigger the slash + const attemptsInRounds = 3; + const delayInEpochs = + attemptsInRounds * slashingRoundSizeInEpochs + // How many rounds to wait until the slash is voted + (slashingExecutionDelayInRounds + slashingOffsetInRounds) * slashingRoundSizeInEpochs + // Wait for execution delay + 4; // A bit extra + + const timeout = delayInEpochs * aztecEpochDuration * aztecSlotDuration; + test.logger.warn(`Waiting ${timeout}s (${delayInEpochs} epochs) until for slash`); + await retryUntil( + () => slashed.length > 0, + 'slash executed', + delayInEpochs * aztecEpochDuration * aztecSlotDuration * 2, + ); + + await sleep(1000); // Wait a bit to ensure no more slashes are recorded + expect(unique(slashed.map(addr => addr.toString()))).toEqual([offlineValidator.toString()]); + }); +}); 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 deleted file mode 100644 index 6055411da53e..000000000000 --- a/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { EthAddress } from '@aztec/aztec.js/addresses'; -import { SlotNumber } from '@aztec/foundation/branded-types'; -import { unique } from '@aztec/foundation/collection'; -import { retryUntil } from '@aztec/foundation/retry'; -import { sleep } from '@aztec/foundation/sleep'; -import { OffenseType } from '@aztec/slasher'; - -import { jest } from '@jest/globals'; -import 'jest-extended'; - -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). -describe('multi-node/slashing/inactivity_slash_with_consecutive_epochs', () => { - let test: InactivityTest; - - const slashInactivityConsecutiveEpochThreshold = 3; - - beforeAll(async () => { - test = await InactivityTest.setup({ - slashInactivityConsecutiveEpochThreshold, - inactiveNodeCount: 2, - }); - }); - - afterAll(async () => { - 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. - it('only slashes validator inactive for N consecutive epochs', async () => { - const [offlineValidator, reenabledValidator] = test.offlineValidators; - - const { - aztecEpochDuration, - slashingExecutionDelayInRounds, - slashingOffsetInRounds, - slashingRoundSizeInEpochs, - aztecSlotDuration, - } = test.config; - - const initialEpoch = Number(test.monitor.l2EpochNumber) + 1; - test.logger.warn(`Waiting until end of epoch ${initialEpoch} to reenable validator ${reenabledValidator}`); - await test.monitor.waitUntilL2Slot(SlotNumber(initialEpoch * aztecEpochDuration)); - - test.logger.warn(`Re-enabling offline validator ${reenabledValidator}`); - const reenabledNode = test.nodes.at(-1)!; - expect(reenabledNode.getSequencer()!.validatorAddresses![0].toString()).toEqual(reenabledValidator.toString()); - await reenabledNode.getSequencer()!.start(); - - test.logger.warn(`Waiting until offenses are created for ${offlineValidator}`); - const offenses = await retryUntil( - async () => { - const offenses = await test.activeNodes[0].getSlashOffenses('all'); - return offenses.length > 0 ? offenses : undefined; - }, - 'slash offenses', - slashInactivityConsecutiveEpochThreshold * aztecEpochDuration * aztecSlotDuration * 4, - ); - expect(unique(offenses.map(o => o.validator.toString()))).toEqual([offlineValidator.toString()]); - expect(unique(offenses.map(o => o.offenseType))).toEqual([OffenseType.INACTIVITY]); - - test.logger.warn(`Expecting offline validator ${offlineValidator} to be slashed but not ${reenabledValidator}`); - const slashed: EthAddress[] = []; - test.rollup.listenToSlash(args => { - test.logger.warn(`Slashed ${args.attester.toString()}`); - slashed.push(args.attester); - }); - - // Wait until after the slashing would have executed for inactivity - // Note that this may take some time if the offline validator is elected as proposer enough times - // that we never get to collect enough votes in a round to trigger the slash - const attemptsInRounds = 3; - const delayInEpochs = - attemptsInRounds * slashingRoundSizeInEpochs + // How many rounds to wait until the slash is voted - (slashingExecutionDelayInRounds + slashingOffsetInRounds) * slashingRoundSizeInEpochs + // Wait for execution delay - 4; // A bit extra - - const timeout = delayInEpochs * aztecEpochDuration * aztecSlotDuration; - test.logger.warn(`Waiting ${timeout}s (${delayInEpochs} epochs) until for slash`); - await retryUntil( - () => slashed.length > 0, - 'slash executed', - delayInEpochs * aztecEpochDuration * aztecSlotDuration * 2, - ); - - await sleep(1000); // Wait a bit to ensure no more slashes are recorded - expect(unique(slashed.map(addr => addr.toString()))).toEqual([offlineValidator.toString()]); - }); -}); 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), From ec30acac68e0f058086a689c18d411df449e2303 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 3 Jul 2026 18:37:31 -0300 Subject: [PATCH 2/2] test: keep inactivity_slash_with_consecutive_epochs as its own suite The two inactivity describes don't share a beforeAll (the InactivityTest fixture is stateful per-instance), so merging them into one file only made it longer without gaining shared setup. Split back into separate files and revert the bootstrap.sh timeout bump that only applied to the merged file. --- yarn-project/end-to-end/bootstrap.sh | 5 - .../end-to-end/src/multi-node/README.md | 2 +- .../slashing/inactivity_slash.test.ts | 89 +---------------- ...vity_slash_with_consecutive_epochs.test.ts | 95 +++++++++++++++++++ 4 files changed, 97 insertions(+), 94 deletions(-) create mode 100644 yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index 79d496fa2954..428646156342 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -85,11 +85,6 @@ function test_cmds { # The long-proving-time scenario waits out a multi-epoch prover delay. test_prefix="$prefix:TIMEOUT=15m" ;; - multi-node/slashing/inactivity_slash) - # Two multi-epoch inactivity scenarios (single- and consecutive-epoch) share one container, - # each waiting out several epochs for a slash to execute; give the pair headroom over the default. - test_prefix="$prefix:TIMEOUT=25m" - ;; esac # Check if this is a .parallel.test.ts file 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 18b55c2e4187..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 — 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 (both the single-epoch and consecutive-epoch cases in `inactivity_slash`, 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`). | +| `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/inactivity_slash.test.ts b/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash.test.ts index 2f8aed2f8b13..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,10 +1,5 @@ import type { EthAddress } from '@aztec/aztec.js/addresses'; -import { SlotNumber } from '@aztec/foundation/branded-types'; -import { unique } from '@aztec/foundation/collection'; import { promiseWithResolvers } from '@aztec/foundation/promise'; -import { retryUntil } from '@aztec/foundation/retry'; -import { sleep } from '@aztec/foundation/sleep'; -import { OffenseType } from '@aztec/slasher'; import { jest } from '@jest/globals'; import 'jest-extended'; @@ -14,9 +9,7 @@ import { InactivityTest } from './inactivity_setup.js'; jest.setTimeout(1000 * 60 * 10); // Inactivity slashing on the shared `InactivityTest` fixture (mock-gossip bus, 6 nodes, fake prover, -// epoch=2, proofSubEpochs=1024, sentinelEnabled). Each suite spins up its own fixture instance because -// the fixture is stateful (it starts nodes and advances epochs during setup), so the two scenarios -// cannot share one beforeAll. +// epoch=2, proofSubEpochs=1024, sentinelEnabled). describe('multi-node/slashing/inactivity_slash', () => { let test: InactivityTest; @@ -45,83 +38,3 @@ describe('multi-node/slashing/inactivity_slash', () => { expect(amount).toEqual(test.slashingAmount); }); }); - -describe('multi-node/slashing/inactivity_slash_with_consecutive_epochs', () => { - let test: InactivityTest; - - const slashInactivityConsecutiveEpochThreshold = 3; - - beforeAll(async () => { - test = await InactivityTest.setup({ - slashInactivityConsecutiveEpochThreshold, - inactiveNodeCount: 2, - }); - }); - - afterAll(async () => { - await test?.teardown(); - }); - - // 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; - - const { - aztecEpochDuration, - slashingExecutionDelayInRounds, - slashingOffsetInRounds, - slashingRoundSizeInEpochs, - aztecSlotDuration, - } = test.config; - - const initialEpoch = Number(test.monitor.l2EpochNumber) + 1; - test.logger.warn(`Waiting until end of epoch ${initialEpoch} to reenable validator ${reenabledValidator}`); - await test.monitor.waitUntilL2Slot(SlotNumber(initialEpoch * aztecEpochDuration)); - - test.logger.warn(`Re-enabling offline validator ${reenabledValidator}`); - const reenabledNode = test.nodes.at(-1)!; - expect(reenabledNode.getSequencer()!.validatorAddresses![0].toString()).toEqual(reenabledValidator.toString()); - await reenabledNode.getSequencer()!.start(); - - test.logger.warn(`Waiting until offenses are created for ${offlineValidator}`); - const offenses = await retryUntil( - async () => { - const offenses = await test.activeNodes[0].getSlashOffenses('all'); - return offenses.length > 0 ? offenses : undefined; - }, - 'slash offenses', - slashInactivityConsecutiveEpochThreshold * aztecEpochDuration * aztecSlotDuration * 4, - ); - expect(unique(offenses.map(o => o.validator.toString()))).toEqual([offlineValidator.toString()]); - expect(unique(offenses.map(o => o.offenseType))).toEqual([OffenseType.INACTIVITY]); - - test.logger.warn(`Expecting offline validator ${offlineValidator} to be slashed but not ${reenabledValidator}`); - const slashed: EthAddress[] = []; - test.rollup.listenToSlash(args => { - test.logger.warn(`Slashed ${args.attester.toString()}`); - slashed.push(args.attester); - }); - - // Wait until after the slashing would have executed for inactivity - // Note that this may take some time if the offline validator is elected as proposer enough times - // that we never get to collect enough votes in a round to trigger the slash - const attemptsInRounds = 3; - const delayInEpochs = - attemptsInRounds * slashingRoundSizeInEpochs + // How many rounds to wait until the slash is voted - (slashingExecutionDelayInRounds + slashingOffsetInRounds) * slashingRoundSizeInEpochs + // Wait for execution delay - 4; // A bit extra - - const timeout = delayInEpochs * aztecEpochDuration * aztecSlotDuration; - test.logger.warn(`Waiting ${timeout}s (${delayInEpochs} epochs) until for slash`); - await retryUntil( - () => slashed.length > 0, - 'slash executed', - delayInEpochs * aztecEpochDuration * aztecSlotDuration * 2, - ); - - await sleep(1000); // Wait a bit to ensure no more slashes are recorded - expect(unique(slashed.map(addr => addr.toString()))).toEqual([offlineValidator.toString()]); - }); -}); 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 new file mode 100644 index 000000000000..1d70fbd846a8 --- /dev/null +++ b/yarn-project/end-to-end/src/multi-node/slashing/inactivity_slash_with_consecutive_epochs.test.ts @@ -0,0 +1,95 @@ +import type { EthAddress } from '@aztec/aztec.js/addresses'; +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { unique } from '@aztec/foundation/collection'; +import { retryUntil } from '@aztec/foundation/retry'; +import { sleep } from '@aztec/foundation/sleep'; +import { OffenseType } from '@aztec/slasher'; + +import { jest } from '@jest/globals'; +import 'jest-extended'; + +import { InactivityTest } from './inactivity_setup.js'; + +jest.setTimeout(1000 * 60 * 10); + +// 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; + + const slashInactivityConsecutiveEpochThreshold = 3; + + beforeAll(async () => { + test = await InactivityTest.setup({ + slashInactivityConsecutiveEpochThreshold, + inactiveNodeCount: 2, + }); + }); + + afterAll(async () => { + await test?.teardown(); + }); + + // 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; + + const { + aztecEpochDuration, + slashingExecutionDelayInRounds, + slashingOffsetInRounds, + slashingRoundSizeInEpochs, + aztecSlotDuration, + } = test.config; + + const initialEpoch = Number(test.monitor.l2EpochNumber) + 1; + test.logger.warn(`Waiting until end of epoch ${initialEpoch} to reenable validator ${reenabledValidator}`); + await test.monitor.waitUntilL2Slot(SlotNumber(initialEpoch * aztecEpochDuration)); + + test.logger.warn(`Re-enabling offline validator ${reenabledValidator}`); + const reenabledNode = test.nodes.at(-1)!; + expect(reenabledNode.getSequencer()!.validatorAddresses![0].toString()).toEqual(reenabledValidator.toString()); + await reenabledNode.getSequencer()!.start(); + + test.logger.warn(`Waiting until offenses are created for ${offlineValidator}`); + const offenses = await retryUntil( + async () => { + const offenses = await test.activeNodes[0].getSlashOffenses('all'); + return offenses.length > 0 ? offenses : undefined; + }, + 'slash offenses', + slashInactivityConsecutiveEpochThreshold * aztecEpochDuration * aztecSlotDuration * 4, + ); + expect(unique(offenses.map(o => o.validator.toString()))).toEqual([offlineValidator.toString()]); + expect(unique(offenses.map(o => o.offenseType))).toEqual([OffenseType.INACTIVITY]); + + test.logger.warn(`Expecting offline validator ${offlineValidator} to be slashed but not ${reenabledValidator}`); + const slashed: EthAddress[] = []; + test.rollup.listenToSlash(args => { + test.logger.warn(`Slashed ${args.attester.toString()}`); + slashed.push(args.attester); + }); + + // Wait until after the slashing would have executed for inactivity + // Note that this may take some time if the offline validator is elected as proposer enough times + // that we never get to collect enough votes in a round to trigger the slash + const attemptsInRounds = 3; + const delayInEpochs = + attemptsInRounds * slashingRoundSizeInEpochs + // How many rounds to wait until the slash is voted + (slashingExecutionDelayInRounds + slashingOffsetInRounds) * slashingRoundSizeInEpochs + // Wait for execution delay + 4; // A bit extra + + const timeout = delayInEpochs * aztecEpochDuration * aztecSlotDuration; + test.logger.warn(`Waiting ${timeout}s (${delayInEpochs} epochs) until for slash`); + await retryUntil( + () => slashed.length > 0, + 'slash executed', + delayInEpochs * aztecEpochDuration * aztecSlotDuration * 2, + ); + + await sleep(1000); // Wait a bit to ensure no more slashes are recorded + expect(unique(slashed.map(addr => addr.toString()))).toEqual([offlineValidator.toString()]); + }); +});