diff --git a/pkg/frost/roast/attempt/attempt_context.go b/pkg/frost/roast/attempt/attempt_context.go index 5cffaa9d36..772ecdaa02 100644 --- a/pkg/frost/roast/attempt/attempt_context.go +++ b/pkg/frost/roast/attempt/attempt_context.go @@ -59,10 +59,21 @@ type AttemptContext struct { // participate in this attempt. Must be sorted ascending. Must not // be empty. IncludedSet []group.MemberIndex - // ExcludedSet is the set of member indices that have been excluded + // ExcludedSet is the set of member indices permanently excluded // from this attempt by the coordinator's transition-evidence - // policy. Must be sorted ascending. May be empty. + // policy. Must be sorted ascending. May be empty. Permanent + // exclusion follows from transport-blamable (overflow) or + // validation-blamable (non-transport reject) evidence, never + // from silence alone. ExcludedSet []group.MemberIndex + // TransientlyParked is the set of member indices skipped from + // THIS attempt only because they were silent (deadline expiry) + // at the previous attempt. Parking is strictly transient: a + // peer is unparked at the attempt after the one that skipped + // them, so a falsely-silenced honest peer (network blip, + // coordinator censorship caught at VerifyBundle) is reinstated + // without intervention. Must be sorted ascending. May be empty. + TransientlyParked []group.MemberIndex // AttemptSeed is derived from group-agreed inputs and binds the // attempt to inputs that no coordinator can manipulate. See // DeriveAttemptSeed. @@ -105,6 +116,11 @@ func DeriveAttemptSeed( // // Returns an error if the included set is empty, if any member appears // in both sets, or if either set contains duplicates. +// +// This is the seven-argument convenience that initialises an attempt +// with no TransientlyParked entries (the attempt-zero shape). For +// later attempts produced by the coordinator's NextAttempt policy, +// use NewAttemptContextWithParking. func NewAttemptContext( sessionID string, keyGroupID string, @@ -113,6 +129,35 @@ func NewAttemptContext( attemptNumber uint32, includedSet []group.MemberIndex, excludedSet []group.MemberIndex, +) (AttemptContext, error) { + return NewAttemptContextWithParking( + sessionID, + keyGroupID, + dkgGroupPublicKey, + messageDigest, + attemptNumber, + includedSet, + excludedSet, + nil, + ) +} + +// NewAttemptContextWithParking is the full constructor used by the +// coordinator's NextAttempt policy. It accepts a transientlyParked +// set in addition to the inputs of NewAttemptContext. +// +// Validation: included set non-empty; no duplicates in any set; +// included/excluded sets disjoint; included/parked sets disjoint; +// excluded/parked sets disjoint. +func NewAttemptContextWithParking( + sessionID string, + keyGroupID string, + dkgGroupPublicKey []byte, + messageDigest [MessageDigestLength]byte, + attemptNumber uint32, + includedSet []group.MemberIndex, + excludedSet []group.MemberIndex, + transientlyParked []group.MemberIndex, ) (AttemptContext, error) { if len(includedSet) == 0 { return AttemptContext{}, errors.New( @@ -127,18 +172,33 @@ func NewAttemptContext( if err != nil { return AttemptContext{}, err } + parked, err := canonicalMemberSet(transientlyParked, "transiently parked") + if err != nil { + return AttemptContext{}, err + } if hasOverlap(included, excluded) { return AttemptContext{}, errors.New( "attempt context: included and excluded sets overlap", ) } + if hasOverlap(included, parked) { + return AttemptContext{}, errors.New( + "attempt context: included and transiently-parked sets overlap", + ) + } + if hasOverlap(excluded, parked) { + return AttemptContext{}, errors.New( + "attempt context: excluded and transiently-parked sets overlap", + ) + } return AttemptContext{ - SessionID: sessionID, - KeyGroupID: keyGroupID, - MessageDigest: messageDigest, - AttemptNumber: attemptNumber, - IncludedSet: included, - ExcludedSet: excluded, + SessionID: sessionID, + KeyGroupID: keyGroupID, + MessageDigest: messageDigest, + AttemptNumber: attemptNumber, + IncludedSet: included, + ExcludedSet: excluded, + TransientlyParked: parked, AttemptSeed: DeriveAttemptSeed( dkgGroupPublicKey, sessionID, @@ -167,6 +227,7 @@ func (c AttemptContext) Hash() [MessageDigestLength]byte { h.Write(attemptNumberBuf[:]) writeMemberSet(h, c.IncludedSet) writeMemberSet(h, c.ExcludedSet) + writeMemberSet(h, c.TransientlyParked) h.Write(c.AttemptSeed[:]) var out [MessageDigestLength]byte copy(out[:], h.Sum(nil)) diff --git a/pkg/frost/roast/attempt/attempt_context_test.go b/pkg/frost/roast/attempt/attempt_context_test.go index 60b49f2bb1..13c5408a8c 100644 --- a/pkg/frost/roast/attempt/attempt_context_test.go +++ b/pkg/frost/roast/attempt/attempt_context_test.go @@ -427,6 +427,7 @@ func referenceHashForFixture(ctx AttemptContext) [MessageDigestLength]byte { h.Write(a[:]) writeMS(ctx.IncludedSet) writeMS(ctx.ExcludedSet) + writeMS(ctx.TransientlyParked) h.Write(ctx.AttemptSeed[:]) var out [MessageDigestLength]byte copy(out[:], h.Sum(nil)) diff --git a/pkg/frost/roast/coordinator_state.go b/pkg/frost/roast/coordinator_state.go index 784da78c42..afbd32792a 100644 --- a/pkg/frost/roast/coordinator_state.go +++ b/pkg/frost/roast/coordinator_state.go @@ -144,6 +144,28 @@ type Coordinator interface { // submitted snapshot is missing or mutated. Returns // ErrSignatureInvalid when any signature fails verification. VerifyBundle(handle AttemptHandle, msg *TransitionMessage) error + // NextAttempt computes the deterministic next AttemptContext + // from a verified TransitionMessage. Callers MUST call + // VerifyBundle before NextAttempt; NextAttempt does not + // re-verify signatures. + // + // threshold is the FROST signing threshold t for the key group; + // it is constant across attempts within a session. A threshold + // of zero disables the infeasibility check (test seam). + // + // dkgGroupPublicKey is the DKG-validated group public key from + // the FFI signer material (RFC-21 Decision 2). It is passed + // here so two honest signers derive the same AttemptSeed for + // the next attempt. + // + // Returns ErrAttemptInfeasible when the next IncludedSet would + // drop below threshold. + NextAttempt( + handle AttemptHandle, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, + ) (attempt.AttemptContext, error) } // ErrNotAggregator is returned by AggregateBundle when the caller diff --git a/pkg/frost/roast/next_attempt.go b/pkg/frost/roast/next_attempt.go new file mode 100644 index 0000000000..e4d450b8f9 --- /dev/null +++ b/pkg/frost/roast/next_attempt.go @@ -0,0 +1,259 @@ +package roast + +import ( + "errors" + "fmt" + "sort" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// OverflowExclusionThreshold is the per-sender overflow-count +// threshold above which the NextAttempt policy permanently excludes +// the sender (transport-blamable). Matches the constant documented in +// RFC-21 Layer B. +const OverflowExclusionThreshold uint = 4 + +// ErrAttemptInfeasible is returned by NextAttempt when the next +// attempt's IncludedSet would drop below the signing threshold t and +// the session can no longer make progress with the original signer +// set. Callers must surface this to the application layer: the +// session is permanently failed. +var ErrAttemptInfeasible = errors.New( + "coordinator: next attempt is infeasible -- included set below threshold", +) + +// NextAttempt computes the deterministic next attempt context from a +// verified TransitionMessage. It is a pure function of +// (previous AttemptContext, bundle, threshold): two honest signers +// fed the same inputs produce byte-identical outputs, so the +// signing-group state machine remains in agreement across the +// network. +// +// Callers MUST call VerifyBundle on the message before passing it to +// NextAttempt. NextAttempt does not re-run the signature checks; it +// assumes the bundle is verified and only applies the policy. +// +// The policy (RFC-21 Layer B): +// +// 1. Permanent exclusion (transport-blamable): a sender whose total +// overflow count across the bundle is at least +// OverflowExclusionThreshold is added to ExcludedSet forever. +// +// 2. Permanent exclusion (validation-blamable): senders with +// confirmed non-transport reject events. Phase 3.4 does not yet +// track reject events, so this is a no-op; the hook is in place +// for a later phase. +// +// 3. Silence parking (strictly transient): a sender in the +// previous attempt's IncludedSet that does not appear in the +// bundle, and is not permanently excluded, is added to the next +// attempt's TransientlyParked set. The attempt after that +// automatically reinstates them, so a falsely-silenced honest +// peer recovers without intervention. +// +// 4. Reinstatement: members in the previous attempt's +// TransientlyParked set automatically rejoin the next attempt's +// IncludedSet (unless they are now permanently excluded for +// another reason). +// +// 5. Infeasibility: if the next attempt's IncludedSet would have +// fewer than threshold members, return ErrAttemptInfeasible. +// +// threshold is the FROST signing threshold t for the key group; it +// is constant across attempts within a session. A threshold of zero +// disables the infeasibility check (useful in tests that exercise +// the policy independently from threshold semantics). +// +// The caller is responsible for supplying the DKG group public key +// from the same source the previous attempt used (the FFI signer +// material, per RFC-21 Decision 2); a different source would +// silently desynchronise the seed derivation. +func (c *inMemoryCoordinator) NextAttempt( + handle AttemptHandle, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, +) (attempt.AttemptContext, error) { + if bundle == nil { + return attempt.AttemptContext{}, errors.New( + "coordinator: cannot compute next attempt from nil bundle", + ) + } + c.mu.Lock() + record, ok := c.attempts[handle.id] + if !ok { + c.mu.Unlock() + return attempt.AttemptContext{}, ErrUnknownAttempt + } + prev := record.context + c.mu.Unlock() + + return computeNextAttempt(prev, bundle, threshold, dkgGroupPublicKey) +} + +// computeNextAttempt is the pure-function policy core: it takes the +// previous AttemptContext, a verified bundle, and the signing +// threshold, and returns the next AttemptContext. Factored out from +// NextAttempt so the policy is independently unit-testable without a +// Coordinator instance. +func computeNextAttempt( + prev attempt.AttemptContext, + bundle *TransitionMessage, + threshold uint, + dkgGroupPublicKey []byte, +) (attempt.AttemptContext, error) { + // (1) Permanent exclusion from overflow evidence. + overflowBlamed := overflowBlamedSenders(bundle, OverflowExclusionThreshold) + + // (2) Reject blame -- Phase 3.4 has no reject category to read. + // rejectBlamed := + + // Merge into permanent exclusion. + exclSet := newMemberSet() + exclSet.addAll(prev.ExcludedSet) + exclSet.addAll(overflowBlamed) + + // (3) Silence parking: senders in prev.IncludedSet but not in + // bundle, that we are not now permanently excluding. + bundleSenders := bundleSenderSet(bundle) + parkSet := newMemberSet() + for _, m := range prev.IncludedSet { + if bundleSenders.contains(m) { + continue + } + if exclSet.contains(m) { + continue + } + parkSet.add(m) + } + + // (4) Original signer set persists across transitions as + // IncludedSet ∪ ExcludedSet ∪ TransientlyParked. Reinstate + // previously parked members by re-including them + // (unless newly permanently excluded -- which they cannot be, + // since they could not have submitted overflow evidence + // this attempt). + original := newMemberSet() + original.addAll(prev.IncludedSet) + original.addAll(prev.ExcludedSet) + original.addAll(prev.TransientlyParked) + + included := original.sorted() + included = filterOut(included, exclSet) + included = filterOut(included, parkSet) + + // (5) Infeasibility check. + if threshold > 0 && uint(len(included)) < threshold { + return attempt.AttemptContext{}, fmt.Errorf( + "%w: %d eligible, threshold %d", + ErrAttemptInfeasible, + len(included), + threshold, + ) + } + + // Convert ExcludedSet to its canonical (sorted, deduped) slice. + nextExcluded := exclSet.sorted() + nextParked := parkSet.sorted() + + next, err := attempt.NewAttemptContextWithParking( + prev.SessionID, + prev.KeyGroupID, + dkgGroupPublicKey, + prev.MessageDigest, + prev.AttemptNumber+1, + included, + nextExcluded, + nextParked, + ) + if err != nil { + return attempt.AttemptContext{}, fmt.Errorf( + "coordinator: next attempt construction: %w", + err, + ) + } + return next, nil +} + +// overflowBlamedSenders returns the senders whose total overflow +// count across every snapshot in the bundle is at least the +// supplied threshold. Counts are summed (not averaged) so a sender +// hitting the threshold from one observer alone is sufficient. +func overflowBlamedSenders( + bundle *TransitionMessage, + threshold uint, +) []group.MemberIndex { + counts := map[group.MemberIndex]uint{} + for i := range bundle.Bundle { + for _, entry := range bundle.Bundle[i].Overflows { + counts[entry.Sender] += entry.Count + } + } + out := make([]group.MemberIndex, 0) + for sender, count := range counts { + if count >= threshold { + out = append(out, sender) + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// bundleSenderSet returns the set of senders that submitted a +// snapshot to the bundle. +func bundleSenderSet(bundle *TransitionMessage) *memberSet { + out := newMemberSet() + for i := range bundle.Bundle { + out.add(bundle.Bundle[i].SenderID()) + } + return out +} + +// memberSet is a small helper for set arithmetic over +// group.MemberIndex. Sufficient for the small (≤256) sizes the +// coordinator deals with. +type memberSet struct { + m map[group.MemberIndex]struct{} +} + +func newMemberSet() *memberSet { + return &memberSet{m: map[group.MemberIndex]struct{}{}} +} + +func (s *memberSet) add(member group.MemberIndex) { s.m[member] = struct{}{} } +func (s *memberSet) contains(m group.MemberIndex) bool { + _, ok := s.m[m] + return ok +} + +func (s *memberSet) addAll(members []group.MemberIndex) { + for _, m := range members { + s.add(m) + } +} + +func (s *memberSet) sorted() []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(s.m)) + for m := range s.m { + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +// filterOut returns members not in the excluded set, preserving +// input order. +func filterOut( + members []group.MemberIndex, + excluded *memberSet, +) []group.MemberIndex { + out := make([]group.MemberIndex, 0, len(members)) + for _, m := range members { + if !excluded.contains(m) { + out = append(out, m) + } + } + return out +} diff --git a/pkg/frost/roast/next_attempt_test.go b/pkg/frost/roast/next_attempt_test.go new file mode 100644 index 0000000000..47972dedd0 --- /dev/null +++ b/pkg/frost/roast/next_attempt_test.go @@ -0,0 +1,392 @@ +package roast + +import ( + "errors" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// nextAttemptFixture builds a previous AttemptContext and an +// associated TransitionMessage for the NextAttempt-policy tests. +// Members 1..5 included; no excluded; no parking. By default every +// member submits a snapshot with no overflow events. +type nextAttemptFixture struct { + included []group.MemberIndex + excluded []group.MemberIndex + parked []group.MemberIndex + overflows map[group.MemberIndex]map[group.MemberIndex]uint + bundleSenders []group.MemberIndex // override default = included + attemptNumber uint32 + dkgGroupPublicKey []byte + threshold uint + sessionID string + messageDigest [attempt.MessageDigestLength]byte +} + +func newNextAttemptFixture() *nextAttemptFixture { + return &nextAttemptFixture{ + included: []group.MemberIndex{1, 2, 3, 4, 5}, + excluded: nil, + parked: nil, + overflows: map[group.MemberIndex]map[group.MemberIndex]uint{}, + bundleSenders: nil, + attemptNumber: 0, + dkgGroupPublicKey: []byte{0x01, 0x02, 0x03}, + threshold: 3, + sessionID: "session-next-attempt", + messageDigest: [attempt.MessageDigestLength]byte{0x42}, + } +} + +func (f *nextAttemptFixture) prev(t *testing.T) attempt.AttemptContext { + t.Helper() + ctx, err := attempt.NewAttemptContextWithParking( + f.sessionID, + "key-group-next-attempt", + f.dkgGroupPublicKey, + f.messageDigest, + f.attemptNumber, + f.included, + f.excluded, + f.parked, + ) + if err != nil { + t.Fatalf("fixture prev: %v", err) + } + return ctx +} + +func (f *nextAttemptFixture) bundle(t *testing.T) *TransitionMessage { + t.Helper() + prev := f.prev(t) + prevHash := prev.Hash() + senders := f.bundleSenders + if senders == nil { + senders = append([]group.MemberIndex{}, f.included...) + } + bundle := make([]LocalEvidenceSnapshot, 0, len(senders)) + for _, s := range senders { + snap := LocalEvidenceSnapshot{ + SenderIDValue: uint32(s), + AttemptContextHash: append([]byte{}, prevHash[:]...), + } + if entries, ok := f.overflows[s]; ok { + ov := make([]OverflowEntry, 0, len(entries)) + for sender, count := range entries { + ov = append(ov, OverflowEntry{Sender: sender, Count: count}) + } + snap.Overflows = sortedOverflowEntries(ov) + } + bundle = append(bundle, snap) + } + return &TransitionMessage{ + AttemptContextHash: append([]byte{}, prevHash[:]...), + CoordinatorIDValue: 1, + Bundle: bundle, + } +} + +func sortedOverflowEntries(in []OverflowEntry) []OverflowEntry { + out := append([]OverflowEntry{}, in...) + // insertion sort; small slices. + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].Sender < out[j-1].Sender; j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out +} + +func TestNextAttempt_NoEvidenceProducesIdenticalIncludedSet(t *testing.T) { + f := newNextAttemptFixture() + prev := f.prev(t) + bundle := f.bundle(t) + next, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSlicesEqual(next.IncludedSet, prev.IncludedSet) { + t.Fatalf( + "included set changed unexpectedly: prev=%v next=%v", + prev.IncludedSet, next.IncludedSet, + ) + } + if len(next.ExcludedSet) != 0 { + t.Fatalf("excluded set should be empty, got %v", next.ExcludedSet) + } + if len(next.TransientlyParked) != 0 { + t.Fatalf("parking set should be empty, got %v", next.TransientlyParked) + } + if next.AttemptNumber != prev.AttemptNumber+1 { + t.Fatalf( + "attempt number not incremented: got %d, want %d", + next.AttemptNumber, prev.AttemptNumber+1, + ) + } +} + +func TestNextAttempt_OverflowThresholdTriggersPermanentExclusion(t *testing.T) { + f := newNextAttemptFixture() + // Members 2..5 all report 1 overflow event each against sender 3. + // 4 observers × 1 event = 4 total = OverflowExclusionThreshold. + for observer := group.MemberIndex(2); observer <= 5; observer++ { + f.overflows[observer] = map[group.MemberIndex]uint{3: 1} + } + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 should be excluded; got included %v", next.IncludedSet) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf("sender 3 should appear in excluded set; got %v", next.ExcludedSet) + } +} + +func TestNextAttempt_OverflowBelowThresholdDoesNotExclude(t *testing.T) { + f := newNextAttemptFixture() + // Only 1 observer reports 1 overflow event against sender 3. + // 1 < threshold (4). + f.overflows[2] = map[group.MemberIndex]uint{3: 1} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf("sender 3 should remain included; got %v", next.IncludedSet) + } +} + +func TestNextAttempt_SilentMemberIsParkedTransiently(t *testing.T) { + f := newNextAttemptFixture() + // Only members 1, 2, 4, 5 submit; member 3 is silent. + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if memberSliceContains(next.IncludedSet, 3) { + t.Fatal("silent sender 3 must not appear in next IncludedSet") + } + if !memberSliceContains(next.TransientlyParked, 3) { + t.Fatalf("silent sender 3 must appear in next TransientlyParked; got %v", next.TransientlyParked) + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatal("silent sender 3 must not be permanently excluded") + } +} + +func TestNextAttempt_PreviouslyParkedAreReinstated(t *testing.T) { + f := newNextAttemptFixture() + // Previous attempt: members 1, 2, 4, 5 included; member 3 parked. + f.included = []group.MemberIndex{1, 2, 4, 5} + f.parked = []group.MemberIndex{3} + // Bundle: only the included set submits (parked cannot). + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.IncludedSet, 3) { + t.Fatalf( + "previously parked member 3 must be reinstated; got included %v", + next.IncludedSet, + ) + } + if memberSliceContains(next.TransientlyParked, 3) { + t.Fatal("member 3 must not be re-parked") + } + if memberSliceContains(next.ExcludedSet, 3) { + t.Fatal("member 3 must not be excluded") + } +} + +func TestNextAttempt_ParkingIsStrictlyTransient_NoEscalation(t *testing.T) { + // Demonstrate the full cycle: park, skip one attempt, reinstate. + // Attempt N: member 3 is silent. + // Attempt N+1: member 3 is parked, did not submit. + // Attempt N+2: member 3 is reinstated. + f := newNextAttemptFixture() + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + prev := f.prev(t) + bundle := f.bundle(t) + attemptN1, err := computeNextAttempt(prev, bundle, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("N -> N+1: %v", err) + } + if !memberSliceContains(attemptN1.TransientlyParked, 3) { + t.Fatalf("N+1 must park member 3; got %v", attemptN1.TransientlyParked) + } + if memberSliceContains(attemptN1.IncludedSet, 3) { + t.Fatal("member 3 must not be in N+1 IncludedSet (parked this attempt)") + } + + // Now compute attempt N+2 from a bundle where parked member 3 + // could not submit (legitimately), and members 1, 2, 4, 5 did + // submit. + attemptN1Hash := attemptN1.Hash() + bundleN1 := &TransitionMessage{ + AttemptContextHash: append([]byte{}, attemptN1Hash[:]...), + CoordinatorIDValue: 1, + Bundle: []LocalEvidenceSnapshot{ + {SenderIDValue: 1, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 2, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 4, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + {SenderIDValue: 5, AttemptContextHash: append([]byte{}, attemptN1Hash[:]...)}, + }, + } + attemptN2, err := computeNextAttempt(attemptN1, bundleN1, f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("N+1 -> N+2: %v", err) + } + if !memberSliceContains(attemptN2.IncludedSet, 3) { + t.Fatalf( + "N+2 must reinstate member 3; got included %v", + attemptN2.IncludedSet, + ) + } + if memberSliceContains(attemptN2.TransientlyParked, 3) { + t.Fatal("N+2 must not re-park member 3") + } + if memberSliceContains(attemptN2.ExcludedSet, 3) { + t.Fatal("N+2 must not permanently exclude member 3") + } +} + +func TestNextAttempt_OriginalSignerSetPreservedAcrossTransitions(t *testing.T) { + f := newNextAttemptFixture() + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} // 3 silent + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + originalSize := len(f.included) + nextSize := len(next.IncludedSet) + len(next.ExcludedSet) + len(next.TransientlyParked) + if nextSize != originalSize { + t.Fatalf( + "original signer set size not preserved: %d vs %d", + nextSize, originalSize, + ) + } +} + +func TestNextAttempt_PolicyIsDeterministic(t *testing.T) { + f := newNextAttemptFixture() + f.bundleSenders = []group.MemberIndex{1, 2, 4, 5} + f.overflows[2] = map[group.MemberIndex]uint{1: 2} + f.overflows[5] = map[group.MemberIndex]uint{1: 2} + a, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("first compute: %v", err) + } + b, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("second compute: %v", err) + } + if a.Hash() != b.Hash() { + t.Fatalf("same inputs produced different next-attempt hashes") + } +} + +func TestNextAttempt_InfeasibilityWhenBelowThreshold(t *testing.T) { + f := newNextAttemptFixture() + f.threshold = 5 // Require all 5 members. + // Silently lose 2 members -> only 3 remain in IncludedSet, below + // threshold of 5. + f.bundleSenders = []group.MemberIndex{1, 2, 3} + _, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if !errors.Is(err, ErrAttemptInfeasible) { + t.Fatalf("expected ErrAttemptInfeasible, got %v", err) + } +} + +func TestNextAttempt_ThresholdZeroDisablesInfeasibilityCheck(t *testing.T) { + f := newNextAttemptFixture() + f.threshold = 0 + // All members silent; without the infeasibility check, the next + // attempt has zero included members. This is documented as a + // test seam, not a production state. + f.bundleSenders = []group.MemberIndex{} + // We need at least one entry in the bundle for TransitionMessage + // to be valid. Add a no-op snapshot from member 1 even though + // they're "silent" by the policy's view. The policy only looks + // at bundle senders that intersect prev.IncludedSet, which all + // of them do here. So instead let's leave member 1 in the + // bundle alone and silent the rest. + f.bundleSenders = []group.MemberIndex{1} + // IncludedSet would become {1}; for threshold=0 that's still + // permitted. + _, err := computeNextAttempt(f.prev(t), f.bundle(t), 0, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("expected success with threshold=0, got %v", err) + } +} + +func TestNextAttempt_OverflowFromMultipleObserversIsSummed(t *testing.T) { + f := newNextAttemptFixture() + // 2 observers each report 2 overflow events = total 4 = threshold. + f.overflows[1] = map[group.MemberIndex]uint{3: 2} + f.overflows[2] = map[group.MemberIndex]uint{3: 2} + next, err := computeNextAttempt(f.prev(t), f.bundle(t), f.threshold, f.dkgGroupPublicKey) + if err != nil { + t.Fatalf("compute: %v", err) + } + if !memberSliceContains(next.ExcludedSet, 3) { + t.Fatalf( + "sender 3 should be excluded by summed overflow; got %v", + next.ExcludedSet, + ) + } +} + +func TestNextAttempt_NilBundleRejected(t *testing.T) { + c := newSignedCoordinatorForMember(0) + handle, _ := c.BeginAttempt(newTestContext(t)) + _, err := c.NextAttempt(handle, nil, 3, []byte{0x01}) + if err == nil { + t.Fatal("expected error for nil bundle") + } +} + +func TestNextAttempt_UnknownHandleRejected(t *testing.T) { + c := newSignedCoordinatorForMember(0) + bogus := AttemptHandle{id: 999} + _, err := c.NextAttempt(bogus, &TransitionMessage{}, 3, []byte{0x01}) + if !errors.Is(err, ErrUnknownAttempt) { + t.Fatalf("expected ErrUnknownAttempt, got %v", err) + } +} + +func TestOverflowExclusionThreshold_MatchesRFC(t *testing.T) { + if OverflowExclusionThreshold != 4 { + t.Fatalf( + "RFC-21 Layer B specifies overflow threshold = 4; constant is %d", + OverflowExclusionThreshold, + ) + } +} + +func memberSliceContains(slice []group.MemberIndex, target group.MemberIndex) bool { + for _, m := range slice { + if m == target { + return true + } + } + return false +} + +func memberSlicesEqual(a, b []group.MemberIndex) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +}