Skip to content

adding doppelganger rechecks mid epoch for key changes - #17285

Open
james-prysm wants to merge 23 commits into
developfrom
doppelganger-key-reload
Open

adding doppelganger rechecks mid epoch for key changes#17285
james-prysm wants to merge 23 commits into
developfrom
doppelganger-key-reload

Conversation

@james-prysm

@james-prysm james-prysm commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?

Feature

What does this PR do? Why is it needed?

with --enable-doppelganger, only keys present at startup were ever checked — keys added to a running validator client via a keymanager reload became eligible for duties immediately, so a key still attesting elsewhere could be hot-imported and double-sign within the same epoch.

With this change (still behind --enable-doppelganger, off by default):

  • Reloaded keys are held out of all duties until a scoped doppelganger check clears them. The hold is immediate: role assignment skips quarantined keys even for duties scheduled before the reload.
  • Background checks run in the last quarter of each epoch, bounded by the slot deadline: at most one successful check per epoch, retrying next slot within that window only if a check fails.
  • A live duplicate is excluded from duties for the life of the process, while the client keeps validating with its other keys. It is only re-evaluated if the key is removed and re-imported (fresh quarantine) or on restart via the startup check.
  • A clean key clears only after 2 full epochs and once the beacon node's head has passed that window — an unevaluated answer from a lagging node can never end a quarantine.
  • Keys the beacon node can't evaluate yet (deposit pending) stay quarantined and re-poll once per epoch. Keys imported while the client is waiting for activation (empty wallet, all keys exited) are tracked the same way.
  • Check failures surface at warn level once per epoch; same-epoch retries log at debug.
  • REST parity: the doppelganger response now omits validators that don't exist onchain instead of reporting them as having no duplicate, with one summary log line per check.
  • Startup is no longer blocked when the validating keys aren't in the beacon state yet — an index-less key cannot have a duplicate anywhere. (Previously gRPC refused to start on this; REST silently passed.)

Not changed: a duplicate detected at startup still prevents the client from starting, everything without the flag behaves as before, and no signing/slashing-protection logic is touched — only duty eligibility and check scheduling.

How this was tested

Kurtosis devnet: 4 prysm/ethrex nodes, gloas at epoch 1, 6s slots; vc-1..3 run --enable-doppelganger, vc-4 is the flag-off control. Every VC's startup log shows Prysm/v7.1.8/1f95dd9a587493383a75cd19644e6182c4c64a49 (this branch's commit). All key changes were made against the running VCs via accounts import / accounts delete — picked up through the keymanager file watcher, no restarts anywhere in the run.

Exact kurtosis config and key-change commands
participants:
  - el_type: ethrex
    el_image: ethpandaops/ethrex:glamsterdam-devnet-5
    el_extra_params:
      - --http.api=eth,net,web3,admin
    cl_type: prysm
    cl_image: gcr.io/offchainlabs/prysm/beacon-chain:latest
    vc_image: gcr.io/offchainlabs/prysm/validator:latest
    supernode: true
    cl_extra_params:
      - --verbosity=debug
    vc_extra_params:
      - --verbosity=debug
      - --enable-doppelganger
    count: 3
  - el_type: ethrex
    el_image: ethpandaops/ethrex:glamsterdam-devnet-5
    el_extra_params:
      - --http.api=eth,net,web3,admin
    cl_type: prysm
    cl_image: gcr.io/offchainlabs/prysm/beacon-chain:latest
    vc_image: gcr.io/offchainlabs/prysm/validator:latest
    supernode: true
    cl_extra_params:
      - --verbosity=debug
    vc_extra_params:
      - --verbosity=debug
    count: 1

network_params:
  fulu_fork_epoch: 0
  gloas_fork_epoch: 1
  seconds_per_slot: 6
  genesis_delay: 30

additional_services:
  - dora

global_log_level: debug
kurtosis run github.com/ethpandaops/ethereum-package \
  --args-file gloas-doppelganger.yml --enclave <name>

Key changes (each VC has 128 active keys; wallet at /validator-keys/prysm, direct keymanager):

docker exec <vc> /validator accounts import --accept-terms-of-use \
  --wallet-dir=/validator-keys/prysm --wallet-password-file=/prysm-password/prysm-password.txt \
  --keys-dir=<dir> --account-password-file=<pass>
docker exec -i <vc> sh -c 'echo y | /validator accounts delete <same wallet flags> --delete-public-keys=<pubkey>'
Scenario Desired outcome How Result
T1 — Duplicate key, still validating on another client Never performs a duty here; blocked before the quarantine could ever end; other keys unaffected Imported a key actively attesting on vc-2 into vc-1 at epoch 1, vc-2 left running Quarantined on import (eligibleEpoch=4); detected and blocked at the epoch-3 poll — one epoch before the earliest clear; 0 attestations all run; the other 128 keys kept attesting
T2 — Key added before its validator exists Quarantined while unevaluable; one check per epoch, no retry churn Imported a keystore with no deposit into vc-2 at epoch 1 One CheckDoppelGanger RPC per epoch for 10+ epochs, zero errors; never cleared, never attested
T3 — Migrated key, idle gap too short Activity still in the liveness window excludes the key (fail-safe), not clears it Deleted a key from vc-1 at epoch 2, imported into vc-3 at epoch 4 Blocked at the first evaluated poll. Migration note: give a key ≥3 idle epochs before importing
T4 — Remove + re-add within ~1s (boundary case) No observable key-set change → no quarantine can or should trigger Deleted and re-imported a vc-2 key within ~1s File watcher coalesced both writes into one no-op reload; key attested uninterrupted
T5 — Happy path: clean re-add after a real gap Quarantine; no false detection from its own old attestations; clear on schedule; duties resume Deleted a vc-2 key, waited 45s, re-imported at an epoch boundary Quarantined (eligibleEpoch=12); cleared exactly at the epoch-12 poll; resumed attesting at slot 422
T6 — Re-add mid-epoch, duties already scheduled Duties fetched before the re-add must not be executed Key with a late attester slot (372): deleted at 354, re-imported at 362 with the duty ahead Quarantined; slot 372 passed with no attestation; cleared at the epoch-14 poll; resumed at slot 499
T7 — Key added while no active keys The waiting-for-activation path must not bypass the check Emptied vc-1's wallet, imported a key while it waited Quarantined on import; 0 attestations
T8 — Control: flag off Reloads behave exactly as before Same import on vc-4 (no flag) Plain reload; zero doppelganger log lines

Chain health: continuous head advance across the gloas fork, finality reached epoch 13, no unexpected error-level lines in any VC log.

Which issue(s) does this PR fix?

Fixes # #17328

Other notes for review

Acknowledgements

  • I have read CONTRIBUTING.md.
  • I have included a uniquely named changelog fragment file.
  • I have added a description with sufficient context for reviewers to understand this PR.
  • I have tested that my changes work as expected and I added a testing plan to the PR description (if applicable).

@james-prysm
james-prysm requested a review from syjn99 August 5, 2026 21:13
@james-prysm
james-prysm requested a review from nalepae August 5, 2026 21:13
@james-prysm
james-prysm marked this pull request as ready for review August 5, 2026 21:57
Comment thread validator/client/doppelganger.go Outdated
if !features.Get().EnableDoppelGanger {
return
}
// Poll late in the epoch (Lighthouse's 3/4 offset) so the beacon node has

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.

Lighthouse?

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.

sorry missed this, it was an artifact checking lighthouse , will make sure this won't happen again
d2e861f broke it out into a helper function anyways

@@ -0,0 +1,559 @@
package client

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.

Nit, instead of having multiple testing functions by tested function, you can use a single testing function with multiple sub-tests (t.Run).

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.

fixed in d2e861f

Comment thread validator/client/doppelganger.go Outdated
}
for _, pk := range keys {
d.checked[pk] = true
delete(d.pending, pk)

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 deletes pending entries without checking p.blocked.
==> A runner restart reruns CheckDoppelGanger over all keys and silently releases a key that was already confirmed as a live duplicate.

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.

good catch on this, definitely missed, fixed in d2e861f this introduced more code changes so now i added status and vet startup

Comment thread validator/client/validator.go Outdated
}
// Keys unknown to the beacon node have no validator index yet, so no
// duplicate can exist anywhere; start normally instead of refusing.
if len(resp.Responses) == 0 {

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.

Startup treats a non evaluable (empty) doppelganger response as permanently clean via markChecked. However checkReloadedKeys fails the close on the identical answer by keeping the keys quarantined.

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.

yeah that's a code smell, this refactor was a bit bigger, now i have doppelgangerfrom startup and then doppelganger mid epoch. i think it worked as intended before but definitely a code smell, hopefully this makes it read more straight forward

@@ -66,7 +77,7 @@ func (v *validator) retryWaitForActivation(ctx context.Context, span octrace.Spa
// Reconnection attempt backoff, up to 60s.
time.Sleep(time.Second * time.Duration(min(uint64(attempts), 60)))
// TODO: refactor this to use the health tracker instead for reattempt

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.

Do you want to keep this TODO before merging?

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.

yeah lets handle this separately

Comment thread validator/client/wait_for_activation.go Outdated
time.Sleep(time.Second * time.Duration(min(uint64(attempts), 60)))
// TODO: refactor this to use the health tracker instead for reattempt
return v.WaitForActivation(incrementRetries(ctx))
return v.waitForActivation(incrementRetries(ctx), false)

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.

retryWaitForActivation is called recursively with a hardcoded accountsChanged=false.
Keys imported during the backoff sleep skip trackReloadedKeysForDoppelGanger.

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.

fixed in d2e861f

if !ok || !isActiveForDuties(st.status, epoch) {
continue
}
// Reloaded keys stay out of duties until their doppelganger check clears.

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.

When every key is quarantined the duty store is reset.
RolesAt fails and emits an ERROR every slot for the whole 3-epoch quarantine window.

@james-prysm james-prysm Aug 6, 2026

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.

another missed case here... i added a usecase, and changed reset to write empty when it's intentionally 0 d2e861f

@james-prysm

Copy link
Copy Markdown
Contributor Author

Other fixes found while considering manu's comments

  1. Boot-wallet snapshot — Keys imported mid-initialization were vetted by the one-shot startup check, which can't see a duplicate whose attestations aren't included on chain yet — so a live-elsewhere key could join duties (and one such import also quarantined the entire wallet). The startup check now owns only the keys present at keymanager init; anything later is quarantined and polled.

  2. Pre-Gloas empty-keys guard — With every key quarantined, pre-Gloas clients still sent a zero-key duties request that errors on REST, recreating the every-slot role-lookup error spam. The guard now covers both duty paths.

  3. A quarantined key could be released unchecked — An imported slashing-protection file can claim a last-attested epoch later than the key's import epoch, which makes the beacon node skip its liveness check ("too recent, assume it's us") at every poll until the quarantine expires — releasing the key without a single real check. We now report at most the import epoch for quarantined keys, so the skip window always ends before release is possible.

  4. Retry origin — A key imported during the activation-wait's backoff sleep was fetched by the retry without being quarantined. The retry now inherits the accounts-changed flag it was called under.

  5. Epoch-0 sentinel — "Never polled" and "polled at epoch 0" shared the value 0, so genesis-epoch keys never got their first check (and epoch-0 failures never warned). Marks store epoch+1; zero now means "never."

  6. Signal fixes — A wallet the node couldn't evaluate at all idled in silence: now one Warn. Empty duty schedules fired a pointless subnet-subscription RPC every epoch: now skipped.

  7. Lock hygiene — block() logged while holding the tracker's write lock, stalling slot-critical duty readers behind log I/O. It now marks under the lock and logs after.

Comment thread validator/client/doppelganger.go Outdated
Comment thread validator/client/doppelganger.go Outdated
Comment thread validator/client/doppelganger.go Outdated
Comment thread validator/client/doppelganger.go Outdated
Comment thread validator/client/doppelganger.go Outdated
Comment thread validator/client/doppelganger.go Outdated
Comment thread validator/client/doppelganger.go Outdated
Comment thread validator/client/beacon-api/doppelganger.go Outdated
Comment thread validator/client/duties.go
Comment thread validator/client/duties.go
Comment thread validator/client/doppelganger.go
Comment thread validator/client/doppelganger.go
Comment thread validator/client/doppelganger.go Outdated
req.ValidatorRequests = append(req.ValidatorRequests,
&ethpb.DoppelGangerRequest_ValidatorRequest{
PublicKey: pkey[:],
Epoch: 0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

		if dph.validatorEpoch+2 < currentEpoch {
			notRecentStringPubKeys = append(notRecentStringPubKeys, spk)
		}

(in validator/client/beacon-api/doppelganger.go)

Setting Epoch as a zero value makes every keys as notRecentStringPubKeys. We can think of VC1 and VC2 and the operator tries to migrate a key (PK1) from VC1 to VC2. Suppose the operator migrates the keys without waiting 3 epochs. Then AttestationHistoryForPubKey will return empty or nil value, and Epoch for the doppelganger request sets to zero.

It is obvious that a validator bound with PK1 has performed duties so far. Thus, doppelganger checker will mark PK1 as "blocked", and there's no way to recover this state when PK1 falls into the blocked state. Operator will only see a line of error logs.

Although we might blame operators, there might be a better solution, like evaluating the pubkey after three epochs if there's no history found in local.

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.

nice catch added fix and test here, wdyt 58573b6

# Conflicts:
#	testing/validator-mock/validator_mock.go
#	validator/client/iface/validator.go
#	validator/client/runner_test.go
#	validator/client/testutil/mock_validator.go
@james-prysm
james-prysm requested a review from syjn99 August 13, 2026 20:16
Comment thread validator/client/doppelganger_test.go
@@ -0,0 +1,6 @@
### Fixed

- Validator client: keys added through a keymanager reload are now held out of duties until a doppelganger check clears them (behind `--enable-doppelganger`).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe we can make this bullet point to ### Added as it's a new feature?

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.

added in 3059375

Comment thread validator/client/doppelganger.go Outdated
watermark := r.Target
// Cap a pending key's watermark at its quarantine clock: the node's
// recency band must expire before the wait, or answers stay unevaluated.
if added, ok := v.doppelGanger.pendingAddedEpoch(pkey); ok && watermark > added {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
if added, ok := v.doppelGanger.pendingAddedEpoch(pkey); ok && watermark > added {
if added, ok := v.doppelGanger.pendingAddedEpoch(pkey); ok {

I think we can just set the recency gate with added epoch, regardless it's greater or lesser than r.Target (== watermark). If r.Target < addedEpoch, say r.Target = 10 and addedEpoch = 20, newly loaded key can be blocked as the doppelganger check returns that it's a duplicate. r.Target is just a local DB view, so it cannot represent whether this key is actually used for signing recently. For pending (but not yet blocked) keys, we can just use addedEpoch. Please correct me if I'm wrong.

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.

I think you're right on this let me fix 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.

3059375 fixed here with test

james-prysm and others added 2 commits August 14, 2026 07:24
Co-authored-by: Jun Song <87601811+syjn99@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants