Skip to content

feat: minimal PTC caching with single previous_ptc field#13

Closed
lodekeeper wants to merge 7 commits intounstablefrom
feat/minimal-ptc-caching
Closed

feat: minimal PTC caching with single previous_ptc field#13
lodekeeper wants to merge 7 commits intounstablefrom
feat/minimal-ptc-caching

Conversation

@lodekeeper
Copy link
Copy Markdown
Owner

Alternative to consensus-specs#4992 that addresses #4979's epoch-boundary PTC bug with minimal state changes.

CL Spec

Branch: lodekeeper/consensus-specs@feat/minimal-ptc-caching

Design

Single previous_ptc field in BeaconState (~4KB) vs ChainSafe#4992's two fields + per-slot rotation (~8KB + per-slot state mutation).

Key differences from ChainSafe#4992:

This PR ChainSafe#4992
State fields 1 (previous_ptc) 2 (previous_ptc + current_ptc)
Per-slot rotation ❌ None ✅ Every slot
process_slots modified ❌ No ✅ Yes
get_ptc_assignment ✅ Preserved ❌ Removed
State size ~4KB ~8KB

How it works:

  1. compute_ptc(state, slot) — pure computation (extracted from old get_ptc)
  2. process_ptc_update — new step at START of process_epoch, saves compute_ptc(state, state.slot) to state.previous_ptc before effective balance updates
  3. get_ptc(state, slot) — returns cached previous_ptc for epoch-boundary case (last slot of previous epoch), delegates to compute_ptc otherwise

Why only one slot needs caching:

process_payload_attestation enforces data.slot + 1 == state.slot. The cross-epoch case (where effective balances differ) only occurs when processing a block at the first slot of epoch N with payload attestations from the last slot of epoch N-1.

Implementation changes:

  • packages/types/src/gloas/sszTypes.ts — add PayloadTimelinessCommittee type + previousPtc to BeaconState
  • packages/state-transition/src/util/seed.ts — new computePayloadTimelinessCommitteeAtSlot()
  • packages/state-transition/src/util/gloas.ts — new processPtcUpdate()
  • packages/state-transition/src/stateTransition.ts — call processPtcUpdate before epoch processing
  • packages/state-transition/src/cache/epochCache.ts — replace full previous-epoch array with single previousEpochLastSlotPtc
  • packages/state-transition/src/slot/upgradeStateToGloas.ts — initialize previousPtc at fork boundary
  • packages/state-transition/src/util/genesis.ts — initialize previousPtc at genesis

lodekeeper and others added 3 commits March 18, 2026 20:55
Implements alternative to consensus-specs#4992 for addressing ChainSafe#4979's
epoch-boundary PTC bug with minimal state changes.

Spec: lodekeeper/consensus-specs@feat/minimal-ptc-caching

Changes:
- Add previousPtc field to gloas BeaconState SSZ type (~4KB, single Vector)
- New processPtcUpdate() called at START of process_epoch, before effective
  balance updates alter the weighted selection
- New computePayloadTimelinessCommitteeAtSlot() for on-demand single-slot
  PTC computation (extracted from epoch-batch computation)
- EpochCache: replace previousPayloadTimelinessCommittees (full epoch array)
  with previousEpochLastSlotPtc (single Uint32Array)
- getPayloadTimelinessCommittee() returns cached previousEpochLastSlotPtc
  for epoch-boundary case, epochCtx cache for current epoch
- Initialize previousPtc to zeros in upgradeStateToGloas and genesis
- Update specrefs for compute_ptc, get_ptc, process_ptc_update

Key design decisions:
- No per-slot state rotation (unlike ChainSafe#4992)
- Only the last slot of the previous epoch is cached in state because
  process_payload_attestation enforces data.slot + 1 == state.slot
- All epoch PTCs still computed eagerly in epochCtx for duties API
- get_ptc_assignment preserved (unlike ChainSafe#4992 which removed it)

Co-authored-by: Nico Flaig <nflaig@users.noreply.github.com>
- compute_ptc and process_ptc_update are custom functions not in official gloas spec
  → remove <spec fn> tags, document as plain spec without ethspecify tracking
- get_ptc entry was using placeholder hash 'minimal-ptc-caching'
  → restore official get_ptc specref (hash ae15f761) at the implementation source
- specrefs are now in sync: ethspecify process + check both pass locally

// Compute PTC for all slots in the prev/current epoch
let previousPayloadTimelinessCommittees: Uint32Array[] = [];
const previousEpochLastSlotPtc = new Uint32Array(0);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be retrieved from state and set in the cache, or alternative is to only use the cache for current epoch and use field in state for this case instead

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — now loads previousPtc from state into the epochCtx cache in createFromState:

previousEpochLastSlotPtc = new Uint32Array((state as CachedBeaconStateGloas).previousPtc.getAll());

This ensures epoch-boundary PTC lookups work correctly after state loading (checkpoint sync, replay). The cache still handles current epoch PTCs, and previousEpochLastSlotPtc is only used for the last slot of the previous epoch.

Also renamed the SSZ field previousEpochLastPtcpreviousPtc to match the consensus-specs naming (previous_ptc).

Address review feedback: previousEpochLastSlotPtc in epochCtx was
initialized to empty Uint32Array(0) instead of being loaded from
state.previousPtc. Now reads from state on createFromState so
epoch-boundary PTC lookups work correctly after state loading
(e.g. checkpoint sync, state replay).

Also rename SSZ field previousEpochLastPtc → previousPtc to match
consensus-specs naming convention (previous_ptc).
@lodekeeper lodekeeper force-pushed the feat/minimal-ptc-caching branch from c4ab249 to a9e701c Compare March 19, 2026 11:42
// [New in Gloas:EIP7732] Cache last-slot PTC before epoch processing changes effective balances
if (fork >= ForkSeq.gloas) {
processPtcUpdate(postState as CachedBeaconStateGloas);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shoudln't this be part of process epoch as per spec?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — moved processPtcUpdate into processEpoch (epoch/index.ts) as the first operation, matching the spec where process_ptc_update is the first step inside process_epoch. stateTransition.ts is now clean (no diff from unstable).

process_ptc_update is defined as the first step inside process_epoch
in the consensus spec, not a separate call before it. Move from
stateTransition.ts into epoch/index.ts as first operation before
processJustificationAndFinalization, gated by fork >= ForkSeq.gloas.
…state directly

The state field previous_epoch_last_ptc IS the cache — reading it is O(1).
No need to duplicate it in epochCache.

- Remove previousEpochLastSlotPtc from EpochCache (field, constructor, createFromState, clone, afterProcessEpoch)
- getPayloadTimelinessCommittee now only handles current epoch (throws for previous)
- processPayloadAttestation reads state.previousEpochLastPtc directly for epoch boundary case
- Remove unused CachedBeaconStateGloas import from epochCache
@lodekeeper
Copy link
Copy Markdown
Owner Author

Closing — spec changes are under discussion upstream. Nico will bring these to Lodestar once the spec settles. Branch preserved for reference.

@lodekeeper lodekeeper closed this Mar 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants