Skip to content

Pre-warm pending deposit signatures before the Gloas fork - #17440

Open
potuz wants to merge 6 commits into
developfrom
potuz-gloas-deposit-signature-warmup
Open

Pre-warm pending deposit signatures before the Gloas fork#17440
potuz wants to merge 6 commits into
developfrom
potuz-gloas-deposit-signature-warmup

Conversation

@potuz

@potuz potuz commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

What

onboard_builders_from_pending_deposits walks the entire pending deposit queue — no churn limit, no finalization gate, no MAX_PENDING_DEPOSITS_PER_EPOCH cap — verifying BLS signatures inline while holding the state write lock. The spec asks us to pre-verify and cache in the slots before the fork:

Note: In the slots leading up to the fork, implementations SHOULD validate pending deposit signatures and cache the results. The pending deposit queue might be large and verifying many signatures at the fork could be slow.

Note: This function naively revalidates deposit signatures on every call [is_pending_validator]. Implementations SHOULD cache verification results to avoid repeated work.

This does that, and removes two superlinear terms that the cache alone would not have covered.

Why caching is sound here

is_valid_deposit_signature uses compute_domain(DOMAIN_DEPOSIT) with a nil fork version and a nil genesis validators root, so it resolves to GenesisForkVersion + ZeroHash (beacon-chain/core/signing/signing_root.go:230; every deposit call site passes nil, nil). Validity is therefore a pure function of (pubkey, withdrawal_credentials, amount, signature) — independent of state, of the fork, and even of the chain's real genesis_validators_root. Results computed before the fork are exactly valid after it, and never expire.

Not to be confused with post-fork BuilderDepositRequest, which signs under DOMAIN_BUILDER_DEPOSIT and is a separate path.

Changes

  • beacon-chain/cache/deposit_signature.go — plain map keyed by the Deposit_Data HTR, with a hard insert cap that refuses rather than evicts, so a warmed entry is never displaced. An LRU would let the warm-up evict its own earliest entries and converge to nothing.
  • helpers.IsValidDepositSignature consults it. One change covers both fork-time call sites. A cache miss falls back to a direct verify and is never treated as invalid.
  • helpers.PendingValidatorIndex replaces the is_pending_validator rescan. Each accumulated deposit is examined at most once. Exact because the accumulator only grows and validity is pure, so a true result is permanent and an invalid prefix never needs rescanning.
  • builderInsertionIndex takes a cursor for the onboarding pass, where inserts only fill slots and none frees one. Not applied post-fork, where exits free earlier slots and the full rescan is required.
  • ForEachPendingDeposit so the warm-up does not deep-copy the queue every slot.
  • Warm-up routine over the two epochs before the fork, bounded per-slot budget, worker pool, readiness metrics with a projection warning, cache cleared one epoch after the fork (the upgrade runs several times around the boundary and needs the entries).

The quadratic

Enqueue k deposits with pubkey P + non-builder credentials + invalid signatures (these accumulate in the kept list), then k with pubkey P + 0xB0 credentials + invalid signatures. Each of the latter rescanned all k accumulated entries, re-verified every one, then was dropped without becoming a builder — so the next one rescanned again. That is k² pairings for 2k deposits at MIN_DEPOSIT_AMOUNT = 1 ETH each.

Measured: at k=2000 (4,000 deposits) verifications now scale as 2k = 4,000, taking 1.9s. The old code's 4M pairings would be ~32 minutes. This shape is in the spec pseudocode too, so it likely affects other clients — worth reporting upstream.

Measurements

M4 Pro, 14 cores, against the pre-fork scenarios in jtraglia/kurtosis-devnets/gloas-deposits. Invalid signatures are well-formed signatures over the wrong message, so each costs a full pairing (~476µs) — malformed byte strings fail G2 decompression at ~9µs and understate the cost by ~50x.

