diff --git a/packages/state-transition/src/block/slashValidator.ts b/packages/state-transition/src/block/slashValidator.ts index 7f4811a8f163..ea2ca91e81ee 100644 --- a/packages/state-transition/src/block/slashValidator.ts +++ b/packages/state-transition/src/block/slashValidator.ts @@ -27,7 +27,7 @@ export function slashValidator( whistleblowerIndex?: ValidatorIndex ): void { const {epochCtx} = state; - const epoch = epochCtx.epoch; + const {epoch, effectiveBalanceIncrements} = epochCtx; const validator = state.validators.get(slashedIndex); // TODO: Bellatrix initiateValidatorExit validators.update() with the one below @@ -37,9 +37,15 @@ export function slashValidator( validator.withdrawableEpoch = Math.max(validator.withdrawableEpoch, epoch + EPOCHS_PER_SLASHINGS_VECTOR); const {effectiveBalance} = validator; - // TODO: could state.slashings be number? + + // state.slashings is initially a Gwei (BigInt) vector, however since Nov 2023 it's converted to UintNum64 (number) vector in the state transition because: + // - state.slashings[nextEpoch % EPOCHS_PER_SLASHINGS_VECTOR] is reset per epoch in processSlashingsReset() + // - max slashed validators per epoch is SLOTS_PER_EPOCH * MAX_ATTESTER_SLASHINGS * MAX_VALIDATORS_PER_COMMITTEE which is 32 * 2 * 2048 = 131072 on mainnet + // - with that and 32_000_000_000 MAX_EFFECTIVE_BALANCE, it still fits in a number given that Math.floor(Number.MAX_SAFE_INTEGER / 32_000_000_000) = 281474 + // - we don't need to compute the total slashings from state.slashings, it's handled by totalSlashingsByIncrement in EpochCache const slashingIndex = epoch % EPOCHS_PER_SLASHINGS_VECTOR; - state.slashings.set(slashingIndex, state.slashings.get(slashingIndex) + BigInt(effectiveBalance)); + state.slashings.set(slashingIndex, (state.slashings.get(slashingIndex) ?? 0) + effectiveBalance); + epochCtx.totalSlashingsByIncrement += effectiveBalanceIncrements[slashedIndex]; const minSlashingPenaltyQuotient = fork === ForkSeq.phase0 diff --git a/packages/state-transition/src/cache/epochCache.ts b/packages/state-transition/src/cache/epochCache.ts index 0ca09526e7ec..8b63b0285098 100644 --- a/packages/state-transition/src/cache/epochCache.ts +++ b/packages/state-transition/src/cache/epochCache.ts @@ -29,6 +29,7 @@ import { import {computeEpochShuffling, EpochShuffling, getShufflingDecisionBlock} from "../util/epochShuffling.js"; import {computeBaseRewardPerIncrement, computeSyncParticipantReward} from "../util/syncCommittee.js"; import {sumTargetUnslashedBalanceIncrements} from "../util/targetUnslashedBalance.js"; +import {getTotalSlashingsByIncrement} from "../epoch/processSlashings.js"; import {EffectiveBalanceIncrements, getEffectiveBalanceIncrementsWithLen} from "./effectiveBalanceIncrements.js"; import {Index2PubkeyCache, PubkeyIndexMap, syncPubkeys} from "./pubkeyCache.js"; import {BeaconStateAllForks, BeaconStateAltair, ShufflingGetter} from "./types.js"; @@ -131,6 +132,10 @@ export class EpochCache { * Effective balances, for altair processAttestations() */ effectiveBalanceIncrements: EffectiveBalanceIncrements; + /** + * Total state.slashings by increment, for processSlashing() + */ + totalSlashingsByIncrement: number; syncParticipantReward: number; syncProposerReward: number; /** @@ -206,6 +211,7 @@ export class EpochCache { currentShuffling: EpochShuffling; nextShuffling: EpochShuffling; effectiveBalanceIncrements: EffectiveBalanceIncrements; + totalSlashingsByIncrement: number; syncParticipantReward: number; syncProposerReward: number; baseRewardPerIncrement: number; @@ -231,6 +237,7 @@ export class EpochCache { this.currentShuffling = data.currentShuffling; this.nextShuffling = data.nextShuffling; this.effectiveBalanceIncrements = data.effectiveBalanceIncrements; + this.totalSlashingsByIncrement = data.totalSlashingsByIncrement; this.syncParticipantReward = data.syncParticipantReward; this.syncProposerReward = data.syncProposerReward; this.baseRewardPerIncrement = data.baseRewardPerIncrement; @@ -277,6 +284,7 @@ export class EpochCache { const validatorCount = validators.length; const effectiveBalanceIncrements = getEffectiveBalanceIncrementsWithLen(validatorCount); + const totalSlashingsByIncrement = getTotalSlashingsByIncrement(state); const previousActiveIndices: ValidatorIndex[] = []; const currentActiveIndices: ValidatorIndex[] = []; const nextActiveIndices: ValidatorIndex[] = []; @@ -425,6 +433,7 @@ export class EpochCache { currentShuffling, nextShuffling, effectiveBalanceIncrements, + totalSlashingsByIncrement, syncParticipantReward, syncProposerReward, baseRewardPerIncrement, @@ -464,6 +473,7 @@ export class EpochCache { // Uint8Array, requires cloning, but it is cloned only when necessary before an epoch transition // See EpochCache.beforeEpochTransition() effectiveBalanceIncrements: this.effectiveBalanceIncrements, + totalSlashingsByIncrement: this.totalSlashingsByIncrement, // Basic types (numbers) cloned implicitly syncParticipantReward: this.syncParticipantReward, syncProposerReward: this.syncProposerReward, diff --git a/packages/state-transition/src/epoch/index.ts b/packages/state-transition/src/epoch/index.ts index 80b6f83f4b8b..bb37ed17f4e1 100644 --- a/packages/state-transition/src/epoch/index.ts +++ b/packages/state-transition/src/epoch/index.ts @@ -1,4 +1,10 @@ -import {ForkSeq} from "@lodestar/params"; +import { + ForkSeq, + MAX_ATTESTER_SLASHINGS, + MAX_EFFECTIVE_BALANCE, + MAX_VALIDATORS_PER_COMMITTEE, + SLOTS_PER_EPOCH, +} from "@lodestar/params"; import { CachedBeaconStateAllForks, CachedBeaconStateCapella, @@ -41,15 +47,29 @@ export { }; export {computeUnrealizedCheckpoints} from "./computeUnrealizedCheckpoints.js"; +const maxValidatorsPerStateSlashing = SLOTS_PER_EPOCH * MAX_ATTESTER_SLASHINGS * MAX_VALIDATORS_PER_COMMITTEE; +const maxSafeValidators = Math.floor(Number.MAX_SAFE_INTEGER / MAX_EFFECTIVE_BALANCE); export function processEpoch(fork: ForkSeq, state: CachedBeaconStateAllForks, cache: EpochTransitionCache): void { + // state.slashings is initially a Gwei (BigInt) vector, however since Nov 2023 it's converted to UintNum64 (number) vector in the state transition because: + // - state.slashings[nextEpoch % EPOCHS_PER_SLASHINGS_VECTOR] is reset per epoch in processSlashingsReset() + // - max slashed validators per epoch is SLOTS_PER_EPOCH * MAX_ATTESTER_SLASHINGS * MAX_VALIDATORS_PER_COMMITTEE which is 32 * 2 * 2048 = 131072 on mainnet + // - with that and 32_000_000_000 MAX_EFFECTIVE_BALANCE, it still fits in a number given that Math.floor(Number.MAX_SAFE_INTEGER / 32_000_000_000) = 281474 + if (maxValidatorsPerStateSlashing > maxSafeValidators) { + throw new Error("Lodestar does not support this network, parameters don't fit number value inside state.slashings"); + } + processJustificationAndFinalization(state, cache); if (fork >= ForkSeq.altair) { processInactivityUpdates(state as CachedBeaconStateAltair, cache); } - processRewardsAndPenalties(state, cache); + // processRewardsAndPenalties() is 2nd step in the specs, we optimize to do it + // after processSlashings() to update balances only once + // processRewardsAndPenalties(state, cache); processRegistryUpdates(state, cache); - processSlashings(state, cache); + // accumulate slashing penalties and only update balances once in processRewardsAndPenalties() + const slashingPenalties = processSlashings(state, cache, false); + processRewardsAndPenalties(state, cache, slashingPenalties); processEth1DataReset(state, cache); processEffectiveBalanceUpdates(state, cache); processSlashingsReset(state, cache); diff --git a/packages/state-transition/src/epoch/processRewardsAndPenalties.ts b/packages/state-transition/src/epoch/processRewardsAndPenalties.ts index 20cf8597298b..61680b81002a 100644 --- a/packages/state-transition/src/epoch/processRewardsAndPenalties.ts +++ b/packages/state-transition/src/epoch/processRewardsAndPenalties.ts @@ -14,7 +14,11 @@ import {getRewardsAndPenaltiesAltair} from "./getRewardsAndPenalties.js"; * * PERF: Cost = 'proportional' to $VALIDATOR_COUNT. Extra work is done per validator the more status flags are set */ -export function processRewardsAndPenalties(state: CachedBeaconStateAllForks, cache: EpochTransitionCache): void { +export function processRewardsAndPenalties( + state: CachedBeaconStateAllForks, + cache: EpochTransitionCache, + slashingPenalties: number[] = [] +): void { // No rewards are applied at the end of `GENESIS_EPOCH` because rewards are for work done in the previous epoch if (cache.currentEpoch === GENESIS_EPOCH) { return; @@ -24,7 +28,7 @@ export function processRewardsAndPenalties(state: CachedBeaconStateAllForks, cac const balances = state.balances.getAll(); for (let i = 0, len = rewards.length; i < len; i++) { - balances[i] += rewards[i] - penalties[i]; + balances[i] += rewards[i] - penalties[i] - (slashingPenalties[i] ?? 0); } // important: do not change state one balance at a time. Set them all at once, constructing the tree in one go diff --git a/packages/state-transition/src/epoch/processSlashings.ts b/packages/state-transition/src/epoch/processSlashings.ts index 456f1ebc31fa..7f4403dc027a 100644 --- a/packages/state-transition/src/epoch/processSlashings.ts +++ b/packages/state-transition/src/epoch/processSlashings.ts @@ -1,4 +1,3 @@ -import {bigIntMin} from "@lodestar/utils"; import { EFFECTIVE_BALANCE_INCREMENT, ForkSeq, @@ -8,33 +7,35 @@ import { } from "@lodestar/params"; import {decreaseBalance} from "../util/index.js"; -import {CachedBeaconStateAllForks, EpochTransitionCache} from "../types.js"; +import {BeaconStateAllForks, CachedBeaconStateAllForks, EpochTransitionCache} from "../types.js"; /** * Update validator registry for validators that activate + exit + * updateBalance is an optimization: + * - For spec test, it's true + * - For processEpoch flow, it's false, i.e to only update balances once in processRewardsAndPenalties() * - * PERF: Cost 'proportional' to only validators that are slashed. For mainnet conditions: + * PERF: almost no (constant) cost. + * - Total slashings by increment is computed once and stored in state.epochCtx.totalSlashingsByIncrement so no need to compute here + * - Penalties for validators with the same effective balance are the same and computed once + * - No need to apply penalties to validators here, do it once in processRewardsAndPenalties() * - indicesToSlash: max len is 8704. But it's very unlikely since it would require all validators on the same * committees to sign slashable attestations. - * * - On normal mainnet conditions indicesToSlash = 0 + * + * @returns slashing penalties to be applied in processRewardsAndPenalties() */ -export function processSlashings(state: CachedBeaconStateAllForks, cache: EpochTransitionCache): void { - // No need to compute totalSlashings if there no index to slash +export function processSlashings( + state: CachedBeaconStateAllForks, + cache: EpochTransitionCache, + updateBalance = true +): number[] { + // Return early if there no index to slash if (cache.indicesToSlash.length === 0) { - return; - } - // TODO: have the regular totalBalance in EpochTransitionCache too? - const totalBalance = BigInt(cache.totalActiveStakeByIncrement) * BigInt(EFFECTIVE_BALANCE_INCREMENT); - - // TODO: Could totalSlashings be number? - // TODO: Could totalSlashing be cached? - let totalSlashings = BigInt(0); - const slashings = state.slashings.getAll(); - for (let i = 0; i < slashings.length; i++) { - totalSlashings += slashings[i]; + return []; } + const totalBalanceByIncrement = cache.totalActiveStakeByIncrement; const fork = state.config.getForkSeq(state.slot); const proportionalSlashingMultiplier = fork === ForkSeq.phase0 @@ -44,12 +45,46 @@ export function processSlashings(state: CachedBeaconStateAllForks, cache: EpochT : PROPORTIONAL_SLASHING_MULTIPLIER_BELLATRIX; const {effectiveBalanceIncrements} = state.epochCtx; - const adjustedTotalSlashingBalance = bigIntMin(totalSlashings * BigInt(proportionalSlashingMultiplier), totalBalance); + const adjustedTotalSlashingBalanceByIncrement = Math.min( + state.epochCtx.totalSlashingsByIncrement * proportionalSlashingMultiplier, + totalBalanceByIncrement + ); const increment = EFFECTIVE_BALANCE_INCREMENT; + const penalties: number[] = []; + + const penaltiesByEffectiveBalanceIncrement = new Map(); for (const index of cache.indicesToSlash) { const effectiveBalanceIncrement = effectiveBalanceIncrements[index]; - const penaltyNumerator = BigInt(effectiveBalanceIncrement) * adjustedTotalSlashingBalance; - const penalty = Number(penaltyNumerator / totalBalance) * increment; - decreaseBalance(state, index, penalty); + let penalty = penaltiesByEffectiveBalanceIncrement.get(effectiveBalanceIncrement); + if (penalty === undefined) { + const penaltyNumeratorByIncrement = effectiveBalanceIncrement * adjustedTotalSlashingBalanceByIncrement; + penalty = Math.floor(penaltyNumeratorByIncrement / totalBalanceByIncrement) * increment; + penaltiesByEffectiveBalanceIncrement.set(effectiveBalanceIncrement, penalty); + } + + if (updateBalance) { + // for spec test only + decreaseBalance(state, index, penalty); + } else { + // do it later in processRewardsAndPenalties() + penalties[index] = penalty; + } + } + + return penalties; +} + +/** + * Get total slashings by increment. + * By default, total slashings are computed every time we run processSlashings() function above. + * We improve it by computing it once and store it in state.epochCtx.totalSlashingsByIncrement + * Every change to state.slashings should update totalSlashingsByIncrement. + */ +export function getTotalSlashingsByIncrement(state: BeaconStateAllForks): number { + let totalSlashingsByIncrement = 0; + const slashings = state.slashings.getAll(); + for (let i = 0; i < slashings.length; i++) { + totalSlashingsByIncrement += Math.floor(slashings[i] / EFFECTIVE_BALANCE_INCREMENT); } + return totalSlashingsByIncrement; } diff --git a/packages/state-transition/src/epoch/processSlashingsReset.ts b/packages/state-transition/src/epoch/processSlashingsReset.ts index 38fd43ffa6a8..6ab22d47526c 100644 --- a/packages/state-transition/src/epoch/processSlashingsReset.ts +++ b/packages/state-transition/src/epoch/processSlashingsReset.ts @@ -1,4 +1,4 @@ -import {EPOCHS_PER_SLASHINGS_VECTOR} from "@lodestar/params"; +import {EFFECTIVE_BALANCE_INCREMENT, EPOCHS_PER_SLASHINGS_VECTOR} from "@lodestar/params"; import {EpochTransitionCache, CachedBeaconStateAllForks} from "../types.js"; /** @@ -10,5 +10,11 @@ export function processSlashingsReset(state: CachedBeaconStateAllForks, cache: E const nextEpoch = cache.currentEpoch + 1; // reset slashings - state.slashings.set(nextEpoch % EPOCHS_PER_SLASHINGS_VECTOR, BigInt(0)); + const slashIndex = nextEpoch % EPOCHS_PER_SLASHINGS_VECTOR; + const oldSlashingValueByIncrement = Math.floor(state.slashings.get(slashIndex) / EFFECTIVE_BALANCE_INCREMENT); + state.slashings.set(slashIndex, 0); + state.epochCtx.totalSlashingsByIncrement = Math.max( + 0, + state.epochCtx.totalSlashingsByIncrement - oldSlashingValueByIncrement + ); } diff --git a/packages/state-transition/test/perf/epoch/epochAltair.test.ts b/packages/state-transition/test/perf/epoch/epochAltair.test.ts index 414cd05164c2..7e84a533e6c6 100644 --- a/packages/state-transition/test/perf/epoch/epochAltair.test.ts +++ b/packages/state-transition/test/perf/epoch/epochAltair.test.ts @@ -126,7 +126,9 @@ function benchmarkAltairEpochSteps(stateOg: LazyValue itBench({ id: `${stateId} - altair processSlashings`, beforeEach: () => stateOg.value.clone() as CachedBeaconStateAltair, - fn: (state) => processSlashings(state, cache.value), + fn: (state) => { + processSlashings(state, cache.value, false); + }, }); itBench({ diff --git a/packages/state-transition/test/perf/epoch/epochCapella.test.ts b/packages/state-transition/test/perf/epoch/epochCapella.test.ts index 86620fa2dfcf..36d5caf11a99 100644 --- a/packages/state-transition/test/perf/epoch/epochCapella.test.ts +++ b/packages/state-transition/test/perf/epoch/epochCapella.test.ts @@ -105,7 +105,9 @@ function benchmarkAltairEpochSteps(stateOg: LazyValue itBench({ id: `${stateId} - capella processSlashings`, beforeEach: () => stateOg.value.clone() as CachedBeaconStateCapella, - fn: (state) => processSlashings(state, cache.value), + fn: (state) => { + processSlashings(state, cache.value, false); + }, }); itBench({ diff --git a/packages/state-transition/test/perf/epoch/epochPhase0.test.ts b/packages/state-transition/test/perf/epoch/epochPhase0.test.ts index fa5538a7a66a..ae6b5a536be2 100644 --- a/packages/state-transition/test/perf/epoch/epochPhase0.test.ts +++ b/packages/state-transition/test/perf/epoch/epochPhase0.test.ts @@ -108,7 +108,9 @@ function benchmarkPhase0EpochSteps(stateOg: LazyValue itBench({ id: `${stateId} - phase0 processSlashings`, beforeEach: () => stateOg.value.clone() as CachedBeaconStatePhase0, - fn: (state) => processSlashings(state, cache.value), + fn: (state) => { + processSlashings(state, cache.value, false); + }, }); itBench({ diff --git a/packages/state-transition/test/perf/epoch/processSlashingsAllForks.test.ts b/packages/state-transition/test/perf/epoch/processSlashingsAllForks.test.ts index e3701985dac8..3b0bfa623fb2 100644 --- a/packages/state-transition/test/perf/epoch/processSlashingsAllForks.test.ts +++ b/packages/state-transition/test/perf/epoch/processSlashingsAllForks.test.ts @@ -1,4 +1,5 @@ import {itBench} from "@dapplion/benchmark"; +import {MAX_EFFECTIVE_BALANCE} from "@lodestar/params"; import { beforeProcessEpoch, CachedBeaconStatePhase0, @@ -34,7 +35,9 @@ describe("phase0 processSlashings", () => { minRuns: 5, // Worst case is very slow before: () => getProcessSlashingsTestData(indicesToSlashLen), beforeEach: ({state, cache}) => ({state: state.clone(), cache}), - fn: ({state, cache}) => processSlashings(state as CachedBeaconStatePhase0, cache), + fn: ({state, cache}) => { + processSlashings(state as CachedBeaconStatePhase0, cache, false); + }, }); } }); @@ -48,6 +51,11 @@ function getProcessSlashingsTestData(indicesToSlashLen: number): { } { const state = generatePerfTestCachedStatePhase0({goBackOneSlot: true}); const cache = beforeProcessEpoch(state); + state.slashings.set(0, indicesToSlashLen * MAX_EFFECTIVE_BALANCE); + for (let i = 1; i < state.slashings.length; i++) { + state.slashings.set(i, MAX_EFFECTIVE_BALANCE); + } + state.commit(); cache.indicesToSlash = linspace(indicesToSlashLen); diff --git a/packages/state-transition/test/utils/capella.ts b/packages/state-transition/test/utils/capella.ts index 5789c260f67c..e2cdc47b7e1d 100644 --- a/packages/state-transition/test/utils/capella.ts +++ b/packages/state-transition/test/utils/capella.ts @@ -102,7 +102,7 @@ export function modifyStateSameValidator(seedState: BeaconStateCapella): BeaconS state.eth1DepositIndex = 1000; state.balances.set(0, 30); state.randaoMixes.set(0, crypto.randomBytes(32)); - state.slashings.set(0, 1n); + state.slashings.set(0, 1); state.previousEpochParticipation.set(0, 0b11111110); state.currentEpochParticipation.set(0, 0b11111110); state.justificationBits.set(0, true); diff --git a/packages/state-transition/test/utils/state.ts b/packages/state-transition/test/utils/state.ts index 614210e44afa..29a1f98b5562 100644 --- a/packages/state-transition/test/utils/state.ts +++ b/packages/state-transition/test/utils/state.ts @@ -11,7 +11,7 @@ import {config} from "@lodestar/config/default"; import {createBeaconConfig, ChainForkConfig} from "@lodestar/config"; import {ZERO_HASH} from "../../src/constants/index.js"; -import {newZeroedBigIntArray} from "../../src/util/index.js"; +import {newZeroedArray} from "../../src/util/index.js"; import { BeaconStatePhase0, @@ -64,7 +64,7 @@ export function generateState(opts?: TestBeaconState): BeaconStatePhase0 { validators: [], balances: [], randaoMixes: Array.from({length: EPOCHS_PER_HISTORICAL_VECTOR}, () => ZERO_HASH), - slashings: newZeroedBigIntArray(EPOCHS_PER_SLASHINGS_VECTOR), + slashings: newZeroedArray(EPOCHS_PER_SLASHINGS_VECTOR), previousEpochAttestations: [], currentEpochAttestations: [], justificationBits: ssz.phase0.JustificationBits.defaultValue(), diff --git a/packages/types/src/phase0/sszTypes.ts b/packages/types/src/phase0/sszTypes.ts index ea06be588849..78988465d274 100644 --- a/packages/types/src/phase0/sszTypes.ts +++ b/packages/types/src/phase0/sszTypes.ts @@ -39,7 +39,6 @@ const { EpochInf, CommitteeIndex, ValidatorIndex, - Gwei, Root, Version, ForkDigest, @@ -248,7 +247,14 @@ export const Validator = ValidatorNodeStruct; export const Validators = new ListCompositeType(ValidatorNodeStruct, VALIDATOR_REGISTRY_LIMIT); export const Balances = new ListBasicType(UintNum64, VALIDATOR_REGISTRY_LIMIT); export const RandaoMixes = new VectorCompositeType(Bytes32, EPOCHS_PER_HISTORICAL_VECTOR); -export const Slashings = new VectorBasicType(Gwei, EPOCHS_PER_SLASHINGS_VECTOR); +/** + * This is initially a Gwei (BigInt) vector, however since Nov 2023 it's converted to UintNum64 (number) vector in the state transition because: + * - state.slashings[nextEpoch % EPOCHS_PER_SLASHINGS_VECTOR] is reset per epoch in processSlashingsReset() + * - max slashed validators per epoch is SLOTS_PER_EPOCH * MAX_ATTESTER_SLASHINGS * MAX_VALIDATORS_PER_COMMITTEE which is 32 * 2 * 2048 = 131072 on mainnet + * - with that and 32_000_000_000 MAX_EFFECTIVE_BALANCE, it still fits in a number given that Math.floor(Number.MAX_SAFE_INTEGER / 32_000_000_000) = 281474 + * - we don't need to compute the total slashings from state.slashings, it's handled by totalSlashingsByIncrement in EpochCache + */ +export const Slashings = new VectorBasicType(UintNum64, EPOCHS_PER_SLASHINGS_VECTOR); export const JustificationBits = new BitVectorType(JUSTIFICATION_BITS_LENGTH); // Misc dependants