fix(zetaclient): keep signing when the keygen record is reset - #4618
fix(zetaclient): keep signing when the keygen record is reset#4618kingpinXD wants to merge 15 commits into
Conversation
zetacore resets the keygen record to a blank value whenever the observer set changes (x/observer/abci.go BeginBlocker). The struct literal it writes zeroes every field it does not name, so the grantee list is erased and the status goes back to pending at block MaxInt64. That record says nothing about a key generated long ago whose shares we still hold, but zetaclient treated it as a gate in three places. On mainnet an observer's validator was jailed and began unbonding, and all of it fired at once: the TSS listener saw a "new keygen" and shut every signer down, and none could start again because an empty grantee list meant an empty p2p whitelist. Prefer the finalized TSS instead: - whitelist peers from TSS.TssParticipantList, the set that produced the key we sign with - skip the keygen ceremony when a key already exists, rather than blocking on a block that never arrives - stop watching the keygen record for restarts Rotation is unaffected. A new key still lands in TSS history and the listener still restarts on it. Dry mode already derived everything from the TSS object this way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
f705c56 to
d630c34
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Full review — read the diff plus the surrounding TSS setup/listener/keygen paths and the zetacore keygen→TSS flow. Core fix is correct and well-tested. One framing point worth correcting in the description, and two robustness notes. Details below + two inline comments.
Verified sound
resolveTSSPeerswhitelist source is right.TSS.TssParticipantListis populated fromkeygen.GetGranteePubkeys()at TSS creation (x/observer/keeper/msg_server_vote_tss.go:124), so for any key made via the normal ceremony it's a non-empty snapshot of the exact participants that produced the key — i.e. a correct superset of the local key-share participants. Empty-TSS → keygen-record path preserves genesis/first-keygen.- Stopping the loop is correct. Removing
waitForNewKeygen+ skipping the ceremony when a finalized key exists breaks the blanked-record restart storm cleanly;waitForUpdate(TSS addr) andwaitForNewKeyGeneration(TSS history) still cover a genuine key change. - Tests are strong — mutation-checked units, the listener finally has tests, and the isolated e2e reproduces the exact zetacore state. The comment that
MsgAddObserveris a stand-in (not the cause) is exactly the right kind of honesty. - Drain compatibility is not just preserved — this is a prerequisite for it.
startDrainIfArmedsits afterzetatss.Setupinstart.go, so while Setup was failing on mainnet the drain couldn't have started either. This restores that lever. 👍
1. Important (framing, not code): "Rotation is unchanged" is not accurate — this disables scheduled keygen rotation
KeygenCeremony is only ever called from Setup (zetaclient/tss/setup.go), and the removed waitForNewKeygen was the trigger that restarted the client on a newly-scheduled keygen → which is what re-ran Setup→KeygenCeremony for the new key. With both gone, while a finalized key exists: nothing runs a scheduled rotation ceremony, so no new key lands in TSS history, so waitForNewKeyGeneration never fires. Genesis/first keygen still works (empty-TSS path).
For the current wind-down (deliberately avoiding keygen, moving funds via the drain) this is fine and probably the intent — but the body says "Rotation is unchanged … the listener still restarts on it," which reads as if rotation still works. It doesn't, until a finalized key is gone. Suggest rewording to "scheduled keygen rotation is disabled while a finalized key exists" so a future operator doesn't assume otherwise, and note the deliberate re-enable path if a real rotation is ever needed.
2. Robustness: the GetTSS error fallback re-opens the exact failure mode (inline)
See inline on setup.go. On a GetTSS error you fall back to the keygen record; if that record is blanked (the incident state) and GetTSS blips, you get both symptoms back (empty whitelist + ceremony waiting on MaxInt64). Strictly better than before, but since the premise is "the keygen record is unreliable," consider retry/fail-loud instead of silently trusting it.
3. Minor: genesis-imported TSS without a participant list (inline)
See inline on resolveTSSPeers. The whitelist now hinges on TssParticipantList being populated; a TSS imported via x/observer/genesis.go without that field would yield an empty whitelist = same crash. Not the mainnet case, worth a guard or a note.
4. Nit
WithMetrics (zetaclient/tss/service.go:109) reads keygen.GranteePubkeys for blame labels — during the blanked window those labels go empty for a cycle. Harmless, just flagging.
5. codecov/patch is red
The isolated e2e path isn't counted in unit coverage; confirm that's the reason and not a genuinely uncovered branch in resolveTSSPeers/listener.
Net: approve-worthy once the description reflects that rotation-keygen is disabled (#1). #2/#3 are cheap hardening on a security-critical path and worth doing while we're here.
Precision notes (independently re-verified)
Two clarifications so the above is airtight — neither changes any verdict:
- #1 mechanism: the listener's trigger is
graceful.ShutdownNow()+ a supervised restart (the process manager brings zetaclient back up), not an in-process restart. Functionally identical for the argument — the restartedSetupis what would re-run the ceremony — but worth stating precisely: the removedwaitForNewKeygencaused a shutdown, and a scheduled rotation now triggers no shutdown at all. - #2 crash path: an empty whitelist doesn't fail at
NewServeron its own —NewServerhard-errors only when the whitelist and the bootstrap peers are both empty. On mainnet bootstrap peers are configured, so Setup passes through the empty whitelist and reachesKeygenCeremony, which then hangs on theMaxInt64block. That matches the observed incident (Setup crash, not aNewServerbootstrap-peer error). If bootstrap peers were also empty you'd instead get the "whitelisted peers missing" abort atNewServer— still a Setup failure, different line.
Run in isolation right after setup the deployer holds no ETH ZRC20, so the withdraw reverted before it could prove anything. Deposit first, while the network is still healthy, so the withdraw after the reset is the only thing under test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
Review follow-ups on the keygen-reset fix. Only a genuine "no TSS on this chain" answer may fall back to the keygen record. A transient RPC failure previously fell back too, which handed back the blanked record and with it both symptoms the fix removes: an empty whitelist and a ceremony waiting on MaxInt64. GetTSS is now retried, absence is matched on the not-found code alone, and anything else fails loudly. A TSS imported from genesis can carry a real key with no participants recorded. That resolved to an empty whitelist and the same crash, so it now falls back to the keygen grantees while still counting as finalized, because the key is real and must not be regenerated. Starting with nothing to whitelist now fails with a clear message rather than surfacing as missing bootstrap peers from go-tss. Also corrects the rotation comments. Removing the keygen watcher removed the only trigger that restarted zetaclient for a scheduled keygen, so while a finalized key exists a rotation ceremony will not start. The earlier note claimed rotation was unaffected, which was circular: nothing would produce the new key it said the listener would react to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
morde08
left a comment
There was a problem hiding this comment.
Approach looks right to me. Whitelisting from TSS.TssParticipantList is the correct source — it is written once from the grantee list at msg_server_vote_tss.go:124, is never touched by the BeginBlocker, and is exactly the set that produced the key in use (9 entries on mainnet today), so it is a correct superset for p2p admission. This restores full function without a governance action or a ceremony, which is strictly better than the MsgUpdateKeygen route in gov-ops#1184.
Two things before this goes to the release branches — one substantive, one a description fix.
1. GetTSS failure falls open into the original outage
currentTSS, tssErr := p.Zetacore.GetTSS(ctx)
if tssErr != nil {
setupLogger.Info().Err(tssErr).Msg("no finalized TSS available")
currentTSS = observertypes.TSS{}
}Any error — including a transient RPC blip — is treated as "no TSS", so resolveTSSPeers falls back to keygen.GranteePubkeys. On mainnet today that is empty, which means an empty whitelist and the exact crash this PR fixes. GetTSSHistory two lines down fails closed and is loud; this one fails open into the bad state, silently, at Info.
Suggest wrapping it in retry.DoTypedWithBackoffAndRetry (as waitForNewKeyGeneration already does for GetTSSHistory) and only treating a genuine not-found as the empty case. This matters most during a coordinated restart against a busy zetacore — which is precisely the rollout for this fix.
Minor, same function: resolveTSSPeers uses TssPubkey != "" as the proxy for "usable participant list". Guarding on len(currentTSS.TssParticipantList) > 0 instead makes it total — otherwise a finalized TSS with an empty participant list yields an empty whitelist and the same crash.
2. "Rotation is unchanged" contradicts the code
KeygenCeremony is called from exactly one place (zetaclient/tss/setup.go:122) and Setup from exactly one (cmd/zetaclientd/start.go:146, at startup). With waitForNewKeygen removed, a running client never restarts on a scheduled keygen; and with the ceremony skipped whenever a finalized TSS exists, a manually restarted one will not run it either. The two remaining watchers are both reactive — TssPubkey changing, and TSS history length growing — and both only fire after a ceremony has already succeeded. So MsgUpdateKeygen becomes inert and there is no remaining path to rotate the key.
The tests make clear this is intended — "finalized key wins over a populated keygen record" codifies it, and the reasoning in that comment is sound. But the summary says the opposite, and the summary is what reviewers and the v37/v38 backport approvers will read. Worth rewording to state the removal explicitly.
Then the real question: if a rotation is ever needed while this is deployed, what is the path? As written it appears to be "roll the clients back first". If that is the accepted answer it is worth writing down. If not, an escape hatch — honour a scheduled keygen when the record has grantees and an operator opts in — would keep the option without reintroducing the strand this PR is removing.
Neither blocks the fix. Nice catch on waitForNewKeygen being the thing that took every running signer down at once — that explains why operators who never touched their nodes were also reporting crashes.
|
Thanks both — all three points were real. Pushed in Rotation framing (@julianrubino #1). You're right and the old wording was circular: nothing would have produced the new key the listener was supposed to react to. The description now leads with "scheduled keygen rotation is disabled while a finalized key exists", and both
Genesis TSS without participants (@julianrubino #3). Confirmed real — Nit #4 — #5 — codecov. The uncovered lines are the new block inside New tests: genesis-TSS-without-participants, and Verification on localnet is unchanged and still green: |
Replaces matching the gRPC status code with a local check. "GetTSS failed" and "there is no TSS" are different answers, and the previous commit told them apart by matching InvalidArgument. That works only because zetacore returns the wrong code for a missing record, and this ships to release branches that talk to a zetacore of a different version, so correcting that code upstream would stop fresh chains from starting. It also relied on errors.Cause unwrapping cleanly. A key share on disk is proof a key exists, so a missing answer is a failure to ask rather than an absence, and we fail loudly. With no share this node has never taken part in a keygen, so the keygen record is the only source it could use anyway. Every error is simply retried. Drops tssNotFound and both grpc imports; 49 lines removed, 30 added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
A node holding a key share cannot legitimately be told there is no TSS, so an RPC failure there is worth riding out rather than giving up on a blip: 5s x 10 instead of the sub-second default. A node holding no share may genuinely be on a chain without a key, and learns that only by asking, so it keeps the fast backoff and does not stall startup on a fresh chain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
…able Reading the shares was made fatal, which breaks a node's first ever start: go-tss creates the TSS directory in NewServer, which runs after Setup consults the shares, so the directory is legitimately missing at that point and ParsePubKeysFromPath returns an error. Refusing to boot there would reject precisely the node that has no key yet. The read now warns and continues. A node that cannot read its shares has no local evidence of a key, which is the same position as one that holds none, so it takes the same path. Localnet did not catch this because its setup pre-creates the directory. Two tests pin both shapes: a missing directory errors, an empty one does not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
Resolving the TSS path still returned, so a node that could not work out where its shares live gave up before asking the server at all. The share lookup only sharpens how the server's answer is read, so nothing in it should be able to stop the query. Both steps now warn and carry on with no shares assumed. Step 6 still verifies the shares properly and is fatal there, so the check is not lost, just moved out of a probe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
Two paragraphs had run together into one block. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
It was deciding whether a failed GetTSS meant "no key" or "could not ask", but nothing needed the answer. Every case ends the same way without it. Blanked record: the grantee list is empty, so there is nothing to whitelist and the existing guard stops startup. Intact record: its grantees are the same set, and a keygen that already succeeded makes the ceremony a noop, so the query recovers on the next attempt and a blip heals itself rather than killing the node. Fresh chain: the record is the correct source and the ceremony is the right next step. So the disk read, the path resolution, the two warning branches and the conditional backoff all go, along with the failure mode they introduced where an unreadable share directory could stop a node booting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
…start The sub-second exponential backoff gave up after about a second, which is shorter than the RPC failures it is meant to cover. It was picked to keep a fresh chain from stalling, but that node goes on to wait for the keygen block regardless, so the wait costs it nothing. Constant 5s x 10 for everyone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
|
Thanks @morde08 — both points were right, and both are addressed as of 1. I did build that first, matching the status code. It was the wrong tool: zetacore answers an absent record with Then it turned out nothing needed the distinction:
Your minor point is fixed as you described: 2. Rotation. Description corrected before your review landed; it now leads with rotation being disabled while a finalized key exists, and both code comments say the same. On your real question — the honest answer today is "roll the clients back first", and I would rather write that down than pretend otherwise. Your escape hatch is the better answer, but it adds an opt-in path to an outage hotfix going to two release branches, so I would rather land this and do it deliberately as a follow-up than smuggle it in here. Happy to be overruled if you would rather it went in now. Verification. Localnet keygen-reset e2e and the drain regression both pass; the record is still blank at the end and both signers stay clean, and a manual restart into the blanked state comes up on the existing key. Re-running both now against the current head, since the last few commits changed the startup path. |
CI panicked with "Log in goroutine after test has completed". Listen's workers keep running after the test returns, and they were writing into zerolog.NewTestWriter(t), which panics once t is done. Local runs passed by luck; it depends on whether a worker logs in the window between the test returning and the context cancel landing. They now log to io.Discard. Nothing asserted on the output. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
|
@morde08 on the rotation path: we are not planning a TSS keygen at any point in the foreseeable future, so I am leaving the escape hatch out rather than adding an opt in path to a hotfix going to two release branches. If that ever changes, the route is to roll the clients back first, and the step 5 comment in |
They schedule a keygen and then wait for it to succeed. Nothing runs the ceremony any more, so both suites sit there until the 40 minute timeout, and they run on the nightly schedule. Also corrects the comment in tss_migration.go that still credited the TSSListener with the restarts that drive the migration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
ws4charlie
left a comment
There was a problem hiding this comment.
lgtm — fail-closed throughout (a blanked keygen record now ends in the "no TSS peers to whitelist" error rather than an empty whitelist), and the migration suites are off with the restore condition documented. Only the freshly-queued CI is outstanding; publish-release-legacy is red on every branch in the repo, not this PR.
Pulls the whitelist resolution out of Setup into resolveWhitelist. Pure move, no behaviour change: same retry, same backoff, same fallback, same guard, same error strings. Setup itself needs a live p2p server and key files on disk, so none of this was reachable from a unit test before. The extracted function takes a one-method interface instead, and a local stub covers it. Five cases, all mutation checked: the mainnet path where a finalized key survives a blanked record, a first-ever node falling back to the keygen grantees, a failed query falling back to the record, both sources empty stopping startup, and an unconvertible pubkey. The failure cases pass context.Canceled, which retry.Retry treats as non-retryable, so they return at once rather than sitting through the 5s x10 backoff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
ws4charlie
left a comment
There was a problem hiding this comment.
LGTM. The GetTSS retry plus the empty-whitelist guard covers my earlier comment nicely — worst case now is a loud startup failure instead of a signer hanging on a block that never comes, and the genesis-TSS-with-no-participants case is a good catch. Rotation staying off is clearly documented in all three places and the migration suites are disabled with a note, so nothing is silently broken.
morde08
left a comment
There was a problem hiding this comment.
Second pass, at 4e3fbd935 (the resolveWhitelist extraction). I re-derived the claims rather than reading them, and the core fix holds up.
Verified independently
TssParticipantListis written once at TSS creation and is untouched byx/observer/abci.go:37'sSetKeygen(Keygen{BlockNumber: MaxInt64}), so it survives the blanking. The struct literal does zeroGranteePubkeysexactly as described.- The e2e stand-in claim checks out:
MsgAddObserverwithAddNodeAccountOnly=truerunsDisableInboundOnly+ the sameSetKeygen(x/observer/keeper/msg_server_add_observer.go:37,51). The comment's insistence that this is a convenience stand-in and not what happened on mainnet is worth keeping exactly as written. - The "ceremony is a noop" reasoning is sound:
zetaclient/tss/keygen.go:104returns(false, nil)onKeyGenSuccessand line 89 returnszc.GetTSS(ctx), so the GetTSS-failed-but-record-intact fallback really does self-heal. - I ran the mutation check myself. Re-adding a keygen watcher to
ListenfailsTestTSSListenerIgnoresKeygenand all threeTestTSSListenersubtests on the unexpectedGetKeyGen. The guard is real, not decorative. All 10 new subtests pass locally.
Nothing below blocks the hotfix. The CI wiring gap is the one I'd want closed before this goes to v37/v38, since it is the only thing that would catch a regression of this exact bug.
…nto CI Review follow-ups from #4618. GetKeyGen and GetTSSHistory were still one-shot fatals. GetKeyGen runs before the retry added earlier, so a contended zetacore killed startup before the hardening could apply. WithMetrics makes a third GetKeyGen call that is equally fatal and easy to miss, since it sits in an option rather than in Setup. All three now use the same constant backoff. start-keygen-reset-test existed in the Makefile and nowhere in the workflow, so nothing ran it. Wired KEYGEN_RESET_TESTS through all five sites, mirroring DRAIN_TESTS. The listener tests slept ~31s against the 5s ticker. It is now a var, shrunk to 10ms per test, and the same mutation still fails them: 30s down to 0.42s. Dropped an assertion of mine that could not fail. blanked is built four lines above without grantees, so the length check was false by construction and guarded nothing. Changelog now tells operators that MsgUpdateKeygen will not rotate while a TSS exists, and the step 5 comment links #4623. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
Setup only ever read the keygen record to hand it straight to resolveWhitelist, so fetching it there makes the function self-contained and drops a parameter. Both queries it needs now sit together under one retry policy. Side effect worth having: the keygen retry and its failure path are reachable from a unit test now, where inside Setup they were not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
julianrubino
left a comment
There was a problem hiding this comment.
Update after independent re-verification (two reviewers re-derived the claims against the current head b000039, which has advanced past my earlier review). Two corrections + one addition:
Correction 1 — my earlier codecov/patch note is stale
It's green on the current head, not red. The coverage gap I flagged was closed by the commits added since. Disregard that point.
Correction 2 — the GetTSS fallback is worse than I rated it (elevating: minor → Medium-High, worth closing before merge)
My inline on setup.go treated the GetTSS-error fallback as defense-in-depth. On closer look it re-opens the exact outage this PR fixes, under the same storm:
GetTSShere is a single-shot call, no retry — contrast the listener (tss_listener.go) which wraps the identical call inretry.DoTypedWithBackoffAndRetry. The grpc handler errors on any transport failure, not just genesis "not found".- On a healthy node whose keygen record is currently blanked (the incident state), a transient
GetTSShiccup at startup →currentTSS = TSS{}→resolveTSSPeersreturnsfinalized=false→ whitelist = empty grantees andKeygenCeremonyruns against the blanked record and blocks forever at thezetaHeight < MaxInt64wait. - And it hangs rather than crash-loops:
NewServeronly errors when whitelist and bootstrap peers are both empty; with bootstrap peers configured (mainnet) it passes through to the stuck ceremony, so a k8s restart does not recover it. - Trigger = mass restart → zetacore RPC pressure → transient
GetTSSfailure while the record is blanked — precisely the storm this PR targets.
Fix: wrap this GetTSS in the same retry the listener uses, and/or distinguish a genuine not-found (genesis → ceremony) from a transient error (→ return it, let the supervisor retry) instead of silently degrading. The genesis empty-participant-list guard I flagged inline would double as the fast-fail here (if finalized && len(participantList)==0 → error loudly).
Addition — no test restarts a client against a blanked record end-to-end
The e2e reproduces the state and proves keep-signing, but never restarts a zetaclient, so the actual mainnet failure mechanism — cold-start whitelist recovery from TssParticipantList against a blanked record — rests entirely on the resolveWhitelist unit test. Solid coverage, but if you want the e2e to truly "reproduce the incident," it needs a client restart against the blanked record.
Everything else in my original review stands and was confirmed: the whitelist-source change is a tightening not a weakening (TssParticipantList = the actual share holders; admission gate ≠ signing threshold), rotation-keygen is genuinely disabled while a finalized key exists (the "rotation unaffected" wording is circular — nothing produces the new key to land in history), and the tests are mutation-resistant. Net: ship-worthy, but I'd close the GetTSS-retry gap first since it re-opens the same outage.
|
@julianrubino thanks for re-running this. Taking the three points in turn. Correction 2 — the premise is stale. currentTSS, tssErr := retry.DoTypedWithBackoffAndRetry(
func() (observertypes.TSS, error) { return client.GetTSS(ctx) },
retry.DefaultConstantBackoff(),
)Same helper and same backoff as the listener — 11 attempts over roughly 50s. And the scenario terminates earlier than you traced it. Blanked record plus a if len(peerSource) == 0 {
return observertypes.TSS{}, nil, false, errors.New("no TSS peers to whitelist: ...")
}Startup stops with that error. It never reaches On "hangs rather than crash-loops": the I also built and then removed the not-found-vs-transient classification you suggest, in an earlier round. It could not be done reliably: the status code crosses an RPC boundary, so Correction 1 — agreed, and thanks. Codecov is green at 77.55%. Addition — you are right, and it is not fixable in the test. The e2e proves keep-signing but never restarts a client, so cold-start recovery rests on the unit tests. I did verify it by hand during development: a Automating it is blocked on infrastructure. The e2e runs inside the |
Summary
x/observer/abci.goBeginBlocker). The struct literal it writes zeroes the grantee list and resets the status to pending at blockMaxInt64.TSS.TssParticipantList, skip the ceremony when a key already exists, and stop watching the keygen record for restarts.start.go) already derived everything from the TSS object this way.What happened on mainnet
An observer's validator was jailed and began unbonding. The staking hook dropped it from the observer set, and the next BeginBlocker blanked the keygen.
waitForNewKeygenread that as a new keygen and shut every signer down; each then failed to restart because an empty grantee list meant an empty p2p whitelist.No transaction was involved. Signing itself never reads the keygen record:
go-tsstakes both the threshold and the participant set from the local key share file, so the existing key stayed perfectly usable throughout.Behaviour change worth flagging: scheduled keygen rotation is disabled while a finalized key exists
An earlier version of this description said rotation was unaffected. That was wrong, and the reasoning was circular — thanks @julianrubino for catching it.
KeygenCeremonyis only called fromSetup, andwaitForNewKeygenwas the only thing that restarted zetaclient when a keygen was scheduled. With the ceremony skipped and the watcher gone, aMsgUpdateKeygenwill not start a ceremony, so no new key reaches TSS history, sowaitForNewKeyGenerationnever fires.That is deliberate — waiting on the record is exactly what strands the signer once it has been reset — but it must not be discovered by an operator mid-rotation. Rotating on purpose means removing the current TSS first, or reintroducing a deliberate trigger. Both
setup.gostep 5 and theListencomment now say so.Genesis and first keygen are unaffected: with no TSS on chain the keygen record is still authoritative.
Tests
resolveTSSPeersunit tests cover the blanked record, a scheduled keygen, a successful keygen, and the no-TSS-yet case. Mutation-checked: reverting the guard fails three of the four.tss_listener_test.go(the listener had no tests). Covers no-shutdown on a blanked record, and that both TSS-address and TSS-history changes still shut down. Mutation-checked: re-adding a keygen watcher fails it.make start-keygen-reset-test. It resets the record, then asserts an outbound still signs under the same TSS key.The e2e uses
MsgAddObserveronly as a stand-in — its handler writes byte-for-byte the same state as the BeginBlocker, in one transaction, without needing to drive a validator through jailing and unbonding. No add-observer transaction was involved in the incident. The test comment says so explicitly.Compatibility with the emergency drain (#4612)
Verified compatible. The drain has no keygen references, and its deterministic leader election comes from
localStateItem.ParticipantKeysin the key share, not the peer whitelist.Worth noting:
startDrainIfArmedsits afterzetatss.Setupinstart.go, so whileSetupfails the drain cannot start either. This PR restores that lever.Hotfix
Targets
release/zetaclient/v37andrelease/zetaclient/v38to follow, same commit. Both touched files are byte-identical across all three branches.🤖 Generated with Claude Code
https://claude.ai/code/session_01Cd1ZRjpN5br7NRYA9sCp7G
Note
High Risk
Changes TSS startup, P2P whitelisting, and restart triggers in security-critical signing path; deliberate behavior change skips scheduled keygen at startup while a finalized key exists.
Overview
Fixes a mainnet-class outage where observer set changes blank the on-chain keygen record (empty grantees, pending at
MaxInt64) while the finalized TSS key is unchanged.zetaclient now treats a finalized TSS as authoritative: P2P whitelist comes from
TSS.TssParticipantList, startup skips the keygen ceremony when a key already exists, and the TSS listener no longer watches the keygen record for restarts (only TSS pubkey changes and new entries in TSS history still trigger restart). Real key rotation behavior is unchanged.Adds unit tests for
resolveTSSPeersand the listener, plus an isolated e2e (make start-keygen-reset-test/--keygen-reset-test) that reproduces the blanked keygen state and asserts an outbound still signs under the same TSS key.Reviewed by Cursor Bugbot for commit f705c56. Configure here.
Greptile Summary
The PR keeps zetaclient operational when observer-set changes blank the keygen record by treating the finalized TSS as authoritative.
Confidence Score: 5/5
The PR appears safe to merge, with no actionable defects identified in the changed signing, startup, listener, or test paths.
The finalized TSS remains authoritative after a keygen-record reset, peer selection retains the original participant set, and the remaining listener triggers continue to respond to actual TSS changes; the deliberate effect on scheduled rotations is clearly documented.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[zetaclient Setup] --> B[Read keygen record] B --> C[Read finalized TSS with retry] C --> D{Finalized TSS exists?} D -->|Yes| E[Whitelist TSS participants] E --> F[Skip keygen ceremony] D -->|No| G[Whitelist keygen grantees] G --> H[Run keygen ceremony] F --> I[Verify key shares and start signing] H --> I J[Observer set changes] --> K[Keygen record reset] K --> L[TSS listener ignores reset] L --> I M[TSS address or history changes] --> N[Trigger zetaclient restart]Reviews (1): Last reviewed commit: "test(zetaclient): stop the listener test..." | Re-trigger Greptile