Scenario Cold onboard Warm onboard Speedup
s2 — 262,144 valid builder deposits, unique pubkeys 2m12s 214ms 618x
s3 — 50,000 invalid builder + 1 valid, same pubkey 23.8s 40ms 591x
s5 — 50,000 invalid validator + 1 valid builder, same pubkey 23.9s 25ms 953x
  • Single verify: 476–512µs cold, 0.6µs warm.
  • The insertion cursor is worth 69s of s2 on its own — 239ms vs 1m9s, measured by direct A/B, not projection. The signature cache alone would have left s2 over a minute.
  • Warming s2 costs 138 core-seconds ≈ 308ms/slot across the two epochs at 7 workers. On 4 cores (2 workers) it is ~975ms/slot, still inside the budget.
  • Cache holds all 262,144 s2 entries with 2x headroom, zero rejections. 80 B/entry measured → 20MB at s2 scale, 40MB at the cap.
  • s5 is why the warm-up candidate set is per public key, not per deposit: a builder-credential-only filter would warm 1 deposit and leave 50,000 cold.

🤖 Generated with Claude Code

`onboard_builders_from_pending_deposits` walks the entire pending deposit
queue with no churn limit, no finalization gate and no per-epoch cap,
verifying BLS signatures inline while holding the state write lock. The
spec asks implementations to pre-verify and cache in the slots before the
fork; this does that, and removes two superlinear terms that the cache
alone would not have covered.

Deposit signatures use a fork-agnostic domain (nil fork version, nil
genesis validators root), so validity is a pure function of the deposit's
pubkey, credentials, amount and signature. Results computed before the
fork stay valid after it, and never expire.

- Add a deposit signature cache: plain map with a hard insert cap that
  refuses rather than evicts, so a warmed entry is never displaced.
- Consult it in IsValidDepositSignature, which covers both fork-time call
  sites. A cache miss falls back to a direct verify and is never treated
  as invalid.
- Replace the quadratic is_pending_validator rescan with
  PendingValidatorIndex, which examines each accumulated deposit at most
  once. Before this, 2k deposits for one pubkey cost k^2 pairings.
- Give builderInsertionIndex a cursor for the onboarding pass, where
  inserts only fill slots and none frees one.
- Add ForEachPendingDeposit so the warm-up does not deep-copy the queue
  each slot.
- Warm up over the two epochs before the fork, on a bounded per-slot
  budget, over the per-pubkey candidate set that onboarding consults.

Measured on an M4 Pro against the pre-fork scenarios from
jtraglia/kurtosis-devnets/gloas-deposits, using well-formed signatures
over the wrong message so each invalid one costs a full pairing:

  s2  262144 valid builder deposits    2m12s -> 214ms
  s3  50000 invalid builder + 1 valid  23.8s -> 40ms
  s5  50000 invalid validator + 1      23.9s -> 25ms

The insertion cursor accounts for 69s of s2 on its own (239ms vs 1m9s
measured directly), so the cache alone would not have been enough.
Warming s2 costs 138 core-seconds, about 308ms per slot across the two
epochs. The cache holds all 262144 entries with 2x headroom.

The upstream gloas fork spectests are red on develop for an unrelated
execution_requests_root mismatch, so equivalence is pinned instead by a
naive transcription of the spec pseudocode, differentially tested over
the named vectors plus randomized queues.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@potuz
potuz requested a review from a team as a code owner September 1, 2026 18:56

