Skip to content

Commit c0202f8

Browse files
mswilkisonclaude
andcommitted
feat(frost): conclude the signing done check on the t-subset (7.3 t-of-included PR3/3)
Under RFC-21 Phase 7.3 t-of-included finalize (PR2 #4093), an interactive signing attempt is signed by the first t responsive committers of an (optionally) oversized included set. The members the coordinator did not pick, plus any offline ones, may never broadcast a signing done check, so the outer done check - which required a confirmation from EVERY attempt member - would hang an otherwise-successful attempt to its timeout and force a needless retry. Make signingDoneCheck conclude on the first t (honestThreshold) matching done checks instead of all attempt members: - newSigningDoneCheck takes honestThreshold; listen sets requiredDoneCount = min(honestThreshold, len(attemptMembersIndexes)). - waitUntilAllDone completes when len(doneSigners) >= requiredDoneCount (>= not ==: an oversized set may have more than t online members report done), with a requiredDoneCount > 0 guard against the pre-listen state. INERT until participant selection oversizes the included set past the threshold: today the selector trims to exactly honestThreshold, so attemptMembersIndexes == threshold and requiredDoneCount == the attempt member count - identical to the previous all-members rule. The coarse path is unchanged. Added TestSigningDoneCheck_ThresholdSubsetConcludes (oversized included set, only t members report done -> concludes with the signature instead of timing out). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 80cf249 commit c0202f8

3 files changed

Lines changed: 105 additions & 10 deletions

File tree

pkg/tbtc/signing.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,7 @@ func (se *signingExecutor) signWithTaprootMerkleRoot(
373373

374374
doneCheck := newSigningDoneCheck(
375375
se.groupParameters.GroupSize,
376+
se.groupParameters.HonestThreshold,
376377
se.broadcastChannel,
377378
se.membershipValidator,
378379
)

pkg/tbtc/signing_done.go

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,23 +47,34 @@ func (sdm *signingDoneMessage) Type() string {
4747
// successful signature calculation across all signing group members.
4848
type signingDoneCheck struct {
4949
groupSize int
50+
honestThreshold int
5051
broadcastChannel net.BroadcastChannel
5152
membershipValidator *group.MembershipValidator
5253

53-
receiveCtx context.Context
54-
cancelReceiveCtx context.CancelFunc
55-
expectedSignersCount int
56-
doneSigners map[group.MemberIndex]*signingDoneMessage
57-
doneSignersMutex sync.RWMutex
54+
receiveCtx context.Context
55+
cancelReceiveCtx context.CancelFunc
56+
// requiredDoneCount is the number of matching done checks that conclude the
57+
// attempt. For a full-included attempt it equals the attempt member count
58+
// (every included member signs), but under RFC-21 Phase 7.3 t-of-included
59+
// finalize the attempt is signed by a t-subset of an oversized included set,
60+
// so the remaining (possibly offline) members never send a done check;
61+
// requiring all of them would hang an otherwise-successful attempt to timeout.
62+
// Capped at honestThreshold, which is the minimum that proves a valid
63+
// threshold signature was produced.
64+
requiredDoneCount int
65+
doneSigners map[group.MemberIndex]*signingDoneMessage
66+
doneSignersMutex sync.RWMutex
5867
}
5968

6069
func newSigningDoneCheck(
6170
groupSize int,
71+
honestThreshold int,
6272
broadcastChannel net.BroadcastChannel,
6373
membershipValidator *group.MembershipValidator,
6474
) *signingDoneCheck {
6575
return &signingDoneCheck{
6676
groupSize: groupSize,
77+
honestThreshold: honestThreshold,
6778
broadcastChannel: broadcastChannel,
6879
membershipValidator: membershipValidator,
6980
}
@@ -91,7 +102,16 @@ func (sdc *signingDoneCheck) listen(
91102
sdc.receiveCtx, sdc.cancelReceiveCtx = context.WithCancel(ctx)
92103

93104
sdc.doneSignersMutex.Lock()
94-
sdc.expectedSignersCount = len(attemptMembersIndexes)
105+
// Conclude on the first t (honestThreshold) matching done checks rather than on
106+
// every attempt member: under RFC-21 Phase 7.3 t-of-included finalize the
107+
// attempt is signed by a t-subset of an oversized included set, so members
108+
// outside the subset (including offline ones) may never report done and
109+
// requiring all of them would hang an otherwise-successful attempt. Capped at
110+
// the attempt member count so a (degenerate) sub-threshold included set still
111+
// requires everyone present. For a full-included attempt (members ==
112+
// threshold, the pre-oversizing default) this equals the member count, so the
113+
// behavior is unchanged until participant selection oversizes the set.
114+
sdc.requiredDoneCount = min(sdc.honestThreshold, len(attemptMembersIndexes))
95115
sdc.doneSigners = make(map[group.MemberIndex]*signingDoneMessage)
96116
sdc.doneSignersMutex.Unlock()
97117

@@ -171,8 +191,14 @@ func (sdc *signingDoneCheck) waitUntilAllDone(ctx context.Context) (
171191
return nil, 0, errWaitDoneTimedOut
172192

173193
case <-ticker.C:
174-
expectedSignersCount, doneSigners := sdc.snapshotDoneSigners()
175-
if expectedSignersCount == len(doneSigners) {
194+
requiredDoneCount, doneSigners := sdc.snapshotDoneSigners()
195+
// >= (not ==): in an oversized included set more than the required t
196+
// members may report done (every online member - signers and observers
197+
// alike - reports the same signature), and the attempt concludes as soon
198+
// as t matching signatures confirm a valid threshold signature. The
199+
// requiredDoneCount > 0 guard rejects the pre-listen state (no attempt
200+
// configured yet) so an empty done set is never read as success.
201+
if requiredDoneCount > 0 && len(doneSigners) >= requiredDoneCount {
176202
var signature *frost.Signature
177203
var latestEndBlock uint64
178204

@@ -262,7 +288,7 @@ func (sdc *signingDoneCheck) snapshotDoneSigners() (
262288
result = append(result, doneMessage.clone())
263289
}
264290

265-
return sdc.expectedSignersCount, result
291+
return sdc.requiredDoneCount, result
266292
}
267293

268294
func (sdm *signingDoneMessage) clone() *signingDoneMessage {

pkg/tbtc/signing_done_test.go

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,16 +281,83 @@ func TestSigningDoneCheck_AnotherSignature(t *testing.T) {
281281
}
282282
}
283283

284+
// TestSigningDoneCheck_ThresholdSubsetConcludes covers RFC-21 Phase 7.3
285+
// t-of-included finalize: the attempt's included set is oversized (larger than
286+
// the honest threshold) and only the t-subset that actually signed reports done -
287+
// the remaining included members are offline. The check must conclude on the t
288+
// matching done messages and return the signature, rather than hanging for the
289+
// absent members the way the pre-7.3 all-members rule did.
290+
func TestSigningDoneCheck_ThresholdSubsetConcludes(t *testing.T) {
291+
groupParameters := &GroupParameters{
292+
GroupSize: 5,
293+
GroupQuorum: 4,
294+
HonestThreshold: 3,
295+
}
296+
297+
doneCheck := setupSigningDoneCheck(t, groupParameters)
298+
299+
memberIndexes := make([]group.MemberIndex, doneCheck.groupSize)
300+
for i := range memberIndexes {
301+
memberIndexes[i] = group.MemberIndex(i + 1)
302+
}
303+
304+
ctx, cancelCtx := context.WithTimeout(context.Background(), 5*time.Second)
305+
defer cancelCtx()
306+
307+
message := big.NewInt(100)
308+
attemptNumber := uint64(1)
309+
attemptTimeoutBlock := uint64(1000)
310+
// Oversized included set: ALL five members are included, but only the first
311+
// three (the chosen signing subset) report done; members 4 and 5 are offline.
312+
attemptMemberIndexes := memberIndexes
313+
result := &signing.Result{
314+
Signature: mustFrostSignatureFromBigInts(big.NewInt(200), big.NewInt(300)),
315+
}
316+
317+
doneCheck.listen(
318+
ctx,
319+
message,
320+
attemptNumber,
321+
attemptTimeoutBlock,
322+
attemptMemberIndexes,
323+
)
324+
325+
for i := 1; i <= groupParameters.HonestThreshold; i++ {
326+
err := doneCheck.signalDone(
327+
ctx,
328+
uint8(i),
329+
message,
330+
attemptNumber,
331+
result,
332+
500+uint64(i),
333+
)
334+
if err != nil {
335+
t.Fatal(err)
336+
}
337+
}
338+
339+
returnedResult, endBlock, err := doneCheck.waitUntilAllDone(ctx)
340+
if err != nil {
341+
t.Fatalf("expected conclusion on the t-subset, got error: [%v]", err)
342+
}
343+
if returnedResult == nil || !result.Signature.Equals(returnedResult.Signature) {
344+
t.Fatalf("unexpected result: [%v]", returnedResult)
345+
}
346+
// Latest end block among the three members that reported done (500+3).
347+
testutils.AssertIntsEqual(t, "end block", 503, int(endBlock))
348+
}
349+
284350
// signingDoneCheckComponents holds the shared state used to construct one or
285351
// more signingDoneCheck instances that communicate over the same channel.
286352
type signingDoneCheckComponents struct {
287353
groupSize int
354+
honestThreshold int
288355
broadcastChannel net.BroadcastChannel
289356
membershipValidator *group.MembershipValidator
290357
}
291358

292359
func (c *signingDoneCheckComponents) newCheck() *signingDoneCheck {
293-
return newSigningDoneCheck(c.groupSize, c.broadcastChannel, c.membershipValidator)
360+
return newSigningDoneCheck(c.groupSize, c.honestThreshold, c.broadcastChannel, c.membershipValidator)
294361
}
295362

296363
// setupSigningDoneCheckComponents builds the shared channel and validator
@@ -341,6 +408,7 @@ func setupSigningDoneCheckComponents(
341408

342409
return &signingDoneCheckComponents{
343410
groupSize: groupParameters.GroupSize,
411+
honestThreshold: groupParameters.HonestThreshold,
344412
broadcastChannel: broadcastChannel,
345413
membershipValidator: membershipValidator,
346414
}

0 commit comments

Comments
 (0)