Skip to content

fix(dkg): do not complete an active-round session without key material#857

Merged
0xHansLee merged 1 commit into
dkg/devfrom
fix-resume-active-requires-key-material
Jul 23, 2026
Merged

fix(dkg): do not complete an active-round session without key material#857
0xHansLee merged 1 commit into
dkg/devfrom
fix-resume-active-requires-key-material

Conversation

@0xHansLee

Copy link
Copy Markdown
Contributor

Problem

resumeFailedSession force-completed any failed active-round session, even one with empty GlobalPubKey/PubKeyShare. The node marked itself finished while contributing zero partial decryptions.

Fix

Complete only sessions that hold key material. A keyless active-round session is recovered node-locally: recompute and seal the share via the kernel (callTEEFinalizeDKG, no on-chain finalize vote for an already-active round). The recovery is guarded by:

  • the per-round DKG service lock (tryAcquireDKGSvc), preventing concurrent recovery goroutines from racing on the shared session; and
  • a check that the recovered GlobalPubKey matches the on-chain consensus key before completing.

On failure the session is left failed and logged. Node-local only — no consensus/KVStore state is touched.

Tests

  • TestResumeFailedSession_ActiveStage_RecoversKeyMaterial, _MissingKeyMaterial, _DivergentKey
  • TestRecoverActiveSessionKeyMaterial_ConcurrentSingleFinalize (-race)

issue: none

@yingyangxu2026

Copy link
Copy Markdown

Every failure path calls MarkFailed, which leaves the session PhaseFailed, so the next block's ResumeDKGService re-enters recovery and runs callTEEFinalizeDKG again — the block-level retry has no backoff and no attempt cap (the kernel call has internal retries, but nothing between blocks). A persistently-failing kernel, or a genuinely divergent key, turns into a per-block finalize retry indefinitely, visible only via the "does not match" error log. Worth adding a backoff plus an escalation/alert after N attempts.

resumeFailedSession force-completed a failed active-round session even when
its GlobalPubKey/PubKeyShare were empty (this node's finalization failed while
the round advanced on-chain via other validators). The session was marked
completed but processDecryptQueue skips it for lacking a global public key, so
the node silently contributed zero partial decryptions.

Now the active branch only completes sessions that hold key material. Sessions
missing it re-run finalization locally via the kernel (recompute+seal the share
from persisted deals) without submitting a stale on-chain finalize vote; on
failure the session is left failed and logged rather than silently completed.

Because ResumeDKGService re-enters this recovery every block while the session
stays failed, the recovery is now bounded: a node-local attempt counter caps
retries at maxActiveRecoveryAttempts, after which the session is left failed
with an escalation alert. A divergent on-chain key (deterministic, never
self-heals) escalates immediately. All of this is node-local JSON/atomic state
under isDKGSvcEnabled, so it never touches consensus state, GasUsed, or the app
hash.

Re-dispatch and exhaustion are gated on whether an attempt is actually in
flight (the per-round service lock dkgSvcRound), not on a wall-clock backoff.
An earlier LastUpdate/time-based gate could be jammed shut by normal
active-round threshold-decrypt traffic (AddDecryptRequest bumps the same
LastUpdate timestamp), permanently blocking re-dispatch and escalation; it also
risked a premature "exhausted" escalation when a single attempt outran the
backoff window. dkgSvcRound is untouched by decrypt-request queuing, so decrypt
traffic cannot jam the gate, and while an attempt is in flight the block simply
returns without spawning a doomed goroutine.

The retry counter is bumped inside the per-round lock as each real attempt
starts, so per-block dispatches that lose the lock race no longer burn the
budget into a false escalation. The session key material read/written on the
recovery path is guarded by DKGSession.mu via HasKeyMaterial/SetKeyMaterial so
a concurrent ABCI-thread read cannot tear a slice header.

Recovery and completion now run under a SINGLE continuous hold of the per-round
lock. recoverActiveSessionKeyMaterial does the cap check, attempt increment,
kernel finalize, key-match guards, and completion inline, and a lock-free
completeSessionLocked helper (extracted from handleDKGComplete) advances the
session to Completed without releasing and re-acquiring the lock. This closes
the finalize/complete gap: previously the session was left PhaseFinalized with
the lock free between the two acquisitions, a window in which ResumeDKGService's
stuck-check (isSessionStuckForStage(PhaseFinalized, DKGStageActive)) could
MarkFailed a recovery that had just succeeded. The cap/escalation now live
inside the locked recovery, so escalation can never fire prematurely while an
attempt still holds the lock and fires exactly once after the final attempt
concludes.
@0xHansLee
0xHansLee force-pushed the fix-resume-active-requires-key-material branch from 07b3e2d to c3034fa Compare July 23, 2026 07:03
@0xHansLee
0xHansLee merged commit 2f5aee0 into dkg/dev Jul 23, 2026
6 checks passed
@0xHansLee
0xHansLee deleted the fix-resume-active-requires-key-material branch July 23, 2026 07:06
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