const (
depositWarmupLeadEpochs = 2
depositWarmupBudget = 2 * stdtime.Second

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Document this constant: BE BRIEF

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

const (
	// Lead time before the fork, and the per-slot wall-clock budget spent pre-verifying.
	depositWarmupLeadEpochs = 2
	depositWarmupBudget     = 2 * stdtime.Second
)

For the record on how these were picked: measured against s2 (262,144 deposits) a verify is ~476us, so the whole queue is ~138 core-seconds, or ~308ms/slot across the 64 slots of a 2-epoch lead at GOMAXPROCS/2 workers. The 2s budget is ~6x more headroom than that needs, which is deliberate: on a 4-core box it works out to ~975ms/slot and still fits.

if err := s.waitUntilEpoch(cfg.GloasForkEpoch-depositWarmupLeadEpochs, cfg.SlotDuration()); err != nil {
return
}
ticker := slots.NewSlotTickerWithOffset(s.genesisTime, cfg.SlotComponentDuration(cfg.AggregateDueBPS), cfg.SlotDuration())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Why at Aggregation time? can't we not due this at 50% of the slot instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The concern was the budget, not the tick: a 2s budget starting at 50% runs from 6s to 8s on a 12s slot, so it lands right on the aggregate deadline (AggregateDueBPS = 6667 = 8s). Starting after the aggregate deadline puts the work in 8-10s, the emptiest part of the slot, with 2s of slack before the boundary.

Added a brief comment saying so. Note runLatePayloadTasks also ticks at PayloadDueBPS = 5000, though that one is not actually a conflict here since it waits for GloasForkEpoch and so is idle during the warm-up window.

Happy to move it to 50% if you prefer the consistency; the budget should then come down to ~1s so it stays clear of the aggregate deadline.


cold := make([]*ethpb.Deposit_Data, 0, len(candidates))
for _, data := range candidates {
key, err := data.HashTreeRoot()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can two deposits have the same root here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, and good catch: this was doing redundant work.

Two byte-identical pending deposits are legal, and fork_multiple_deposits_same_builder is exactly that (three of them). The key is the Deposit_Data HTR over pubkey/credentials/amount/signature, so identical deposits collapse to one key. PendingDeposit.Slot differs but is deliberately excluded, since it is not part of the signed message.

Sharing the key is what we want, but the loop only checked Has before appending, and on a cold cache every duplicate passed that check, so N identical deposits cost N verifications for one entry. Now deduped by key while building the list, with a test asserting the two roots are equal and that the pair produces a single cache entry.

"remaining": remaining,
"slotsUntil": uint64(forkSlot - slot),
"perSlot": uint64(perSlot),
}).Warn("Gloas pending deposit signature pre-verification will not finish before the fork")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Is this even actionable? why is this a WARN level log if the user can't do anything about this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed, removed. Nothing an operator can do at that point makes BLS faster, and a restart does not help either.

gloas_deposit_warmup_remaining already carries the signal for anyone alerting on it, so this also deletes the projection arithmetic and the slot parameter that only existed to feed it.

Comment on lines +1 to +18
### Security

- Pre-verify pending deposit signatures in the two epochs before the Gloas fork and serve the
results from a cache, so `onboard_builders_from_pending_deposits` does no BLS work at the fork
boundary. Deposit signatures use a fork-agnostic domain, so a result is a pure function of the
deposit and stays valid across the fork.
- Remove the quadratic `is_pending_validator` rescan from the Gloas fork upgrade. Onboarding now
examines each accumulated pending deposit at most once instead of re-verifying the whole
accumulator for every builder-credential deposit sharing a public key.

### Changed

- `builderInsertionIndex` no longer rescans the builder registry from index 0 on every insert
during the Gloas fork upgrade, where no already-passed entry can become reusable.

### Added

- `ForEachPendingDeposit` reads the pending deposit queue without deep-copying it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Suggested change
### Security
- Pre-verify pending deposit signatures in the two epochs before the Gloas fork and serve the
results from a cache, so `onboard_builders_from_pending_deposits` does no BLS work at the fork
boundary. Deposit signatures use a fork-agnostic domain, so a result is a pure function of the
deposit and stays valid across the fork.
- Remove the quadratic `is_pending_validator` rescan from the Gloas fork upgrade. Onboarding now
examines each accumulated pending deposit at most once instead of re-verifying the whole
accumulator for every builder-credential deposit sharing a public key.
### Changed
- `builderInsertionIndex` no longer rescans the builder registry from index 0 on every insert
during the Gloas fork upgrade, where no already-passed entry can become reusable.
### Added
- `ForEachPendingDeposit` reads the pending deposit queue without deep-copying it.
### Fixed
- Cache certain pending deposit signatures pre Gloas fork.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied. Also dropped the ForEachPendingDeposit line, since it is an internal helper, and trimmed the rest to match:

### Fixed

- Cache certain pending deposit signatures pre Gloas fork.
- Remove the quadratic `is_pending_validator` rescan from the Gloas fork upgrade.
- Stop rescanning the builder registry from index 0 on every insert during the Gloas fork upgrade.

