Pre-warm pending deposit signatures before the Gloas fork - #17440
Conversation
`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>
|
|
||
| const ( | ||
| depositWarmupLeadEpochs = 2 | ||
| depositWarmupBudget = 2 * stdtime.Second |
There was a problem hiding this comment.
Document this constant: BE BRIEF
There was a problem hiding this comment.
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()) |
There was a problem hiding this comment.
Why at Aggregation time? can't we not due this at 50% of the slot instead?
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
Can two deposits have the same root here?
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
Is this even actionable? why is this a WARN level log if the user can't do anything about this?
There was a problem hiding this comment.
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.
| ### 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. |
There was a problem hiding this comment.
| ### 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. |
There was a problem hiding this comment.
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>
|
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 ( Dropped the projection warning — you're right that it isn't actionable, and 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() |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
does this work for epoch 1 forks? ( or does it matter i mean for devnets)
There was a problem hiding this comment.
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)) |
There was a problem hiding this comment.
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>
| // 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. |
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- 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>
…t-signature-warmup
What
onboard_builders_from_pending_depositswalks the entire pending deposit queue — no churn limit, no finalization gate, noMAX_PENDING_DEPOSITS_PER_EPOCHcap — 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:This does that, and removes two superlinear terms that the cache alone would not have covered.
Why caching is sound here
is_valid_deposit_signatureusescompute_domain(DOMAIN_DEPOSIT)with a nil fork version and a nil genesis validators root, so it resolves toGenesisForkVersion+ZeroHash(beacon-chain/core/signing/signing_root.go:230; every deposit call site passesnil, 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 realgenesis_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 underDOMAIN_BUILDER_DEPOSITand is a separate path.Changes
beacon-chain/cache/deposit_signature.go— plain map keyed by theDeposit_DataHTR, 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.IsValidDepositSignatureconsults 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.PendingValidatorIndexreplaces theis_pending_validatorrescan. Each accumulated deposit is examined at most once. Exact because the accumulator only grows and validity is pure, so atrueresult is permanent and an invalid prefix never needs rescanning.builderInsertionIndextakes 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.ForEachPendingDepositso the warm-up does not deep-copy the queue every slot.The quadratic
Enqueue k deposits with pubkey
P+ non-builder credentials + invalid signatures (these accumulate in the kept list), then k with pubkeyP+0xB0credentials + 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 atMIN_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.
🤖 Generated with Claude Code