Files
zeekayandHanzo Dev 5dd666c267 threshold: make 3-of-5 unambiguous, and prove it drives a real Safe
The stack had two different numbers called "threshold". cmp.Keygen and
frost.Keygen take t, the polynomial degree, and need t+1 signers. Operators,
dashboards and runbooks say k-of-n. Provisioning a "3-of-5" wallet by passing
threshold=3 produces a 4-of-5, silently — nothing errors, and the mistake only
surfaces when three custodians hold a ceremony and cannot sign a key that
already holds value.

pkg/quorum is now the single place those two numbers meet. Policy{K,N} states
what operators mean; Degree() == K-1 is the only k->t conversion in the tree.
Callers pass policy.Degree() and never compute the off-by-one themselves.

Proofs, all against the real protocols rather than mocks:

  protocols/quorum_3of5_test.go — five-party CGGMP21 DKG at the policy degree,
  three parties sign, and the signature both verifies under the group key and
  ECRECOVERS to the group's EVM address. Recovery is the part that matters for
  custody: a key can verify correctly and still resolve to an address no one
  controls. Four different 3-subsets all sign for one address. Negatives: every
  2-of-5 subset is refused, and a forged third share cannot produce a signature
  that verifies. FROST covered at the same policy.

  protocols/safe_3of5_test.go — the custody claim end to end. Real SafeL2 v1.5.0
  bytecode behind the real SafeProxyFactory on the luxfi/geth EVM, owned solely
  by the 3-of-5 group address. The digest comes from the deployed Safe's own
  getTransactionHash, so the test cannot sign something the contract would not
  check. execTransaction succeeds, value moves, the nonce advances, a replay is
  rejected, and a valid signature from a different 3-of-5 key is rejected.

Fixes found along the way:

  protocols/lss/adapters/evm.go — GenerateEVMAddress hashed the COMPRESSED
  point, i.e. 32 bytes of X, where the EVM requires Keccak over the 64-byte
  uncompressed X||Y. Every address it produced was well-formed and belonged to
  no key; value sent to one is unspendable. It had no callers, so this was a
  loaded gun rather than a live wound. Now decompresses, and reports failure
  instead of returning a plausible wrong answer.

  internal/test/harness.go — WithTimeout moved the harness deadline but not the
  handler's own ProtocolTimeout, which was hard-coded to five minutes, so a
  raised timeout silently did nothing and a CGGMP21 DKG under -race died at
  five minutes blaming the context. The harness now owns one deadline. Also
  removes a duplicate RunProtocolWithTimeout.

  protocols/cmp/cmp_threshold_test.go — cases labelled "3-of-5" passed degree 3
  and signed with four parties. They were 4-of-5. Labels now derive from the
  policy, so the name and the behaviour are one fact.

  go.sum — was missing entries for luxfi/metric and mattn/go-isatty; the FROST
  packages did not compile from a clean checkout.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-25 11:40:29 -07:00

315 lines
8.3 KiB
Go

