diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 629a7970c19e..097b196940ab 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -108,7 +108,7 @@ import { getTelemetryClient, trackSpan, } from '@aztec/telemetry-client'; -import { ValidatorClient, createValidatorClient } from '@aztec/validator-client'; +import { NodeKeystoreAdapter, ValidatorClient, createValidatorClient } from '@aztec/validator-client'; import { createWorldStateSynchronizer } from '@aztec/world-state'; import { createPublicClient, fallback, http } from 'viem'; @@ -373,6 +373,10 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { if (!config.disableValidator) { // We create a slasher only if we have a sequencer, since all slashing actions go through the sequencer publisher // as they are executed when the node is selected as proposer. + const validatorAddresses = keyStoreManager + ? NodeKeystoreAdapter.fromKeyStoreManager(keyStoreManager).getAddresses() + : []; + slasherClient = await createSlasher( config, config.l1Contracts, @@ -380,6 +384,8 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, Traceable { watchers, dateProvider, epochCache, + validatorAddresses, + undefined, // logger ); await slasherClient.start(); diff --git a/yarn-project/aztec-node/src/sentinel/sentinel.test.ts b/yarn-project/aztec-node/src/sentinel/sentinel.test.ts index fcc99b9a0da8..c610f43ee223 100644 --- a/yarn-project/aztec-node/src/sentinel/sentinel.test.ts +++ b/yarn-project/aztec-node/src/sentinel/sentinel.test.ts @@ -45,13 +45,9 @@ describe('sentinel', () => { let epoch: bigint; let ts: bigint; let l1Constants: L1RollupConstants; - const config: Pick< - SlasherConfig, - 'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashPayloadTtlSeconds' - > = { + const config: Pick = { slashInactivityPenalty: 100n, slashInactivityTargetPercentage: 0.8, - slashPayloadTtlSeconds: 60 * 60, }; beforeEach(async () => { @@ -466,10 +462,7 @@ class TestSentinel extends Sentinel { archiver: L2BlockSource, p2p: P2PClient, store: SentinelStore, - config: Pick< - SlasherConfig, - 'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashPayloadTtlSeconds' - >, + config: Pick, protected override blockStream: L2BlockStream, ) { super(epochCache, archiver, p2p, store, config); diff --git a/yarn-project/aztec-node/src/sentinel/sentinel.ts b/yarn-project/aztec-node/src/sentinel/sentinel.ts index c6a1d0ec868d..d90d56b282d2 100644 --- a/yarn-project/aztec-node/src/sentinel/sentinel.ts +++ b/yarn-project/aztec-node/src/sentinel/sentinel.ts @@ -44,10 +44,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme protected archiver: L2BlockSource, protected p2p: P2PClient, protected store: SentinelStore, - protected config: Pick< - SlasherConfig, - 'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashPayloadTtlSeconds' - >, + protected config: Pick, protected logger = createLogger('node:sentinel'), ) { super(); diff --git a/yarn-project/aztec/src/cli/chain_l2_config.ts b/yarn-project/aztec/src/cli/chain_l2_config.ts index 858f2bf892a9..bddba40c9725 100644 --- a/yarn-project/aztec/src/cli/chain_l2_config.ts +++ b/yarn-project/aztec/src/cli/chain_l2_config.ts @@ -69,7 +69,6 @@ export const testnetIgnitionL2ChainConfig: L2ChainConfig = { provingCostPerMana: 0n, slasherFlavor: 'none', - slashPayloadTtlSeconds: 0, slashAmountSmall: 0n, slashAmountMedium: 0n, slashAmountLarge: 0n, @@ -153,7 +152,6 @@ export const alphaTestnetL2ChainConfig: L2ChainConfig = { slashAmountLarge: DefaultL1ContractsConfig.slashAmountLarge, // Slashing stuff - slashPayloadTtlSeconds: 36 * 32 * 6 * 6, // 6 rounds (a bit longer than lifetime) slashMinPenaltyPercentage: 0.5, slashMaxPenaltyPercentage: 2.0, slashInactivityTargetPercentage: 0.7, @@ -325,7 +323,6 @@ export async function enrichEnvironmentWithChainConfig(networkName: NetworkNames enrichEthAddressVar('AZTEC_SLASHING_VETOER', config.slashingVetoer.toString()); // Slashing - enrichVar('SLASH_PAYLOAD_TTL_SECONDS', config.slashPayloadTtlSeconds.toString()); enrichVar('SLASH_MIN_PENALTY_PERCENTAGE', config.slashMinPenaltyPercentage.toString()); enrichVar('SLASH_MAX_PENALTY_PERCENTAGE', config.slashMaxPenaltyPercentage.toString()); enrichVar('SLASH_PRUNE_PENALTY', config.slashPrunePenalty.toString()); diff --git a/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts index fddf0165fc9a..e6e521a452ba 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts @@ -62,6 +62,7 @@ describe('e2e_p2p_data_withholding_slash', () => { slashAmountSmall: slashingUnit, slashAmountMedium: slashingUnit * 2n, slashAmountLarge: slashingUnit * 3n, + slashSelfAllowed: true, minTxsPerBlock: 0, }, }); diff --git a/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts b/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts index 6819391f33ba..cc18ccb73fa3 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts @@ -83,6 +83,7 @@ describe('veto slash', () => { aztecEpochDuration: EPOCH_DURATION, validatorReexecute: false, sentinelEnabled: true, + slashSelfAllowed: true, slashingOffsetInRounds: SLASH_OFFSET_IN_ROUNDS, slashAmountSmall: SLASHING_UNIT, slashAmountMedium: SLASHING_UNIT * 2n, diff --git a/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned.test.ts b/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned.test.ts index 9b13ab8de50c..0b9e2cfbe6d1 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned.test.ts @@ -49,6 +49,7 @@ describe('e2e_p2p_valid_epoch_pruned', () => { aztecProofSubmissionEpochs: 0, // reorg as soon as epoch ends slashingQuorum, slashingRoundSize, + slashSelfAllowed: true, slashAmountSmall: slashingUnit, slashAmountMedium: slashingUnit * 2n, slashAmountLarge: slashingUnit * 3n, diff --git a/yarn-project/ethereum/src/contracts/index.ts b/yarn-project/ethereum/src/contracts/index.ts index c8d54af49ee6..d340cd36166e 100644 --- a/yarn-project/ethereum/src/contracts/index.ts +++ b/yarn-project/ethereum/src/contracts/index.ts @@ -11,3 +11,4 @@ export * from './registry.js'; export * from './rollup.js'; export * from './empire_slashing_proposer.js'; export * from './tally_slashing_proposer.js'; +export * from './slasher_contract.js'; diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index 7557810ac7f3..3c190cfd6b6c 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -27,6 +27,7 @@ import type { ViemClient } from '../types.js'; import { formatViemError } from '../utils.js'; import { EmpireSlashingProposerContract } from './empire_slashing_proposer.js'; import { GSEContract } from './gse.js'; +import { SlasherContract } from './slasher_contract.js'; import { TallySlashingProposerContract } from './tally_slashing_proposer.js'; import { checkBlockTag } from './utils.js'; @@ -267,6 +268,14 @@ export class RollupContract { return this.rollup.read.getSlasher(); } + /** + * Returns a SlasherContract instance for interacting with the slasher contract. + */ + async getSlasherContract(): Promise { + const slasherAddress = await this.getSlasher(); + return new SlasherContract(this.client, EthAddress.fromString(slasherAddress)); + } + getOwner() { return this.rollup.read.owner(); } diff --git a/yarn-project/ethereum/src/contracts/slasher_contract.ts b/yarn-project/ethereum/src/contracts/slasher_contract.ts new file mode 100644 index 000000000000..ff93b8f1da61 --- /dev/null +++ b/yarn-project/ethereum/src/contracts/slasher_contract.ts @@ -0,0 +1,67 @@ +import { EthAddress } from '@aztec/foundation/eth-address'; +import { createLogger } from '@aztec/foundation/log'; +import { SlasherAbi } from '@aztec/l1-artifacts/SlasherAbi'; + +import { type GetContractReturnType, getContract } from 'viem'; + +import type { ViemClient } from '../types.js'; + +/** + * Typescript wrapper around the Slasher contract. + */ +export class SlasherContract { + private contract: GetContractReturnType; + + constructor( + private readonly client: ViemClient, + private readonly address: EthAddress, + private readonly log = createLogger('slasher-contract'), + ) { + this.contract = getContract({ + address: this.address.toString(), + abi: SlasherAbi, + client: this.client, + }); + } + + /** + * Checks if a slash payload is vetoed. + * @param payloadAddress - The address of the payload to check + * @returns True if the payload is vetoed, false otherwise + */ + public async isPayloadVetoed(payloadAddress: EthAddress): Promise { + try { + return await this.contract.read.vetoedPayloads([payloadAddress.toString()]); + } catch (error) { + this.log.error(`Error checking if payload ${payloadAddress} is vetoed`, error); + throw error; + } + } + + /** + * Gets the current vetoer address. + * @returns The vetoer address + */ + public async getVetoer(): Promise { + const vetoer = await this.contract.read.VETOER(); + return EthAddress.fromString(vetoer); + } + + /** + * Gets the current governance address. + * @returns The governance address + */ + public async getGovernance(): Promise { + const governance = await this.contract.read.GOVERNANCE(); + return EthAddress.fromString(governance); + } + + /** + * Gets the current proposer address. + * @returns The proposer address + */ + public async getProposer(): Promise { + const proposer = await this.contract.read.PROPOSER(); + return EthAddress.fromString(proposer); + } +} diff --git a/yarn-project/ethereum/src/contracts/tally_slashing_proposer.ts b/yarn-project/ethereum/src/contracts/tally_slashing_proposer.ts index 0dc944bd6494..938e404a7771 100644 --- a/yarn-project/ethereum/src/contracts/tally_slashing_proposer.ts +++ b/yarn-project/ethereum/src/contracts/tally_slashing_proposer.ts @@ -97,21 +97,20 @@ export class TallySlashingProposerContract extends EventEmitter { public async getPayload( round: bigint, ): Promise<{ actions: { slashAmount: bigint; validator: EthAddress }[]; address: EthAddress }> { - const result = await this.getTallyFromContract(round); - const address = await this.contract.read.getPayloadAddress([round, result]); - const actions = this.mapSlashActions(result); + const { result: committees } = await this.contract.simulate.getSlashTargetCommittees([round]); + const tally = await this.contract.read.getTally([round, committees]); + const address = await this.contract.read.getPayloadAddress([round, tally]); + const actions = this.mapSlashActions(tally); return { actions, address: EthAddress.fromString(address) }; } /** Returns the slash actions to be executed for a given round based on votes */ - public async getTally(round: bigint): Promise<{ slashAmount: bigint; validator: EthAddress }[]> { - const result = await this.getTallyFromContract(round); - return this.mapSlashActions(result); - } - - private async getTallyFromContract(round: bigint): Promise { + public async getTally( + round: bigint, + ): Promise<{ actions: { slashAmount: bigint; validator: EthAddress }[]; committees: EthAddress[][] }> { const { result: committees } = await this.contract.simulate.getSlashTargetCommittees([round]); - return await this.contract.read.getTally([round, committees]); + const tally = await this.contract.read.getTally([round, committees]); + return { actions: this.mapSlashActions(tally), committees: committees.map(c => c.map(EthAddress.fromString)) }; } private mapSlashActions( diff --git a/yarn-project/foundation/src/collection/array.test.ts b/yarn-project/foundation/src/collection/array.test.ts index ebdac5df5f84..8d8e184a60d0 100644 --- a/yarn-project/foundation/src/collection/array.test.ts +++ b/yarn-project/foundation/src/collection/array.test.ts @@ -6,6 +6,7 @@ import { maxBy, mean, median, + partition, removeArrayPaddingEnd, stdDev, times, @@ -261,3 +262,40 @@ describe('chunk', () => { expect(result).toEqual([[1], [2], [3]]); }); }); + +describe('partition', () => { + it('partitions an array into pass and fail arrays based on the predicate', () => { + const input = [1, 2, 3, 4, 5]; + const [even, odd] = partition(input, x => x % 2 === 0); + expect(even).toEqual([2, 4]); + expect(odd).toEqual([1, 3, 5]); + }); + + it('returns all items in the first array if all pass the predicate', () => { + const input = [2, 4, 6]; + const [pass, fail] = partition(input, x => x % 2 === 0); + expect(pass).toEqual([2, 4, 6]); + expect(fail).toEqual([]); + }); + + it('returns all items in the second array if none pass the predicate', () => { + const input = [1, 3, 5]; + const [pass, fail] = partition(input, x => x % 2 === 0); + expect(pass).toEqual([]); + expect(fail).toEqual([1, 3, 5]); + }); + + it('handles an empty array', () => { + const input: number[] = []; + const [pass, fail] = partition(input, x => x > 0); + expect(pass).toEqual([]); + expect(fail).toEqual([]); + }); + + it('works with objects and custom predicates', () => { + const input = [{ a: 1 }, { a: 2 }, { a: 3 }]; + const [even, odd] = partition(input, obj => obj.a % 2 === 0); + expect(even).toEqual([{ a: 2 }]); + expect(odd).toEqual([{ a: 1 }, { a: 3 }]); + }); +}); diff --git a/yarn-project/foundation/src/collection/array.ts b/yarn-project/foundation/src/collection/array.ts index b88af1c14490..2016f50407b9 100644 --- a/yarn-project/foundation/src/collection/array.ts +++ b/yarn-project/foundation/src/collection/array.ts @@ -237,3 +237,17 @@ export function chunk(items: T[], chunkSize: number): T[][] { } return chunks; } + +/** Partitions the given iterable into two arrays based on the predicate. */ +export function partition(items: T[], predicate: (item: T) => boolean): [T[], T[]] { + const pass: T[] = []; + const fail: T[] = []; + for (const item of items) { + if (predicate(item)) { + pass.push(item); + } else { + fail.push(item); + } + } + return [pass, fail]; +} diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index c2a67aca1124..9316e771dd6e 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -208,7 +208,6 @@ export type EnvVar = | 'SLASH_INACTIVITY_TARGET_PERCENTAGE' | 'SLASH_INVALID_BLOCK_PENALTY' | 'SLASH_OVERRIDE_PAYLOAD' - | 'SLASH_PAYLOAD_TTL_SECONDS' | 'SLASH_PROPOSE_INVALID_ATTESTATIONS_PENALTY' | 'SLASH_ATTEST_DESCENDANT_OF_INVALID_PENALTY' | 'SLASH_UNKNOWN_PENALTY' diff --git a/yarn-project/slasher/README.md b/yarn-project/slasher/README.md index f2fa2008ecbf..49f633e5f990 100644 --- a/yarn-project/slasher/README.md +++ b/yarn-project/slasher/README.md @@ -32,6 +32,7 @@ Key characteristics: - Requires quorum to execute slashing - L1 contract determines which offenses reach consensus - Execution happens after a delay period for review +- Slash payloads can be vetoed during the execution delay period ### Empire Model @@ -56,13 +57,14 @@ Common interface implemented by both tally and empire clients. Provides methods #### SlashOffensesCollector Collects slashable offenses from watchers and stores them in the offenses store. Features: - Subscribes to `WANT_TO_SLASH_EVENT` from watchers -- Manages offense lifecycle and expiration +- Manages offense lifecycle and automatic expiration #### SlasherOffensesStore Persistent storage for offenses. Tracks: - Pending offenses awaiting slashing - Executed offenses to prevent double slashing - Round-based offense organization +- Automatic expiration of old offenses based on configurable rounds #### SlashRoundMonitor Monitors slashing rounds and triggers actions on round transitions: @@ -85,6 +87,15 @@ Actions returned by the slasher client to the SequencerPublisher: 4. **Action Execution**: SequencerPublisher receives actions and executes them on L1 5. **Round Monitoring**: SlashRoundMonitor tracks rounds and triggers execution when conditions are met +## Vetoing + +The slashing system includes a veto mechanism that allows designated vetoers to block slash payloads during the execution delay period. When a slash payload is ready for execution, the system first checks if it has been vetoed before proceeding. + +Key features: +- Slash payloads can be vetoed by authorized addresses on the L1 slasher contract +- Veto checks are performed automatically before execution attempts +- The veto mechanism provides a safety valve for incorrectly proposed slashes + ## Slashable Offenses ### DATA_WITHHOLDING @@ -132,15 +143,27 @@ Actions returned by the slasher client to the SequencerPublisher: ## Configuration -### Slasher Configuration -- `slashGracePeriodL2Slots`: Number of initial L2 slots where slashing is disabled -- `slashMaxPayloadSize`: Maximum size of slash payloads (empire model) -- `slashingRoundSize`: Number of slots per slashing round +### L1 System Settings (L1ContractsConfig) +These settings are deployed with the L1 contracts and apply system-wide to the protocol: + +- `slashingRoundSize`: Number of slots per slashing round (default: 192, must be multiple of epochs) - `slashingQuorumSize`: Votes required to slash (tally model) - `slashingOffsetInRounds`: How many rounds to look back for offenses (tally model) - `slashingExecutionDelayInRounds`: Rounds to wait before execution - `slashingLifetimeInRounds`: Maximum age of executable rounds -- `slashingUnit`: Base slashing amount per unit +- `slashingAmounts`: Valid values for each individual slash (tally model) + +### Local Node Configuration (SlasherConfig) +These settings are configured locally on each validator node: -### Environment Variables -- `SLASHER_CLIENT_TYPE`: Select between 'tally' or 'empire' (default: 'tally') +- `slashGracePeriodL2Slots`: Number of initial L2 slots where slashing is disabled +- `slashMaxPayloadSize`: Maximum size of slash payloads (empire model) +- `slashOffenseExpirationRounds`: Number of rounds after which pending offenses expire +- `slashValidatorsAlways`: Array of validator addresses that should always be slashed +- `slashValidatorsNever`: Array of validator addresses that should never be slashed (own validator addresses are automatically added to this list) +- `slashPrunePenalty`: Penalty for DATA_WITHHOLDING and VALID_EPOCH_PRUNED offenses +- `slashInactivityPenalty`: Penalty for INACTIVITY offenses +- `slashBroadcastedInvalidBlockPenalty`: Penalty for broadcasting invalid blocks +- `slashProposeInvalidAttestationsPenalty`: Penalty for proposing with insufficient/incorrect attestations +- `slashAttestDescendantOfInvalidPenalty`: Penalty for attesting to descendants of invalid blocks +- `slashUnknownPenalty`: Default penalty for unknown offense types diff --git a/yarn-project/slasher/src/config.ts b/yarn-project/slasher/src/config.ts index 4259926c859b..e4dd5113f399 100644 --- a/yarn-project/slasher/src/config.ts +++ b/yarn-project/slasher/src/config.ts @@ -1,17 +1,21 @@ import type { ConfigMappingsType } from '@aztec/foundation/config'; -import { bigintConfigHelper, floatConfigHelper, numberConfigHelper } from '@aztec/foundation/config'; +import { + bigintConfigHelper, + booleanConfigHelper, + floatConfigHelper, + numberConfigHelper, +} from '@aztec/foundation/config'; import { EthAddress } from '@aztec/foundation/eth-address'; import type { SlasherConfig } from '@aztec/stdlib/interfaces/server'; export type { SlasherConfig }; export const DefaultSlasherConfig: SlasherConfig = { - slashPayloadTtlSeconds: 60 * 60 * 24, // 1 day slashOverridePayload: undefined, slashMinPenaltyPercentage: 0.5, // 50% of penalty slashMaxPenaltyPercentage: 2.0, //2x of penalty - slashValidatorsAlways: '', // Empty by default - slashValidatorsNever: '', // Empty by default + slashValidatorsAlways: [], // Empty by default + slashValidatorsNever: [], // Empty by default slashPrunePenalty: 1n, slashInactivityTargetPercentage: 0.9, slashBroadcastedInvalidBlockPenalty: 1n, @@ -22,14 +26,10 @@ export const DefaultSlasherConfig: SlasherConfig = { slashOffenseExpirationRounds: 4, slashMaxPayloadSize: 50, slashGracePeriodL2Slots: 0, + slashSelfAllowed: false, }; export const slasherConfigMappings: ConfigMappingsType = { - slashPayloadTtlSeconds: { - env: 'SLASH_PAYLOAD_TTL_SECONDS', - description: 'Time-to-live for slash payloads in seconds.', - ...numberConfigHelper(DefaultSlasherConfig.slashPayloadTtlSeconds), - }, slashOverridePayload: { env: 'SLASH_OVERRIDE_PAYLOAD', description: 'An Ethereum address for a slash payload to vote for unconditionally.', @@ -49,13 +49,23 @@ export const slasherConfigMappings: ConfigMappingsType = { slashValidatorsAlways: { env: 'SLASH_VALIDATORS_ALWAYS', description: 'Comma-separated list of validator addresses that should always be slashed.', - parseEnv: (val: string) => val || '', + parseEnv: (val: string) => + val + .split(',') + .map(addr => addr.trim()) + .filter(addr => addr.length > 0) + .map(addr => EthAddress.fromString(addr)), defaultValue: DefaultSlasherConfig.slashValidatorsAlways, }, slashValidatorsNever: { env: 'SLASH_VALIDATORS_NEVER', description: 'Comma-separated list of validator addresses that should never be slashed.', - parseEnv: (val: string) => val || '', + parseEnv: (val: string) => + val + .split(',') + .map(addr => addr.trim()) + .filter(addr => addr.length > 0) + .map(addr => EthAddress.fromString(addr)), defaultValue: DefaultSlasherConfig.slashValidatorsNever, }, slashPrunePenalty: { @@ -114,4 +124,8 @@ export const slasherConfigMappings: ConfigMappingsType = { env: 'SLASH_GRACE_PERIOD_L2_SLOTS', ...numberConfigHelper(DefaultSlasherConfig.slashGracePeriodL2Slots), }, + slashSelfAllowed: { + description: 'Whether to allow slashes to own validators', + ...booleanConfigHelper(DefaultSlasherConfig.slashSelfAllowed), + }, }; diff --git a/yarn-project/slasher/src/empire_slasher_client.test.ts b/yarn-project/slasher/src/empire_slasher_client.test.ts index 975cf1c9c9d2..e3ce81a78ff8 100644 --- a/yarn-project/slasher/src/empire_slasher_client.test.ts +++ b/yarn-project/slasher/src/empire_slasher_client.test.ts @@ -1,4 +1,4 @@ -import { EmpireSlashingProposerContract } from '@aztec/ethereum'; +import { EmpireSlashingProposerContract, RollupContract, SlasherContract } from '@aztec/ethereum'; import { EthAddress } from '@aztec/foundation/eth-address'; import { type Logger, createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; @@ -28,6 +28,8 @@ describe('EmpireSlasherClient', () => { let slasherClient: TestEmpireSlasherClient; let slashFactoryContract: MockProxy; let slashingProposer: MockProxy; + let rollupContract: MockProxy; + let slasherContract: MockProxy; let dummyWatcher: DummyWatcher; let kvStore: ReturnType; let offensesStore: SlasherOffensesStore; @@ -35,7 +37,6 @@ describe('EmpireSlasherClient', () => { let dateProvider: DateProvider; let logger: Logger; - const rollupAddress = EthAddress.random(); const settings: EmpireSlasherSettings = { slashingExecutionDelayInRounds: 2, slashingPayloadLifetimeInRounds: 10, @@ -144,12 +145,23 @@ describe('EmpireSlasherClient', () => { // Create real stores with in-memory database kvStore = openTmpStore(); - offensesStore = new SlasherOffensesStore(kvStore, settings); - payloadsStore = new SlasherPayloadsStore(kvStore); + offensesStore = new SlasherOffensesStore(kvStore, { + ...settings, + slashOffenseExpirationRounds: config.slashOffenseExpirationRounds, + }); + payloadsStore = new SlasherPayloadsStore(kvStore, { + slashingPayloadLifetimeInRounds: settings.slashingPayloadLifetimeInRounds, + }); // Create mocks for L1 contracts slashFactoryContract = mockDeep(); slashingProposer = mockDeep(); + rollupContract = mockDeep(); + slasherContract = mockDeep(); + + // Setup rollup and slasher contract mocks + rollupContract.getSlasherContract.mockResolvedValue(slasherContract); + slasherContract.isPayloadVetoed.mockResolvedValue(false); // Create watcher dummyWatcher = new DummyWatcher(); @@ -160,7 +172,7 @@ describe('EmpireSlasherClient', () => { settings, slashFactoryContract, slashingProposer, - rollupAddress, + rollupContract, [dummyWatcher], dateProvider, offensesStore, @@ -254,10 +266,6 @@ describe('EmpireSlasherClient', () => { }); }); - describe('handleNewRound', () => { - it.todo('clears expired payloads and offenses'); - }); - describe('handleProposalExecutable', () => { it('tracks executable payloads and marks offenses as not pending', async () => { const validator = EthAddress.random(); @@ -632,7 +640,7 @@ class TestEmpireSlasherClient extends EmpireSlasherClient { settings: EmpireSlasherSettings, slashFactoryContract: SlashFactoryContract, slashingProposer: EmpireSlashingProposerContract, - rollupAddress: EthAddress, + rollup: RollupContract, watchers: Watcher[], dateProvider: DateProvider, offensesStore: SlasherOffensesStore, @@ -644,7 +652,7 @@ class TestEmpireSlasherClient extends EmpireSlasherClient { settings, slashFactoryContract, slashingProposer, - rollupAddress, + rollup, watchers, dateProvider, offensesStore, diff --git a/yarn-project/slasher/src/empire_slasher_client.ts b/yarn-project/slasher/src/empire_slasher_client.ts index 3a7a814c8e68..6aaccfd18e93 100644 --- a/yarn-project/slasher/src/empire_slasher_client.ts +++ b/yarn-project/slasher/src/empire_slasher_client.ts @@ -1,4 +1,4 @@ -import { EmpireSlashingProposerContract } from '@aztec/ethereum'; +import { EmpireSlashingProposerContract, RollupContract } from '@aztec/ethereum'; import { sumBigint } from '@aztec/foundation/bigint'; import { compactArray, filterAsync, maxBy, pick } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; @@ -121,7 +121,7 @@ export class EmpireSlasherClient implements ProposerSlashActionProvider, Slasher private settings: EmpireSlasherSettings, private slashFactoryContract: SlashFactoryContract, private slashingProposer: EmpireSlashingProposerContract, - private rollupAddress: EthAddress, + private rollup: RollupContract, watchers: Watcher[], private dateProvider: DateProvider, private offensesStore: SlasherOffensesStore, @@ -299,7 +299,7 @@ export class EmpireSlasherClient implements ProposerSlashActionProvider, Slasher return; } const votes = await this.slashingProposer.getPayloadSignals( - this.rollupAddress.toString(), + this.rollup.address, round, payloadAddress.toString(), ); @@ -396,13 +396,23 @@ export class EmpireSlasherClient implements ProposerSlashActionProvider, Slasher continue; } - const roundInfo = await this.slashingProposer.getRoundInfo(this.rollupAddress.toString(), payload.round); + const roundInfo = await this.slashingProposer.getRoundInfo(this.rollup.address, payload.round); if (roundInfo.executed) { this.log.verbose(`Payload ${payload.payload} for round ${payload.round} has already been executed`); toRemove.push(payload); continue; } + // Check if the slash payload is vetoed + const slasherContract = await this.rollup.getSlasherContract(); + const isVetoed = await slasherContract.isPayloadVetoed(payload.payload); + + if (isVetoed) { + this.log.info(`Payload ${payload.payload} from round ${payload.round} is vetoed, skipping execution`); + toRemove.push(payload); + continue; + } + this.log.info(`Executing payload ${payload.payload} from round ${payload.round}`); toExecute = payload; break; diff --git a/yarn-project/slasher/src/factory/create_facade.ts b/yarn-project/slasher/src/factory/create_facade.ts new file mode 100644 index 000000000000..1d51225035ca --- /dev/null +++ b/yarn-project/slasher/src/factory/create_facade.ts @@ -0,0 +1,52 @@ +import { EpochCache } from '@aztec/epoch-cache'; +import type { L1ReaderConfig, ViemClient } from '@aztec/ethereum'; +import { RollupContract } from '@aztec/ethereum/contracts'; +import { unique } from '@aztec/foundation/collection'; +import { EthAddress } from '@aztec/foundation/eth-address'; +import { createLogger } from '@aztec/foundation/log'; +import { DateProvider } from '@aztec/foundation/timer'; +import type { DataStoreConfig } from '@aztec/kv-store/config'; +import { createStore } from '@aztec/kv-store/lmdb-v2'; +import type { SlasherConfig } from '@aztec/stdlib/interfaces/server'; + +import { SlasherClientFacade } from '../slasher_client_facade.js'; +import type { SlasherClientInterface } from '../slasher_client_interface.js'; +import { SCHEMA_VERSION } from '../stores/schema_version.js'; +import type { Watcher } from '../watcher.js'; + +/** Creates a slasher client facade that updates itself whenever the rollup slasher changes */ +export async function createSlasherFacade( + config: SlasherConfig & DataStoreConfig & { ethereumSlotDuration: number }, + l1Contracts: Pick, + l1Client: ViemClient, + watchers: Watcher[], + dateProvider: DateProvider, + epochCache: EpochCache, + /** List of own validator addresses to add to the slashValidatorNever list unless slashSelfAllowed is true */ + validatorAddresses: EthAddress[] = [], + logger = createLogger('slasher'), +): Promise { + if (!l1Contracts.rollupAddress || l1Contracts.rollupAddress.equals(EthAddress.ZERO)) { + throw new Error('Cannot initialize SlasherClient without a Rollup address'); + } + + const kvStore = await createStore('slasher', SCHEMA_VERSION, config, createLogger('slasher:lmdb')); + const rollup = new RollupContract(l1Client, l1Contracts.rollupAddress); + + const slashValidatorsNever = config.slashSelfAllowed + ? config.slashValidatorsNever + : unique([...config.slashValidatorsNever, ...validatorAddresses].map(a => a.toString())).map(EthAddress.fromString); + const updatedConfig = { ...config, slashValidatorsNever }; + + return new SlasherClientFacade( + updatedConfig, + rollup, + l1Client, + l1Contracts.slashFactoryAddress, + watchers, + epochCache, + dateProvider, + kvStore, + logger, + ); +} diff --git a/yarn-project/slasher/src/factory.ts b/yarn-project/slasher/src/factory/create_implementation.ts similarity index 75% rename from yarn-project/slasher/src/factory.ts rename to yarn-project/slasher/src/factory/create_implementation.ts index b5839f99c205..1984beeaab93 100644 --- a/yarn-project/slasher/src/factory.ts +++ b/yarn-project/slasher/src/factory/create_implementation.ts @@ -1,5 +1,5 @@ import { EpochCache } from '@aztec/epoch-cache'; -import type { L1ReaderConfig, ViemClient } from '@aztec/ethereum'; +import type { ViemClient } from '@aztec/ethereum'; import { EmpireSlashingProposerContract, RollupContract, @@ -9,49 +9,16 @@ import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; import { DateProvider } from '@aztec/foundation/timer'; import type { DataStoreConfig } from '@aztec/kv-store/config'; -import { AztecLMDBStoreV2, createStore } from '@aztec/kv-store/lmdb-v2'; +import { AztecLMDBStoreV2 } from '@aztec/kv-store/lmdb-v2'; import type { SlasherConfig } from '@aztec/stdlib/interfaces/server'; import { SlashFactoryContract } from '@aztec/stdlib/l1-contracts'; -import { EmpireSlasherClient, type EmpireSlasherSettings } from './empire_slasher_client.js'; -import { NullSlasherClient } from './null_slasher_client.js'; -import { SlasherClientFacade } from './slasher_client_facade.js'; -import type { SlasherClientInterface } from './slasher_client_interface.js'; -import { SlasherOffensesStore } from './stores/offenses_store.js'; -import { SlasherPayloadsStore } from './stores/payloads_store.js'; -import { SCHEMA_VERSION } from './stores/schema_version.js'; -import { TallySlasherClient, type TallySlasherSettings } from './tally_slasher_client.js'; -import type { Watcher } from './watcher.js'; - -/** Creates a slasher client facade that updates itself whenever the rollup slasher changes */ -export async function createSlasher( - config: SlasherConfig & DataStoreConfig & { ethereumSlotDuration: number }, - l1Contracts: Pick, - l1Client: ViemClient, - watchers: Watcher[], - dateProvider: DateProvider, - epochCache: EpochCache, - logger = createLogger('slasher'), -): Promise { - if (!l1Contracts.rollupAddress || l1Contracts.rollupAddress.equals(EthAddress.ZERO)) { - throw new Error('Cannot initialize SlasherClient without a Rollup address'); - } - - const kvStore = await createStore('slasher', SCHEMA_VERSION, config, createLogger('slasher:lmdb')); - const rollup = new RollupContract(l1Client, l1Contracts.rollupAddress); - - return new SlasherClientFacade( - config, - rollup, - l1Client, - l1Contracts.slashFactoryAddress, - watchers, - epochCache, - dateProvider, - kvStore, - logger, - ); -} +import { EmpireSlasherClient, type EmpireSlasherSettings } from '../empire_slasher_client.js'; +import { NullSlasherClient } from '../null_slasher_client.js'; +import { SlasherOffensesStore } from '../stores/offenses_store.js'; +import { SlasherPayloadsStore } from '../stores/payloads_store.js'; +import { TallySlasherClient, type TallySlasherSettings } from '../tally_slasher_client.js'; +import type { Watcher } from '../watcher.js'; /** Creates a slasher client implementation (either tally or empire) based on the slasher proposer type in the rollup */ export async function createSlasherImplementation( @@ -128,15 +95,20 @@ async function createEmpireSlasher( ethereumSlotDuration: config.ethereumSlotDuration, }; - const payloadsStore = new SlasherPayloadsStore(kvStore); - const offensesStore = new SlasherOffensesStore(kvStore, settings); + const payloadsStore = new SlasherPayloadsStore(kvStore, { + slashingPayloadLifetimeInRounds: settings.slashingPayloadLifetimeInRounds, + }); + const offensesStore = new SlasherOffensesStore(kvStore, { + ...settings, + slashOffenseExpirationRounds: config.slashOffenseExpirationRounds, + }); return new EmpireSlasherClient( config, settings, slashFactoryContract, slashingProposer, - EthAddress.fromString(rollup.address), + rollup, watchers, dateProvider, offensesStore, @@ -199,7 +171,10 @@ async function createTallySlasher( targetCommitteeSize: Number(targetCommitteeSize), }; - const offensesStore = new SlasherOffensesStore(kvStore, settings); + const offensesStore = new SlasherOffensesStore(kvStore, { + ...settings, + slashOffenseExpirationRounds: config.slashOffenseExpirationRounds, + }); return new TallySlasherClient( config, diff --git a/yarn-project/slasher/src/factory/index.ts b/yarn-project/slasher/src/factory/index.ts new file mode 100644 index 000000000000..ca21a8febf10 --- /dev/null +++ b/yarn-project/slasher/src/factory/index.ts @@ -0,0 +1 @@ +export { createSlasherFacade as createSlasher } from './create_facade.js'; diff --git a/yarn-project/slasher/src/index.ts b/yarn-project/slasher/src/index.ts index 3b1adb41ad79..0d59ddc39f5f 100644 --- a/yarn-project/slasher/src/index.ts +++ b/yarn-project/slasher/src/index.ts @@ -5,6 +5,6 @@ export * from './empire_slasher_client.js'; export * from './tally_slasher_client.js'; export * from './slash_offenses_collector.js'; export * from './slasher_client_interface.js'; -export * from './factory.js'; +export * from './factory/index.js'; export * from './watcher.js'; export * from '@aztec/stdlib/slashing'; diff --git a/yarn-project/slasher/src/slash_offenses_collector.test.ts b/yarn-project/slasher/src/slash_offenses_collector.test.ts index be308adac765..4db90fc62206 100644 --- a/yarn-project/slasher/src/slash_offenses_collector.test.ts +++ b/yarn-project/slasher/src/slash_offenses_collector.test.ts @@ -45,7 +45,11 @@ describe('SlashOffensesCollector', () => { beforeEach(() => { kvStore = openTmpStore(true); - offensesStore = new SlasherOffensesStore(kvStore, { slashingRoundSize: 32 * 6, epochDuration: 32 }); + offensesStore = new SlasherOffensesStore(kvStore, { + slashingRoundSize: 32 * 6, + epochDuration: 32, + slashOffenseExpirationRounds: 4, + }); dummyWatcher = new DummyWatcher(); logger = createLogger('test'); diff --git a/yarn-project/slasher/src/slash_offenses_collector.ts b/yarn-project/slasher/src/slash_offenses_collector.ts index 3a7bd4806235..1934f5a69b97 100644 --- a/yarn-project/slasher/src/slash_offenses_collector.ts +++ b/yarn-project/slasher/src/slash_offenses_collector.ts @@ -87,7 +87,10 @@ export class SlashOffensesCollector { */ public async handleNewRound(round: bigint) { this.log.verbose(`Clearing expired offenses for new slashing round ${round}`); - await this.offensesStore.clearExpiredOffenses(round); + const cleared = await this.offensesStore.clearExpiredOffenses(round); + if (cleared && cleared > 0) { + this.log.verbose(`Cleared ${cleared} expired offenses`); + } } /** diff --git a/yarn-project/slasher/src/slasher_client_facade.ts b/yarn-project/slasher/src/slasher_client_facade.ts index f7d7cc07bf1b..9129d954788b 100644 --- a/yarn-project/slasher/src/slasher_client_facade.ts +++ b/yarn-project/slasher/src/slasher_client_facade.ts @@ -9,7 +9,7 @@ import { AztecLMDBStoreV2 } from '@aztec/kv-store/lmdb-v2'; import type { SlasherConfig } from '@aztec/stdlib/interfaces/server'; import type { Offense, ProposerSlashAction, SlashPayloadRound } from '@aztec/stdlib/slashing'; -import { createSlasherImplementation } from './factory.js'; +import { createSlasherImplementation } from './factory/create_implementation.js'; import type { SlasherClientInterface } from './slasher_client_interface.js'; import type { Watcher } from './watcher.js'; diff --git a/yarn-project/slasher/src/stores/offenses_store.test.ts b/yarn-project/slasher/src/stores/offenses_store.test.ts index 1ca1ab05a0ef..9b6cfb5a2d00 100644 --- a/yarn-project/slasher/src/stores/offenses_store.test.ts +++ b/yarn-project/slasher/src/stores/offenses_store.test.ts @@ -15,7 +15,10 @@ describe('SlasherOffensesStore', () => { beforeEach(async () => { kvStore = await openTmpStore('slasher-offenses-store-test'); - store = new SlasherOffensesStore(kvStore, defaultSettings); + store = new SlasherOffensesStore(kvStore, { + ...defaultSettings, + slashOffenseExpirationRounds: 4, + }); }); afterEach(async () => { @@ -311,7 +314,56 @@ describe('SlasherOffensesStore', () => { }); describe('clearExpiredOffenses', () => { - it.todo('should clear expired offenses'); + it('should clear expired offenses based on expiration rounds', async () => { + const currentRound = 8n; + + // Round 6: slots 600-699, Round 1: slots 100-199, Round 0: slots 0-99 + const recentOffense = createOffense(EthAddress.random(), 1000n, OffenseType.INACTIVITY, 650n / 32n); // Round 6, should not expire + const expiredOffense1 = createOffense(EthAddress.random(), 1000n, OffenseType.INACTIVITY, 150n / 32n); // Round 1, should expire + const expiredOffense2 = createOffense(EthAddress.random(), 1000n, OffenseType.INACTIVITY, 50n / 32n); // Round 0, should expire + + await store.addPendingOffense(recentOffense); + await store.addPendingOffense(expiredOffense1); + await store.addPendingOffense(expiredOffense2); + + // Verify all offenses are present + expect(await store.hasOffense(recentOffense)).toBe(true); + expect(await store.hasOffense(expiredOffense1)).toBe(true); + expect(await store.hasOffense(expiredOffense2)).toBe(true); + + // Clear expired offenses + await store.clearExpiredOffenses(currentRound); + + // Recent offense should remain, expired offenses should be gone + expect(await store.hasOffense(recentOffense)).toBe(true); + expect(await store.hasOffense(expiredOffense1)).toBe(false); + expect(await store.hasOffense(expiredOffense2)).toBe(false); + }); + + it('should not clear anything when expiration is disabled', async () => { + const storeWithNoExpiration = new SlasherOffensesStore(kvStore, { + ...defaultSettings, + slashOffenseExpirationRounds: 0, + }); + + const offense = createOffense(EthAddress.random(), 1000n, OffenseType.INACTIVITY, 10n); + await storeWithNoExpiration.addPendingOffense(offense); + + await storeWithNoExpiration.clearExpiredOffenses(100n); + + expect(await storeWithNoExpiration.hasOffense(offense)).toBe(true); + }); + + it('should not clear anything when not enough rounds have passed', async () => { + const currentRound = 2n; // Less than expiration rounds + + const offense = createOffense(EthAddress.random(), 1000n, OffenseType.INACTIVITY, 10n); + await store.addPendingOffense(offense); + + await store.clearExpiredOffenses(currentRound); + + expect(await store.hasOffense(offense)).toBe(true); + }); }); describe('edge cases', () => { diff --git a/yarn-project/slasher/src/stores/offenses_store.ts b/yarn-project/slasher/src/stores/offenses_store.ts index f9b3dea7796d..656e6437285a 100644 --- a/yarn-project/slasher/src/stores/offenses_store.ts +++ b/yarn-project/slasher/src/stores/offenses_store.ts @@ -1,3 +1,4 @@ +import { createLogger } from '@aztec/aztec.js'; import type { AztecAsyncKVStore, AztecAsyncMap, AztecAsyncMultiMap, AztecAsyncSet } from '@aztec/kv-store'; import { type Offense, @@ -19,11 +20,14 @@ export class SlasherOffensesStore { /** Multimap from round to offense keys (only used for consensus based slashing) */ private roundsOffenses: AztecAsyncMultiMap; + private log = createLogger('slasher:store:offenses'); + constructor( private kvStore: AztecAsyncKVStore, private settings: { slashingRoundSize: number; epochDuration: number; + slashOffenseExpirationRounds?: number; }, ) { this.offenses = kvStore.openMap('offenses'); @@ -47,7 +51,7 @@ export class SlasherOffensesStore { /** Returns all offenses tracked for the given round */ public async getOffensesForRound(round: bigint): Promise { const offenses: Offense[] = []; - for await (const key of this.roundsOffenses.getValuesAsync(round.toString())) { + for await (const key of this.roundsOffenses.getValuesAsync(this.getRoundKey(round))) { const buffer = await this.offenses.getAsync(key); if (buffer) { const offense = deserializeOffense(buffer); @@ -74,7 +78,8 @@ export class SlasherOffensesStore { const key = this.getOffenseKey(offense); await this.offenses.set(key, serializeOffense(offense)); const round = getRoundForOffense(offense, this.settings); - await this.roundsOffenses.set(round.toString(), key); + await this.roundsOffenses.set(this.getRoundKey(round), key); + this.log.trace(`Adding pending offense ${key} for round ${round}`); } /** Marks the given offenses as slashed (regardless of whether they are known or not) */ @@ -87,12 +92,54 @@ export class SlasherOffensesStore { }); } - public async clearExpiredOffenses(_currentRound: bigint): Promise { - // TODO(palla/slash): Implement expiration logic + /** Prunes all offenses expired from the store */ + public async clearExpiredOffenses(currentRound: bigint): Promise { + const expirationRounds = this.settings.slashOffenseExpirationRounds ?? 0; + if (expirationRounds <= 0) { + return 0; // No expiration configured + } + + const expiredBefore = currentRound - BigInt(expirationRounds); + if (expiredBefore < 0) { + return 0; // Not enough rounds have passed to expire anything + } + + // Collect expired offenses and rounds + const expiredRoundKeys = new Set(); + const expiredOffenseKeys = new Set(); + for await (const [roundKey, offenseKey] of this.roundsOffenses.entriesAsync({ + end: this.getRoundKey(expiredBefore), + })) { + expiredOffenseKeys.add(offenseKey); + expiredRoundKeys.add(roundKey); + } + + if (expiredOffenseKeys.size === 0 && expiredRoundKeys.size === 0) { + return 0; // Nothing to clean up + } + + // Remove expired stuff in a transaction + await this.kvStore.transactionAsync(async () => { + for (const key of expiredOffenseKeys) { + this.log.trace(`Deleting offense ${key}`); + await this.offenses.delete(key); + await this.offensesSlashed.delete(key); + } + for (const roundKey of expiredRoundKeys) { + this.log.trace(`Deleting round info for ${roundKey}`); + await this.roundsOffenses.delete(roundKey); + } + }); + + return expiredOffenseKeys.size; } /** Generate a unique key for an offense */ private getOffenseKey(offense: OffenseIdentifier): string { return `${offense.validator.toString()}:${offense.offenseType}:${offense.epochOrSlot}`; } + + private getRoundKey(round: bigint): string { + return round.toString().padStart(16, '0'); + } } diff --git a/yarn-project/slasher/src/stores/payloads_store.test.ts b/yarn-project/slasher/src/stores/payloads_store.test.ts index d22cf4563379..4398b3a85c65 100644 --- a/yarn-project/slasher/src/stores/payloads_store.test.ts +++ b/yarn-project/slasher/src/stores/payloads_store.test.ts @@ -10,7 +10,9 @@ describe('SlasherPayloadsStore', () => { beforeEach(() => { kvStore = openTmpStore(); - store = new SlasherPayloadsStore(kvStore); + store = new SlasherPayloadsStore(kvStore, { + slashingPayloadLifetimeInRounds: 5, + }); }); afterEach(async () => { @@ -321,7 +323,36 @@ describe('SlasherPayloadsStore', () => { }); describe('clearExpiredPayloads', () => { - it.todo('should clear expired payloads'); + it('should clear expired payload votes and unused payloads', async () => { + const currentRound = 10n; + + // Add payloads for different rounds + const recentPayload = createSlashPayloadRound(createSlashPayload(), 5n, 7n); // Should not expire + const expiredPayload1 = createSlashPayloadRound(createSlashPayload(), 5n, 3n); // Should expire + const expiredPayload2 = createSlashPayloadRound(createSlashPayload(), 5n, 4n); // Should expire + + await store.addPayload(recentPayload); + await store.addPayload(expiredPayload1); + await store.addPayload(expiredPayload2); + + // Verify all payloads are present + expect(await store.hasPayload(recentPayload.address)).toBe(true); + expect(await store.hasPayload(expiredPayload1.address)).toBe(true); + expect(await store.hasPayload(expiredPayload2.address)).toBe(true); + + // Clear expired payloads + await store.clearExpiredPayloads(currentRound); + + // Verify expired votes are cleared, but recent votes remain + const recentPayloads = await store.getPayloadsForRound(7n); + expect(recentPayloads).toHaveLength(1); + + const expiredPayloads1 = await store.getPayloadsForRound(3n); + expect(expiredPayloads1).toHaveLength(0); + + const expiredPayloads2 = await store.getPayloadsForRound(4n); + expect(expiredPayloads2).toHaveLength(0); + }); }); describe('edge cases', () => { diff --git a/yarn-project/slasher/src/stores/payloads_store.ts b/yarn-project/slasher/src/stores/payloads_store.ts index a8e43ba74b4f..46a179ca234a 100644 --- a/yarn-project/slasher/src/stores/payloads_store.ts +++ b/yarn-project/slasher/src/stores/payloads_store.ts @@ -14,7 +14,12 @@ export class SlasherPayloadsStore { /** Map from `round:payload` to votes */ private roundPayloadVotes: AztecAsyncMap; - constructor(private kvStore: AztecAsyncKVStore) { + constructor( + private kvStore: AztecAsyncKVStore, + private settings?: { + slashingPayloadLifetimeInRounds?: number; + }, + ) { this.payloads = kvStore.openMap('slash-payloads'); this.roundPayloadVotes = kvStore.openMap('round-payload-votes'); } @@ -46,29 +51,69 @@ export class SlasherPayloadsStore { private async getVotesForRound(round: bigint): Promise<[string, bigint][]> { const votes: [string, bigint][] = []; - const roundPrefix = `${round.toString()}:`; for await (const [fullKey, roundVotes] of this.roundPayloadVotes.entriesAsync( this.getPayloadVotesKeyRangeForRound(round), )) { // Extract just the address part from the key (remove "round:" prefix) - const address = fullKey.substring(roundPrefix.length); + const address = fullKey.split(':')[1]; votes.push([address, roundVotes]); } return votes; } + private getRoundKey(round: bigint): string { + return round.toString().padStart(16, '0'); + } + private getPayloadVotesKey(round: bigint, payloadAddress: EthAddress | string): string { - return `${round.toString()}:${payloadAddress.toString()}`; + return `${this.getRoundKey(round)}:${payloadAddress.toString()}`; } private getPayloadVotesKeyRangeForRound(round: bigint): { start: string; end: string } { - const start = `${round.toString()}:`; - const end = `${round.toString()}:Z`; // 'Z' sorts after any hex address, 0x-prefixed or not + const start = `${this.getRoundKey(round)}:`; + const end = `${this.getRoundKey(round)}:Z`; // 'Z' sorts after any hex address, 0x-prefixed or not return { start, end }; } - public async clearExpiredPayloads(_currentRound: bigint): Promise { - // TODO(palla/slash): Implement me! + /** + * Purge vote payload data for expired rounds. Does not delete actual payload data. + */ + public async clearExpiredPayloads(currentRound: bigint): Promise { + const lifetimeInRounds = this.settings?.slashingPayloadLifetimeInRounds ?? 0; + if (lifetimeInRounds <= 0) { + return; // No lifetime configured + } + + const expiredBefore = currentRound - BigInt(lifetimeInRounds); + if (expiredBefore < 0) { + return; // Not enough rounds have passed to expire anything + } + + // Collect expired payload votes by scanning round-payload keys + const expiredPayloads: string[] = []; + const expiredVoteKeys: string[] = []; + + for await (const key of this.roundPayloadVotes.keysAsync({ + end: `${this.getRoundKey(expiredBefore)}:Z`, + })) { + const [roundStr, payloadAddress] = key.split(':'); + if (BigInt(roundStr) <= expiredBefore) { + expiredVoteKeys.push(key); + expiredPayloads.push(payloadAddress); + } + } + + if (expiredVoteKeys.length === 0) { + return; // No expired payloads to clean up + } + + // Remove expired payload vote records + // Note that we do not delete payload data since these could be repurposed in future votes + await this.kvStore.transactionAsync(async () => { + for (const key of expiredVoteKeys) { + await this.roundPayloadVotes.delete(key); + } + }); } public async incrementPayloadVotes(payloadAddress: EthAddress, round: bigint): Promise { diff --git a/yarn-project/slasher/src/tally_slasher_client.test.ts b/yarn-project/slasher/src/tally_slasher_client.test.ts index 017315935d3a..fc6a2b17d365 100644 --- a/yarn-project/slasher/src/tally_slasher_client.test.ts +++ b/yarn-project/slasher/src/tally_slasher_client.test.ts @@ -1,6 +1,6 @@ import { sleep } from '@aztec/aztec.js'; import type { EpochCache } from '@aztec/epoch-cache'; -import { RollupContract, TallySlashingProposerContract } from '@aztec/ethereum/contracts'; +import { RollupContract, SlasherContract, TallySlashingProposerContract } from '@aztec/ethereum/contracts'; import { times } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; import { type Logger, createLogger } from '@aztec/foundation/log'; @@ -23,6 +23,7 @@ describe('TallySlasherClient', () => { let tallySlasherClient: TestTallySlasherClient; let tallySlashingProposer: MockProxy; let rollup: MockProxy; + let slasherContract: MockProxy; let dummyWatcher: DummyWatcher; let kvStore: ReturnType; let offensesStore: SlasherOffensesStore; @@ -111,7 +112,10 @@ describe('TallySlasherClient', () => { beforeEach(() => { kvStore = openTmpStore(true); - offensesStore = new SlasherOffensesStore(kvStore, settings); + offensesStore = new SlasherOffensesStore(kvStore, { + ...settings, + slashOffenseExpirationRounds: config.slashOffenseExpirationRounds, + }); dummyWatcher = new DummyWatcher(); dateProvider = new DateProvider(); logger = createLogger('test'); @@ -134,10 +138,22 @@ describe('TallySlasherClient', () => { // Create mocks for L1 contracts tallySlashingProposer = mockDeep(); rollup = mockDeep(); + slasherContract = mockDeep(); // Setup mock responses tallySlashingProposer.getRound.mockResolvedValue({ isExecuted: false, readyToExecute: false, voteCount: 0n }); - tallySlashingProposer.getTally.mockResolvedValue([{ validator: committee[0], slashAmount: slashingUnit }]); + tallySlashingProposer.getTally.mockResolvedValue({ + actions: [{ validator: committee[0], slashAmount: slashingUnit }], + committees: [committee], + }); + tallySlashingProposer.getPayload.mockResolvedValue({ + address: EthAddress.random(), + actions: [{ validator: committee[0], slashAmount: slashingUnit }], + }); + + // Setup rollup and slasher contract mocks + rollup.getSlasherContract.mockResolvedValue(slasherContract); + slasherContract.isPayloadVetoed.mockResolvedValue(false); // Mock event listeners to return unwatch functions tallySlashingProposer.listenToVoteCast.mockReturnValue(() => {}); @@ -331,7 +347,7 @@ describe('TallySlasherClient', () => { voteCount: 120n, }); - tallySlashingProposer.getTally.mockResolvedValueOnce([]); + tallySlashingProposer.getTally.mockResolvedValueOnce({ actions: [], committees: [committee] }); const actions = await tallySlasherClient.getProposerActions(currentSlot); @@ -641,6 +657,178 @@ describe('TallySlasherClient', () => { expectActionVoteOffenses(action!, currentRound, [1, 3, 3]); }); }); + + describe('validator override lists', () => { + describe('slashValidatorsAlways', () => { + it('should slash validators on the always list with maximum slash units', async () => { + const alwaysSlashValidator = committee[0]; + const normalValidator = committee[1]; + + // Update the existing client's config + tallySlasherClient.updateConfig({ + slashValidatorsAlways: [alwaysSlashValidator], + slashValidatorsNever: [], + }); + + const currentRound = 5n; + const currentSlot = currentRound * BigInt(roundSize); + + // Add offense for normal validator (should be processed normally) + await addPendingOffense({ + validator: normalValidator, + epochOrSlot: (currentRound - 2n) * BigInt(roundSize), + amount: slashingUnit, // 1 unit + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const action = await tallySlasherClient.getVoteOffensesAction(currentSlot); + expectActionVoteOffenses(action!, currentRound, [3, 1]); // Always validator gets 3 units, normal gets 1 + }); + + it('should handle multiple validators in always list', async () => { + const alwaysSlashValidator1 = committee[0]; + const alwaysSlashValidator2 = committee[1]; + + // Update the existing client's config + tallySlasherClient.updateConfig({ + slashValidatorsAlways: [alwaysSlashValidator1, alwaysSlashValidator2], + slashValidatorsNever: [], + }); + + const currentRound = 5n; + const currentSlot = currentRound * BigInt(roundSize); + + const action = await tallySlasherClient.getVoteOffensesAction(currentSlot); + expectActionVoteOffenses(action!, currentRound, [3, 3, 0]); // Both always validators get 3 units, normal gets 0 + }); + }); + + describe('slashValidatorsNever', () => { + it('should never slash validators on the never list', async () => { + const neverSlashValidator = committee[0]; + const normalValidator = committee[1]; + + // Update the existing client's config + tallySlasherClient.updateConfig({ + slashValidatorsAlways: [], + slashValidatorsNever: [neverSlashValidator], + }); + + const currentRound = 5n; + const currentSlot = currentRound * BigInt(roundSize); + + // Add offenses for both validators + await addPendingOffense({ + validator: neverSlashValidator, + epochOrSlot: (currentRound - 2n) * BigInt(roundSize), + amount: slashingUnit * 10n, // Large amount that would normally result in 3 units + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + await addPendingOffense({ + validator: normalValidator, + epochOrSlot: (currentRound - 2n) * BigInt(roundSize), + amount: slashingUnit, // 1 unit + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const action = await tallySlasherClient.getVoteOffensesAction(currentSlot); + expectActionVoteOffenses(action!, currentRound, [0, 1]); // Never validator gets 0 units, normal gets 1 + }); + + it('should handle multiple validators in never list', async () => { + const neverSlashValidator1 = committee[0]; + const neverSlashValidator2 = committee[1]; + const normalValidator = committee[2]; + + // Update the existing client's config + tallySlasherClient.updateConfig({ + slashValidatorsAlways: [], + slashValidatorsNever: [neverSlashValidator1, neverSlashValidator2], + }); + + const currentRound = 5n; + const currentSlot = currentRound * BigInt(roundSize); + + // Add offenses for all validators + for (const validator of [neverSlashValidator1, neverSlashValidator2, normalValidator]) { + await addPendingOffense({ + validator, + epochOrSlot: (currentRound - 2n) * BigInt(roundSize), + amount: slashingUnit * 2n, // 2 units + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + } + + const action = await tallySlasherClient.getVoteOffensesAction(currentSlot); + expectActionVoteOffenses(action!, currentRound, [0, 0, 2]); // Never validators get 0, normal gets 2 + }); + }); + + describe('combined always and never lists', () => { + it('should prioritize never list over always list', async () => { + const conflictValidator = committee[0]; // This validator is in both lists + const alwaysValidator = committee[1]; + const neverValidator = committee[2]; + + // Update the existing client's config + tallySlasherClient.updateConfig({ + slashValidatorsAlways: [conflictValidator, alwaysValidator], + slashValidatorsNever: [conflictValidator, neverValidator], + }); + + const currentRound = 5n; + const currentSlot = currentRound * BigInt(roundSize); + + const action = await tallySlasherClient.getVoteOffensesAction(currentSlot); + expectActionVoteOffenses(action!, currentRound, [0, 3, 0]); // Conflict gets 0 (never wins), always gets 3, never gets 0 + }); + }); + + describe('mixed validators in lists', () => { + it('should handle mixed validators correctly', async () => { + const alwaysValidator = committee[0]; + + // Update the existing client's config + tallySlasherClient.updateConfig({ + slashValidatorsAlways: [alwaysValidator], + slashValidatorsNever: [], + }); + + const currentRound = 5n; + const currentSlot = currentRound * BigInt(roundSize); + + const action = await tallySlasherClient.getVoteOffensesAction(currentSlot); + expectActionVoteOffenses(action!, currentRound, [3]); // Always validator should get max slash units + }); + }); + + describe('empty lists', () => { + it('should handle empty always and never lists', async () => { + const normalValidator = committee[0]; + + // Update the existing client's config + tallySlasherClient.updateConfig({ + slashValidatorsAlways: [], + slashValidatorsNever: [], // Empty array + }); + + const currentRound = 5n; + const currentSlot = currentRound * BigInt(roundSize); + + // Add offense for normal processing + await addPendingOffense({ + validator: normalValidator, + epochOrSlot: (currentRound - 2n) * BigInt(roundSize), + amount: slashingUnit, // 1 unit + offenseType: OffenseType.PROPOSED_INSUFFICIENT_ATTESTATIONS, + }); + + const action = await tallySlasherClient.getVoteOffensesAction(currentSlot); + expectActionVoteOffenses(action!, currentRound, [1]); // Normal processing should work + }); + }); + }); }); // Test helper class that exposes protected methods for testing diff --git a/yarn-project/slasher/src/tally_slasher_client.ts b/yarn-project/slasher/src/tally_slasher_client.ts index f82f1a68a3a9..003aa971e079 100644 --- a/yarn-project/slasher/src/tally_slasher_client.ts +++ b/yarn-project/slasher/src/tally_slasher_client.ts @@ -1,7 +1,7 @@ import { EthAddress } from '@aztec/aztec.js'; import type { EpochCache } from '@aztec/epoch-cache'; import { RollupContract, TallySlashingProposerContract } from '@aztec/ethereum/contracts'; -import { compactArray, times } from '@aztec/foundation/collection'; +import { compactArray, partition, times } from '@aztec/foundation/collection'; import { createLogger } from '@aztec/foundation/log'; import { sleep } from '@aztec/foundation/sleep'; import type { DateProvider } from '@aztec/foundation/timer'; @@ -43,7 +43,8 @@ export type TallySlasherSettings = Prettify< } >; -export type TallySlasherClientConfig = SlashOffensesCollectorConfig; +export type TallySlasherClientConfig = SlashOffensesCollectorConfig & + Pick; /** * The Tally Slasher client is responsible for managing slashable offenses using @@ -197,22 +198,39 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC return undefined; } - const slashActions = await this.tallySlashingProposer.getTally(executableRound); + const { actions: slashActions, committees } = await this.tallySlashingProposer.getTally(executableRound); if (slashActions.length === 0) { this.log.verbose(`Round ${executableRound} does not resolve in any slashing`, logData); return undefined; - } else { - this.log.info(`Round ${executableRound} is ready to execute with ${slashActions.length} slashes`, { - slashActions, - ...logData, - }); - const committees = await this.collectCommitteesActiveDuringRound(this.getSlashedRound(executableRound)); - this.log.debug(`Collected ${committees.length} committees for executing round ${executableRound}`, { - committees, + } + + // Check if the slash payload is vetoed + const payload = await this.tallySlashingProposer.getPayload(executableRound); + const slasherContract = await this.rollup.getSlasherContract(); + const isVetoed = await slasherContract.isPayloadVetoed(payload.address); + if (isVetoed) { + this.log.warn(`Round ${executableRound} payload is vetoed (skipping execution)`, { + payloadAddress: payload.address.toString(), ...logData, }); - return { type: 'execute-slash', round: executableRound, committees }; + return undefined; } + + this.log.info(`Round ${executableRound} is ready to execute with ${slashActions.length} slashes`, { + slashActions, + payloadAddress: payload.address.toString(), + ...logData, + }); + + // We only need to post committees that are actually slashed + const slashedCommittees = committees.map(c => + c.some(validator => slashActions.some(action => action.validator.equals(validator))) ? c : [], + ); + this.log.debug(`Collected ${committees.length} committees for executing round ${executableRound}`, { + slashedCommittees, + ...logData, + }); + return { type: 'execute-slash', round: executableRound, committees: slashedCommittees }; } catch (error) { this.log.error(`Error checking round to execute ${executableRound}`, error); } @@ -229,13 +247,42 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC return undefined; } - const offensesToSlash = await this.gatherOffensesForRound(currentRound); + // Compute offenses to slash, by loading the offenses for this round, adding synthetic offenses + // for validators that should always be slashed, and removing the ones that should never be slashed. + const offensesForRound = await this.gatherOffensesForRound(currentRound); + const offensesFromAlwaysSlash = (this.config.slashValidatorsAlways ?? []).map(validator => ({ + validator, + amount: this.settings.slashingAmounts[2], + })); + const [offensesToForgive, offensesToSlash] = partition([...offensesForRound, ...offensesFromAlwaysSlash], offense => + this.config.slashValidatorsNever?.some(v => v.equals(offense.validator)), + ); + + if (offensesFromAlwaysSlash.length > 0) { + this.log.verbose(`Slashing ${offensesFromAlwaysSlash.length} validators due to always-slash config`, { + slotNumber, + currentRound, + slashedRound, + offensesToForgive, + slashValidatorsAlways: this.config.slashValidatorsAlways, + }); + } + + if (offensesToForgive.length > 0) { + this.log.verbose(`Skipping slashing of ${offensesToForgive.length} offenses`, { + slotNumber, + currentRound, + slashedRound, + offensesToForgive, + slashValidatorsNever: this.config.slashValidatorsNever, + }); + } + if (offensesToSlash.length === 0) { this.log.debug(`No offenses to slash for round ${slashedRound}`, { currentRound, slotNumber, slashedRound }); return undefined; } - const committees = await this.collectCommitteesActiveDuringRound(slashedRound); this.log.info(`Voting to slash ${offensesToSlash.length} offenses`, { slotNumber, currentRound, @@ -243,6 +290,7 @@ export class TallySlasherClient implements ProposerSlashActionProvider, SlasherC offensesToSlash, }); + const committees = await this.collectCommitteesActiveDuringRound(slashedRound); const votes = getSlashConsensusVotesFromOffenses(offensesToSlash, committees, this.settings); this.log.debug(`Computed votes for slashing ${offensesToSlash.length} offenses`, { slashedRound, diff --git a/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts b/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts index dd0ddeaeed87..34464def7413 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts @@ -133,14 +133,13 @@ class MockAztecNodeAdmin implements AztecNodeAdmin { proverAgentCount: 1, coinbase: EthAddress.random(), maxTxPoolSize: 1000, - slashPayloadTtlSeconds: 1000, slashAmountSmall: 500n, slashAmountMedium: 1000n, slashAmountLarge: 2000n, slashMinPenaltyPercentage: 0.1, slashMaxPenaltyPercentage: 3.0, - slashValidatorsAlways: '', - slashValidatorsNever: '', + slashValidatorsAlways: [], + slashValidatorsNever: [], slashPrunePenalty: 1000n, slashInactivityTargetPercentage: 0.5, slashInactivityPenalty: 1000n, diff --git a/yarn-project/stdlib/src/interfaces/slasher.ts b/yarn-project/stdlib/src/interfaces/slasher.ts index 9ad7c9dae381..c918d9c61f52 100644 --- a/yarn-project/stdlib/src/interfaces/slasher.ts +++ b/yarn-project/stdlib/src/interfaces/slasher.ts @@ -7,11 +7,11 @@ export type SlasherClientType = 'empire' | 'tally'; export interface SlasherConfig { slashOverridePayload?: EthAddress; - slashPayloadTtlSeconds: number; // TTL for payloads, in seconds slashMinPenaltyPercentage: number; slashMaxPenaltyPercentage: number; - slashValidatorsAlways: string; // Comma-separated list of validator addresses - slashValidatorsNever: string; // Comma-separated list of validator addresses + slashSelfAllowed?: boolean; // Whether to allow slashes to own validators + slashValidatorsAlways: EthAddress[]; // Array of validator addresses + slashValidatorsNever: EthAddress[]; // Array of validator addresses slashInactivityTargetPercentage: number; // 0-1, 0.9 means 90%. Must be greater than 0 slashPrunePenalty: bigint; slashInactivityPenalty: bigint; @@ -26,11 +26,10 @@ export interface SlasherConfig { export const SlasherConfigSchema = z.object({ slashOverridePayload: schemas.EthAddress.optional(), - slashPayloadTtlSeconds: z.number(), slashMinPenaltyPercentage: z.number(), slashMaxPenaltyPercentage: z.number(), - slashValidatorsAlways: z.string(), - slashValidatorsNever: z.string(), + slashValidatorsAlways: z.array(schemas.EthAddress), + slashValidatorsNever: z.array(schemas.EthAddress), slashPrunePenalty: schemas.BigInt, slashInactivityTargetPercentage: z.number(), slashInactivityPenalty: schemas.BigInt, @@ -41,4 +40,5 @@ export const SlasherConfigSchema = z.object({ slashMaxPayloadSize: z.number(), slashGracePeriodL2Slots: z.number(), slashBroadcastedInvalidBlockPenalty: schemas.BigInt, + slashSelfAllowed: z.boolean().optional(), }) satisfies ZodFor; diff --git a/yarn-project/stdlib/src/slashing/tally.ts b/yarn-project/stdlib/src/slashing/tally.ts index b1fd1eb96107..c2ac9fc6965a 100644 --- a/yarn-project/stdlib/src/slashing/tally.ts +++ b/yarn-project/stdlib/src/slashing/tally.ts @@ -1,28 +1,40 @@ import { sumBigint } from '@aztec/foundation/bigint'; -import type { EthAddress } from '@aztec/foundation/eth-address'; +import { EthAddress } from '@aztec/foundation/eth-address'; import type { Offense, ValidatorSlashVote } from './types.js'; /** * Creates a consensus-slash vote for a given set of committees based on a set of Offenses + * @param offenses - Array of offenses to consider + * @param committees - Array of committees (each containing array of validator addresses) + * @param settings - Settings including slashingAmounts and optional validator override lists * @returns Array of ValidatorSlashVote, where each vote is how many slash units the validator in that position should be slashed */ export function getSlashConsensusVotesFromOffenses( - offenses: Offense[], + offenses: Pick[], committees: EthAddress[][], - settings: { slashingAmounts: [bigint, bigint, bigint] }, + settings: { + slashingAmounts: [bigint, bigint, bigint]; + }, ): ValidatorSlashVote[] { const { slashingAmounts } = settings; - const slashed: Set = new Set(); + + const slashedSet: Set = new Set(); + const votes = committees.flatMap(committee => committee.map(validator => { - if (slashed.has(validator.toString())) { - return 0; // Already voted for slashing this validator + const validatorStr = validator.toString(); + + // If already voted for slashing this validator, skip + if (slashedSet.has(validatorStr)) { + return 0; } + + // Normal offense-based slashing logic const validatorOffenses = offenses.filter(o => o.validator.equals(validator)); const slashAmount = sumBigint(validatorOffenses.map(o => o.amount)); const slashUnits = getSlashUnitsForAmount(slashAmount, slashingAmounts); - slashed.add(validator.toString()); + slashedSet.add(validatorStr); return Number(slashUnits); }), );