diff --git a/packages/api/package.json b/packages/api/package.json index 4cddda4b4033..7c3f73db80eb 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -70,7 +70,7 @@ }, "dependencies": { "@chainsafe/persistent-merkle-tree": "^0.6.1", - "@chainsafe/ssz": "^0.13.0", + "@chainsafe/ssz": "^0.14.0", "@lodestar/config": "^1.11.3", "@lodestar/params": "^1.11.3", "@lodestar/types": "^1.11.3", diff --git a/packages/beacon-node/package.json b/packages/beacon-node/package.json index 88c50e6c291f..68a643d815d7 100644 --- a/packages/beacon-node/package.json +++ b/packages/beacon-node/package.json @@ -104,7 +104,7 @@ "@chainsafe/libp2p-noise": "^13.0.1", "@chainsafe/persistent-merkle-tree": "^0.6.1", "@chainsafe/prometheus-gc-stats": "^1.0.0", - "@chainsafe/ssz": "^0.13.0", + "@chainsafe/ssz": "^0.14.0", "@chainsafe/threads": "^1.11.1", "@ethersproject/abi": "^5.7.0", "@fastify/bearer-auth": "^9.0.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index a595d401caed..7a7a64820ff7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,7 +59,7 @@ "@chainsafe/bls-keystore": "^2.0.0", "@chainsafe/blst": "^0.2.9", "@chainsafe/discv5": "^5.1.0", - "@chainsafe/ssz": "^0.13.0", + "@chainsafe/ssz": "^0.14.0", "@chainsafe/threads": "^1.11.1", "@libp2p/crypto": "^2.0.4", "@libp2p/peer-id": "^3.0.2", diff --git a/packages/config/package.json b/packages/config/package.json index 79df337ea1c5..29868a6d9d60 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -64,7 +64,7 @@ "blockchain" ], "dependencies": { - "@chainsafe/ssz": "^0.13.0", + "@chainsafe/ssz": "^0.14.0", "@lodestar/params": "^1.11.3", "@lodestar/types": "^1.11.3" } diff --git a/packages/db/package.json b/packages/db/package.json index a8e48a5eb020..fd898d6134a6 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -37,7 +37,7 @@ "check-readme": "typescript-docs-verifier" }, "dependencies": { - "@chainsafe/ssz": "^0.13.0", + "@chainsafe/ssz": "^0.14.0", "@lodestar/config": "^1.11.3", "@lodestar/utils": "^1.11.3", "@types/levelup": "^4.3.3", diff --git a/packages/fork-choice/package.json b/packages/fork-choice/package.json index ec12497592ac..221c8c7cbb5e 100644 --- a/packages/fork-choice/package.json +++ b/packages/fork-choice/package.json @@ -38,7 +38,7 @@ "check-readme": "typescript-docs-verifier" }, "dependencies": { - "@chainsafe/ssz": "^0.13.0", + "@chainsafe/ssz": "^0.14.0", "@lodestar/config": "^1.11.3", "@lodestar/params": "^1.11.3", "@lodestar/state-transition": "^1.11.3", diff --git a/packages/light-client/package.json b/packages/light-client/package.json index 8a33f2fa862c..04a62af807c2 100644 --- a/packages/light-client/package.json +++ b/packages/light-client/package.json @@ -66,7 +66,7 @@ "dependencies": { "@chainsafe/bls": "7.1.1", "@chainsafe/persistent-merkle-tree": "^0.6.1", - "@chainsafe/ssz": "^0.13.0", + "@chainsafe/ssz": "^0.14.0", "@lodestar/api": "^1.11.3", "@lodestar/config": "^1.11.3", "@lodestar/params": "^1.11.3", diff --git a/packages/state-transition/package.json b/packages/state-transition/package.json index ec2b7dfe0b31..325877de05ac 100644 --- a/packages/state-transition/package.json +++ b/packages/state-transition/package.json @@ -59,9 +59,10 @@ "dependencies": { "@chainsafe/as-sha256": "^0.3.1", "@chainsafe/bls": "7.1.1", + "@chainsafe/blst": "^0.2.9", "@chainsafe/persistent-merkle-tree": "^0.6.1", "@chainsafe/persistent-ts": "^0.19.1", - "@chainsafe/ssz": "^0.13.0", + "@chainsafe/ssz": "^0.14.0", "@lodestar/config": "^1.11.3", "@lodestar/params": "^1.11.3", "@lodestar/types": "^1.11.3", diff --git a/packages/state-transition/src/cache/epochCache.ts b/packages/state-transition/src/cache/epochCache.ts index 9892de37a569..781d5f6683b8 100644 --- a/packages/state-transition/src/cache/epochCache.ts +++ b/packages/state-transition/src/cache/epochCache.ts @@ -26,12 +26,12 @@ import { computeProposers, getActivationChurnLimit, } from "../util/index.js"; -import {computeEpochShuffling, EpochShuffling} from "../util/epochShuffling.js"; +import {computeEpochShuffling, EpochShuffling, getShufflingDecisionBlock} from "../util/epochShuffling.js"; import {computeBaseRewardPerIncrement, computeSyncParticipantReward} from "../util/syncCommittee.js"; import {sumTargetUnslashedBalanceIncrements} from "../util/targetUnslashedBalance.js"; import {EffectiveBalanceIncrements, getEffectiveBalanceIncrementsWithLen} from "./effectiveBalanceIncrements.js"; import {Index2PubkeyCache, PubkeyIndexMap, syncPubkeys} from "./pubkeyCache.js"; -import {BeaconStateAllForks, BeaconStateAltair} from "./types.js"; +import {BeaconStateAllForks, BeaconStateAltair, ShufflingGetter} from "./types.js"; import { computeSyncCommitteeCache, getSyncCommitteeCache, @@ -51,6 +51,7 @@ export type EpochCacheImmutableData = { export type EpochCacheOpts = { skipSyncCommitteeCache?: boolean; skipSyncPubkeys?: boolean; + shufflingGetter?: ShufflingGetter; }; /** Defers computing proposers by persisting only the seed, and dropping it once indexes are computed */ @@ -280,21 +281,32 @@ export class EpochCache { const currentActiveIndices: ValidatorIndex[] = []; const nextActiveIndices: ValidatorIndex[] = []; + // BeaconChain could provide a shuffling cache to avoid re-computing shuffling every epoch + // in that case, we don't need to compute shufflings again + const previousShufflingDecisionBlock = getShufflingDecisionBlock(state, previousEpoch); + const cachedPreviousShuffling = opts?.shufflingGetter?.(previousEpoch, previousShufflingDecisionBlock); + const currentShufflingDecisionBlock = getShufflingDecisionBlock(state, currentEpoch); + const cachedCurrentShuffling = opts?.shufflingGetter?.(currentEpoch, currentShufflingDecisionBlock); + const nextShufflingDecisionBlock = getShufflingDecisionBlock(state, nextEpoch); + const cachedNextShuffling = opts?.shufflingGetter?.(nextEpoch, nextShufflingDecisionBlock); + for (let i = 0; i < validatorCount; i++) { const validator = validators[i]; // Note: Not usable for fork-choice balances since in-active validators are not zero'ed effectiveBalanceIncrements[i] = Math.floor(validator.effectiveBalance / EFFECTIVE_BALANCE_INCREMENT); - if (isActiveValidator(validator, previousEpoch)) { + // we only need to track active indices for previous, current and next epoch if we have to compute shufflings + // skip doing that if we already have cached shufflings + if (cachedPreviousShuffling == null && isActiveValidator(validator, previousEpoch)) { previousActiveIndices.push(i); } - if (isActiveValidator(validator, currentEpoch)) { + if (cachedCurrentShuffling == null && isActiveValidator(validator, currentEpoch)) { currentActiveIndices.push(i); // We track totalActiveBalanceIncrements as ETH to fit total network balance in a JS number (53 bits) totalActiveBalanceIncrements += effectiveBalanceIncrements[i]; } - if (isActiveValidator(validator, nextEpoch)) { + if (cachedNextShuffling == null && isActiveValidator(validator, nextEpoch)) { nextActiveIndices.push(i); } @@ -317,11 +329,11 @@ export class EpochCache { throw Error("totalActiveBalanceIncrements >= Number.MAX_SAFE_INTEGER. MAX_EFFECTIVE_BALANCE is too low."); } - const currentShuffling = computeEpochShuffling(state, currentActiveIndices, currentEpoch); - const previousShuffling = isGenesis - ? currentShuffling - : computeEpochShuffling(state, previousActiveIndices, previousEpoch); - const nextShuffling = computeEpochShuffling(state, nextActiveIndices, nextEpoch); + const currentShuffling = cachedCurrentShuffling ?? computeEpochShuffling(state, currentActiveIndices, currentEpoch); + const previousShuffling = + cachedPreviousShuffling ?? + (isGenesis ? currentShuffling : computeEpochShuffling(state, previousActiveIndices, previousEpoch)); + const nextShuffling = cachedNextShuffling ?? computeEpochShuffling(state, nextActiveIndices, nextEpoch); const currentProposerSeed = getSeed(state, currentEpoch, DOMAIN_BEACON_PROPOSER); diff --git a/packages/state-transition/src/cache/stateCache.ts b/packages/state-transition/src/cache/stateCache.ts index f8ce97d5ffbd..14a29b5f09c0 100644 --- a/packages/state-transition/src/cache/stateCache.ts +++ b/packages/state-transition/src/cache/stateCache.ts @@ -1,4 +1,7 @@ +import bls from "@chainsafe/bls"; +import {CoordType} from "@chainsafe/blst"; import {BeaconConfig} from "@lodestar/config"; +import {loadState} from "../util/loadState/loadState.js"; import {EpochCache, EpochCacheImmutableData, EpochCacheOpts} from "./epochCache.js"; import { BeaconStateAllForks, @@ -137,13 +140,49 @@ export function createCachedBeaconState( immutableData: EpochCacheImmutableData, opts?: EpochCacheOpts ): T & BeaconStateCache { - return getCachedBeaconState(state, { + const epochCache = EpochCache.createFromState(state, immutableData, opts); + const cachedState = getCachedBeaconState(state, { config: immutableData.config, - epochCtx: EpochCache.createFromState(state, immutableData, opts), + epochCtx: epochCache, clonedCount: 0, clonedCountWithTransferCache: 0, createdWithTransferCache: false, }); + + return cachedState; +} + +/** + * Create a CachedBeaconState given a cached seed state and state bytes + * This guarantees that the returned state shares the same tree with the seed state + * Check loadState() api for more details + * TODO: after EIP-6110 need to provide a pivotValidatorIndex to decide which comes to finalized validators cache, which comes to unfinalized cache + */ +export function loadUnfinalizedCachedBeaconState( + cachedSeedState: T, + stateBytes: Uint8Array, + opts?: EpochCacheOpts +): T { + const {state: migratedState, modifiedValidators} = loadState(cachedSeedState.config, cachedSeedState, stateBytes); + const {pubkey2index, index2pubkey} = cachedSeedState.epochCtx; + // Get the validators sub tree once for all the loop + const validators = migratedState.validators; + for (const validatorIndex of modifiedValidators) { + const validator = validators.getReadonly(validatorIndex); + const pubkey = validator.pubkey; + pubkey2index.set(pubkey, validatorIndex); + index2pubkey[validatorIndex] = bls.PublicKey.fromBytes(pubkey, CoordType.jacobian); + } + + return createCachedBeaconState( + migratedState, + { + config: cachedSeedState.config, + pubkey2index, + index2pubkey, + }, + {...(opts ?? {}), ...{skipSyncPubkeys: true}} + ) as T; } /** diff --git a/packages/state-transition/src/cache/types.ts b/packages/state-transition/src/cache/types.ts index 9d0115cee780..39b1dbb4b45b 100644 --- a/packages/state-transition/src/cache/types.ts +++ b/packages/state-transition/src/cache/types.ts @@ -1,5 +1,6 @@ import {CompositeViewDU} from "@chainsafe/ssz"; -import {ssz} from "@lodestar/types"; +import {Epoch, RootHex, ssz} from "@lodestar/types"; +import {EpochShuffling} from "../util/epochShuffling.js"; export type BeaconStatePhase0 = CompositeViewDU; export type BeaconStateAltair = CompositeViewDU; @@ -20,3 +21,5 @@ export type BeaconStateAllForks = | BeaconStateDeneb; export type BeaconStateExecutions = BeaconStateBellatrix | BeaconStateCapella | BeaconStateDeneb; + +export type ShufflingGetter = (shufflingEpoch: Epoch, dependentRoot: RootHex) => EpochShuffling | null; diff --git a/packages/state-transition/src/index.ts b/packages/state-transition/src/index.ts index f9db0bb84887..3c71c7687602 100644 --- a/packages/state-transition/src/index.ts +++ b/packages/state-transition/src/index.ts @@ -25,6 +25,7 @@ export type { // Main state caches export { createCachedBeaconState, + loadUnfinalizedCachedBeaconState, type BeaconStateCache, isCachedBeaconState, isStateBalancesNodesPopulated, diff --git a/packages/state-transition/src/util/epochShuffling.ts b/packages/state-transition/src/util/epochShuffling.ts index 37ac6ba0c8d9..f9172126250f 100644 --- a/packages/state-transition/src/util/epochShuffling.ts +++ b/packages/state-transition/src/util/epochShuffling.ts @@ -1,4 +1,5 @@ -import {Epoch, ValidatorIndex} from "@lodestar/types"; +import {toHexString} from "@chainsafe/ssz"; +import {Epoch, RootHex, ValidatorIndex} from "@lodestar/types"; import {intDiv} from "@lodestar/utils"; import { DOMAIN_BEACON_ATTESTER, @@ -9,6 +10,8 @@ import { import {BeaconStateAllForks} from "../types.js"; import {getSeed} from "./seed.js"; import {unshuffleList} from "./shuffle.js"; +import {computeStartSlotAtEpoch} from "./epoch.js"; +import {getBlockRootAtSlot} from "./blockRoot.js"; /** * Readonly interface for EpochShuffling. @@ -95,3 +98,8 @@ export function computeEpochShuffling( committeesPerSlot, }; } + +export function getShufflingDecisionBlock(state: BeaconStateAllForks, epoch: Epoch): RootHex { + const pivotSlot = computeStartSlotAtEpoch(epoch - 1) - 1; + return toHexString(getBlockRootAtSlot(state, pivotSlot)); +} diff --git a/packages/state-transition/src/util/loadState/findModifiedInactivityScores.ts b/packages/state-transition/src/util/loadState/findModifiedInactivityScores.ts new file mode 100644 index 000000000000..f76e4dc650dc --- /dev/null +++ b/packages/state-transition/src/util/loadState/findModifiedInactivityScores.ts @@ -0,0 +1,47 @@ +// UintNum64 = 8 bytes +export const INACTIVITY_SCORE_SIZE = 8; + +/** + * As monitored on mainnet, inactivityScores are not changed much and they are mostly 0 + * Using Buffer.compare is the fastest way as noted in `./findModifiedValidators.ts` + * @returns output parameter modifiedValidators: validator indices that are modified + */ +export function findModifiedInactivityScores( + inactivityScoresBytes: Uint8Array, + inactivityScoresBytes2: Uint8Array, + modifiedValidators: number[], + validatorOffset = 0 +): void { + if (inactivityScoresBytes.length !== inactivityScoresBytes2.length) { + throw new Error( + "inactivityScoresBytes.length !== inactivityScoresBytes2.length " + + inactivityScoresBytes.length + + " vs " + + inactivityScoresBytes2.length + ); + } + + if (Buffer.compare(inactivityScoresBytes, inactivityScoresBytes2) === 0) { + return; + } + + if (inactivityScoresBytes.length === INACTIVITY_SCORE_SIZE) { + modifiedValidators.push(validatorOffset); + return; + } + + const numValidator = Math.floor(inactivityScoresBytes.length / INACTIVITY_SCORE_SIZE); + const halfValidator = Math.floor(numValidator / 2); + findModifiedInactivityScores( + inactivityScoresBytes.subarray(0, halfValidator * INACTIVITY_SCORE_SIZE), + inactivityScoresBytes2.subarray(0, halfValidator * INACTIVITY_SCORE_SIZE), + modifiedValidators, + validatorOffset + ); + findModifiedInactivityScores( + inactivityScoresBytes.subarray(halfValidator * INACTIVITY_SCORE_SIZE), + inactivityScoresBytes2.subarray(halfValidator * INACTIVITY_SCORE_SIZE), + modifiedValidators, + validatorOffset + halfValidator + ); +} diff --git a/packages/state-transition/src/util/loadState/findModifiedValidators.ts b/packages/state-transition/src/util/loadState/findModifiedValidators.ts new file mode 100644 index 000000000000..b47789f42b47 --- /dev/null +++ b/packages/state-transition/src/util/loadState/findModifiedValidators.ts @@ -0,0 +1,46 @@ +import {VALIDATOR_BYTES_SIZE} from "../sszBytes.js"; + +/** + * Find modified validators by comparing two validators bytes using Buffer.compare() recursively + * - As noted in packages/state-transition/test/perf/util/loadState/findModifiedValidators.test.ts, serializing validators and compare Uint8Array is the fastest way + * - The performance is quite stable and can afford a lot of difference in validators (the benchmark tested up to 10k but it's not likely we have that difference in mainnet) + * - Also packages/state-transition/test/perf/misc/byteArrayEquals.test.ts shows that Buffer.compare() is very efficient for large Uint8Array + * + * @returns output parameter modifiedValidators: validator indices that are modified + */ +export function findModifiedValidators( + validatorsBytes: Uint8Array, + validatorsBytes2: Uint8Array, + modifiedValidators: number[], + validatorOffset = 0 +): void { + if (validatorsBytes.length !== validatorsBytes2.length) { + throw new Error( + "validatorsBytes.length !== validatorsBytes2.length " + validatorsBytes.length + " vs " + validatorsBytes2.length + ); + } + + if (Buffer.compare(validatorsBytes, validatorsBytes2) === 0) { + return; + } + + if (validatorsBytes.length === VALIDATOR_BYTES_SIZE) { + modifiedValidators.push(validatorOffset); + return; + } + + const numValidator = Math.floor(validatorsBytes.length / VALIDATOR_BYTES_SIZE); + const halfValidator = Math.floor(numValidator / 2); + findModifiedValidators( + validatorsBytes.subarray(0, halfValidator * VALIDATOR_BYTES_SIZE), + validatorsBytes2.subarray(0, halfValidator * VALIDATOR_BYTES_SIZE), + modifiedValidators, + validatorOffset + ); + findModifiedValidators( + validatorsBytes.subarray(halfValidator * VALIDATOR_BYTES_SIZE), + validatorsBytes2.subarray(halfValidator * VALIDATOR_BYTES_SIZE), + modifiedValidators, + validatorOffset + halfValidator + ); +} diff --git a/packages/state-transition/src/util/loadState/loadState.ts b/packages/state-transition/src/util/loadState/loadState.ts new file mode 100644 index 000000000000..83377101609d --- /dev/null +++ b/packages/state-transition/src/util/loadState/loadState.ts @@ -0,0 +1,201 @@ +import {deserializeContainerIgnoreFields, ssz} from "@lodestar/types"; +import {ForkSeq} from "@lodestar/params"; +import {ChainForkConfig} from "@lodestar/config"; +import {BeaconStateAllForks, BeaconStateAltair} from "../../types.js"; +import {VALIDATOR_BYTES_SIZE, getForkFromStateBytes, getStateTypeFromBytes} from "../sszBytes.js"; +import {findModifiedValidators} from "./findModifiedValidators.js"; +import {findModifiedInactivityScores} from "./findModifiedInactivityScores.js"; +import {loadValidator} from "./loadValidator.js"; + +type MigrateStateOutput = {state: BeaconStateAllForks; modifiedValidators: number[]}; + +/** + * Load state from bytes given a seed state so that we share the same base tree. This gives some benefits: + * - Have single base tree across the application + * - Faster to load state + * - Less memory usage + * - Utilize the cached HashObjects in seed state due to a lot of validators are not changed, also the inactivity scores. + * @returns the new state and modified validators + */ +export function loadState( + config: ChainForkConfig, + seedState: BeaconStateAllForks, + stateBytes: Uint8Array +): MigrateStateOutput { + // casting only to make typescript happy + const stateType = getStateTypeFromBytes(config, stateBytes) as typeof ssz.capella.BeaconState; + const dataView = new DataView(stateBytes.buffer, stateBytes.byteOffset, stateBytes.byteLength); + const fieldRanges = stateType.getFieldRanges(dataView, 0, stateBytes.length); + const allFields = Object.keys(stateType.fields); + const validatorsFieldIndex = allFields.indexOf("validators"); + // start with default view has the same performance to start with seed state + // and it is not fork dependent + const migratedState = deserializeContainerIgnoreFields( + stateType, + stateBytes, + ["validators", "inactivityScores"], + fieldRanges + ) as BeaconStateAllForks; + + // validators are rarely changed + const validatorsRange = fieldRanges[validatorsFieldIndex]; + const modifiedValidators = loadValidators( + migratedState, + seedState, + stateBytes.subarray(validatorsRange.start, validatorsRange.end) + ); + + // inactivityScores are rarely changed + // this saves ~500ms of hashTreeRoot() time of state + const fork = getForkFromStateBytes(config, stateBytes); + const seedFork = config.getForkSeq(seedState.slot); + + if (fork >= ForkSeq.altair && seedFork >= ForkSeq.altair) { + const inactivityScoresIndex = allFields.indexOf("inactivityScores"); + const inactivityScoresRange = fieldRanges[inactivityScoresIndex]; + loadInactivityScores( + migratedState as BeaconStateAltair, + seedState as BeaconStateAltair, + stateBytes.subarray(inactivityScoresRange.start, inactivityScoresRange.end) + ); + } + migratedState.commit(); + + return {state: migratedState, modifiedValidators}; +} + +/** + * This value is rarely changed as monitored 3 month state diffs on mainnet as of Sep 2023. + * Reusing this data helps save hashTreeRoot time of state ~500ms + * + * Given the below tree: + * + * seedState.inactivityScores ====> ROOT + * / \ + * Hash01 Hash23 + * / \ / \ + * Sco0 Sco1 Sco2 Sco3 + * + * if score 3 is modified, the new tree looks like this: + * + * migratedState.inactivityScores ====> ROOTa + * / \ + * Hash01 Hash23a + * / \ / \ + * Sco0 Sco1 Sco2 Sco3a + */ +function loadInactivityScores( + migratedState: BeaconStateAltair, + seedState: BeaconStateAltair, + inactivityScoresBytes: Uint8Array +): void { + // migratedState starts with the same inactivityScores to seed state + migratedState.inactivityScores = seedState.inactivityScores.clone(); + const oldValidator = migratedState.inactivityScores.length; + // UintNum64 = 8 bytes + const newValidator = inactivityScoresBytes.length / 8; + const minValidator = Math.min(oldValidator, newValidator); + const oldInactivityScores = migratedState.inactivityScores.serialize(); + const isMoreValidator = newValidator >= oldValidator; + const modifiedValidators: number[] = []; + findModifiedInactivityScores( + isMoreValidator ? oldInactivityScores : oldInactivityScores.subarray(0, minValidator * 8), + isMoreValidator ? inactivityScoresBytes.subarray(0, minValidator * 8) : inactivityScoresBytes, + modifiedValidators + ); + + for (const validatorIndex of modifiedValidators) { + migratedState.inactivityScores.set( + validatorIndex, + ssz.UintNum64.deserialize(inactivityScoresBytes.subarray(validatorIndex * 8, (validatorIndex + 1) * 8)) + ); + } + + if (isMoreValidator) { + // add new inactivityScores + for (let validatorIndex = oldValidator; validatorIndex < newValidator; validatorIndex++) { + migratedState.inactivityScores.push( + ssz.UintNum64.deserialize(inactivityScoresBytes.subarray(validatorIndex * 8, (validatorIndex + 1) * 8)) + ); + } + } else { + if (newValidator - 1 < 0) { + migratedState.inactivityScores = ssz.altair.InactivityScores.defaultViewDU(); + } else { + migratedState.inactivityScores = migratedState.inactivityScores.sliceTo(newValidator - 1); + } + } +} + +/** + * As of Sep 2021, common validators of 2 mainnet states are rarely changed. However, the benchmark shows that + * 10k modified validators is not an issue. (see packages/state-transition/test/perf/util/loadState/findModifiedValidators.test.ts) + * + * This method loads validators from bytes given a seed state so that they share the same base tree. This gives some benefits: + * - Have single base tree across the application + * - Faster to load state + * - Less memory usage + * - Utilize the cached HashObjects in seed state due to a lot of validators are not changed + * + * Given the below tree: + * + * seedState.validators ====> ROOT + * / \ + * Hash01 Hash23 + * / \ / \ + * Val0 Val1 Val2 Val3 + * + * if validator 3 is modified, the new tree looks like this: + * + * migratedState.validators ====> ROOTa + * / \ + * Hash01 Hash23a + * / \ / \ + * Val0 Val1 Val2 Val3a + * + * @param migratedState state to be migrated, the validators are loaded to this state + * @returns modified validator indices + */ +function loadValidators( + migratedState: BeaconStateAllForks, + seedState: BeaconStateAllForks, + newValidatorsBytes: Uint8Array +): number[] { + const seedValidatorCount = seedState.validators.length; + const newValidatorCount = Math.floor(newValidatorsBytes.length / VALIDATOR_BYTES_SIZE); + const isMoreValidator = newValidatorCount >= seedValidatorCount; + const minValidatorCount = Math.min(seedValidatorCount, newValidatorCount); + // migrated state starts with the same validators to seed state + migratedState.validators = seedState.validators.clone(); + const seedValidatorsBytes = seedState.validators.serialize(); + const modifiedValidators: number[] = []; + findModifiedValidators( + isMoreValidator ? seedValidatorsBytes : seedValidatorsBytes.subarray(0, minValidatorCount * VALIDATOR_BYTES_SIZE), + isMoreValidator ? newValidatorsBytes.subarray(0, minValidatorCount * VALIDATOR_BYTES_SIZE) : newValidatorsBytes, + modifiedValidators + ); + + for (const i of modifiedValidators) { + const seedValidator = seedState.validators.get(i); + const newValidatorBytes = newValidatorsBytes.subarray(i * VALIDATOR_BYTES_SIZE, (i + 1) * VALIDATOR_BYTES_SIZE); + migratedState.validators.set(i, loadValidator(seedValidator, newValidatorBytes)); + } + + if (newValidatorCount >= seedValidatorCount) { + // add new validators + for (let validatorIndex = seedValidatorCount; validatorIndex < newValidatorCount; validatorIndex++) { + migratedState.validators.push( + ssz.phase0.Validator.deserializeToViewDU( + newValidatorsBytes.subarray( + validatorIndex * VALIDATOR_BYTES_SIZE, + (validatorIndex + 1) * VALIDATOR_BYTES_SIZE + ) + ) + ); + modifiedValidators.push(validatorIndex); + } + } else { + migratedState.validators = migratedState.validators.sliceTo(newValidatorCount - 1); + } + return modifiedValidators; +} diff --git a/packages/state-transition/src/util/loadState/loadValidator.ts b/packages/state-transition/src/util/loadState/loadValidator.ts new file mode 100644 index 000000000000..dcf5051c9c6d --- /dev/null +++ b/packages/state-transition/src/util/loadState/loadValidator.ts @@ -0,0 +1,44 @@ +import {CompositeViewDU} from "@chainsafe/ssz"; +import {deserializeContainerIgnoreFields, ssz} from "@lodestar/types"; + +/** + * Load validator from bytes given a seed validator. + * - Reuse pubkey and withdrawal credentials if possible to save memory + * - If it's a new validator, deserialize it + */ +export function loadValidator( + seedValidator: CompositeViewDU, + newValidatorBytes: Uint8Array +): CompositeViewDU { + const ignoredFields = getSameFields(seedValidator, newValidatorBytes); + if (ignoredFields.length > 0) { + const newValidatorValue = deserializeContainerIgnoreFields(ssz.phase0.Validator, newValidatorBytes, ignoredFields); + for (const field of ignoredFields) { + newValidatorValue[field] = seedValidator[field]; + } + return ssz.phase0.Validator.toViewDU(newValidatorValue); + } else { + return ssz.phase0.Validator.deserializeToViewDU(newValidatorBytes); + } +} + +/** + * Return pubkey or withdrawalCredentials or both if they are the same. + */ +function getSameFields( + validator: CompositeViewDU, + validatorBytes: Uint8Array +): ("pubkey" | "withdrawalCredentials")[] { + const ignoredFields: ("pubkey" | "withdrawalCredentials")[] = []; + const pubkey = validatorBytes.subarray(0, 48); + if (Buffer.compare(pubkey, validator.pubkey) === 0) { + ignoredFields.push("pubkey"); + } + + const withdrawalCredentials = validatorBytes.subarray(48, 80); + if (Buffer.compare(withdrawalCredentials, validator.withdrawalCredentials) === 0) { + ignoredFields.push("withdrawalCredentials"); + } + + return ignoredFields; +} diff --git a/packages/state-transition/src/util/sszBytes.ts b/packages/state-transition/src/util/sszBytes.ts new file mode 100644 index 000000000000..25b65626a0dd --- /dev/null +++ b/packages/state-transition/src/util/sszBytes.ts @@ -0,0 +1,55 @@ +import {ChainForkConfig} from "@lodestar/config"; +import {ForkSeq} from "@lodestar/params"; +import {Slot, allForks} from "@lodestar/types"; +import {bytesToInt} from "@lodestar/utils"; + +/** + * Slot uint64 + */ +const SLOT_BYTE_COUNT = 8; + +/** + * 48 + 32 + 8 + 1 + 8 + 8 + 8 + 8 = 121 + * ``` + * class Validator(Container): + pubkey: BLSPubkey [fixed - 48 bytes] + withdrawal_credentials: Bytes32 [fixed - 32 bytes] + effective_balance: Gwei [fixed - 8 bytes] + slashed: boolean [fixed - 1 byte] + # Status epochs + activation_eligibility_epoch: Epoch [fixed - 8 bytes] + activation_epoch: Epoch [fixed - 8 bytes] + exit_epoch: Epoch [fixed - 8 bytes] + withdrawable_epoch: Epoch [fixed - 8 bytes] + ``` + */ +export const VALIDATOR_BYTES_SIZE = 121; + +/** + * 8 + 32 = 40 + * ``` + * class BeaconState(Container): + * genesis_time: uint64 [fixed - 8 bytes] + * genesis_validators_root: Root [fixed - 32 bytes] + * slot: Slot [fixed - 8 bytes] + * ... + * ``` + */ +const SLOT_BYTES_POSITION_IN_STATE = 40; + +export function getForkFromStateBytes(config: ChainForkConfig, bytes: Buffer | Uint8Array): ForkSeq { + const slot = bytesToInt(bytes.subarray(SLOT_BYTES_POSITION_IN_STATE, SLOT_BYTES_POSITION_IN_STATE + SLOT_BYTE_COUNT)); + return config.getForkSeq(slot); +} + +export function getStateTypeFromBytes( + config: ChainForkConfig, + bytes: Buffer | Uint8Array +): allForks.AllForksSSZTypes["BeaconState"] { + const slot = getStateSlotFromBytes(bytes); + return config.getForkTypes(slot).BeaconState; +} + +export function getStateSlotFromBytes(bytes: Uint8Array): Slot { + return bytesToInt(bytes.subarray(SLOT_BYTES_POSITION_IN_STATE, SLOT_BYTES_POSITION_IN_STATE + SLOT_BYTE_COUNT)); +} diff --git a/packages/state-transition/test/perf/misc/byteArrayEquals.test.ts b/packages/state-transition/test/perf/misc/byteArrayEquals.test.ts new file mode 100644 index 000000000000..64057a26d103 --- /dev/null +++ b/packages/state-transition/test/perf/misc/byteArrayEquals.test.ts @@ -0,0 +1,114 @@ +import crypto from "node:crypto"; +import {itBench} from "@dapplion/benchmark"; +import {byteArrayEquals} from "@chainsafe/ssz"; +import {generateState} from "../../utils/state.js"; +import {generateValidators} from "../../utils/validator.js"; + +/** + * compare Uint8Array, the longer the array, the better performance Buffer.compare() is + * - with 32 bytes, Buffer.compare() is 1.5x faster (rootEquals.test.ts showed > 2x faster) + * ✔ byteArrayEquals 32 1.004480e+7 ops/s 99.55400 ns/op - 19199 runs 2.08 s + * ✔ Buffer.compare 32 1.553495e+7 ops/s 64.37100 ns/op - 3634 runs 0.303 s + * + * - with 1024 bytes, Buffer.compare() is 21.8x faster + * ✔ byteArrayEquals 1024 379239.7 ops/s 2.636855 us/op - 117 runs 0.811 s + * ✔ Buffer.compare 1024 8269999 ops/s 120.9190 ns/op - 3330 runs 0.525 s + * + * - with 16384 bytes, Buffer.compare() is 41x faster + * ✔ byteArrayEquals 16384 23808.76 ops/s 42.00135 us/op - 13 runs 1.05 s + * ✔ Buffer.compare 16384 975058.0 ops/s 1.025580 us/op - 297 runs 0.806 s + * + * - with 123687377 bytes, Buffer.compare() is 38x faster + * ✔ byteArrayEquals 123687377 3.077884 ops/s 324.8985 ms/op - 1 runs 64.5 s + * ✔ Buffer.compare 123687377 114.7834 ops/s 8.712061 ms/op - 13 runs 12.1 s + */ +describe("compare Uint8Array using byteArrayEquals() vs Buffer.compare()", () => { + const numValidator = 1_000_000; + const validators = generateValidators(numValidator); + const state = generateState({validators: validators}); + const stateBytes = state.serialize(); + + const lengths = [32, 1024, 16384, stateBytes.length]; + describe("same bytes", () => { + for (const length of lengths) { + const runsFactor = length > 16384 ? 100 : 1000; + const bytes = stateBytes.subarray(0, length); + const bytes2 = bytes.slice(); + itBench({ + id: `byteArrayEquals ${length}`, + fn: () => { + for (let i = 0; i < runsFactor; i++) { + byteArrayEquals(bytes, bytes2); + } + }, + runsFactor, + }); + + itBench({ + id: `Buffer.compare ${length}`, + fn: () => { + for (let i = 0; i < runsFactor; i++) { + Buffer.compare(bytes, bytes2); + } + }, + runsFactor, + }); + } + }); + + describe("different at the last byte", () => { + for (const length of lengths) { + const runsFactor = length > 16384 ? 100 : 1000; + const bytes = stateBytes.subarray(0, length); + const bytes2 = bytes.slice(); + bytes2[bytes2.length - 1] = bytes2[bytes2.length - 1] + 1; + itBench({ + id: `byteArrayEquals ${length} - diff last byte`, + fn: () => { + for (let i = 0; i < runsFactor; i++) { + byteArrayEquals(bytes, bytes2); + } + }, + runsFactor, + }); + + itBench({ + id: `Buffer.compare ${length} - diff last byte`, + fn: () => { + for (let i = 0; i < runsFactor; i++) { + Buffer.compare(bytes, bytes2); + } + }, + runsFactor, + }); + } + }); + + describe("totally different", () => { + for (const length of lengths) { + const runsFactor = length > 16384 ? 100 : 1000; + const bytes = crypto.randomBytes(length); + const bytes2 = crypto.randomBytes(length); + + itBench({ + id: `byteArrayEquals ${length} - random bytes`, + fn: () => { + for (let i = 0; i < runsFactor; i++) { + byteArrayEquals(bytes, bytes2); + } + }, + runsFactor, + }); + + itBench({ + id: `Buffer.compare ${length} - random bytes`, + fn: () => { + for (let i = 0; i < runsFactor; i++) { + Buffer.compare(bytes, bytes2); + } + }, + runsFactor, + }); + } + }); +}); diff --git a/packages/state-transition/test/perf/misc/rootEquals.test.ts b/packages/state-transition/test/perf/misc/rootEquals.test.ts index 9e39ebe13f89..f941e764c26b 100644 --- a/packages/state-transition/test/perf/misc/rootEquals.test.ts +++ b/packages/state-transition/test/perf/misc/rootEquals.test.ts @@ -2,12 +2,11 @@ import {itBench, setBenchOpts} from "@dapplion/benchmark"; import {byteArrayEquals, fromHexString} from "@chainsafe/ssz"; import {ssz} from "@lodestar/types"; -// As of Jun 17 2021 -// Compare state root -// ================================================================ -// ssz.Root.equals 891265.6 ops/s 1.122000 us/op 10017946 runs 15.66 s -// ssz.Root.equals with valueOf() 692041.5 ops/s 1.445000 us/op 8179741 runs 15.28 s -// byteArrayEquals with valueOf() 853971.0 ops/s 1.171000 us/op 9963051 runs 16.07 s +// As of Sep 2023 +// root equals +// ✔ ssz.Root.equals 2.703872e+7 ops/s 36.98400 ns/op - 74234 runs 2.83 s +// ✔ byteArrayEquals 2.773617e+7 ops/s 36.05400 ns/op - 15649 runs 0.606 s +// ✔ Buffer.compare 7.099247e+7 ops/s 14.08600 ns/op - 26965 runs 0.404 s describe("root equals", () => { setBenchOpts({noThreshold: true}); @@ -16,11 +15,34 @@ describe("root equals", () => { const rootTree = ssz.Root.toViewDU(stateRoot); // This benchmark is very unstable in CI. We already know that "ssz.Root.equals" is the fastest - itBench("ssz.Root.equals", () => { - ssz.Root.equals(rootTree, stateRoot); + const runsFactor = 1000; + itBench({ + id: "ssz.Root.equals", + fn: () => { + for (let i = 0; i < runsFactor; i++) { + ssz.Root.equals(rootTree, stateRoot); + } + }, + runsFactor, }); - itBench("byteArrayEquals", () => { - byteArrayEquals(rootTree, stateRoot); + itBench({ + id: "byteArrayEquals", + fn: () => { + for (let i = 0; i < runsFactor; i++) { + byteArrayEquals(rootTree, stateRoot); + } + }, + runsFactor, + }); + + itBench({ + id: "Buffer.compare", + fn: () => { + for (let i = 0; i < runsFactor; i++) { + Buffer.compare(rootTree, stateRoot); + } + }, + runsFactor, }); }); diff --git a/packages/state-transition/test/perf/util.ts b/packages/state-transition/test/perf/util.ts index 169b205ce5c6..46faf11c50f1 100644 --- a/packages/state-transition/test/perf/util.ts +++ b/packages/state-transition/test/perf/util.ts @@ -211,8 +211,11 @@ export function cachedStateAltairPopulateCaches(state: CachedBeaconStateAltair): state.inactivityScores.getAll(); } -export function generatePerfTestCachedStateAltair(opts?: {goBackOneSlot: boolean}): CachedBeaconStateAltair { - const {pubkeys, pubkeysMod, pubkeysModObj} = getPubkeys(); +export function generatePerfTestCachedStateAltair(opts?: { + goBackOneSlot: boolean; + vc?: number; +}): CachedBeaconStateAltair { + const {pubkeys, pubkeysMod, pubkeysModObj} = getPubkeys(opts?.vc); const {pubkey2index, index2pubkey} = getPubkeyCaches({pubkeys, pubkeysMod, pubkeysModObj}); // eslint-disable-next-line @typescript-eslint/naming-convention @@ -247,7 +250,7 @@ export function generatePerfTestCachedStateAltair(opts?: {goBackOneSlot: boolean export function generatePerformanceStateAltair(pubkeysArg?: Uint8Array[]): BeaconStateAltair { if (!altairState) { const pubkeys = pubkeysArg || getPubkeys().pubkeys; - const statePhase0 = buildPerformanceStatePhase0(); + const statePhase0 = buildPerformanceStatePhase0(pubkeys); const state = statePhase0 as allForks.BeaconState as altair.BeaconState; state.previousEpochParticipation = newFilledArray(pubkeys.length, 0b111); diff --git a/packages/state-transition/test/perf/util/loadState/findModifiedValidators.test.ts b/packages/state-transition/test/perf/util/loadState/findModifiedValidators.test.ts new file mode 100644 index 000000000000..4028104f0bdc --- /dev/null +++ b/packages/state-transition/test/perf/util/loadState/findModifiedValidators.test.ts @@ -0,0 +1,185 @@ +import {expect} from "chai"; +import {itBench} from "@dapplion/benchmark"; +import {CompositeViewDU} from "@chainsafe/ssz"; +import {ssz} from "@lodestar/types"; +import {bytesToInt} from "@lodestar/utils"; +import {findModifiedValidators} from "../../../../src/util/loadState/findModifiedValidators.js"; +import {VALIDATOR_BYTES_SIZE} from "../../../../src/util/sszBytes.js"; +import {generateValidators} from "../../../utils/validator.js"; +import {generateState} from "../../../utils/state.js"; + +/** + * find modified validators by different ways. This proves that findModifiedValidators() leveraging Buffer.compare() is the fastest way. + * - Method 0 - serialize validators then findModifiedValidators, this is the selected implementation + * ✔ findModifiedValidators - 10000 modified validators 2.261799 ops/s 442.1260 ms/op - 14 runs 7.80 s + * ✔ findModifiedValidators - 1000 modified validators 2.310899 ops/s 432.7321 ms/op - 12 runs 6.35 s + * ✔ findModifiedValidators - 100 modified validators 2.259907 ops/s 442.4960 ms/op - 16 runs 7.93 s + * ✔ findModifiedValidators - 10 modified validators 2.297018 ops/s 435.3470 ms/op - 12 runs 6.23 s + * ✔ findModifiedValidators - 1 modified validators 2.344447 ops/s 426.5398 ms/op - 12 runs 5.81 s + * ✔ findModifiedValidators - no difference 2.327252 ops/s 429.6914 ms/op - 12 runs 5.70 s + * + * - Method 1 - deserialize validators then compare validator ViewDUs: 8.8x slower + * ✔ compare ViewDUs 0.2643101 ops/s 3.783434 s/op - 12 runs 50.3 s + * + * - Method 2 - serialize each validator then compare Uin8Array: 3.1x slower + * ✔ compare each validator Uint8Array 0.7424619 ops/s 1.346870 s/op - 12 runs 17.8 s + * + * - Method 3 - compare validator ViewDU to Uint8Array: 3x slower + * ✔ compare ViewDU to Uint8Array 0.7791557 ops/s 1.283441 s/op - 12 runs 16.8 s + */ +describe("find modified validators by different ways", function () { + this.timeout(0); + // To get state bytes from any persisted state, do this: + // const stateBytes = new Uint8Array(fs.readFileSync(path.join(folder, "mainnet_state_7335296.ssz"))); + // const stateType = ssz.capella.BeaconState; + const numValidator = 1_000_000; + const validators = generateValidators(numValidator); + const state = generateState({validators: validators}); + const stateType = ssz.phase0.BeaconState; + const stateBytes = state.serialize(); + + // const state = stateType.deserializeToViewDU(stateBytes); + const dataView = new DataView(stateBytes.buffer, stateBytes.byteOffset, stateBytes.byteLength); + const fieldRanges = stateType.getFieldRanges(dataView, 0, stateBytes.length); + const validatorsFieldIndex = Object.keys(stateType.fields).indexOf("validators"); + const validatorsRange = fieldRanges[validatorsFieldIndex]; + + describe("serialize validators then findModifiedValidators", () => { + const expectedModifiedValidatorsArr: number[][] = [ + // mainnet state has 700k validators as of Sep 2023 + Array.from({length: 10_000}, (_, i) => 70 * i), + Array.from({length: 1_000}, (_, i) => 700 * i), + Array.from({length: 100}, (_, i) => 700 * i), + Array.from({length: 10}, (_, i) => 700 * i), + Array.from({length: 1}, (_, i) => 10 * i), + [], + ]; + for (const expectedModifiedValidators of expectedModifiedValidatorsArr) { + const prefix = "findModifiedValidators"; + const testCaseName = + expectedModifiedValidators.length === 0 + ? "no difference" + : expectedModifiedValidators.length + " modified validators"; + itBench({ + id: `${prefix} - ${testCaseName}`, + beforeEach: () => { + const clonedState = state.clone(); + for (const validatorIndex of expectedModifiedValidators) { + clonedState.validators.get(validatorIndex).pubkey = Buffer.alloc(48, 0); + } + clonedState.commit(); + return clonedState; + }, + fn: (clonedState) => { + const validatorsBytes = Uint8Array.from(stateBytes.subarray(validatorsRange.start, validatorsRange.end)); + const validatorsBytes2 = clonedState.validators.serialize(); + const modifiedValidators: number[] = []; + findModifiedValidators(validatorsBytes, validatorsBytes2, modifiedValidators); + expect(modifiedValidators.sort((a, b) => a - b)).to.be.deep.equal(expectedModifiedValidators); + }, + }); + } + }); + + describe("deserialize validators then compare validator ViewDUs", () => { + const validatorsBytes = stateBytes.subarray(validatorsRange.start, validatorsRange.end); + itBench("compare ViewDUs", () => { + const numValidator = state.validators.length; + const validators = stateType.fields.validators.deserializeToViewDU(validatorsBytes); + for (let i = 0; i < numValidator; i++) { + if (!ssz.phase0.Validator.equals(state.validators.get(i), validators.get(i))) { + throw Error(`validator ${i} is not equal`); + } + } + }); + }); + + describe("serialize each validator then compare Uin8Array", () => { + const validators = state.validators.getAllReadonly(); + itBench("compare each validator Uint8Array", () => { + for (let i = 0; i < state.validators.length; i++) { + const validatorBytes = ssz.phase0.Validator.serialize(validators[i]); + if ( + Buffer.compare( + validatorBytes, + stateBytes.subarray( + validatorsRange.start + i * VALIDATOR_BYTES_SIZE, + validatorsRange.start + (i + 1) * VALIDATOR_BYTES_SIZE + ) + ) !== 0 + ) { + throw Error(`validator ${i} is not equal`); + } + } + }); + }); + + describe("compare validator ViewDU to Uint8Array", () => { + itBench("compare ViewDU to Uint8Array", () => { + const numValidator = state.validators.length; + for (let i = 0; i < numValidator; i++) { + const diff = validatorDiff( + state.validators.get(i), + stateBytes.subarray( + validatorsRange.start + i * VALIDATOR_BYTES_SIZE, + validatorsRange.start + (i + 1) * VALIDATOR_BYTES_SIZE + ) + ); + + if (diff !== null) { + throw Error(`validator ${i} is not equal at ${diff}`); + } + } + }); + }); +}); + +function validatorDiff(validator: CompositeViewDU, bytes: Uint8Array): string | null { + const pubkey = bytes.subarray(0, 48); + if (Buffer.compare(validator.pubkey, pubkey) !== 0) { + return "pubkey"; + } + + const withdrawalCredentials = bytes.subarray(48, 80); + if (Buffer.compare(validator.withdrawalCredentials, withdrawalCredentials) !== 0) { + return "withdrawalCredentials"; + } + + if (validator.effectiveBalance !== bytesToInt(bytes.subarray(80, 88))) { + return "effectiveBalance"; + } + + if (validator.slashed !== Boolean(bytes[88])) { + return "slashed"; + } + + if (validator.activationEligibilityEpoch !== toNumberOrInfinity(bytes.subarray(89, 97))) { + return "activationEligibilityEpoch"; + } + + if (validator.activationEpoch !== toNumberOrInfinity(bytes.subarray(97, 105))) { + return "activationEpoch"; + } + + if (validator.exitEpoch !== toNumberOrInfinity(bytes.subarray(105, 113))) { + return "exitEpoch"; + } + + if (validator.withdrawableEpoch !== toNumberOrInfinity(bytes.subarray(113, 121))) { + return "withdrawableEpoch"; + } + + return null; +} + +function toNumberOrInfinity(bytes: Uint8Array): number { + let isInfinity = true; + for (const byte of bytes) { + if (byte !== 255) { + isInfinity = false; + break; + } + } + + return isInfinity ? Infinity : bytesToInt(bytes); +} diff --git a/packages/state-transition/test/perf/util/loadState/loadState.test.ts b/packages/state-transition/test/perf/util/loadState/loadState.test.ts new file mode 100644 index 000000000000..c0df6cf1af47 --- /dev/null +++ b/packages/state-transition/test/perf/util/loadState/loadState.test.ts @@ -0,0 +1,98 @@ +import bls from "@chainsafe/bls"; +import {CoordType} from "@chainsafe/blst"; +import {itBench, setBenchOpts} from "@dapplion/benchmark"; +import {loadState} from "../../../../src/util/loadState/loadState.js"; +import {createCachedBeaconState} from "../../../../src/cache/stateCache.js"; +import {Index2PubkeyCache, PubkeyIndexMap} from "../../../../src/cache/pubkeyCache.js"; +import {generatePerfTestCachedStateAltair} from "../../util.js"; + +/** + * This benchmark shows a stable performance from 2s to 3s on a Mac M1. And it does not really depend on the seed validators, + * only the modified and new validators + * + * - On mainnet, as of Oct 2023, there are ~1M validators + * + * ✔ migrate state 1000000 validators, 24 modified, 0 new 0.4475463 ops/s 2.234406 s/op - 3 runs 62.1 s + * ✔ migrate state 1000000 validators, 1700 modified, 1000 new 0.3663298 ops/s 2.729781 s/op - 21 runs 62.1 s + * ✔ migrate state 1000000 validators, 3400 modified, 2000 new 0.3413125 ops/s 2.929866 s/op - 19 runs 60.9 s + + * - On holesky, there are ~1.5M validators + * ✔ migrate state 1500000 validators, 24 modified, 0 new 0.4278145 ops/s 2.337461 s/op - 24 runs 61.1 s + * ✔ migrate state 1500000 validators, 1700 modified, 1000 new 0.3642085 ops/s 2.745680 s/op - 20 runs 60.1 s + * ✔ migrate state 1500000 validators, 3400 modified, 2000 new 0.3344296 ops/s 2.990166 s/op - 19 runs 62.4 s + */ +describe("loadState", function () { + this.timeout(0); + + setBenchOpts({ + minMs: 60_000, + }); + + const testCases: {seedValidators: number; numModifiedValidators: number; numNewValidators: number}[] = [ + // this 1_000_000 is similar to mainnet state as of Oct 2023 + // similar to migrating from state 7335296 to state 7335360 on mainnet, this is 2 epochs difference + {seedValidators: 1_000_000, numModifiedValidators: 24, numNewValidators: 0}, + {seedValidators: 1_000_000, numModifiedValidators: 1700, numNewValidators: 1000}, + // similar to migrating from state 7327776 to state 7335360 on mainnet, this is 237 epochs difference ~ 1 day + {seedValidators: 1_000_000, numModifiedValidators: 3400, numNewValidators: 2000}, + // same tests on holesky with 1_500_000 validators + {seedValidators: 1_500_000, numModifiedValidators: 24, numNewValidators: 0}, + {seedValidators: 1_500_000, numModifiedValidators: 1700, numNewValidators: 1000}, + {seedValidators: 1_500_000, numModifiedValidators: 3400, numNewValidators: 2000}, + ]; + for (const {seedValidators, numModifiedValidators, numNewValidators} of testCases) { + itBench({ + id: `migrate state ${seedValidators} validators, ${numModifiedValidators} modified, ${numNewValidators} new`, + before: () => { + const seedState = generatePerfTestCachedStateAltair({vc: seedValidators, goBackOneSlot: false}); + // cache all HashObjects + seedState.hashTreeRoot(); + const newState = seedState.clone(); + for (let i = 0; i < numModifiedValidators; i++) { + const validatorIndex = i * Math.floor((seedState.validators.length - 1) / numModifiedValidators); + const modifiedValidator = newState.validators.get(validatorIndex); + modifiedValidator.withdrawalCredentials = Buffer.alloc(32, 0x01); + newState.inactivityScores.set(validatorIndex, 100); + } + + for (let i = 0; i < numNewValidators; i++) { + newState.validators.push(seedState.validators.get(0).clone()); + newState.inactivityScores.push(seedState.inactivityScores.get(0)); + newState.balances.push(seedState.balances.get(0)); + } + + const newStateBytes = newState.serialize(); + return {seedState, newStateBytes}; + }, + beforeEach: ({seedState, newStateBytes}) => { + return {seedState: seedState.clone(), newStateBytes}; + }, + fn: ({seedState, newStateBytes}) => { + const {state: migratedState, modifiedValidators} = loadState(seedState.config, seedState, newStateBytes); + migratedState.hashTreeRoot(); + // Get the validators sub tree once for all the loop + const validators = migratedState.validators; + const pubkey2index = new PubkeyIndexMap(); + const index2pubkey: Index2PubkeyCache = []; + for (const validatorIndex of modifiedValidators) { + const validator = validators.getReadonly(validatorIndex); + const pubkey = validator.pubkey; + pubkey2index.set(pubkey, validatorIndex); + index2pubkey[validatorIndex] = bls.PublicKey.fromBytes(pubkey, CoordType.jacobian); + } + // skip computimg shuffling in performance test because in reality we have a ShufflingCache + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const shufflingGetter = () => seedState.epochCtx.currentShuffling; + createCachedBeaconState( + migratedState, + { + config: seedState.config, + pubkey2index, + index2pubkey, + }, + {skipSyncPubkeys: true, skipSyncCommitteeCache: true, shufflingGetter} + ); + }, + }); + } +}); diff --git a/packages/state-transition/test/unit/cachedBeaconState.test.ts b/packages/state-transition/test/unit/cachedBeaconState.test.ts index 0367fd636e78..072261c1000e 100644 --- a/packages/state-transition/test/unit/cachedBeaconState.test.ts +++ b/packages/state-transition/test/unit/cachedBeaconState.test.ts @@ -1,7 +1,13 @@ import {expect} from "chai"; import {ssz} from "@lodestar/types"; import {toHexString} from "@lodestar/utils"; +import {config} from "@lodestar/config/default"; +import {createBeaconConfig} from "@lodestar/config"; import {createCachedBeaconStateTest} from "../utils/state.js"; +import {PubkeyIndexMap} from "../../src/cache/pubkeyCache.js"; +import {createCachedBeaconState, loadUnfinalizedCachedBeaconState} from "../../src/cache/stateCache.js"; +import {interopPubkeysCached} from "../utils/interop.js"; +import {modifyStateSameValidator, newStateWithValidators} from "../utils/capella.js"; describe("CachedBeaconState", () => { it("Clone and mutate", () => { @@ -54,4 +60,96 @@ describe("CachedBeaconState", () => { ".serialize() does not automatically commit" ); }); + + describe("loadCachedBeaconState", () => { + const numValidator = 16; + const pubkeys = interopPubkeysCached(2 * numValidator); + + const stateView = newStateWithValidators(numValidator); + const seedState = createCachedBeaconState( + stateView, + { + config: createBeaconConfig(config, stateView.genesisValidatorsRoot), + pubkey2index: new PubkeyIndexMap(), + index2pubkey: [], + }, + {skipSyncCommitteeCache: true} + ); + + const capellaStateType = ssz.capella.BeaconState; + + for (let validatorCountDelta = -numValidator; validatorCountDelta <= numValidator; validatorCountDelta++) { + const testName = `loadCachedBeaconState - ${validatorCountDelta > 0 ? "more" : "less"} ${Math.abs( + validatorCountDelta + )} validators`; + it(testName, () => { + const state = modifyStateSameValidator(stateView); + for (let i = 0; i < state.validators.length; i++) { + // only modify some validators + if (i % 5 === 0) { + state.inactivityScores.set(i, state.inactivityScores.get(i) + 1); + state.validators.get(i).effectiveBalance += 1; + } + } + + if (validatorCountDelta < 0) { + state.validators = state.validators.sliceTo(state.validators.length - 1 + validatorCountDelta); + + // inactivityScores + if (state.inactivityScores.length - 1 + validatorCountDelta >= 0) { + state.inactivityScores = state.inactivityScores.sliceTo( + state.inactivityScores.length - 1 + validatorCountDelta + ); + } else { + state.inactivityScores = capellaStateType.fields.inactivityScores.defaultViewDU(); + } + + // previousEpochParticipation + if (state.previousEpochParticipation.length - 1 + validatorCountDelta >= 0) { + state.previousEpochParticipation = state.previousEpochParticipation.sliceTo( + state.previousEpochParticipation.length - 1 + validatorCountDelta + ); + } else { + state.previousEpochParticipation = capellaStateType.fields.previousEpochParticipation.defaultViewDU(); + } + + // currentEpochParticipation + if (state.currentEpochParticipation.length - 1 + validatorCountDelta >= 0) { + state.currentEpochParticipation = state.currentEpochParticipation.sliceTo( + state.currentEpochParticipation.length - 1 + validatorCountDelta + ); + } else { + state.currentEpochParticipation = capellaStateType.fields.currentEpochParticipation.defaultViewDU(); + } + } else { + // more validators + for (let i = 0; i < validatorCountDelta; i++) { + const validator = ssz.phase0.Validator.defaultViewDU(); + validator.pubkey = pubkeys[numValidator + i]; + state.validators.push(validator); + state.inactivityScores.push(1); + state.previousEpochParticipation.push(0b11111111); + state.currentEpochParticipation.push(0b11111111); + } + } + state.commit(); + + // confirm loadState() result + const stateBytes = state.serialize(); + const newCachedState = loadUnfinalizedCachedBeaconState(seedState, stateBytes, {skipSyncCommitteeCache: true}); + const newStateBytes = newCachedState.serialize(); + expect(newStateBytes).to.be.deep.equal(stateBytes, "loadState: state bytes are not equal"); + expect(newCachedState.hashTreeRoot()).to.be.deep.equal( + state.hashTreeRoot(), + "loadState: state root is not equal" + ); + + // confirm loadUnfinalizedCachedBeaconState() result + for (let i = 0; i < newCachedState.validators.length; i++) { + expect(newCachedState.epochCtx.pubkey2index.get(newCachedState.validators.get(i).pubkey)).to.be.equal(i); + expect(newCachedState.epochCtx.index2pubkey[i].toBytes()).to.be.deep.equal(pubkeys[i]); + } + }); + } + }); }); diff --git a/packages/state-transition/test/unit/upgradeState.test.ts b/packages/state-transition/test/unit/upgradeState.test.ts index 13ec613d69bf..ba9ff187a26c 100644 --- a/packages/state-transition/test/unit/upgradeState.test.ts +++ b/packages/state-transition/test/unit/upgradeState.test.ts @@ -1,11 +1,12 @@ import {expect} from "chai"; import {ssz} from "@lodestar/types"; import {ForkName} from "@lodestar/params"; -import {createCachedBeaconState, PubkeyIndexMap} from "@lodestar/state-transition"; import {createBeaconConfig, ChainForkConfig, createChainForkConfig} from "@lodestar/config"; import {config as chainConfig} from "@lodestar/config/default"; import {upgradeStateToDeneb} from "../../src/slot/upgradeStateToDeneb.js"; +import {createCachedBeaconState} from "../../src/cache/stateCache.js"; +import {PubkeyIndexMap} from "../../src/cache/pubkeyCache.js"; describe("upgradeState", () => { it("upgradeStateToDeneb", () => { diff --git a/packages/state-transition/test/unit/util/loadState/findModifiedInactivityScores.test.ts b/packages/state-transition/test/unit/util/loadState/findModifiedInactivityScores.test.ts new file mode 100644 index 000000000000..e1ad0cf972da --- /dev/null +++ b/packages/state-transition/test/unit/util/loadState/findModifiedInactivityScores.test.ts @@ -0,0 +1,33 @@ +import {expect} from "chai"; +import { + INACTIVITY_SCORE_SIZE, + findModifiedInactivityScores, +} from "../../../../src/util/loadState/findModifiedInactivityScores.js"; + +describe("findModifiedInactivityScores", () => { + const numValidator = 100; + const expectedModifiedValidatorsArr: number[][] = [ + [], + [0, 2], + [0, 2, 4, 5, 6, 7, 8, 9], + [10, 20, 30, 40, 50, 60, 70, 80, 90, 91, 92, 93, 94], + ]; + + const inactivityScoresBytes = new Uint8Array(numValidator * INACTIVITY_SCORE_SIZE); + + for (const expectedModifiedValidators of expectedModifiedValidatorsArr) { + const testCaseName = + expectedModifiedValidators.length === 0 + ? "no difference" + : expectedModifiedValidators.length + " modified validators"; + it(testCaseName, () => { + const inactivityScoresBytes2 = inactivityScoresBytes.slice(); + for (const validatorIndex of expectedModifiedValidators) { + inactivityScoresBytes2[validatorIndex * INACTIVITY_SCORE_SIZE] = 1; + } + const modifiedValidators: number[] = []; + findModifiedInactivityScores(inactivityScoresBytes, inactivityScoresBytes2, modifiedValidators); + expect(modifiedValidators.sort((a, b) => a - b)).to.be.deep.equal(expectedModifiedValidators); + }); + } +}); diff --git a/packages/state-transition/test/unit/util/loadState/findModifiedValidators.test.ts b/packages/state-transition/test/unit/util/loadState/findModifiedValidators.test.ts new file mode 100644 index 000000000000..aa2378276d22 --- /dev/null +++ b/packages/state-transition/test/unit/util/loadState/findModifiedValidators.test.ts @@ -0,0 +1,41 @@ +import {expect} from "chai"; +import {fromHexString} from "@chainsafe/ssz"; +import {findModifiedValidators} from "../../../../src/util/loadState/findModifiedValidators.js"; +import {generateState} from "../../../utils/state.js"; +import {generateValidators} from "../../../utils/validator.js"; + +describe("findModifiedValidators", () => { + const numValidator = 800_000; + const expectedModifiedValidatorsArr: number[][] = [ + Array.from({length: 10_000}, (_, i) => 70 * i), + Array.from({length: 1_000}, (_, i) => 700 * i), + Array.from({length: 100}, (_, i) => 700 * i), + Array.from({length: 10}, (_, i) => 700 * i), + Array.from({length: 1}, (_, i) => 10 * i), + [], + ]; + + const validators = generateValidators(numValidator); + const state = generateState({validators: validators}); + const validatorsBytes = state.validators.serialize(); + + for (const expectedModifiedValidators of expectedModifiedValidatorsArr) { + const testCaseName = + expectedModifiedValidators.length === 0 + ? "no difference" + : expectedModifiedValidators.length + " modified validators"; + const modifiedPubkey = fromHexString( + "0x98d732925b0388ceb8b2b7efbe1163e4bc39082bb791940b2cda3837b0982c8de8fad8ee7912abca4ab0ae7ad50d1b95" + ); + it(testCaseName, () => { + const clonedState = state.clone(); + for (const validatorIndex of expectedModifiedValidators) { + clonedState.validators.get(validatorIndex).pubkey = modifiedPubkey; + } + const validatorsBytes2 = clonedState.validators.serialize(); + const modifiedValidators: number[] = []; + findModifiedValidators(validatorsBytes, validatorsBytes2, modifiedValidators); + expect(modifiedValidators.sort((a, b) => a - b)).to.be.deep.equal(expectedModifiedValidators); + }); + } +}); diff --git a/packages/state-transition/test/unit/util/loadState/loadValidator.test.ts b/packages/state-transition/test/unit/util/loadState/loadValidator.test.ts new file mode 100644 index 000000000000..7c3112537490 --- /dev/null +++ b/packages/state-transition/test/unit/util/loadState/loadValidator.test.ts @@ -0,0 +1,123 @@ +import {expect} from "chai"; +import {CompositeViewDU} from "@chainsafe/ssz"; +import {phase0, ssz} from "@lodestar/types"; +import {loadValidator} from "../../../../src/util/loadState/loadValidator.js"; + +describe("loadValidator", () => { + const validatorValue: phase0.Validator = { + pubkey: Buffer.from( + "0xb18e1737e1a1a76b8dff905ba7a4cb1ff5c526a4b7b0788188aade0488274c91e9c797e75f0f8452384ff53d44fad3df", + "hex" + ), + withdrawalCredentials: Buffer.from("0x98d732925b0388ceb8b2b7efbe1163e4bc39082bb791940b2cda3837b0982c8d", "hex"), + effectiveBalance: 32, + slashed: false, + activationEligibilityEpoch: 10, + activationEpoch: 20, + exitEpoch: 30, + withdrawableEpoch: 40, + }; + const validator = ssz.phase0.Validator.toViewDU(validatorValue); + + const testCases: {name: string; getValidator: () => CompositeViewDU}[] = [ + { + name: "diff pubkey", + getValidator: () => { + const newValidator = validator.clone(); + newValidator.pubkey = Buffer.alloc(1, 48); + return newValidator; + }, + }, + { + name: "diff withdrawal credentials", + getValidator: () => { + const newValidator = validator.clone(); + newValidator.withdrawalCredentials = Buffer.alloc(1, 32); + return newValidator; + }, + }, + { + name: "diff effective balance", + getValidator: () => { + const newValidator = validator.clone(); + newValidator.effectiveBalance = 100; + return newValidator; + }, + }, + { + name: "diff slashed", + getValidator: () => { + const newValidator = validator.clone(); + newValidator.slashed = true; + return newValidator; + }, + }, + { + name: "diff activation eligibility epoch", + getValidator: () => { + const newValidator = validator.clone(); + newValidator.activationEligibilityEpoch = 100; + return newValidator; + }, + }, + { + name: "diff activation epoch", + getValidator: () => { + const newValidator = validator.clone(); + newValidator.activationEpoch = 100; + return newValidator; + }, + }, + { + name: "diff exit epoch", + getValidator: () => { + const newValidator = validator.clone(); + newValidator.exitEpoch = 100; + return newValidator; + }, + }, + { + name: "diff withdrawable epoch", + getValidator: () => { + const newValidator = validator.clone(); + newValidator.withdrawableEpoch = 100; + return newValidator; + }, + }, + { + name: "diff all", + getValidator: () => { + const newValidator = validator.clone(); + newValidator.pubkey = Buffer.alloc(1, 48); + newValidator.withdrawalCredentials = Buffer.alloc(1, 32); + newValidator.effectiveBalance = 100; + newValidator.slashed = true; + newValidator.activationEligibilityEpoch = 100; + newValidator.activationEpoch = 100; + newValidator.exitEpoch = 100; + newValidator.withdrawableEpoch = 100; + return newValidator; + }, + }, + { + name: "same validator", + getValidator: () => validator.clone(), + }, + ]; + + for (const {name, getValidator} of testCases) { + it(name, () => { + const newValidator = getValidator(); + const newValidatorBytes = newValidator.serialize(); + const loadedValidator = loadValidator(validator, newValidatorBytes); + expect(Buffer.compare(loadedValidator.hashTreeRoot(), newValidator.hashTreeRoot())).to.be.equal( + 0, + "root is not correct" + ); + expect(Buffer.compare(loadedValidator.serialize(), newValidator.serialize())).to.be.equal( + 0, + "serialized value is not correct" + ); + }); + } +}); diff --git a/packages/state-transition/test/utils/capella.ts b/packages/state-transition/test/utils/capella.ts index f0f44ae94710..5789c260f67c 100644 --- a/packages/state-transition/test/utils/capella.ts +++ b/packages/state-transition/test/utils/capella.ts @@ -1,9 +1,11 @@ +import crypto from "node:crypto"; import {ssz} from "@lodestar/types"; import {config} from "@lodestar/config/default"; -import {BLS_WITHDRAWAL_PREFIX, ETH1_ADDRESS_WITHDRAWAL_PREFIX} from "@lodestar/params"; -import {CachedBeaconStateCapella} from "../../src/index.js"; +import {BLS_WITHDRAWAL_PREFIX, ETH1_ADDRESS_WITHDRAWAL_PREFIX, SLOTS_PER_EPOCH} from "@lodestar/params"; +import {BeaconStateCapella, CachedBeaconStateCapella} from "../../src/index.js"; import {createCachedBeaconStateTest} from "./state.js"; import {mulberry32} from "./rand.js"; +import {interopPubkeysCached} from "./interop.js"; export interface WithdrawalOpts { excessBalance: number; @@ -58,3 +60,59 @@ export function getExpectedWithdrawalsTestData(vc: number, opts: WithdrawalOpts) return createCachedBeaconStateTest(state, config, {skipSyncPubkeys: true}); } + +export function newStateWithValidators(numValidator: number): BeaconStateCapella { + // use real pubkeys to test loadCachedBeaconState api + const pubkeys = interopPubkeysCached(numValidator); + const capellaStateType = ssz.capella.BeaconState; + const stateView = capellaStateType.defaultViewDU(); + stateView.slot = config.CAPELLA_FORK_EPOCH * SLOTS_PER_EPOCH + 100; + + for (let i = 0; i < numValidator; i++) { + const validator = ssz.phase0.Validator.defaultViewDU(); + validator.pubkey = pubkeys[i]; + stateView.validators.push(validator); + stateView.balances.push(32); + stateView.inactivityScores.push(0); + stateView.previousEpochParticipation.push(0b11111111); + stateView.currentEpochParticipation.push(0b11111111); + } + stateView.commit(); + return stateView; +} + +/** + * Modify a state without changing number of validators + */ +export function modifyStateSameValidator(seedState: BeaconStateCapella): BeaconStateCapella { + const state = seedState.clone(); + state.slot = seedState.slot + 10; + state.latestBlockHeader = ssz.phase0.BeaconBlockHeader.toViewDU({ + slot: state.slot, + proposerIndex: 0, + parentRoot: state.hashTreeRoot(), + stateRoot: state.hashTreeRoot(), + bodyRoot: ssz.phase0.BeaconBlockBody.hashTreeRoot(ssz.phase0.BeaconBlockBody.defaultValue()), + }); + state.blockRoots.set(0, crypto.randomBytes(32)); + state.stateRoots.set(0, crypto.randomBytes(32)); + state.historicalRoots.push(crypto.randomBytes(32)); + state.eth1Data.depositCount = 1000; + state.eth1DataVotes.push(ssz.phase0.Eth1Data.toViewDU(ssz.phase0.Eth1Data.defaultValue())); + state.eth1DepositIndex = 1000; + state.balances.set(0, 30); + state.randaoMixes.set(0, crypto.randomBytes(32)); + state.slashings.set(0, 1n); + state.previousEpochParticipation.set(0, 0b11111110); + state.currentEpochParticipation.set(0, 0b11111110); + state.justificationBits.set(0, true); + state.previousJustifiedCheckpoint.epoch = 1; + state.currentJustifiedCheckpoint.epoch = 1; + state.finalizedCheckpoint.epoch++; + state.latestExecutionPayloadHeader.blockNumber = 1; + state.nextWithdrawalIndex = 1000; + state.nextWithdrawalValidatorIndex = 1000; + state.historicalSummaries.push(ssz.capella.HistoricalSummary.toViewDU(ssz.capella.HistoricalSummary.defaultValue())); + state.commit(); + return state; +} diff --git a/packages/types/package.json b/packages/types/package.json index da9c8c179933..c84c4ba38973 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -67,7 +67,7 @@ }, "types": "lib/index.d.ts", "dependencies": { - "@chainsafe/ssz": "^0.13.0", + "@chainsafe/ssz": "^0.14.0", "@lodestar/params": "^1.11.3" }, "keywords": [ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 825b962c5f1f..d90b55909884 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -4,3 +4,5 @@ export * as ssz from "./sszTypes.js"; export * from "./utils/typeguards.js"; // String type export {StringType, stringType} from "./utils/StringType.js"; +// Container utils +export * from "./utils/container.js"; diff --git a/packages/types/src/utils/container.ts b/packages/types/src/utils/container.ts new file mode 100644 index 000000000000..9fc21c201d80 --- /dev/null +++ b/packages/types/src/utils/container.ts @@ -0,0 +1,37 @@ +import {CompositeTypeAny, CompositeViewDU, ContainerType, Type} from "@chainsafe/ssz"; +type BytesRange = {start: number; end: number}; + +/** + * Deserialize a state from bytes ignoring some fields. + */ +export function deserializeContainerIgnoreFields>>( + sszType: ContainerType, + bytes: Uint8Array, + ignoreFields: (keyof Fields)[], + fieldRanges?: BytesRange[] +): CompositeViewDU { + const allFields = Object.keys(sszType.fields); + const object = sszType.defaultViewDU(); + if (!fieldRanges) { + const dataView = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + fieldRanges = sszType.getFieldRanges(dataView, 0, bytes.length); + } + + for (const [field, type] of Object.entries(sszType.fields)) { + // loaded above + if (ignoreFields.includes(field)) { + continue; + } + const fieldIndex = allFields.indexOf(field); + const fieldRange = fieldRanges[fieldIndex]; + if (type.isBasic) { + object[field as keyof Fields] = type.deserialize(bytes.subarray(fieldRange.start, fieldRange.end)) as never; + } else { + object[field as keyof Fields] = (type as CompositeTypeAny).deserializeToViewDU( + bytes.subarray(fieldRange.start, fieldRange.end) + ) as never; + } + } + + return object; +} diff --git a/packages/validator/package.json b/packages/validator/package.json index 5294318b5536..9140d9591146 100644 --- a/packages/validator/package.json +++ b/packages/validator/package.json @@ -49,7 +49,7 @@ ], "dependencies": { "@chainsafe/bls": "7.1.1", - "@chainsafe/ssz": "^0.13.0", + "@chainsafe/ssz": "^0.14.0", "@lodestar/api": "^1.11.3", "@lodestar/config": "^1.11.3", "@lodestar/db": "^1.11.3", diff --git a/yarn.lock b/yarn.lock index 60d316df8964..9e55dfb64073 100644 --- a/yarn.lock +++ b/yarn.lock @@ -642,10 +642,10 @@ "@chainsafe/as-sha256" "^0.4.1" "@chainsafe/persistent-merkle-tree" "^0.6.1" -"@chainsafe/ssz@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@chainsafe/ssz/-/ssz-0.13.0.tgz#0bd11af6abe023d4cc24067a46889dcabbe573e5" - integrity sha512-73PF5bFXE9juLD1+dkmYV/CMO/5ip0TmyzgYw87vAn8Cn+CbwCOp/HyNNdYCmdl104a2bqcORFJzirCvvc+nNw== +"@chainsafe/ssz@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@chainsafe/ssz/-/ssz-0.14.0.tgz#fe9e4fd3cf673013bd57f77c3ab0fdc5ebc5d916" + integrity sha512-KTc33pWu7ItXlzMAz5/1osOHsvhx25kpM3j7Ez+PNZLyyhIoNzAhhozvxy+ul0fCDfHbvaCRp3lJQnzsb5Iv0A== dependencies: "@chainsafe/as-sha256" "^0.4.1" "@chainsafe/persistent-merkle-tree" "^0.6.1"