mirror of
https://github.com/luxfi/threshold.git
synced 2026-10-30 15:15:53 +00:00
Squashed batch covering:
- protocols/pulsar/: lattice threshold lane (round-based wrapper for
github.com/luxfi/pulsar kernel); doc.go documents Photon/Lumen/Beam/
Pulsar/Pulse/Prism/Horizon/Quasar vocabulary stack from LP-105
- protocols/lens/: planned curve threshold sister kernel (design
intent only; mirrors Pulsar shape; replaces stub LSS-FROST)
- protocols/lss/lss_pulsar.go + tests: LSS-Pulsar adapter
(DynamicResharePulsar + PulsarSnapshotManager + BuildActivationTranscript).
Now SHA3-suite-aware: PulsarConfig carries NebulaRoot, HashSuiteID,
ImplementationVersion as optional transcript-binding fields that
flow through to the activation message.
10/10 acceptance tests pass:
1. GroupKey preservation
2. KeyEraID preservation
3. Generation +1
4. RollbackFrom=0 on forward
5. t_old != t_new
6. Disjoint set rotation
7. Valid signature under unchanged GroupKey
8. Pairwise material regenerated
9. Rollback semantics
10. Error surface
Plus 1 new test for BuildActivationTranscript Nebula+suite fields.
- protocols/lss/lss_frost.go: marked DEPRECATED (placeholder Sign()/
Refresh(), single-process simulation, hardcoded ChainKey/RID); will
be replaced by lss_lens.go.
- corona KAT cross-oracle vs Go reference (N=16 deterministic seeds)
- corona+protocol+harness hardening against parallel-run flakes
- lss/adapters: drop NewXRPL/NewCardano panic; ship real XRPL address
derivation
- corona SignWithConfig API expansion + test fixes
- v1.6.5 changelog
- go.sum h1 hashes for luxfi/log and 4 deps
Architecture (LP-105 / pulsar/DESIGN.md):
LSS owns lifecycle (Generation, Rollback, snapshots, dealer/
coordinator role separation). Pulsar owns lattice math (R_q shares,
lattice Pedersen commits, Sign1/Sign2/Combine, Pulsar-SHA3 hash
profile). The lss_pulsar adapter wires them. lss_lens (planned) does
the same for curve math.
go.mod adds local replace github.com/luxfi/pulsar => ../pulsar.
440 lines
14 KiB
Go
440 lines
14 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package lss
|
|
|
|
import (
|
|
"io"
|
|
"testing"
|
|
|
|
"github.com/luxfi/threshold/pkg/party"
|
|
|
|
"github.com/luxfi/lens/keyera"
|
|
"github.com/luxfi/lens/primitives"
|
|
"github.com/luxfi/lens/sign"
|
|
lensThreshold "github.com/luxfi/lens/threshold"
|
|
|
|
"github.com/zeebo/blake3"
|
|
)
|
|
|
|
// Acceptance tests for the LSS-Lens adapter, mirroring the 10 items
|
|
// in lps/LP-103-lens.md "Acceptance criteria":
|
|
//
|
|
// 1. DynamicReshareLens preserves GroupKey.
|
|
// 2. DynamicReshareLens preserves KeyEraID.
|
|
// 3. Generation increments by one.
|
|
// 4. RollbackFrom is zero for ordinary forward transition.
|
|
// 5. t_old != t_new works.
|
|
// 6. old set != new set works.
|
|
// 7. regenerated shares can produce a valid Lens (FROST) signature.
|
|
// 8. old shares are not needed after activation.
|
|
// 9. seeds/MAC keys are regenerated for exactly the new party set.
|
|
// 10. rollback restores the previous generation snapshot.
|
|
|
|
// bootstrapLensEra creates a fresh in-process key era for testing.
|
|
func bootstrapLensEra(t *testing.T, c primitives.Curve, threshold int, validators []party.ID, seed string) *keyera.KeyEra {
|
|
t.Helper()
|
|
stringIDs := make([]string, len(validators))
|
|
for i, v := range validators {
|
|
stringIDs[i] = string(v)
|
|
}
|
|
era, err := keyera.Bootstrap(c, threshold, stringIDs, 0, 1, deterministicLensRand(seed))
|
|
if err != nil {
|
|
t.Fatalf("Bootstrap: %v", err)
|
|
}
|
|
return era
|
|
}
|
|
|
|
// lensEraToConfigs splits a KeyEra into per-party LensConfigs, the
|
|
// input shape DynamicReshareLens consumes.
|
|
func lensEraToConfigs(era *keyera.KeyEra) map[party.ID]*LensConfig {
|
|
out := make(map[party.ID]*LensConfig, len(era.State.Shares))
|
|
for vStr, share := range era.State.Shares {
|
|
id := party.ID(vStr)
|
|
perParty := &keyera.EpochShareState{
|
|
KeyEraID: era.State.KeyEraID,
|
|
Generation: era.State.Generation,
|
|
RollbackFrom: era.State.RollbackFrom,
|
|
Epoch: era.State.Epoch,
|
|
Validators: era.State.Validators,
|
|
Threshold: era.State.Threshold,
|
|
Shares: map[string]*lensThreshold.KeyShare{vStr: share},
|
|
}
|
|
out[id] = &LensConfig{State: perParty, PartyID: id}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Test 1: DynamicReshareLens preserves GroupKey.
|
|
// Test 2: DynamicReshareLens preserves KeyEraID.
|
|
// Test 3: Generation increments by one.
|
|
// Test 4: RollbackFrom is zero for ordinary forward transition.
|
|
func TestLensAdapter_PreservesLineage(t *testing.T) {
|
|
c := primitives.NewEd25519()
|
|
oldSet := []party.ID{"a", "b", "c"}
|
|
era := bootstrapLensEra(t, c, 3, oldSet, "preserves-lineage-genesis")
|
|
gkBefore := era.GroupKey
|
|
eraIDBefore := era.State.KeyEraID
|
|
genBefore := era.State.Generation
|
|
|
|
oldCfgs := lensEraToConfigs(era)
|
|
|
|
newCfgs, err := DynamicReshareLens(oldCfgs, oldSet, 3, nil, deterministicLensRand("preserves-lineage-reshare"))
|
|
if err != nil {
|
|
t.Fatalf("DynamicReshareLens: %v", err)
|
|
}
|
|
if len(newCfgs) != len(oldSet) {
|
|
t.Fatalf("output config count: want %d got %d", len(oldSet), len(newCfgs))
|
|
}
|
|
|
|
for id, cfg := range newCfgs {
|
|
share, ok := cfg.State.Shares[string(id)]
|
|
if !ok {
|
|
t.Fatalf("missing share for %s", id)
|
|
}
|
|
if share.GroupKey != gkBefore {
|
|
t.Errorf("party %s: GroupKey pointer changed across reshare", id)
|
|
}
|
|
if cfg.KeyEraID() != eraIDBefore {
|
|
t.Errorf("party %s: KeyEraID: want %d got %d", id, eraIDBefore, cfg.KeyEraID())
|
|
}
|
|
if cfg.Generation() != genBefore+1 {
|
|
t.Errorf("party %s: Generation: want %d got %d", id, genBefore+1, cfg.Generation())
|
|
}
|
|
if cfg.RollbackFrom() != 0 {
|
|
t.Errorf("party %s: RollbackFrom on forward transition: want 0 got %d", id, cfg.RollbackFrom())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test 5: t_old != t_new works.
|
|
// Test 6: old set != new set works.
|
|
func TestLensAdapter_SetAndThresholdRotation(t *testing.T) {
|
|
c := primitives.NewSecp256k1()
|
|
oldSet := []party.ID{"v1", "v2", "v3"}
|
|
era := bootstrapLensEra(t, c, 3, oldSet, "rotation-genesis")
|
|
oldCfgs := lensEraToConfigs(era)
|
|
|
|
newSet := []party.ID{"v4", "v5", "v6", "v7", "v8"}
|
|
const tNew = 5
|
|
newCfgs, err := DynamicReshareLens(oldCfgs, newSet, tNew, nil, deterministicLensRand("rotation-reshare"))
|
|
if err != nil {
|
|
t.Fatalf("DynamicReshareLens (rotation): %v", err)
|
|
}
|
|
if len(newCfgs) != len(newSet) {
|
|
t.Fatalf("rotation: output count want %d got %d", len(newSet), len(newCfgs))
|
|
}
|
|
for _, id := range newSet {
|
|
cfg, ok := newCfgs[id]
|
|
if !ok {
|
|
t.Errorf("missing config for new party %s", id)
|
|
continue
|
|
}
|
|
if cfg.State.Threshold != tNew {
|
|
t.Errorf("party %s: threshold: want %d got %d", id, tNew, cfg.State.Threshold)
|
|
}
|
|
}
|
|
for _, oldID := range oldSet {
|
|
if _, ok := newCfgs[oldID]; ok {
|
|
t.Errorf("old party %s should not be in new config set after disjoint rotation", oldID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test 7: regenerated shares can produce a valid Lens (FROST)
|
|
// signature.
|
|
// Test 8: signing under the new committee verifies against the
|
|
// unchanged GroupKey, demonstrating that old shares are not needed
|
|
// after activation.
|
|
func TestLensAdapter_NewCommitteeSigns(t *testing.T) {
|
|
for _, c := range []primitives.Curve{
|
|
primitives.NewEd25519(),
|
|
primitives.NewSecp256k1(),
|
|
primitives.NewRistretto255(),
|
|
} {
|
|
t.Run(c.Name(), func(t *testing.T) {
|
|
oldSet := []party.ID{"a", "b", "c"}
|
|
era := bootstrapLensEra(t, c, 3, oldSet, "signs-genesis")
|
|
gkBefore := era.GroupKey
|
|
oldCfgs := lensEraToConfigs(era)
|
|
|
|
newSet := []party.ID{"x", "y", "z"}
|
|
newCfgs, err := DynamicReshareLens(oldCfgs, newSet, 3, nil, deterministicLensRand("signs-reshare"))
|
|
if err != nil {
|
|
t.Fatalf("DynamicReshareLens: %v", err)
|
|
}
|
|
|
|
// Run FROST signing.
|
|
signers := []int{1, 2, 3}
|
|
signersByID := make(map[int]*sign.Signer, len(newSet))
|
|
keysByID := make(map[int]*lensThreshold.KeyShare, len(newSet))
|
|
for _, id := range newSet {
|
|
ks := newCfgs[id].State.Shares[string(id)]
|
|
signersByID[ks.PartyID] = sign.NewSigner(ks)
|
|
keysByID[ks.PartyID] = ks
|
|
}
|
|
const message = "lss-lens-test-message"
|
|
commits := make(map[int]*sign.CommitMsg, len(signers))
|
|
for _, id := range signers {
|
|
cm, err := signersByID[id].Round1([]byte(message), signers, deterministicLensRand("sign-round1"))
|
|
if err != nil {
|
|
t.Fatalf("Round1 for %d: %v", id, err)
|
|
}
|
|
commits[id] = cm
|
|
}
|
|
responses := make(map[int]*sign.ResponseMsg, len(signers))
|
|
for _, id := range signers {
|
|
rm, err := signersByID[id].Round2([]byte(message), commits)
|
|
if err != nil {
|
|
t.Fatalf("Round2 for %d: %v", id, err)
|
|
}
|
|
responses[id] = rm
|
|
}
|
|
verShares := make(map[int]primitives.Point, len(keysByID))
|
|
for id, ks := range keysByID {
|
|
verShares[id] = ks.VerificationShare
|
|
}
|
|
sig, err := sign.Aggregate(gkBefore, []byte(message), signers, commits, responses, verShares)
|
|
if err != nil {
|
|
t.Fatalf("Aggregate: %v", err)
|
|
}
|
|
if err := sign.Verify(gkBefore, []byte(message), sig); err != nil {
|
|
t.Fatalf("new-committee signature failed to verify under UNCHANGED GroupKey: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test 9: pairwise material is regenerated for the new party set.
|
|
// Each new share has Seeds and MACKeys keyed by the new committee's
|
|
// index space (0..K-1), and the bytes differ from the old shares.
|
|
func TestLensAdapter_PairwiseMaterialRegenerated(t *testing.T) {
|
|
c := primitives.NewEd25519()
|
|
oldSet := []party.ID{"a", "b", "c"}
|
|
era := bootstrapLensEra(t, c, 3, oldSet, "pairwise-genesis")
|
|
oldCfgs := lensEraToConfigs(era)
|
|
|
|
var oldSeed0 []byte
|
|
for _, cfg := range oldCfgs {
|
|
for _, share := range cfg.State.Shares {
|
|
if share.Seeds != nil && share.Seeds[0] != nil {
|
|
oldSeed0 = append([]byte(nil), share.Seeds[0][0]...)
|
|
}
|
|
break
|
|
}
|
|
break
|
|
}
|
|
|
|
newSet := []party.ID{"x", "y", "z"}
|
|
newCfgs, err := DynamicReshareLens(oldCfgs, newSet, 3, nil, deterministicLensRand("pairwise-reshare"))
|
|
if err != nil {
|
|
t.Fatalf("DynamicReshareLens: %v", err)
|
|
}
|
|
|
|
for _, id := range newSet {
|
|
share := newCfgs[id].State.Shares[string(id)]
|
|
if got := len(share.Seeds); got != len(newSet) {
|
|
t.Errorf("party %s: Seeds outer dimension: want %d got %d", id, len(newSet), got)
|
|
}
|
|
for i := 0; i < len(newSet); i++ {
|
|
if got := len(share.Seeds[i]); got != len(newSet) {
|
|
t.Errorf("party %s: Seeds[%d] dimension: want %d got %d", id, i, len(newSet), got)
|
|
}
|
|
}
|
|
if got, want := len(share.MACKeys), len(newSet)-1; got != want {
|
|
t.Errorf("party %s: MACKeys size: want %d got %d", id, want, got)
|
|
}
|
|
newSeed0 := share.Seeds[0][0]
|
|
if equalLensBytes(oldSeed0, newSeed0) {
|
|
t.Errorf("party %s: pairwise material was not regenerated (seed[0][0] identical to old)", id)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Test 10: rollback restores the previous generation snapshot.
|
|
func TestLensAdapter_RollbackRestoresGeneration(t *testing.T) {
|
|
c := primitives.NewEd25519()
|
|
oldSet := []party.ID{"a", "b", "c"}
|
|
era := bootstrapLensEra(t, c, 3, oldSet, "rollback-genesis")
|
|
oldCfgs := lensEraToConfigs(era)
|
|
|
|
mgr := NewLensSnapshotManager(5)
|
|
|
|
if err := mgr.SaveSnapshot(era.State, oldSet); err != nil {
|
|
t.Fatalf("SaveSnapshot gen 0: %v", err)
|
|
}
|
|
|
|
gen1Cfgs, err := DynamicReshareLens(oldCfgs, oldSet, 3, nil, deterministicLensRand("rollback-reshare-1"))
|
|
if err != nil {
|
|
t.Fatalf("DynamicReshareLens gen 1: %v", err)
|
|
}
|
|
gen1State := mergeLensConfigs(gen1Cfgs, oldSet)
|
|
if err := mgr.SaveSnapshot(gen1State, oldSet); err != nil {
|
|
t.Fatalf("SaveSnapshot gen 1: %v", err)
|
|
}
|
|
if got := mgr.CurrentGeneration(); got != 1 {
|
|
t.Fatalf("current generation after gen 1 save: want 1 got %d", got)
|
|
}
|
|
|
|
restored, err := mgr.Rollback(0)
|
|
if err != nil {
|
|
t.Fatalf("Rollback to 0: %v", err)
|
|
}
|
|
if restored.Generation != 2 {
|
|
t.Errorf("restored.Generation: want 2 got %d", restored.Generation)
|
|
}
|
|
if restored.RollbackFrom != 1 {
|
|
t.Errorf("restored.RollbackFrom: want 1 got %d", restored.RollbackFrom)
|
|
}
|
|
if restored.KeyEraID != era.State.KeyEraID {
|
|
t.Errorf("restored.KeyEraID: want %d got %d", era.State.KeyEraID, restored.KeyEraID)
|
|
}
|
|
if restored.Threshold != era.State.Threshold {
|
|
t.Errorf("restored.Threshold: want %d got %d", era.State.Threshold, restored.Threshold)
|
|
}
|
|
}
|
|
|
|
// TestLensAdapter_InvalidInputs covers the error surface.
|
|
func TestLensAdapter_InvalidInputs(t *testing.T) {
|
|
c := primitives.NewEd25519()
|
|
if _, err := DynamicReshareLens(nil, nil, 0, nil, nil); err == nil {
|
|
t.Error("expected error for empty old configs")
|
|
}
|
|
if _, err := DynamicReshareLens(map[party.ID]*LensConfig{}, nil, 0, nil, nil); err == nil {
|
|
t.Error("expected error for empty old configs")
|
|
}
|
|
era := bootstrapLensEra(t, c, 3, []party.ID{"a", "b", "c"}, "errs-bootstrap")
|
|
cfgs := lensEraToConfigs(era)
|
|
if _, err := DynamicReshareLens(cfgs, []party.ID{"x", "y"}, 0, nil, nil); err == nil {
|
|
t.Error("expected error for threshold < 1")
|
|
}
|
|
if _, err := DynamicReshareLens(cfgs, []party.ID{"x", "y"}, 3, nil, nil); err == nil {
|
|
t.Error("expected error for threshold > new-set-size")
|
|
}
|
|
}
|
|
|
|
// TestLensAdapter_BuildActivationTranscript_NewFields covers the
|
|
// transcript-binding fields propagated to the activation message.
|
|
func TestLensAdapter_BuildActivationTranscript_NewFields(t *testing.T) {
|
|
c := primitives.NewEd25519()
|
|
oldSet := []party.ID{"a", "b", "c"}
|
|
era := bootstrapLensEra(t, c, 3, oldSet, "build-transcript-genesis")
|
|
oldCfgs := lensEraToConfigs(era)
|
|
|
|
newCfgs, err := DynamicReshareLens(oldCfgs, oldSet, 3, nil, deterministicLensRand("build-transcript-reshare"))
|
|
if err != nil {
|
|
t.Fatalf("DynamicReshareLens: %v", err)
|
|
}
|
|
|
|
nebulaRoot := [32]byte{0xAA, 0xBB, 0xCC, 0xDD}
|
|
groupPubKeyHash := [32]byte{0x11, 0x22, 0x33, 0x44}
|
|
|
|
got := BuildLensActivationTranscript(
|
|
oldCfgs, newCfgs,
|
|
[]byte("lux-mainnet"), []byte("network-1"), []byte("group-0"),
|
|
groupPubKeyHash,
|
|
nebulaRoot,
|
|
"", "",
|
|
"reshare",
|
|
nil,
|
|
)
|
|
|
|
if got.NebulaRoot != nebulaRoot {
|
|
t.Errorf("NebulaRoot not threaded through: want %x got %x", nebulaRoot, got.NebulaRoot)
|
|
}
|
|
if got.HashSuiteID != "Lens-SHA3" {
|
|
t.Errorf("HashSuiteID default: want %q got %q", "Lens-SHA3", got.HashSuiteID)
|
|
}
|
|
if got.ImplementationVersion != "lss-lens-test-1.0" {
|
|
t.Errorf("ImplementationVersion default: want %q got %q", "lss-lens-test-1.0", got.ImplementationVersion)
|
|
}
|
|
if got.GroupPublicKeyHash != groupPubKeyHash {
|
|
t.Errorf("GroupPublicKeyHash not threaded through")
|
|
}
|
|
if got.Variant != "reshare" {
|
|
t.Errorf("Variant: want reshare got %q", got.Variant)
|
|
}
|
|
if got.CurveName != "ed25519" {
|
|
t.Errorf("CurveName: want ed25519 got %q", got.CurveName)
|
|
}
|
|
if got.KeyEraID != era.State.KeyEraID {
|
|
t.Errorf("KeyEraID: want %d got %d", era.State.KeyEraID, got.KeyEraID)
|
|
}
|
|
if got.NewGeneration != era.State.Generation+1 {
|
|
t.Errorf("NewGeneration: want %d got %d", era.State.Generation+1, got.NewGeneration)
|
|
}
|
|
|
|
// Override defaults.
|
|
got2 := BuildLensActivationTranscript(
|
|
oldCfgs, newCfgs,
|
|
[]byte("lux-mainnet"), nil, []byte("group-0"),
|
|
groupPubKeyHash,
|
|
nebulaRoot,
|
|
"Lens-BLAKE3", "lss-lens-prod-2.0",
|
|
"refresh",
|
|
nil,
|
|
)
|
|
if got2.HashSuiteID != "Lens-BLAKE3" {
|
|
t.Errorf("HashSuiteID override: want Lens-BLAKE3 got %q", got2.HashSuiteID)
|
|
}
|
|
if got2.ImplementationVersion != "lss-lens-prod-2.0" {
|
|
t.Errorf("ImplementationVersion override: want lss-lens-prod-2.0 got %q", got2.ImplementationVersion)
|
|
}
|
|
if got2.Variant != "refresh" {
|
|
t.Errorf("Variant override: want refresh got %q", got2.Variant)
|
|
}
|
|
}
|
|
|
|
// mergeLensConfigs is a test helper that gathers per-party LensConfigs
|
|
// back into a single EpochShareState for snapshot purposes.
|
|
func mergeLensConfigs(cfgs map[party.ID]*LensConfig, ids []party.ID) *keyera.EpochShareState {
|
|
if len(cfgs) == 0 {
|
|
return nil
|
|
}
|
|
var ref *keyera.EpochShareState
|
|
for _, c := range cfgs {
|
|
ref = c.State
|
|
break
|
|
}
|
|
merged := &keyera.EpochShareState{
|
|
KeyEraID: ref.KeyEraID,
|
|
Generation: ref.Generation,
|
|
RollbackFrom: ref.RollbackFrom,
|
|
Epoch: ref.Epoch,
|
|
Validators: ref.Validators,
|
|
Threshold: ref.Threshold,
|
|
Shares: make(map[string]*lensThreshold.KeyShare, len(cfgs)),
|
|
}
|
|
for _, id := range ids {
|
|
v := string(id)
|
|
if cfg, ok := cfgs[id]; ok {
|
|
if share, ok := cfg.State.Shares[v]; ok {
|
|
merged.Shares[v] = share
|
|
}
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func equalLensBytes(a, b []byte) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// deterministicLensRand returns an unbounded byte stream from a seed
|
|
// string for KAT-replay.
|
|
func deterministicLensRand(seed string) io.Reader {
|
|
h := blake3.New()
|
|
_, _ = h.Write([]byte("lss.lens.test.rng.v1"))
|
|
_, _ = h.Write([]byte(seed))
|
|
return h.Digest()
|
|
}
|