diff --git a/ethereum/statetransition/src/jmh/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationBenchmark.java b/ethereum/statetransition/src/jmh/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationBenchmark.java
new file mode 100644
index 00000000000..6ec01697721
--- /dev/null
+++ b/ethereum/statetransition/src/jmh/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationBenchmark.java
@@ -0,0 +1,383 @@
+/*
+ * Copyright Consensys Software Inc., 2026
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
+ * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations under the License.
+ */
+
+package tech.pegasys.teku.statetransition.forkchoice.fastconfirmation;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+import org.apache.tuweni.bytes.Bytes32;
+import org.apache.tuweni.bytes.Bytes48;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+import tech.pegasys.teku.bls.BLSPublicKey;
+import tech.pegasys.teku.infrastructure.unsigned.UInt64;
+import tech.pegasys.teku.spec.Spec;
+import tech.pegasys.teku.spec.TestSpecFactory;
+import tech.pegasys.teku.spec.datastructures.blocks.BlockCheckpoints;
+import tech.pegasys.teku.spec.datastructures.blocks.SlotAndBlockRoot;
+import tech.pegasys.teku.spec.datastructures.forkchoice.FastConfirmationStore;
+import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoiceNode;
+import tech.pegasys.teku.spec.datastructures.forkchoice.ForkChoicePayloadStatus;
+import tech.pegasys.teku.spec.datastructures.forkchoice.ProtoNodeData;
+import tech.pegasys.teku.spec.datastructures.forkchoice.ProtoNodeValidationStatus;
+import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyForkChoiceStrategy;
+import tech.pegasys.teku.spec.datastructures.forkchoice.ReadOnlyStore;
+import tech.pegasys.teku.spec.datastructures.forkchoice.VoteSnapshot;
+import tech.pegasys.teku.spec.datastructures.forkchoice.VoteTracker;
+import tech.pegasys.teku.spec.datastructures.state.Checkpoint;
+import tech.pegasys.teku.spec.datastructures.state.beaconstate.BeaconState;
+import tech.pegasys.teku.spec.util.DataStructureUtil;
+
+/**
+ * Benchmarks the Fast Confirmation Rule catch-up cost (issue #10994 acceptance criterion): the
+ * ~2-epoch catch-up slot that ends the warm-up — {@code get_latest_confirmed} restarting the
+ * confirmed root from the observed justified checkpoint and advancing it across a whole epoch of
+ * blocks to the head — must complete comfortably inside one slot at mainnet scale.
+ *
+ *
Fixture: mainnet preset, a linear chain with one block at every slot of epochs 0 and 1, the
+ * current slot starting epoch 2, and every validator voting (90% for the head, 10% spread across
+ * the previous epoch to exercise the prefix binary search). Fork choice is a lightweight in-memory
+ * linear chain whose ancestor lookups walk the parent chain the way protoarray does, so per-check
+ * costs are comparable to production. The head state is built directly in the current epoch, so the
+ * benchmark excludes the epoch-transition (pull-up) cost, which the scoring optimization does not
+ * affect.
+ *
+ *
{@code perBlockScoreTwoEpochChain} reproduces the pre-optimization cost model (one full
+ * validator pass per block) for direct comparison with {@code batchScoreTwoEpochChain} (one pass
+ * for the whole segment).
+ *
+ *
Quick run: {@code ./gradlew :ethereum:statetransition:jmh --args="FastConfirmationBenchmark -p
+ * validatorCount=1000000 -wi 1 -i 2 -f 0"}
+ */
+@Warmup(iterations = 2, time = 2, timeUnit = TimeUnit.SECONDS)
+@Measurement(iterations = 5, time = 2, timeUnit = TimeUnit.SECONDS)
+@Fork(
+ value = 1,
+ jvmArgsAppend = {"-Xmx8g"})
+@State(Scope.Thread)
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MILLISECONDS)
+public class FastConfirmationBenchmark {
+
+ private static final Spec SPEC = TestSpecFactory.createMainnetPhase0();
+ private static final int SLOTS_PER_EPOCH = SPEC.getGenesisSpecConfig().getSlotsPerEpoch();
+
+ /** One block at every slot of epochs 0 and 1; the current slot is the first of epoch 2. */
+ private static final int CHAIN_LENGTH = SLOTS_PER_EPOCH * 2;
+
+ private static final UInt64 CURRENT_SLOT = UInt64.valueOf(CHAIN_LENGTH);
+
+ @Param({"100000", "1000000"})
+ private int validatorCount;
+
+ private BeaconState balanceSource;
+ private FastConfirmationStore fcrStore;
+ private FastConfirmationStates states;
+ private Bytes32 head;
+ private List scoredChain;
+
+ @Setup(Level.Trial)
+ public void init() {
+ final Random random = new Random(2718281828L);
+ // Pubkeys are never used by the FCR, so generate cheap (lazily parsed) random keys instead of
+ // paying real BLS key generation for a mainnet-sized validator set.
+ final DataStructureUtil dataStructureUtil =
+ new DataStructureUtil(SPEC)
+ .withPubKeyGenerator(
+ () -> {
+ final byte[] keyBytes = new byte[48];
+ random.nextBytes(keyBytes);
+ return BLSPublicKey.fromBytesCompressed(Bytes48.wrap(keyBytes));
+ });
+ balanceSource =
+ dataStructureUtil.randomBeaconStateWithActiveValidators(validatorCount, CURRENT_SLOT);
+
+ final List chain = new ArrayList<>(CHAIN_LENGTH);
+ for (int slot = 0; slot < CHAIN_LENGTH; slot++) {
+ chain.add(Bytes32.random(random));
+ }
+ head = chain.get(CHAIN_LENGTH - 1);
+ final Bytes32 previousSlotHead = chain.get(CHAIN_LENGTH - 2);
+
+ final Checkpoint finalized = new Checkpoint(UInt64.ZERO, chain.get(0));
+ final Checkpoint observedJustified = new Checkpoint(UInt64.ONE, chain.get(SLOTS_PER_EPOCH));
+ // Every block reports the epoch-1 checkpoint as its unrealized justification, matching the
+ // healthy-network shape the restart branch expects.
+ final BlockCheckpoints blockCheckpoints =
+ new BlockCheckpoints(finalized, finalized, observedJustified, finalized);
+ final LinearChainForkChoiceStrategy forkChoice =
+ new LinearChainForkChoiceStrategy(chain, blockCheckpoints);
+
+ final VoteTracker[] votes = new VoteTracker[validatorCount];
+ for (int index = 0; index < validatorCount; index++) {
+ final Bytes32 votedRoot =
+ index % 10 == 0
+ ? chain.get(SLOTS_PER_EPOCH + 1 + ((index / 10) % (SLOTS_PER_EPOCH - 2)))
+ : head;
+ votes[index] = new VoteTracker(Bytes32.ZERO, votedRoot);
+ }
+ final VoteSnapshot voteSnapshot =
+ VoteSnapshot.create(UInt64.valueOf(validatorCount - 1), votes);
+
+ final ReadOnlyStore store = mock(ReadOnlyStore.class);
+ when(store.getForkChoiceStrategy()).thenReturn(forkChoice);
+ when(store.getVoteSnapshot()).thenReturn(voteSnapshot);
+ when(store.getFinalizedCheckpoint()).thenReturn(finalized);
+
+ // Warm-up exit shape: the confirmed root is still the (2-epoch-old) finalized block, so
+ // get_latest_confirmed reverts it, restarts it from the observed justified checkpoint at the
+ // epoch-1 start, and advances it across the whole previous epoch to the head.
+ fcrStore =
+ new FastConfirmationStore(
+ store,
+ chain.get(0),
+ finalized,
+ observedJustified,
+ observedJustified,
+ previousSlotHead,
+ head);
+ states = new FastConfirmationStates(Optional.of(balanceSource), balanceSource, balanceSource);
+ // The full 2-epoch segment above the finalized block, for the scoring benchmarks.
+ scoredChain = List.copyOf(chain.subList(1, CHAIN_LENGTH));
+
+ // Fail fast if the fixture stops exercising the full catch-up path.
+ final long startNanos = System.nanoTime();
+ final Bytes32 confirmed = newCalculator().getLatestConfirmed();
+ if (!confirmed.equals(head)) {
+ throw new IllegalStateException(
+ "Benchmark fixture did not advance the confirmed root to the head: " + confirmed);
+ }
+ System.out.printf(
+ "init done: %d validators, cold 2-epoch catch-up took %d ms%n",
+ validatorCount, TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos));
+ }
+
+ /** The acceptance-criterion scenario: must average comfortably below one slot (12s). */
+ @Benchmark
+ public void twoEpochCatchUpGetLatestConfirmed(final Blackhole bh) {
+ bh.consume(newCalculator().getLatestConfirmed());
+ }
+
+ /** The optimized core: the whole 2-epoch segment scored in one validator pass. */
+ @Benchmark
+ public void batchScoreTwoEpochChain(final Blackhole bh) {
+ bh.consume(newCalculator().computeChainAttestationScores(scoredChain, balanceSource));
+ }
+
+ /** Pre-optimization cost model: one full validator pass per block of the segment. */
+ @Benchmark
+ public void perBlockScoreTwoEpochChain(final Blackhole bh) {
+ final FastConfirmationCalculator calculator = newCalculator();
+ for (final Bytes32 blockRoot : scoredChain) {
+ bh.consume(calculator.getAttestationScore(blockRoot, balanceSource));
+ }
+ }
+
+ private FastConfirmationCalculator newCalculator() {
+ return new FastConfirmationCalculator(SPEC, fcrStore, states, CURRENT_SLOT);
+ }
+
+ public static void main(final String[] args) {
+ final FastConfirmationBenchmark benchmark = new FastConfirmationBenchmark();
+ benchmark.validatorCount = args.length > 0 ? Integer.parseInt(args[0]) : 1_000_000;
+ benchmark.init();
+ for (int i = 0; i < 5; i++) {
+ final long catchUpStart = System.nanoTime();
+ benchmark.newCalculator().getLatestConfirmed();
+ final long batchStart = System.nanoTime();
+ benchmark
+ .newCalculator()
+ .computeChainAttestationScores(benchmark.scoredChain, benchmark.balanceSource);
+ final long perBlockStart = System.nanoTime();
+ final FastConfirmationCalculator calculator = benchmark.newCalculator();
+ for (final Bytes32 blockRoot : benchmark.scoredChain) {
+ calculator.getAttestationScore(blockRoot, benchmark.balanceSource);
+ }
+ final long endNanos = System.nanoTime();
+ System.out.printf(
+ "catch-up %d ms, batch score %d ms, per-block score (old model) %d ms%n",
+ TimeUnit.NANOSECONDS.toMillis(batchStart - catchUpStart),
+ TimeUnit.NANOSECONDS.toMillis(perBlockStart - batchStart),
+ TimeUnit.NANOSECONDS.toMillis(endNanos - perBlockStart));
+ }
+ }
+
+ /**
+ * Minimal linear-chain fork-choice view: one block per slot, parent = previous slot. Ancestor
+ * lookups walk the chain slot by slot the way protoarray walks parent links, so per-check cost is
+ * comparable to production. Only the methods the fast confirmation calculator touches are
+ * implemented.
+ */
+ private static final class LinearChainForkChoiceStrategy implements ReadOnlyForkChoiceStrategy {
+ private final List rootBySlot;
+ private final Map slotByRoot = new HashMap<>();
+ private final BlockCheckpoints blockCheckpoints;
+
+ private LinearChainForkChoiceStrategy(
+ final List rootBySlot, final BlockCheckpoints blockCheckpoints) {
+ this.rootBySlot = rootBySlot;
+ this.blockCheckpoints = blockCheckpoints;
+ for (int slot = 0; slot < rootBySlot.size(); slot++) {
+ slotByRoot.put(rootBySlot.get(slot), slot);
+ }
+ }
+
+ @Override
+ public Optional blockSlot(final Bytes32 blockRoot) {
+ return Optional.ofNullable(slotByRoot.get(blockRoot)).map(UInt64::valueOf);
+ }
+
+ @Override
+ public Optional blockParentRoot(final Bytes32 blockRoot) {
+ final Integer slot = slotByRoot.get(blockRoot);
+ if (slot == null) {
+ return Optional.empty();
+ }
+ return Optional.of(slot == 0 ? Bytes32.ZERO : rootBySlot.get(slot - 1));
+ }
+
+ @Override
+ public Optional getAncestor(final Bytes32 blockRoot, final UInt64 slot) {
+ final Integer blockSlot = slotByRoot.get(blockRoot);
+ if (blockSlot == null) {
+ return Optional.empty();
+ }
+ // Walk down one slot at a time like protoarray's parent-pointer walk.
+ int currentSlot = blockSlot;
+ while (UInt64.valueOf(currentSlot).isGreaterThan(slot)) {
+ currentSlot--;
+ }
+ return Optional.of(rootBySlot.get(currentSlot));
+ }
+
+ @Override
+ public Optional getAncestorNode(final ForkChoiceNode node, final UInt64 slot) {
+ return getAncestor(node.blockRoot(), slot).map(ForkChoiceNode::createBase);
+ }
+
+ @Override
+ public boolean contains(final Bytes32 blockRoot) {
+ return slotByRoot.containsKey(blockRoot);
+ }
+
+ @Override
+ public boolean isFullyValidated(final Bytes32 blockRoot) {
+ return contains(blockRoot);
+ }
+
+ @Override
+ public Optional isOptimistic(final Bytes32 blockRoot) {
+ return Optional.of(false);
+ }
+
+ @Override
+ public Optional getBlockData(final Bytes32 blockRoot) {
+ return blockSlot(blockRoot)
+ .map(
+ slot ->
+ new ProtoNodeData(
+ slot,
+ blockRoot,
+ blockParentRoot(blockRoot).orElse(Bytes32.ZERO),
+ Bytes32.ZERO,
+ UInt64.ZERO,
+ Bytes32.ZERO,
+ UInt64.ZERO,
+ ProtoNodeValidationStatus.VALID,
+ blockCheckpoints,
+ UInt64.ZERO,
+ ForkChoicePayloadStatus.PAYLOAD_STATUS_PENDING));
+ }
+
+ @Override
+ public List getBlockData() {
+ return rootBySlot.stream().map(root -> getBlockData(root).orElseThrow()).toList();
+ }
+
+ @Override
+ public Optional executionBlockNumber(final Bytes32 blockRoot) {
+ throw notUsed();
+ }
+
+ @Override
+ public Optional executionBlockHash(final Bytes32 blockRoot) {
+ throw notUsed();
+ }
+
+ @Override
+ public Optional getParentBeaconBlockNode(final ForkChoiceNode node) {
+ throw notUsed();
+ }
+
+ @Override
+ public Optional findCommonAncestor(
+ final Bytes32 blockRoot1, final Bytes32 blockRoot2) {
+ throw notUsed();
+ }
+
+ @Override
+ public List getBlockRootsAtSlot(final UInt64 slot) {
+ throw notUsed();
+ }
+
+ @Override
+ public List getChainHeads(final boolean includeNonViableHeads) {
+ throw notUsed();
+ }
+
+ @Override
+ public Optional getOptimisticallySyncedTransitionBlockRoot(final Bytes32 head) {
+ throw notUsed();
+ }
+
+ @Override
+ public boolean shouldExtendPayload(
+ final ReadOnlyStore store, final SlotAndBlockRoot slotAndBlockRoot) {
+ throw notUsed();
+ }
+
+ @Override
+ public boolean shouldBuildOnFull(
+ final ReadOnlyStore store, final UInt64 slot, final ForkChoiceNode head) {
+ throw notUsed();
+ }
+
+ @Override
+ public Optional getWeight(final Bytes32 blockRoot) {
+ throw notUsed();
+ }
+
+ private static UnsupportedOperationException notUsed() {
+ return new UnsupportedOperationException("Not used by the fast confirmation calculator");
+ }
+ }
+}
diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java
index e130c56dadb..4c81a7a6c21 100644
--- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java
+++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculator.java
@@ -13,12 +13,18 @@
package tech.pegasys.teku.statetransition.forkchoice.fastconfirmation;
+import it.unimi.dsi.fastutil.ints.IntArrayList;
+import it.unimi.dsi.fastutil.ints.IntList;
import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
import it.unimi.dsi.fastutil.ints.IntSet;
import java.util.ArrayDeque;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Deque;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -72,6 +78,22 @@ class FastConfirmationCalculator {
// Lazily computed once per instance (single-threaded per slot); see getPulledUpHeadState.
private BeaconState pulledUpHeadState;
+ // Per-instance (per-slot) memoization. The safety-threshold helpers query the same slot
+ // committees and slot ranges for every block they score (and re-evaluate a block across the two
+ // walk phases), the justifiability gates recompute the current target score and honest FFG
+ // support, and the equivocator set is fixed for the slot's vote snapshot; single-threaded per
+ // slot, so plain fields suffice. The per-balance-source maps are identity-keyed: a calculator
+ // sees at most two source states, and value equality on BeaconState would hash the whole state.
+ private final Map slotCommitteeBySlot = new HashMap<>();
+ private final Map committeeByRange = new HashMap<>();
+ private final IdentityHashMap> adversarialWeightBySource =
+ new IdentityHashMap<>();
+ private final IdentityHashMap> safetyThresholdBySource =
+ new IdentityHashMap<>();
+ private IntList equivocatingValidatorIndices;
+ private UInt64 currentTargetScore;
+ private UInt64 honestFfgSupportForCurrentTarget;
+
FastConfirmationCalculator(
final Spec spec,
final FastConfirmationStore fcrStore,
@@ -147,15 +169,23 @@ BeaconState getPulledUpHeadState() {
* epoch behind (a long run of empty slots) would therefore throw {@code StateTooOldException} for
* current-epoch slots and abort confirmation. The intervening slots are empty, so advancing the
* head state through them is deterministic and yields the same shuffling the real chain would.
+ *
+ * Memoized per slot: the ranges queried while scoring successive blocks overlap heavily, and
+ * the shuffling source is fixed for the whole run.
*/
IntSet getSlotCommittee(final UInt64 slot) {
+ return slotCommitteeBySlot.computeIfAbsent(slot, this::computeSlotCommittee);
+ }
+
+ private IntSet computeSlotCommittee(final UInt64 slot) {
final BeaconState shufflingSource = getPulledUpHeadState();
final UInt64 epoch = spec.computeEpochAtSlot(slot);
- final int committeesCount = spec.getCommitteeCountPerSlot(shufflingSource, epoch).intValue();
+ final UInt64 committeesCount = spec.getCommitteeCountPerSlot(shufflingSource, epoch);
final IntSet participants = new IntOpenHashSet();
- for (int committeeIndex = 0; committeeIndex < committeesCount; committeeIndex++) {
- participants.addAll(
- spec.getBeaconCommittee(shufflingSource, slot, UInt64.valueOf(committeeIndex)));
+ for (UInt64 committeeIndex = UInt64.ZERO;
+ committeeIndex.isLessThan(committeesCount);
+ committeeIndex = committeeIndex.increment()) {
+ participants.addAll(spec.getBeaconCommittee(shufflingSource, slot, committeeIndex));
}
return participants;
}
@@ -203,6 +233,96 @@ UInt64 getAttestationScore(final Bytes32 nodeRoot, final BeaconState balanceSour
return score;
}
+ /**
+ * Computes {@code get_attestation_score} for every block of {@code chainRoots} (blocks of a
+ * single chain, ordered oldest first) in one pass over the active validator set, instead of one
+ * pass per block.
+ *
+ *
Because the blocks lie on one chain, the blocks a vote supports form a prefix of the list:
+ * supporting a descendant of a block implies supporting a descendant of every earlier block.
+ * (Ancestry is checked against base/PENDING nodes, exactly as {@link #getAttestationScore} does,
+ * and under Gloas a PENDING ancestor matches any payload status, so this is plain block ancestry
+ * — transitive along the chain on all forks.) Each vote is therefore resolved once and bucketed
+ * at the latest chain block it supports; a block's score is the sum of the buckets from its own
+ * position onward.
+ */
+ Map computeChainAttestationScores(
+ final List chainRoots, final BeaconState balanceSource) {
+ if (chainRoots.isEmpty()) {
+ return Map.of();
+ }
+ final List chainNodes = chainRoots.stream().map(this::getNodeForRoot).toList();
+ // supportByLatestIndex[i]: total balance of votes whose latest supported chain block is i.
+ final UInt64[] supportByLatestIndex = new UInt64[chainNodes.size()];
+ Arrays.fill(supportByLatestIndex, UInt64.ZERO);
+
+ final UInt64 balanceSourceEpoch = spec.getCurrentEpoch(balanceSource);
+ final SszList validators = balanceSource.getValidators();
+ for (final int index : spec.getActiveValidatorIndices(balanceSource, balanceSourceEpoch)) {
+ final Validator validator = validators.get(index);
+ if (validator.isSlashed()) {
+ continue;
+ }
+ final VoteTracker vote = votes.getVote(index);
+ if (vote.isEquivocating()) {
+ continue;
+ }
+ final Bytes32 votedRoot = vote.getNextRoot();
+ // A zero root means the validator is not in store.latest_messages.
+ if (votedRoot.isZero()) {
+ continue;
+ }
+ final Optional maybeVotedNode =
+ forkChoice.getSupportedNode(
+ currentSlot, votedRoot, vote.getNextSlot(), vote.isNextFullPayloadHint());
+ if (maybeVotedNode.isEmpty()) {
+ continue;
+ }
+ final int latestSupported =
+ findLatestSupportedChainIndex(chainNodes, maybeVotedNode.orElseThrow());
+ if (latestSupported >= 0) {
+ supportByLatestIndex[latestSupported] =
+ supportByLatestIndex[latestSupported].plus(validator.getEffectiveBalance());
+ }
+ }
+
+ // score(chainRoots[i]) = votes supporting chainRoots[i] or any later chain block.
+ final Map scores = HashMap.newHashMap(chainRoots.size());
+ UInt64 runningScore = UInt64.ZERO;
+ for (int i = chainRoots.size() - 1; i >= 0; i--) {
+ runningScore = runningScore.plus(supportByLatestIndex[i]);
+ scores.put(chainRoots.get(i), runningScore);
+ }
+ return scores;
+ }
+
+ /**
+ * The index of the latest (highest-slot) chain block the vote supports (i.e. of which the voted
+ * node is a descendant), or {@code -1} when it supports none. Tests the newest block first — the
+ * common case, since most latest messages vote at or near the head and so support the whole chain
+ * — and otherwise binary-searches the boundary of the supported prefix.
+ */
+ int findLatestSupportedChainIndex(
+ final List chainNodes, final ForkChoiceNode votedNode) {
+ final int last = chainNodes.size() - 1;
+ if (isAncestor(votedNode, chainNodes.get(last))) {
+ return last;
+ }
+ int latestSupported = -1;
+ int low = 0;
+ int high = last - 1;
+ while (low <= high) {
+ final int mid = (low + high) >>> 1;
+ if (isAncestor(votedNode, chainNodes.get(mid))) {
+ latestSupported = mid;
+ low = mid + 1;
+ } else {
+ high = mid - 1;
+ }
+ }
+ return latestSupported;
+ }
+
/**
* Implements {@code get_block_support_between_slots}: the total effective balance (per {@code
* balanceSource}) of unslashed, active, non-equivocating validators assigned to the inclusive
@@ -233,14 +353,24 @@ UInt64 getBlockSupportBetweenSlots(
* Implements {@code get_equivocation_score}: the total effective balance (per {@code
* balanceSource}) of active, equivocating validators assigned to the inclusive slot range. Per
* spec, slashed validators are not filtered out here (they are very likely already equivocating).
+ *
+ * Iterates the (almost always empty) equivocator set and checks each member's committee
+ * assignment, rather than materializing the committees of the whole slot range and filtering them
+ * for equivocators as the spec does. The result is identical — both select the validators that
+ * are equivocating and assigned to the range — but no slot committee is computed at all in the
+ * common no-equivocation case, which makes {@code compute_adversarial_weight} pure arithmetic.
*/
UInt64 getEquivocationScore(
final BeaconState balanceSource, final UInt64 startSlot, final UInt64 endSlot) {
+ final IntList equivocatingIndices = getEquivocatingValidatorIndices();
+ if (equivocatingIndices.isEmpty()) {
+ return UInt64.ZERO;
+ }
final UInt64 balanceSourceEpoch = spec.getCurrentEpoch(balanceSource);
final SszList validators = balanceSource.getValidators();
UInt64 score = UInt64.ZERO;
- for (final int index : getCommitteeBetweenSlots(startSlot, endSlot)) {
- if (!votes.getVote(index).isEquivocating()) {
+ for (final int index : equivocatingIndices) {
+ if (!isInCommitteeBetweenSlots(index, startSlot, endSlot)) {
continue;
}
final Validator validator = validators.get(index);
@@ -251,13 +381,50 @@ UInt64 getEquivocationScore(
return score;
}
+ /** Indices of validators marked equivocating in the vote snapshot, collected once per slot. */
+ private IntList getEquivocatingValidatorIndices() {
+ if (equivocatingValidatorIndices == null) {
+ final IntArrayList indices = new IntArrayList();
+ for (int index = 0; index < votes.size(); index++) {
+ if (votes.getVote(index).isEquivocating()) {
+ indices.add(index);
+ }
+ }
+ equivocatingValidatorIndices = indices;
+ }
+ return equivocatingValidatorIndices;
+ }
+
+ /** Whether the validator is assigned to any committee of the inclusive slot range. */
+ private boolean isInCommitteeBetweenSlots(
+ final int validatorIndex, final UInt64 startSlot, final UInt64 endSlot) {
+ for (UInt64 slot = startSlot; slot.isLessThanOrEqualTo(endSlot); slot = slot.increment()) {
+ if (getSlotCommittee(slot).contains(validatorIndex)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Implements {@code compute_adversarial_weight}: the maximum weight that could be adversarial in
* the committees of the slot range, assuming {@code CONFIRMATION_BYZANTINE_THRESHOLD} and
* discounting validators already known to be equivocating.
+ *
+ * Memoized per (balance source, slot range): the honest-FFG gate re-queries the current
+ * epoch's range on every call, and blocks re-evaluated across the walk phases repeat theirs.
*/
UInt64 computeAdversarialWeight(
final BeaconState balanceSource, final UInt64 startSlot, final UInt64 endSlot) {
+ return adversarialWeightBySource
+ .computeIfAbsent(balanceSource, __ -> new HashMap<>())
+ .computeIfAbsent(
+ new SlotRange(startSlot, endSlot),
+ range -> calculateAdversarialWeight(balanceSource, range.startSlot(), range.endSlot()));
+ }
+
+ private UInt64 calculateAdversarialWeight(
+ final BeaconState balanceSource, final UInt64 startSlot, final UInt64 endSlot) {
final UInt64 totalActiveBalance = spec.getTotalActiveBalance(balanceSource);
final UInt64 maximumWeight =
FastConfirmationRuleUtil.estimateCommitteeWeightBetweenSlots(
@@ -316,8 +483,21 @@ UInt64 getSupportDiscount(final BeaconState balanceSource, final Bytes32 blockRo
return computeEmptySlotSupportDiscount(balanceSource, blockRoot);
}
- /** Implements {@code compute_safety_threshold}: the LMD-GHOST safety threshold for the block. */
+ /**
+ * Implements {@code compute_safety_threshold}: the LMD-GHOST safety threshold for the block.
+ *
+ *
Memoized per (balance source, block): the block that stops the previous-epoch walk is
+ * re-evaluated by the current-epoch walk, repeating the support-discount and adversarial-weight
+ * range work below.
+ */
UInt64 computeSafetyThreshold(final Bytes32 blockRoot, final BeaconState balanceSource) {
+ return safetyThresholdBySource
+ .computeIfAbsent(balanceSource, __ -> new HashMap<>())
+ .computeIfAbsent(blockRoot, root -> calculateSafetyThreshold(root, balanceSource));
+ }
+
+ private UInt64 calculateSafetyThreshold(
+ final Bytes32 blockRoot, final BeaconState balanceSource) {
final UInt64 parentSlot = getBlockSlot(getBlockParentRoot(blockRoot));
final UInt64 totalActiveBalance = spec.getTotalActiveBalance(balanceSource);
final UInt64 proposerScore = spec.getProposerBoostAmount(balanceSource);
@@ -344,7 +524,19 @@ boolean isOneConfirmed(final BeaconState balanceSource, final Bytes32 blockRoot)
if (!isValidForConfirmation(blockRoot)) {
return false;
}
- final UInt64 support = getAttestationScore(blockRoot, balanceSource);
+ return isOneConfirmedWithSupport(
+ balanceSource, blockRoot, getAttestationScore(blockRoot, balanceSource));
+ }
+
+ /**
+ * {@code is_one_confirmed} evaluated against a precomputed attestation score (see {@link
+ * #computeChainAttestationScores}); otherwise identical to {@link #isOneConfirmed}.
+ */
+ private boolean isOneConfirmedWithSupport(
+ final BeaconState balanceSource, final Bytes32 blockRoot, final UInt64 support) {
+ if (!isValidForConfirmation(blockRoot)) {
+ return false;
+ }
final UInt64 safetyThreshold = computeSafetyThreshold(blockRoot, balanceSource);
return support.isGreaterThan(safetyThreshold);
}
@@ -368,8 +560,19 @@ private boolean isValidForConfirmation(final Bytes32 blockRoot) {
/**
* Implements {@code get_current_target_score}: the estimated FFG support of the current-epoch
* target, using the pulled-up head state's validator set and the LMD votes received so far.
+ *
+ *
Memoized: a full pass over the active validator set that the justifiability gates ({@code
+ * will_no_conflicting_checkpoint_be_justified}, {@code will_current_target_be_justified}) would
+ * otherwise recompute several times per slot against the same snapshot.
*/
UInt64 getCurrentTargetScore() {
+ if (currentTargetScore == null) {
+ currentTargetScore = computeCurrentTargetScore();
+ }
+ return currentTargetScore;
+ }
+
+ private UInt64 computeCurrentTargetScore() {
final Checkpoint target = getCurrentTarget();
final BeaconState state = getPulledUpHeadState();
final UInt64 epoch = spec.getCurrentEpoch(state);
@@ -411,8 +614,18 @@ UInt64 getCurrentTargetScore() {
* Implements {@code compute_honest_ffg_support_for_current_target}: the minimum honest FFG
* support the current-epoch target can be assured of, assuming synchrony and {@code
* CONFIRMATION_BYZANTINE_THRESHOLD}.
+ *
+ *
Memoized: both justifiability gates derive from it and can each run more than once per slot
+ * against the same snapshot.
*/
UInt64 computeHonestFfgSupportForCurrentTarget() {
+ if (honestFfgSupportForCurrentTarget == null) {
+ honestFfgSupportForCurrentTarget = calculateHonestFfgSupportForCurrentTarget();
+ }
+ return honestFfgSupportForCurrentTarget;
+ }
+
+ private UInt64 calculateHonestFfgSupportForCurrentTarget() {
final BeaconState balanceSource = getPulledUpHeadState();
final UInt64 totalActiveBalance = spec.getTotalActiveBalance(balanceSource);
final UInt64 ffgSupportForCheckpoint = getCurrentTargetScore();
@@ -496,8 +709,13 @@ boolean isConfirmedChainSafe(final Bytes32 confirmedRoot) {
() ->
new IllegalStateException(
"Previous balance source is required for reconfirmation"));
- return getAncestorRoots(confirmedRoot, startRootExclusive).stream()
- .allMatch(root -> isOneConfirmed(previousBalanceSource, root));
+ final List chainToReconfirm = getAncestorRoots(confirmedRoot, startRootExclusive);
+ // Score the whole chain in a single pass over the validator set instead of one per block.
+ final Map chainScores =
+ computeChainAttestationScores(chainToReconfirm, previousBalanceSource);
+ return chainToReconfirm.stream()
+ .allMatch(
+ root -> isOneConfirmedWithSupport(previousBalanceSource, root, chainScores.get(root)));
}
/**
@@ -513,6 +731,13 @@ Bytes32 findLatestConfirmedDescendant(final Bytes32 latestConfirmedRoot) {
final BeaconState currentBalanceSource = states.currentBalanceSource();
Bytes32 confirmedRoot = latestConfirmedRoot;
+ // Both walk phases score blocks from this one chain (the second phase from a suffix of it), so
+ // their attestation scores are computed together in a single pass over the validator set on
+ // first need (see computeChainAttestationScores) instead of one pass per block. Left null until
+ // a phase actually scores, so slots where neither gate passes do no scoring work at all.
+ final List candidateChain = getAncestorRoots(head, latestConfirmedRoot);
+ Map chainScores = null;
+
// The previous slot head is a root persisted in the FCR store across slots, so it may have been
// pruned from fork choice (protoarray only keeps finalized-onward blocks, while the spec
// assumes
@@ -528,8 +753,9 @@ && epochPlusIsAtLeastCurrent(getVotingSource(previousSlotHead).getEpoch(), 2)
getUnrealizedJustification(previousSlotHead).getEpoch(), 1)
|| epochPlusIsAtLeastCurrent(
getUnrealizedJustification(head).getEpoch(), 1))))) {
+ chainScores = computeChainAttestationScores(candidateChain, currentBalanceSource);
// Advance towards the head over previous-epoch blocks; stop at the first unconfirmed one.
- for (final Bytes32 blockRoot : getAncestorRoots(head, confirmedRoot)) {
+ for (final Bytes32 blockRoot : candidateChain) {
// Only meant to confirm previous-epoch blocks.
if (getBlockEpoch(blockRoot).equals(currentEpoch)) {
break;
@@ -538,7 +764,8 @@ && epochPlusIsAtLeastCurrent(getVotingSource(previousSlotHead).getEpoch(), 2)
if (!isAncestor(previousSlotHead, blockRoot)) {
break;
}
- if (!isOneConfirmed(currentBalanceSource, blockRoot)) {
+ if (!isOneConfirmedWithSupport(
+ currentBalanceSource, blockRoot, chainScores.get(blockRoot))) {
break;
}
confirmedRoot = blockRoot;
@@ -546,14 +773,20 @@ && epochPlusIsAtLeastCurrent(getVotingSource(previousSlotHead).getEpoch(), 2)
}
if (atEpochStart || epochPlusIsAtLeastCurrent(getUnrealizedJustification(head).getEpoch(), 1)) {
+ if (chainScores == null) {
+ chainScores = computeChainAttestationScores(candidateChain, currentBalanceSource);
+ }
Bytes32 tentativeConfirmedRoot = confirmedRoot;
+ // A suffix of candidateChain: confirmedRoot only ever advances along it, so every walked
+ // block already has a precomputed score.
for (final Bytes32 blockRoot : getAncestorRoots(head, confirmedRoot)) {
// Only true the first time the walk advances into the current epoch.
if (getBlockEpoch(blockRoot).isGreaterThan(getBlockEpoch(tentativeConfirmedRoot))
&& !willCurrentTargetBeJustified()) {
break;
}
- if (!isOneConfirmed(currentBalanceSource, blockRoot)) {
+ if (!isOneConfirmedWithSupport(
+ currentBalanceSource, blockRoot, chainScores.get(blockRoot))) {
break;
}
tentativeConfirmedRoot = blockRoot;
@@ -650,8 +883,15 @@ private Checkpoint getStoreUnrealizedJustifiedCheckpoint() {
/**
* Union of {@code get_slot_committee} over the inclusive slot range {@code [startSlot, endSlot]}.
+ * Memoized per range, so a re-evaluated block does not rebuild its union.
*/
private IntSet getCommitteeBetweenSlots(final UInt64 startSlot, final UInt64 endSlot) {
+ return committeeByRange.computeIfAbsent(
+ new SlotRange(startSlot, endSlot),
+ range -> computeCommitteeBetweenSlots(range.startSlot(), range.endSlot()));
+ }
+
+ private IntSet computeCommitteeBetweenSlots(final UInt64 startSlot, final UInt64 endSlot) {
final IntSet participants = new IntOpenHashSet();
for (UInt64 slot = startSlot; slot.isLessThanOrEqualTo(endSlot); slot = slot.increment()) {
participants.addAll(getSlotCommittee(slot));
@@ -757,4 +997,7 @@ private BlockCheckpoints getCheckpoints(final Bytes32 blockRoot) {
.map(ProtoNodeData::getCheckpoints)
.orElseThrow(() -> new IllegalStateException("Missing checkpoints for " + blockRoot));
}
+
+ /** Inclusive slot range used as a memoization key. */
+ private record SlotRange(UInt64 startSlot, UInt64 endSlot) {}
}
diff --git a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java
index 35f0220b115..301186a7e65 100644
--- a/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java
+++ b/ethereum/statetransition/src/main/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/ForkChoiceFastConfirmation.java
@@ -172,13 +172,13 @@ private SafeFuture withSegmentTimeout(final SafeFuture segment, fina
*
* Relaxed to {@link #WARM_UP_SEGMENT_TIMEOUT_SLOTS} slots while the tracker is warming up. The
* update that ends the warm-up advances {@code confirmed_root} across every block accumulated
- * while it was pinned to finality — up to two epochs — and {@code
- * find_latest_confirmed_descendant} scores each of those blocks over the whole active validator
- * set, so that single slot can exceed a one-slot budget on a large network. It is a one-off: in
- * steady state the walk covers a single block. Timing out here would not stop the work (the
- * timeout does not cancel it) but would log it as a failure and send the slot's fcU late, so the
- * bound is widened for the catch-up instead. A stopgap — the real fix is to score the whole chain
- * segment in one pass over the validator set.
+ * while it was pinned to finality — up to two epochs. {@code find_latest_confirmed_descendant}
+ * scores that whole chain in a single pass over the active validator set (see {@code
+ * FastConfirmationCalculator#computeChainAttestationScores}), but the catch-up slot remains the
+ * heaviest one on a large network, so the widened bound is kept as a safety margin. It is a
+ * one-off: in steady state the walk covers a single block. Timing out here would not stop the
+ * work (the timeout does not cancel it) but would log it as a failure and send the slot's fcU
+ * late.
*/
private Duration segmentTimeout(final UInt64 slot) {
final long slotDurationMillis = spec.getSlotDurationMillis(slot);
diff --git a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java
index 69e90172647..757f3a479a7 100644
--- a/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java
+++ b/ethereum/statetransition/src/test/java/tech/pegasys/teku/statetransition/forkchoice/fastconfirmation/FastConfirmationCalculatorTest.java
@@ -16,6 +16,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -26,6 +27,7 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.stream.LongStream;
import org.apache.tuweni.bytes.Bytes32;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -53,12 +55,18 @@ class FastConfirmationCalculatorTest {
private final ReadOnlyStore store = mock(ReadOnlyStore.class);
private final ReadOnlyForkChoiceStrategy forkChoice = mock(ReadOnlyForkChoiceStrategy.class);
- // A canonical linear chain where block at index i has slot i and parent = chain(i - 1).
+ // A canonical linear chain where block at index i has parent = chain(i - 1); slots are
+ // ascending but not necessarily consecutive (see buildLinearChainAtSlots).
private final List chain = new ArrayList<>();
- private final Map slotByRoot = new HashMap<>();
+ private final List chainSlots = new ArrayList<>();
+ private final Map indexByRoot = new HashMap<>();
@BeforeEach
void setUp() {
+ chain.clear();
+ chainSlots.clear();
+ indexByRoot.clear();
+
when(store.getForkChoiceStrategy()).thenReturn(forkChoice);
// Default to no votes; the weight tests re-stub with specific votes before building a
// calculator.
@@ -182,8 +190,9 @@ void shouldUnionAllSlotCommitteesToTheActiveValidatorSetOverAnEpoch() {
final FastConfirmationCalculator calculator = calculatorWithHeadState(headState, 0);
final IntSet allCommitteeMembers = new IntOpenHashSet();
- for (int slot = 0; slot < spec.getSlotsPerEpoch(UInt64.ZERO); slot++) {
- allCommitteeMembers.addAll(calculator.getSlotCommittee(UInt64.valueOf(slot)));
+ final UInt64 slotsPerEpoch = UInt64.valueOf(spec.getSlotsPerEpoch(UInt64.ZERO));
+ for (UInt64 slot = UInt64.ZERO; slot.isLessThan(slotsPerEpoch); slot = slot.increment()) {
+ allCommitteeMembers.addAll(calculator.getSlotCommittee(slot));
}
// Every active validator is assigned to exactly one committee per epoch, so the union of all
@@ -228,6 +237,200 @@ void shouldSumAttestationScoreForNonEquivocatingUnslashedVotersSupportingADescen
assertThat(calculator.getAttestationScore(chain.get(3), balanceSource)).isEqualTo(expected);
}
+ @Test
+ void shouldComputeChainAttestationScoresInOnePassMatchingPerBlockScores() {
+ buildLinearChain(8);
+ // Validator 5 votes for a descendant of the whole chain but is slashed -> excluded everywhere.
+ final BeaconState balanceSource = withSlashedValidator(genesisState(), 5);
+ final Bytes32 offChainRoot = Bytes32.random();
+ when(store.getVoteSnapshot())
+ .thenReturn(
+ voteSnapshot(
+ Map.of(
+ 0, vote(chain.get(7)), // supports every scored block
+ 1, vote(chain.get(4)), // supports chain[3..4] only
+ 2, vote(chain.get(2)), // below the scored chain -> supports none of it
+ 3, equivocatingVote(chain.get(7)), // equivocating -> excluded
+ 4, vote(offChainRoot), // unknown to fork choice -> supports none
+ 5, vote(chain.get(7))))); // slashed -> excluded
+ final FastConfirmationCalculator calculator = calculator(chain.get(7), 7);
+
+ final List scoredChain =
+ List.of(chain.get(3), chain.get(4), chain.get(5), chain.get(6), chain.get(7));
+ final Map batchScores =
+ calculator.computeChainAttestationScores(scoredChain, balanceSource);
+
+ assertThat(batchScores).containsOnlyKeys(scoredChain);
+ for (final Bytes32 root : scoredChain) {
+ assertThat(batchScores.get(root))
+ .isEqualTo(calculator.getAttestationScore(root, balanceSource));
+ }
+ }
+
+ @Test
+ void shouldPrefixSumChainScoresFromVotesBucketedAtTheirLatestSupportedBlock() {
+ buildLinearChain(6);
+ final BeaconState balanceSource = genesisState();
+ when(store.getVoteSnapshot())
+ .thenReturn(
+ voteSnapshot(
+ Map.of(
+ 0, vote(chain.get(5)), // latest supported: chain[5] -> last bucket
+ 1, vote(chain.get(3)), // latest supported: chain[3]
+ 2, vote(chain.get(2)), // latest supported: chain[2] -> first scored block
+ 3, vote(chain.get(0))))); // below the scored chain -> supports none
+ final FastConfirmationCalculator calculator = calculator(chain.get(5), 5);
+
+ final List scoredChain =
+ List.of(chain.get(2), chain.get(3), chain.get(4), chain.get(5));
+ final Map scores =
+ calculator.computeChainAttestationScores(scoredChain, balanceSource);
+
+ // score(block) = sum of the buckets at the block and every later chain block.
+ final UInt64 b0 = effectiveBalance(balanceSource, 0);
+ final UInt64 b1 = effectiveBalance(balanceSource, 1);
+ final UInt64 b2 = effectiveBalance(balanceSource, 2);
+ assertThat(scores.get(chain.get(5))).isEqualTo(b0);
+ assertThat(scores.get(chain.get(4))).isEqualTo(b0);
+ assertThat(scores.get(chain.get(3))).isEqualTo(b0.plus(b1));
+ assertThat(scores.get(chain.get(2))).isEqualTo(b0.plus(b1).plus(b2));
+ }
+
+ @Test
+ void shouldReturnNoChainScoresForAnEmptyChain() {
+ final FastConfirmationCalculator calculator = calculatorWithHeadState(genesisState(), 0);
+
+ assertThat(calculator.computeChainAttestationScores(List.of(), genesisState())).isEmpty();
+ }
+
+ @Test
+ void shouldScoreSingleBlockChainLikePerBlockScoring() {
+ buildLinearChain(4);
+ final BeaconState balanceSource = genesisState();
+ when(store.getVoteSnapshot())
+ .thenReturn(
+ voteSnapshot(
+ Map.of(
+ 0, vote(chain.get(3)), // descendant -> counts
+ 1, vote(chain.get(1))))); // ancestor -> excluded
+ final FastConfirmationCalculator calculator = calculator(chain.get(3), 3);
+
+ final Map scores =
+ calculator.computeChainAttestationScores(List.of(chain.get(2)), balanceSource);
+
+ assertThat(scores)
+ .containsExactly(
+ Map.entry(chain.get(2), calculator.getAttestationScore(chain.get(2), balanceSource)));
+ }
+
+ @Test
+ void shouldExcludeChainScoreVoteWhoseSupportedNodeCannotBeResolved() {
+ buildLinearChain(4);
+ final BeaconState balanceSource = genesisState();
+ final Bytes32 unresolvableRoot = Bytes32.random();
+ when(store.getVoteSnapshot())
+ .thenReturn(voteSnapshot(Map.of(0, vote(unresolvableRoot), 1, vote(chain.get(3)))));
+ // get_supported_node cannot resolve the vote (e.g. the voted block was pruned).
+ when(forkChoice.getSupportedNode(any(), eq(unresolvableRoot), any(), anyBoolean()))
+ .thenReturn(Optional.empty());
+ final FastConfirmationCalculator calculator = calculator(chain.get(3), 3);
+
+ final Map scores =
+ calculator.computeChainAttestationScores(
+ List.of(chain.get(2), chain.get(3)), balanceSource);
+
+ assertThat(scores.get(chain.get(2))).isEqualTo(effectiveBalance(balanceSource, 1));
+ assertThat(scores.get(chain.get(3))).isEqualTo(effectiveBalance(balanceSource, 1));
+ }
+
+ @Test
+ void shouldFindLatestSupportedChainIndexAtEveryPrefixBoundary() {
+ buildLinearChain(7);
+ final FastConfirmationCalculator calculator = calculator(chain.get(6), 6);
+ // Scored chain: blocks 1..6 as base nodes (list index i holds block i + 1).
+ final List chainNodes =
+ chain.subList(1, 7).stream().map(ForkChoiceNode::createBase).toList();
+
+ // A vote for block p supports exactly the chain prefix up to block p: list index p - 1, and
+ // no block at all for p == 0. Walking every position covers the whole-chain fast path
+ // (p == 6) and every binary-search boundary in between.
+ for (int votedBlock = 0; votedBlock < 7; votedBlock++) {
+ final ForkChoiceNode votedNode = ForkChoiceNode.createBase(chain.get(votedBlock));
+ assertThat(calculator.findLatestSupportedChainIndex(chainNodes, votedNode))
+ .isEqualTo(votedBlock - 1);
+ }
+ }
+
+ @Test
+ void shouldFindLatestSupportedChainIndexAcrossEmptySlotGaps() {
+ // Consecutive chain blocks separated by runs of empty slots.
+ buildLinearChainAtSlots(0, 1, 4, 8, 11);
+ final FastConfirmationCalculator calculator = calculator(chain.get(4), 11);
+ final List chainNodes =
+ chain.subList(1, 5).stream().map(ForkChoiceNode::createBase).toList();
+
+ // Same prefix-boundary sweep as the gap-free test: the search must follow block ancestry,
+ // with the empty slots skipped by the get_ancestor resolution.
+ for (int votedBlock = 0; votedBlock < 5; votedBlock++) {
+ final ForkChoiceNode votedNode = ForkChoiceNode.createBase(chain.get(votedBlock));
+ assertThat(calculator.findLatestSupportedChainIndex(chainNodes, votedNode))
+ .isEqualTo(votedBlock - 1);
+ }
+ }
+
+ @Test
+ void shouldFindNoSupportedChainIndexForAVoteOutsideTheChain() {
+ buildLinearChain(4);
+ final FastConfirmationCalculator calculator = calculator(chain.get(3), 3);
+ final List chainNodes =
+ chain.subList(1, 4).stream().map(ForkChoiceNode::createBase).toList();
+
+ final ForkChoiceNode unknownNode = ForkChoiceNode.createBase(Bytes32.random());
+ assertThat(calculator.findLatestSupportedChainIndex(chainNodes, unknownNode)).isEqualTo(-1);
+ }
+
+ @Test
+ void shouldFindSupportedChainIndexOnSingleBlockChain() {
+ buildLinearChain(4);
+ final FastConfirmationCalculator calculator = calculator(chain.get(3), 3);
+ final List chainNodes = List.of(ForkChoiceNode.createBase(chain.get(2)));
+
+ // Descendant (and the block itself) support it; an ancestor does not.
+ assertThat(
+ calculator.findLatestSupportedChainIndex(
+ chainNodes, ForkChoiceNode.createBase(chain.get(3))))
+ .isEqualTo(0);
+ assertThat(
+ calculator.findLatestSupportedChainIndex(
+ chainNodes, ForkChoiceNode.createBase(chain.get(2))))
+ .isEqualTo(0);
+ assertThat(
+ calculator.findLatestSupportedChainIndex(
+ chainNodes, ForkChoiceNode.createBase(chain.get(1))))
+ .isEqualTo(-1);
+ }
+
+ @Test
+ void shouldMemoizeSlotCommitteesWithinTheSlotRun() {
+ final BeaconState headState = genesisState();
+ final FastConfirmationCalculator calculator = calculatorWithHeadState(headState, 0);
+
+ assertThat(calculator.getSlotCommittee(UInt64.ZERO))
+ .isSameAs(calculator.getSlotCommittee(UInt64.ZERO));
+ }
+
+ @Test
+ void shouldSkipCommitteeComputationEntirelyWhenNoValidatorIsEquivocating() {
+ // No equivocating votes: the score is zero by definition and no slot committee is materialized
+ // — a committee query this far beyond the head state's epoch would otherwise throw.
+ final FastConfirmationCalculator calculator = calculatorWithHeadState(genesisState(), 0);
+
+ assertThat(
+ calculator.getEquivocationScore(
+ genesisState(), UInt64.valueOf(1_000_000), UInt64.valueOf(1_000_001)))
+ .isEqualTo(UInt64.ZERO);
+ }
+
@Test
void shouldResolveGloasVoteToSupportedNodeBeforeCheckingAncestry() {
buildLinearChain(6);
@@ -356,6 +559,43 @@ void shouldReduceAdversarialWeightByEquivocationScore() {
assertThat(withEquivocation).isLessThan(withoutEquivocation);
}
+ @Test
+ void shouldMemoizeAdversarialWeightPerSlotRange() {
+ buildLinearChain(11);
+ final BeaconState balanceSource = genesisState();
+ final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 10);
+
+ // Identity, not just equality: the second call must return the memoized instance.
+ assertThat(
+ calculator.computeAdversarialWeight(
+ balanceSource, UInt64.valueOf(3), UInt64.valueOf(6)))
+ .isSameAs(
+ calculator.computeAdversarialWeight(
+ balanceSource, UInt64.valueOf(3), UInt64.valueOf(6)));
+ }
+
+ @Test
+ void shouldMemoizeSafetyThresholdPerBlock() {
+ buildLinearChain(11);
+ final BeaconState balanceSource = genesisState();
+ final FastConfirmationCalculator calculator = calculatorWithHeadState(balanceSource, 5);
+
+ // Identity, not just equality: the second call must return the memoized instance.
+ assertThat(calculator.computeSafetyThreshold(chain.get(3), balanceSource))
+ .isSameAs(calculator.computeSafetyThreshold(chain.get(3), balanceSource));
+ }
+
+ @Test
+ void shouldMemoizeHonestFfgSupportForCurrentTarget() {
+ buildLinearChain(11);
+ final BeaconState balanceSource = genesisState();
+ final FastConfirmationCalculator calculator = calculator(balanceSource, chain.get(10), 12);
+
+ // Identity, not just equality: the second call must return the memoized instance.
+ assertThat(calculator.computeHonestFfgSupportForCurrentTarget())
+ .isSameAs(calculator.computeHonestFfgSupportForCurrentTarget());
+ }
+
@Test
void shouldDiscountParentSupportInEmptySlotsBeyondAdversarialWeight() {
final BeaconState balanceSource = genesisState();
@@ -787,16 +1027,22 @@ private FastConfirmationCalculator gloasCalculator(
}
private void buildLinearChain(final int length) {
- for (int slot = 0; slot < length; slot++) {
+ buildLinearChainAtSlots(LongStream.range(0, length).toArray());
+ }
+
+ /** A canonical linear chain with one block per given (ascending) slot; gaps are empty slots. */
+ private void buildLinearChainAtSlots(final long... slots) {
+ for (final long slot : slots) {
final Bytes32 root = Bytes32.random();
+ indexByRoot.put(root, chain.size());
chain.add(root);
- slotByRoot.put(root, slot);
+ chainSlots.add(UInt64.valueOf(slot));
}
- for (int slot = 0; slot < length; slot++) {
- final Bytes32 root = chain.get(slot);
- when(forkChoice.blockSlot(root)).thenReturn(Optional.of(UInt64.valueOf(slot)));
+ for (int i = 0; i < chain.size(); i++) {
+ final Bytes32 root = chain.get(i);
+ when(forkChoice.blockSlot(root)).thenReturn(Optional.of(chainSlots.get(i)));
when(forkChoice.contains(root)).thenReturn(true);
- final Bytes32 parent = slot > 0 ? chain.get(slot - 1) : Bytes32.ZERO;
+ final Bytes32 parent = i > 0 ? chain.get(i - 1) : Bytes32.ZERO;
when(forkChoice.blockParentRoot(root)).thenReturn(Optional.of(parent));
}
// get_ancestor(root, slot): walk up the parent chain until reaching a block at or before slot.
@@ -820,13 +1066,16 @@ private void buildLinearChain(final int length) {
}
private Optional ancestorIndex(final Bytes32 root, final UInt64 targetSlot) {
- final Integer index = slotByRoot.get(root);
+ final Integer index = indexByRoot.get(root);
if (index == null) {
return Optional.empty();
}
int i = index;
- while (UInt64.valueOf(i).isGreaterThan(targetSlot)) {
+ while (chainSlots.get(i).isGreaterThan(targetSlot)) {
i--;
+ if (i < 0) {
+ return Optional.empty();
+ }
}
return Optional.of(i);
}