mirror of
https://github.com/luxfi/threshold.git
synced 2026-07-27 04:01:59 +00:00
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 <dev@hanzo.ai>
216 lines
5.7 KiB
Go
216 lines
5.7 KiB
Go
package cmp_test
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"fmt"
|
|
|
|
"github.com/luxfi/threshold/internal/test"
|
|
"github.com/luxfi/threshold/pkg/math/curve"
|
|
"github.com/luxfi/threshold/pkg/party"
|
|
"github.com/luxfi/threshold/pkg/pool"
|
|
"github.com/luxfi/threshold/pkg/quorum"
|
|
"github.com/luxfi/threshold/protocols/cmp"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestCMPThresholdPerformance tests CMP with exactly T+1 parties (minimum required)
|
|
func TestCMPThresholdPerformance(t *testing.T) {
|
|
// Stated as quorum policies, not bare degrees. The names used to be
|
|
// written out by hand alongside a raw `threshold` field that cmp.Keygen
|
|
// reads as the polynomial DEGREE, so the case labelled "3-of-5" passed
|
|
// degree 3 and signed with 4 parties — it was a 4-of-5. Deriving both the
|
|
// degree and the signer count from one quorum.Policy makes the label and
|
|
// the behaviour the same fact.
|
|
testCases := []struct {
|
|
policy quorum.Policy
|
|
timeout time.Duration
|
|
}{
|
|
{quorum.MustNew(2, 3), 5 * time.Second},
|
|
{quorum.MustNew(3, 5), 8 * time.Second},
|
|
{quorum.MustNew(4, 7), 10 * time.Second},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.policy.String(), func(t *testing.T) {
|
|
// Exactly K parties sign — the minimum the policy allows.
|
|
allParties := test.PartyIDs(tc.policy.N)
|
|
signingParties := allParties[:tc.policy.K]
|
|
|
|
pl := pool.NewPool(0)
|
|
defer pl.TearDown()
|
|
|
|
// Quick keygen initialization test
|
|
for _, id := range signingParties {
|
|
startFunc := cmp.Keygen(curve.Secp256k1{}, id, allParties, tc.policy.Degree(), pl)
|
|
require.NotNil(t, startFunc, "Keygen should initialize for party %s", id)
|
|
|
|
// Test that start function creates a valid round
|
|
round, err := startFunc(nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, round)
|
|
}
|
|
|
|
t.Logf("%s: CMP threshold performance test passed with %d signers at degree %d",
|
|
tc.policy, len(signingParties), tc.policy.Degree())
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCMPMinimalParties tests with minimal party set (T+1) for efficiency
|
|
func TestCMPMinimalParties(t *testing.T) {
|
|
N := 5
|
|
T := 3
|
|
|
|
partyIDs := test.PartyIDs(N)
|
|
// Use only T+1 parties for the protocol
|
|
activeParties := partyIDs[:T+1]
|
|
|
|
pl := pool.NewPool(0)
|
|
defer pl.TearDown()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
var wg sync.WaitGroup
|
|
errors := make(chan error, len(activeParties))
|
|
|
|
for _, id := range activeParties {
|
|
wg.Add(1)
|
|
go func(partyID party.ID) {
|
|
defer wg.Done()
|
|
|
|
startFunc := cmp.Keygen(curve.Secp256k1{}, partyID, partyIDs, T, pl)
|
|
if startFunc == nil {
|
|
errors <- nil
|
|
return
|
|
}
|
|
|
|
round, err := startFunc(nil)
|
|
if err != nil {
|
|
errors <- err
|
|
return
|
|
}
|
|
|
|
if round == nil {
|
|
errors <- nil
|
|
return
|
|
}
|
|
|
|
// Just test initialization, don't run full protocol
|
|
errors <- nil
|
|
}(id)
|
|
}
|
|
|
|
// Wait with timeout
|
|
done := make(chan struct{})
|
|
go func() {
|
|
wg.Wait()
|
|
close(done)
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
t.Logf("CMP minimal parties test completed successfully with %d/%d parties", T+1, N)
|
|
case <-ctx.Done():
|
|
t.Logf("CMP minimal parties test completed (timeout expected for full protocol)")
|
|
}
|
|
|
|
// Check for any errors
|
|
close(errors)
|
|
for err := range errors {
|
|
require.NoError(t, err)
|
|
}
|
|
}
|
|
|
|
// TestCMPSubsetSigners tests signing with different signer subsets
|
|
func TestCMPSubsetSigners(t *testing.T) {
|
|
N := 7
|
|
T := 4
|
|
|
|
partyIDs := test.PartyIDs(N)
|
|
pl := pool.NewPool(0)
|
|
defer pl.TearDown()
|
|
|
|
// Test different valid signer subsets
|
|
subsets := [][]party.ID{
|
|
partyIDs[:T+1], // First T+1 parties
|
|
partyIDs[1 : T+2], // Middle T+1 parties
|
|
partyIDs[N-T-1:], // Last T+1 parties
|
|
}
|
|
|
|
for i, signers := range subsets {
|
|
t.Run(fmt.Sprintf("Subset%d", i+1), func(t *testing.T) {
|
|
// Create mock configs for testing
|
|
configs := make(map[party.ID]*cmp.Config)
|
|
for _, id := range partyIDs {
|
|
configs[id] = &cmp.Config{
|
|
ID: id,
|
|
Threshold: T,
|
|
}
|
|
}
|
|
|
|
// Test that we can create sign protocol with subset
|
|
message := []byte("test message")
|
|
for _, signer := range signers {
|
|
if config, ok := configs[signer]; ok {
|
|
startFunc := cmp.Sign(config, signers, message, pl)
|
|
// Sign may not be fully implemented, just check it doesn't panic
|
|
if startFunc != nil {
|
|
t.Logf("Sign function created for signer %s in subset %d", signer, i+1)
|
|
}
|
|
}
|
|
}
|
|
|
|
require.Equal(t, T+1, len(signers), "Should have exactly T+1 signers")
|
|
t.Logf("Subset %d: Successfully tested with %d signers", i+1, len(signers))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCMPPerformanceScaling tests performance with increasing party counts
|
|
func TestCMPPerformanceScaling(t *testing.T) {
|
|
// As above: the policy is the stated value, the degree is derived from it.
|
|
// Printing a raw degree as if it were the signer count is how "3-of-5"
|
|
// ends up naming a 4-of-5 configuration.
|
|
testCases := []struct {
|
|
policy quorum.Policy
|
|
maxTime time.Duration
|
|
}{
|
|
{quorum.MustNew(2, 3), 1 * time.Second},
|
|
{quorum.MustNew(3, 5), 2 * time.Second},
|
|
{quorum.MustNew(5, 7), 3 * time.Second},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
t.Run(tc.policy.String(), func(t *testing.T) {
|
|
partyIDs := test.PartyIDs(tc.policy.N)
|
|
pl := pool.NewPool(0)
|
|
defer pl.TearDown()
|
|
|
|
start := time.Now()
|
|
|
|
// Initialize keygen for all parties
|
|
var initTime time.Duration
|
|
for _, id := range partyIDs {
|
|
startFunc := cmp.Keygen(curve.Secp256k1{}, id, partyIDs, tc.policy.Degree(), pl)
|
|
require.NotNil(t, startFunc)
|
|
|
|
round, err := startFunc(nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, round)
|
|
}
|
|
|
|
initTime = time.Since(start)
|
|
require.Less(t, initTime, tc.maxTime,
|
|
"Initialization for %d parties should complete within %v", tc.policy.N, tc.maxTime)
|
|
|
|
t.Logf("%s: Initialization completed in %v (limit: %v)",
|
|
tc.policy, initTime, tc.maxTime)
|
|
})
|
|
}
|
|
}
|