Kept the last two as separate lines because they are independently user-visible: the rescan removal is what takes the 2k-deposit DoS from k^2 pairings to 2k, and the registry one is worth 69s of s2 on its own.

- Dedupe warm-up candidates by cache key. Byte-identical deposits are
  legal and share a key, so they now cost one verification instead of N.
- Drop the projection warning. It was not actionable, and
  gloas_deposit_warmup_remaining already carries the signal.
- Brief comments on the warm-up constants and the ticker offset.
- Trim the changelog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@potuz

potuz commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 6412479 addressing all five.

One was a real bug, not just a comment nit: the duplicate-root question. Byte-identical pending deposits are legal (fork_multiple_deposits_same_builder has three), and they correctly share a cache key since Slot is excluded from it. But the warm-up only checked Has before queueing, so on a cold cache every duplicate passed that check and N identical deposits cost N verifications to populate one entry. Now deduped by key while building the list, with a test asserting both that the roots collide and that the pair yields a single entry.

Dropped the projection warning — you're right that it isn't actionable, and gloas_deposit_warmup_remaining already carries the signal. That also removed the projection arithmetic and the slot parameter that only existed to feed it.

Left the ticker at the aggregate deadline, with a comment explaining why: the issue is the 2s budget, not the tick. Starting at 50% runs 6s–8s on a 12s slot, landing exactly on the aggregate deadline; starting after it puts the work in 8s–10s with slack before the boundary. Easy to switch to 50% if you'd rather have the consistency, but the budget should drop to ~1s if we do.

Constants documented, changelog trimmed to your wording.

select {
case slot := <-ticker.C():
if slots.ToEpoch(slot) > cfg.GloasForkEpoch {
cache.DepositSignature.Clear()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this might be a dumb question, since it depends on the wallclock for slotticker would this be safer if we checked if gloas is finalized vs slot is passed gloas fork?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

hmm ohhh you're worried that we may need to do the state transition twice because of a reorg pass the boundary!? yeah that's a great catch James... not sure what the fix is though, it may be fine to just wait for finalization but then it'll keep verifying unnecessarily. Will think what's best.

return
}
cfg := params.BeaconConfig()
if cfg.GloasForkEpoch == math.MaxUint64 || cfg.GloasForkEpoch < depositWarmupLeadEpochs {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

does this work for epoch 1 forks? ( or does it matter i mean for devnets)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

it doesn't but it doesn't matter in that case.

require.DeepEqual(t, k0, k1)

s := &Service{ctx: context.Background()}
require.Equal(t, 2, s.verifyDepositSignatures(context.Background(), candidates))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this skips the deduplication i think, probably need to rework the test

- Keep the pre-fork signature cache until the fork epoch is finalized. It
  was cleared on wall clock one epoch after the fork, so a reorg back
  across the boundary would re-run the upgrade against a cold cache,
  which is the stall this is meant to prevent, at the worst moment.
  Warming still stops at the fork epoch, so nothing is verified
  unnecessarily while we wait for finality.
- Extract coldDepositCandidates and test it. The previous test called
  verifyDepositSignatures directly, which does not dedupe, so it
  asserted the un-deduped count and never exercised the dedupe at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +80 to +82
// Warming is pointless once the fork epoch has passed, but a reorg across the
// boundary re-runs the upgrade, so the results are only safe to drop once the fork
// epoch can no longer be reverted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Remove this comment

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@syjn99
syjn99 self-requested a review September 2, 2026 10:58
Comment thread beacon-chain/blockchain/gloas_deposit_warmup.go Outdated
Comment thread beacon-chain/cache/deposit_signature.go Outdated
Comment thread beacon-chain/blockchain/gloas_deposit_warmup.go
potuz and others added 2 commits September 2, 2026 11:39
- Remove gloas_deposit_warmup_verify_seconds. Its only consumer was the
  projection warning removed in an earlier review, and as Jun points out
  the value was wall clock divided by the pool's throughput, so it never
  measured a single verification.
- Unexport depositSignatureCache. Nothing needs the type name.
- Skip warming when the cache is at capacity. Puts would be refused, so
  every candidate would stay cold and be reverified every slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

3 participants