package test
import (
"context"
"fmt"
"math/rand"
"sync"
"testing"
"time"
log "github.com/luxfi/log"
"github.com/luxfi/metric"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/protocol"
)
// Harness provides a complete test environment for protocol testing
type Harness struct {
t testing.TB
ctx context.Context
cancel context.CancelFunc
network *Network
logger log.Logger
registry metric.Registry
// timeout bounds both the harness context and each handler's own
// ProtocolTimeout. Keeping one value for both is what makes WithTimeout
// mean anything: the handler config used to hard-code 5 minutes, so
// raising the harness timeout moved the deadline that fired but not the
// one inside the handler, and a slow protocol (CGGMP21 DKG under -race)
// still died at five minutes with a misleading error.
timeout time.Duration
mu sync.RWMutex
handlers map[party.ID]*protocol.Handler
results map[party.ID]interface{}
errors map[party.ID]error
}
// DefaultTimeout bounds a harness run unless WithTimeout raises it.
const DefaultTimeout = 5 * time.Minute
// NewHarness creates a new test harness with proper context management.
// Default 5-minute timeout matches the per-handler ProtocolTimeout below;
// `go test ./...` runs many packages in parallel and harness wall-clock can
// stretch under CPU contention even when the protocol itself takes <1s.
func NewHarness(t testing.TB, partyIDs []party.ID) *Harness {
ctx, cancel := context.WithTimeout(context.Background(), DefaultTimeout)
h := &Harness{
t: t,
ctx: ctx,
cancel: cancel,
network: NewNetwork(partyIDs),
logger: log.NewTestLogger(log.InfoLevel),
registry: metric.NewRegistry(),
timeout: DefaultTimeout,
handlers: make(map[party.ID]*protocol.Handler),
results: make(map[party.ID]interface{}),
errors: make(map[party.ID]error),
}
if t != nil {
t.Cleanup(func() {
h.Cleanup()
})
}
return h
}
// WithTimeout sets the deadline for the harness context and for every handler
// created afterwards. Call it before CreateHandler; handlers already created
// keep the timeout they were built with.
func (h *Harness) WithTimeout(timeout time.Duration) *Harness {
h.cancel() // Cancel old context
h.ctx, h.cancel = context.WithTimeout(context.Background(), timeout)
h.timeout = timeout
return h
}
// WithLogger sets a custom logger
func (h *Harness) WithLogger(logger log.Logger) *Harness {
h.logger = logger
return h
}
// CreateHandler creates a new protocol handler for a party
func (h *Harness) CreateHandler(id party.ID, startFunc protocol.StartFunc, sessionID []byte) (*protocol.Handler, error) {
h.mu.Lock()
defer h.mu.Unlock()
// Create a new registry for each handler to avoid conflicts
registry := metric.NewRegistry()
// Create config with sensible defaults
config := &protocol.Config{
Workers: 4, // Need regular workers for p2p messages!
PriorityWorkers: 4,
BufferSize: 10000,
PriorityBuffer: 1000,
MessageTimeout: 30 * time.Second,
RoundTimeout: 60 * time.Second,
ProtocolTimeout: h.timeout, // the harness owns the deadline; see WithTimeout
}
// Use a context without timeout - the harness manages timeouts
handler, err := protocol.NewHandler(
context.Background(), // Don't use h.ctx to avoid premature cancellation
h.logger,
registry,
startFunc,
sessionID,
config,
)
if err != nil {
return nil, fmt.Errorf("failed to create handler for party %s: %w", id, err)
}
h.handlers[id] = handler
return handler, nil
}
// Run executes all handlers concurrently with proper synchronization
func (h *Harness) Run() error {
var wg sync.WaitGroup
errChan := make(chan error, len(h.handlers))
// Start all handler loops
for id, handler := range h.handlers {
wg.Add(1)
go func(partyID party.ID, handler *protocol.Handler) {
defer wg.Done()
// Run the handler loop with context awareness
err := h.runHandlerLoop(partyID, handler)
if err != nil {
errChan <- fmt.Errorf("party %s: %w", partyID, err)
}
}(id, handler)
}
// Wait for completion or timeout
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// All handlers completed successfully
close(errChan)
// Check for any errors
var errs []error
for err := range errChan {
errs = append(errs, err)
}
if len(errs) > 0 {
return fmt.Errorf("protocol execution failed: %v", errs)
}
return nil
case <-h.ctx.Done():
// Context canceled or timed out
h.cancel() // Ensure all handlers are canceled
return fmt.Errorf("harness context canceled: %w", h.ctx.Err())
}
}
// runHandlerLoop runs a single handler with proper message routing
func (h *Harness) runHandlerLoop(id party.ID, handler *protocol.Handler) error {
// Create done channel for coordinating goroutines
done := make(chan struct{})
defer close(done)
// Start goroutine to handle incoming network messages
go func() {
for {
select {
case <-done:
return
case msg := <-h.network.Next(id):
if msg != nil {
fmt.Printf("[HARNESS] %s: RECV msg from %s (round=%d, broadcast=%v)\n",
id, msg.From, msg.RoundNumber, msg.Broadcast)
handler.Accept(msg)
}
}
}
}()
// Start goroutine to handle outgoing messages
go func() {
for {
select {
case <-done:
return
case msg, ok := <-handler.Listen():
if !ok {
// Channel closed, handler completed
fmt.Printf("[HARNESS] %s: Listen channel closed\n", id)
return
}
if msg != nil {
fmt.Printf("[HARNESS] %s: SEND msg to %s (round=%d, broadcast=%v)\n",
id, msg.To, msg.RoundNumber, msg.Broadcast)
h.network.Send(msg)
}
}
}
}()
// Wait for handler to complete
result, err := handler.WaitForResult()
h.mu.Lock()
h.results[id] = result
h.errors[id] = err
h.mu.Unlock()
// Don't call network.Done() - it causes deadlocks
return err
}
// Result returns the result for a specific party
func (h *Harness) Result(id party.ID) (interface{}, error) {
h.mu.RLock()
defer h.mu.RUnlock()
result, ok := h.results[id]
if !ok {
return nil, fmt.Errorf("no result for party %s", id)
}
err := h.errors[id]
return result, err
}
// Results returns all results
func (h *Harness) Results() map[party.ID]interface{} {
h.mu.RLock()
defer h.mu.RUnlock()
results := make(map[party.ID]interface{})
for id, result := range h.results {
results[id] = result
}
return results
}
// Errors returns all errors
func (h *Harness) Errors() map[party.ID]error {
h.mu.RLock()
defer h.mu.RUnlock()
errors := make(map[party.ID]error)
for id, err := range h.errors {
if err != nil {
errors[id] = err
}
}
return errors
}
// Cleanup releases all resources
func (h *Harness) Cleanup() {
h.cancel()
// Close all handlers
h.mu.Lock()
for _, handler := range h.handlers {
// Handler cleanup is handled by context cancellation
_ = handler
}
h.handlers = nil
h.mu.Unlock()
}
// RunProtocol is a convenience function to run a protocol with all parties,
// bounded by DefaultTimeout.
func RunProtocol(t testing.TB, partyIDs []party.ID, sessionID []byte, createStart func(party.ID) protocol.StartFunc) (map[party.ID]interface{}, error) {
return RunProtocolWithTimeout(t, partyIDs, sessionID, DefaultTimeout, createStart)
}
// RunProtocolWithTimeout is RunProtocol with an explicit deadline, for
// protocols that legitimately need longer than DefaultTimeout — a full
// CGGMP21 key generation builds Paillier moduli and their ring-mod proofs for
// every party, which under the race detector runs well past five minutes.
func RunProtocolWithTimeout(t testing.TB, partyIDs []party.ID, sessionID []byte, timeout time.Duration, createStart func(party.ID) protocol.StartFunc) (map[party.ID]interface{}, error) {
harness := NewHarness(t, partyIDs).WithTimeout(timeout)
// If no session ID provided, generate a unique one
if sessionID == nil {
sessionID = []byte(fmt.Sprintf("session-%d-%d", time.Now().UnixNano(), rand.Int63()))
}
// Create handlers for all parties
for _, id := range partyIDs {
startFunc := createStart(id)
_, err := harness.CreateHandler(id, startFunc, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to create handler for party %s: %w", id, err)
}
}
// Run the protocol
if err := harness.Run(); err != nil {
return nil, err
}
// Return all results
return harness.Results(), nil
}