mpcvm: M-Chain to 98.6%

The lane that did this was killed before it could commit; the tree it left is
green and is preserved here rather than lost. Root package 51.5% at the start
of the day, 93.3% at its last checkpoint, 98.6% now, with cert, fhe, types and
cmd/plugin all passing.

Includes the fault-injection suite it was building when it stopped: a node that
cannot read its state does not start, and one that cannot enumerate its
registry does not either.

Not reviewed by me beyond build, vet, gofmt and the full suite under -count=1.
The lane's own report is owed and did not arrive, so its mutation count and its
verdict on the mpcvm/fhe duplication are unrecorded.
This commit is contained in:
2026-08-28 13:25:30 -07:00
parent 75c971b172
commit 6981a3cd6c
59 changed files with 10090 additions and 2969 deletions
+330
View File
@@ -0,0 +1,330 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// attest_test.go — what a threshold attestation binds, and what verifying one
// actually checks.
import (
"crypto/sha256"
"encoding/binary"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/ownership"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/quorum"
)
// attestationOver builds the attestation the ceremony would have produced for a
// domain-bound payload, signed by the key that actually holds custody.
func (c *custody) attestationOver(t *testing.T, domain AttestationDomain, subject, root [32]byte, epoch uint64) *QuantumAttestation {
t.Helper()
payload := ComputeAttestationPayload(domain, subject, root, epoch)
return &QuantumAttestation{
Domain: domain,
SubjectID: subject,
CommitmentRoot: root,
Epoch: epoch,
KeyID: c.rec.KeyID,
Policy: c.rec.Policy,
Signers: c.rec.Participants[:c.rec.Policy.K],
Signature: c.sign(t, payload[:]),
}
}
// -----------------------------------------------------------------------------
// The payload
// -----------------------------------------------------------------------------
// Domain separation is what stops an attestation about one thing being replayed
// as an attestation about another. Every domain M-Chain issues produces a
// different payload for identical inputs.
func TestEveryAttestationDomainSignsADifferentThing(t *testing.T) {
subject, root := [32]byte{1}, [32]byte{2}
seen := map[[32]byte]AttestationDomain{}
for domain := range domainSeparators {
payload := ComputeAttestationPayload(domain, subject, root, 7)
prev, dup := seen[payload]
require.Falsef(t, dup, "%s and %s sign the same payload", domain, prev)
seen[payload] = domain
}
require.Len(t, seen, len(domainSeparators))
// A domain M-Chain does not issue falls to one separator, which is why it is
// refused at verification rather than trusted to be distinct.
require.Equal(t,
ComputeAttestationPayload("made/up", subject, root, 7),
ComputeAttestationPayload("also/made/up", subject, root, 7))
}
// Every field of the payload is bound: subject, commitment and epoch each move
// the signature.
func TestAnAttestationPayloadBindsItsSubjectRootAndEpoch(t *testing.T) {
base := ComputeAttestationPayload(DomainOracleWrite, [32]byte{1}, [32]byte{2}, 7)
require.NotEqual(t, base, ComputeAttestationPayload(DomainOracleWrite, [32]byte{9}, [32]byte{2}, 7))
require.NotEqual(t, base, ComputeAttestationPayload(DomainOracleWrite, [32]byte{1}, [32]byte{9}, 7))
require.NotEqual(t, base, ComputeAttestationPayload(DomainOracleWrite, [32]byte{1}, [32]byte{2}, 8),
"without the epoch, an attestation from one epoch is an attestation for every epoch")
}
// The bridge and ownership domains register themselves into the same table, so
// the tooling that verifies a quantum attestation verifies theirs too, and no
// registration displaces another.
func TestTheDomainTableIsAdditive(t *testing.T) {
for _, d := range []AttestationDomain{
DomainOracleWrite, DomainOracleRead, DomainSessionComplete, DomainEpochBeacon,
DomainBridgeTransfer, DomainNFTOwnership,
} {
require.Containsf(t, domainSeparators, d, "%s is not a domain this chain issues", d)
}
require.Equal(t, []byte(ownership.DomainTag), domainSeparators[DomainNFTOwnership])
require.Equal(t, []byte(bridgeTransferDomainTag), domainSeparators[DomainBridgeTransfer])
}
// -----------------------------------------------------------------------------
// Verification
// -----------------------------------------------------------------------------
// The registry decides the quorum, not the attestation.
//
// The check used to compare len(Signers) against the attestation's OWN Policy
// field — two halves of the same attacker-supplied document — so it held for
// every attestation ever written, including one declaring 1-of-1 with one
// signer. A quorum check against the checked party's own claim is not a check.
func TestAnAttestationCannotDeclareItsOwnQuorum(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 90)
key.register(t, vm)
att := key.attestationOver(t, DomainSessionComplete, [32]byte{1}, [32]byte{2}, 7)
require.NoError(t, vm.VerifyAttestation(att))
// A real signature by the real custody key, re-labelled as a 2-of-3 key
// signed by two parties. Under the old rule this passed.
att.Policy = quorum.MustNew(2, 3)
att.Signers = key.rec.Participants[:2]
require.ErrorIs(t, vm.VerifyAttestation(att), ErrInvalidOperation)
}
// A quorum is a set of distinct parties that hold a share of THIS key, counted
// the way the key's policy counts. The same three rules Block.verifySign holds,
// so an attestation cannot pass here under a rule a block would have refused.
func TestAnAttestationsQuorumIsRealAndCanonical(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 91)
key.register(t, vm)
one := key.rec.Participants[0]
for name, signers := range map[string][]party.ID{
"too few": key.rec.Participants[:2],
"none": nil,
"one repeated": {one, one, one},
"unsorted": {key.rec.Participants[2], key.rec.Participants[0], key.rec.Participants[1]},
"strangers": {"za", "zb", "zc"},
} {
att := key.attestationOver(t, DomainEpochBeacon, [32]byte{1}, [32]byte{2}, 7)
att.Signers = signers
require.Errorf(t, vm.VerifyAttestation(att), "%s must not count as a quorum", name)
}
}
func TestAnAttestationInADomainThisChainDoesNotIssueIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 92)
key.register(t, vm)
att := key.attestationOver(t, "made/up", [32]byte{1}, [32]byte{2}, 7)
require.ErrorContains(t, vm.VerifyAttestation(att), "unknown attestation domain")
require.ErrorContains(t, vm.VerifyAttestation(nil), "nil attestation")
}
func TestAnAttestationForAnUnregisteredKeyIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "never-registered", quorum.MustNew(3, 5), 93)
att := key.attestationOver(t, DomainOracleRead, [32]byte{1}, [32]byte{2}, 7)
require.ErrorIs(t, vm.VerifyAttestation(att), ErrUnknownKey)
}
// The signature is checked against the payload recomputed from the
// attestation's own fields, so moving a signature onto different content fails.
func TestAnAttestationsSignatureIsCheckedAgainstItsRecomputedPayload(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 94)
key.register(t, vm)
for name, mangle := range map[string]func(*QuantumAttestation){
"a different subject": func(a *QuantumAttestation) { a.SubjectID = [32]byte{9} },
"a different root": func(a *QuantumAttestation) { a.CommitmentRoot = [32]byte{9} },
"a different epoch": func(a *QuantumAttestation) { a.Epoch++ },
"a different domain": func(a *QuantumAttestation) { a.Domain = DomainOracleRead },
"a mangled r": func(a *QuantumAttestation) { a.Signature[0] ^= 0xff },
"a mangled s": func(a *QuantumAttestation) { a.Signature[40] ^= 0xff },
} {
att := key.attestationOver(t, DomainOracleWrite, [32]byte{1}, [32]byte{2}, 7)
mangle(att)
require.ErrorIsf(t, vm.VerifyAttestation(att), ErrBadArtifact, "%s", name)
}
}
// A signature by a key that is not the registered custodian is refused, even
// when everything the attestation says about itself is consistent.
func TestAnAttestationSignedByTheWrongKeyIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 95)
key.register(t, vm)
impostor := newCustody(t, "vault", quorum.MustNew(3, 5), 96)
att := key.attestationOver(t, DomainSessionComplete, [32]byte{1}, [32]byte{2}, 7)
payload := ComputeAttestationPayload(att.Domain, att.SubjectID, att.CommitmentRoot, att.Epoch)
att.Signature = impostor.sign(t, payload[:])
require.ErrorIs(t, vm.VerifyAttestation(att), ErrBadArtifact)
}
// The recovery byte is not verified against, here as everywhere: a wrong v
// cannot make a good attestation fail, and cannot make a bad one pass.
func TestTheRecoveryByteChangesNoAttestationVerdict(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 99)
key.register(t, vm)
att := key.attestationOver(t, DomainOracleRead, [32]byte{1}, [32]byte{2}, 7)
att.Signature[64] ^= 0xff
require.NoError(t, vm.VerifyAttestation(att))
}
// -----------------------------------------------------------------------------
// Equivocation
// -----------------------------------------------------------------------------
// Two attestations equivocate when they say different things about the SAME
// subject in the SAME domain at the SAME epoch. Anything else is two facts, not
// a contradiction — which is what makes re-attesting at a later epoch the
// legitimate way a fact changes.
func TestEquivocationIsTwoAnswersToOneQuestion(t *testing.T) {
base := func() *QuantumAttestation {
return &QuantumAttestation{
Domain: DomainOracleWrite,
SubjectID: [32]byte{1},
CommitmentRoot: [32]byte{2},
Epoch: 7,
}
}
a, b := base(), base()
require.False(t, DetectEquivocation(a, b), "the same answer twice is not a contradiction")
b.CommitmentRoot = [32]byte{3}
require.True(t, DetectEquivocation(a, b))
for name, mangle := range map[string]func(*QuantumAttestation){
"a different domain": func(x *QuantumAttestation) { x.Domain = DomainOracleRead },
"a different subject": func(x *QuantumAttestation) { x.SubjectID = [32]byte{9} },
"a later epoch": func(x *QuantumAttestation) { x.Epoch = 8 },
} {
c := base()
c.CommitmentRoot = [32]byte{3}
mangle(c)
require.Falsef(t, DetectEquivocation(a, c), "%s is a different question, not a contradiction", name)
}
require.False(t, DetectEquivocation(nil, a))
require.False(t, DetectEquivocation(a, nil))
}
// -----------------------------------------------------------------------------
// Bridge transfers
// -----------------------------------------------------------------------------
// One attestation authorises exactly one mint — of that amount, to that
// recipient, on that route, once. Every field the digest commits to is a field
// a verifier can flip to make the signature stop verifying.
func TestABridgeSignatureAuthorisesExactlyOneTransfer(t *testing.T) {
key := newCustody(t, "vault", quorum.MustNew(3, 5), 97)
bt := BridgeTransfer{
SrcChainID: 200201,
DstChainID: 97368,
Asset: [32]byte{'L', 'U', 'X'},
Amount: 1_000_000,
Recipient: [20]byte{1, 2, 3},
Nonce: 7,
}
digest := bt.Digest()
sig := key.sign(t, digest[:])
pub := key.rec.GroupPublicKey
require.True(t, VerifyBridgeAttestation(pub, bt, sig))
require.True(t, VerifyBridgeAttestation(pub, bt, sig[:64]), "r‖s alone verifies; the recovery id is not checked against")
for name, mangle := range map[string]func(BridgeTransfer) BridgeTransfer{
"a different source": func(x BridgeTransfer) BridgeTransfer { x.SrcChainID++; return x },
"a different destination": func(x BridgeTransfer) BridgeTransfer { x.DstChainID++; return x },
"a different asset": func(x BridgeTransfer) BridgeTransfer { x.Asset[0]++; return x },
"a different amount": func(x BridgeTransfer) BridgeTransfer { x.Amount++; return x },
"a different recipient": func(x BridgeTransfer) BridgeTransfer { x.Recipient[0]++; return x },
"a replayed nonce": func(x BridgeTransfer) BridgeTransfer { x.Nonce++; return x },
} {
require.Falsef(t, VerifyBridgeAttestation(pub, mangle(bt), sig), "%s must not verify", name)
require.NotEqualf(t, digest, mangle(bt).Digest(), "%s must move the digest", name)
}
}
func TestABridgeSignatureOfTheWrongShapeIsRefused(t *testing.T) {
key := newCustody(t, "vault", quorum.MustNew(3, 5), 98)
bt := BridgeTransfer{Amount: 1}
digest := bt.Digest()
sig := key.sign(t, digest[:])
require.False(t, VerifyBridgeAttestation(key.rec.GroupPublicKey, bt, sig[:32]))
require.False(t, VerifyBridgeAttestation(key.rec.GroupPublicKey, bt, nil))
require.False(t, VerifyBridgeAttestation(nil, bt, sig))
require.False(t, VerifyBridgeAttestation(flip(key.rec.GroupPublicKey), bt, sig))
}
// A bridge digest can never be replayed as any other message M signs.
func TestABridgeDigestIsNotAnyOtherMessageMChainSigns(t *testing.T) {
bt := BridgeTransfer{SrcChainID: 1, DstChainID: 2, Amount: 3, Nonce: 4}
digest := bt.Digest()
for domain := range domainSeparators {
payload := ComputeAttestationPayload(domain, [32]byte{}, [32]byte{}, 0)
require.NotEqualf(t, digest, payload, "a bridge digest collides with the %s payload", domain)
}
// And the tag is really in it: the same fields hashed without the domain
// produce something else.
h := sha256.New()
var b [8]byte
binary.BigEndian.PutUint32(b[:4], bt.SrcChainID)
h.Write(b[:4])
var bare [32]byte
copy(bare[:], h.Sum(nil))
require.NotEqual(t, digest, bare)
}
// -----------------------------------------------------------------------------
// Ownership
// -----------------------------------------------------------------------------
// The ownership domain and its separator both come from chains/ownership, which
// is also what every verifier hashes — one constant, so M-Chain and its
// verifiers cannot drift into signing and checking different bytes.
func TestOwnershipIsSignedWithTheVerifiersOwnConstants(t *testing.T) {
require.Equal(t, ownership.Domain, string(DomainNFTOwnership))
claim := ownership.Claim{Token: 42, Block: 9}
payload := ComputeAttestationPayload(DomainNFTOwnership, claim.Subject(), claim.Root(), claim.Block)
require.NotEqual(t, [32]byte{}, payload)
// A different token at the same block is a different payload, and the same
// token at a different block is too — which is what gives equivocation its
// precise meaning and re-attestation its legitimate one.
other := ownership.Claim{Token: 43, Block: 9}
require.NotEqual(t, payload, ComputeAttestationPayload(DomainNFTOwnership, other.Subject(), other.Root(), other.Block))
later := ownership.Claim{Token: 42, Block: 10}
require.NotEqual(t, payload, ComputeAttestationPayload(DomainNFTOwnership, later.Subject(), later.Root(), later.Block))
}
-19
View File
@@ -118,22 +118,3 @@ func backendDylibLeaves(kind GPUBackendKind) []string {
return nil
}
}
// backendBuildDirname maps each backend to its cmake build subdirectory
// under the lux GPU plugin build/.
func backendBuildDirname(kind GPUBackendKind) string {
switch kind {
case GPUBackendCUDA:
return "cuda_backend"
case GPUBackendHIP:
return "hip_backend"
case GPUBackendMetal:
return "metal_backend"
case GPUBackendVulkan:
return "vulkan_backend"
case GPUBackendWebGPU:
return "webgpu_backend"
default:
return ""
}
}
+77 -10
View File
@@ -56,8 +56,41 @@ var (
ErrInvalidOperation = errors.New("mpcvm: invalid operation")
ErrBadArtifact = errors.New("mpcvm: ceremony artifact does not verify")
ErrQuorumTooSmall = errors.New("mpcvm: signer set smaller than the key's policy requires")
ErrStaleParent = errors.New("mpcvm: parent block is not this node's applied state")
ErrBlockTooLarge = errors.New("mpcvm: block carries more operations than a block may")
ErrFutureBlock = errors.New("mpcvm: block timestamp is too far ahead of this node's clock")
)
// maxOpsPerBlock bounds the operations one block may carry.
//
// It is a CONSTANT, not configuration, because it is a consensus rule: Verify
// enforces it on every block this node receives, and a bound each operator
// could set independently would let two honestly-configured validators reach
// opposite verdicts on the same block. It bounds the work an arriving block can
// make every validator do — each sign operation costs an ECDSA verification, so
// an unbounded block is an unbounded verification cost paid by the whole
// network at whatever rate a proposer chooses.
//
// The same constant bounds assembly, so this node never builds a block its
// peers would refuse. One predicate, three places.
const maxOpsPerBlock = 64
// maxBlockBytes bounds the wire encoding a block may arrive as, checked before
// anything is decoded. maxOpsPerBlock operations of the largest shape M-Chain
// produces — a keygen carrying a key record with N party ids — is on the order
// of 50 KiB, so this is roughly twenty times the largest honest block and still
// small enough that a peer cannot make this node allocate against it.
const maxBlockBytes = 1 << 20
// maxFutureSkew is how far ahead of this node's clock a block's timestamp may
// be. Timestamps are monotonic (Verify refuses one below its parent's), so an
// unbounded timestamp is not a one-block error: it raises the floor every later
// block must clear, and one block dated a year out stops the chain producing
// for a year. The bound is loose enough to absorb ordinary clock disagreement
// between honest validators and tight enough that the damage from an
// out-of-range one is a single rejected block.
const maxFutureSkew = 10 * time.Second
// Operation is one verifiable state transition.
//
// Key is non-nil exactly when Type is OpTypeKeygen: the operation carries the
@@ -80,8 +113,7 @@ type Operation struct {
// Signers is the participating set, canonically ordered.
Signers []party.ID
// Key is the registration carried by a keygen operation; nil otherwise.
Key *KeyRecord
Timestamp int64
Key *KeyRecord
}
// digest returns the operation's contribution to the state root. Domain
@@ -138,17 +170,11 @@ func (b *Block) ChoicesStatus() choices.Status { return b.status }
func (b *Block) SetStatus(status choices.Status) { b.status = status }
// Bytes returns the block's canonical wire encoding.
func (b *Block) Bytes() []byte {
raw, _ := b.Marshal()
return raw
}
func (b *Block) Bytes() []byte { return b.Marshal() }
// computeID is the block id: the hash of its canonical bytes. Because
// StateRoot is inside those bytes, the id commits to the post-state.
func (b *Block) computeID() ids.ID {
raw, _ := b.Marshal()
return ids.ID(sha256.Sum256(raw))
}
func (b *Block) computeID() ids.ID { return ids.ID(sha256.Sum256(b.Marshal())) }
// Verify re-checks the proposed transition against this validator's own state.
// It mutates nothing: a rejected block must leave state untouched.
@@ -174,9 +200,15 @@ func (b *Block) Verify(ctx context.Context) error {
if b.BlockTimestamp < parent.BlockTimestamp {
return fmt.Errorf("mpcvm: timestamp %d precedes parent %d", b.BlockTimestamp, parent.BlockTimestamp)
}
if skew := time.Until(b.Timestamp()); skew > maxFutureSkew {
return fmt.Errorf("%w: %s ahead, at most %s allowed", ErrFutureBlock, skew, maxFutureSkew)
}
if len(b.Operations) == 0 {
return errors.New("mpcvm: empty block")
}
if len(b.Operations) > maxOpsPerBlock {
return fmt.Errorf("%w: %d operations, at most %d", ErrBlockTooLarge, len(b.Operations), maxOpsPerBlock)
}
vm.mu.RLock()
defer vm.mu.RUnlock()
@@ -184,6 +216,21 @@ func (b *Block) Verify(ctx context.Context) error {
// Fold the operations, checking each against state plus the effects of the
// operations before it in this same block, and accumulating the root.
root := vm.state.Root()
// Every check below reads APPLIED state — the registry, the ceremony log,
// this node's own shares — so the transition is only meaningful as an
// extension of what is applied. A block whose parent's post-state is not
// this node's current state is being checked against the wrong state, and
// the accumulated root would be right for a history the chain does not
// have: it verifies now and fails at Accept, after consensus has decided
// it, which is the divergence the root exists to prevent. Comparing the
// parent's declared post-state to ours says exactly that, and says it
// whether the parent is an unapplied sibling, an ancestor, or a block still
// in flight above the tip.
if parent.StateRoot != root {
return fmt.Errorf("%w: parent %s leaves root %x, this node is at %x",
ErrStaleParent, b.ParentID_, parent.StateRoot[:8], root[:8])
}
pending := make(map[string]*KeyRecord, len(b.Operations))
seen := make(map[string]struct{}, len(b.Operations))
@@ -270,6 +317,26 @@ func (vm *VM) verifyKeygen(op *Operation, pending map[string]*KeyRecord) error {
return fmt.Errorf("%w: %s", ErrKeyExists, rec.KeyID)
}
// The signer set of a keygen IS its participant set: a DKG has no K-subset,
// every party runs every round. Leaving it unchecked let a proposer write
// any names it liked into the ceremony log's Signers — the log is the
// replicated evidence of who generated a custody key, and unverified
// evidence is worse than none.
if !samePartySet(op.Signers, rec.Participants) {
return fmt.Errorf("%w: keygen signers are not the key's participant set", ErrInvalidOperation)
}
// The ceremony id must be the one this exact registration derives —
// domain-separated from a signing id, so the two can never collide.
//
// Leaving it unchecked made the id proposer-chosen, and a recorded id is
// permanently unusable (verifyOperation refuses a ceremony already in the
// log). A keygen carrying the DERIVED id of some future signing task would
// therefore censor that task forever, and signing ids are derived from
// public inputs, so the task to censor can be named in advance.
if want := keygenCeremonyID(rec.KeyID, rec.Policy, rec.Participants); want != op.CeremonyID {
return fmt.Errorf("%w: ceremony id %q is not derived from this registration (want %q)",
ErrInvalidOperation, op.CeremonyID, want)
}
// The ceremony must have signed the commit digest for exactly this record.
commit := KeyCommitDigest(rec)
if string(op.Digest) != string(commit[:]) {
+861
View File
@@ -0,0 +1,861 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// block_test.go — what a validator refuses.
//
// Every test here is an adversary: it builds the block a dishonest proposer
// would build, hands it to Verify, and holds that Verify says no. A test that
// only builds honest blocks proves the happy path and nothing about custody.
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/consensus/core/choices"
luxcrypto "github.com/luxfi/crypto"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/quorum"
cmpconfig "github.com/luxfi/threshold/protocols/cmp/config"
)
func ctx() context.Context { return context.Background() }
// -----------------------------------------------------------------------------
// The block extends applied state, or it extends nothing
// -----------------------------------------------------------------------------
// A block whose parent this node has not applied is refused, even though its
// height, its parent link and its accumulated root are all internally
// consistent.
//
// This is the defect that halts the chain. Every check in verifyOperation reads
// APPLIED state — the registry, the ceremony log, this node's own shares — and
// the root is accumulated from the applied root. So a block built on a parent
// still in flight carries a root computed as if the parent's operations had not
// happened. It verifies on every peer, wins consensus, and then fails at Accept
// once the parent lands, because Write recomputes the root against state that
// has moved. A decided block that cannot be applied is not a rejected block; it
// is a stopped chain.
//
// The parent's own declared post-state is what says this, and it says it
// without asking the store for its tip: a parent that leaves a root this node
// is not at is a parent this node has not applied.
func TestABlockOnAnUnappliedParentIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 7)
key.register(t, vm)
// A block that is verified but NOT accepted — the engine's preferred block
// while consensus is still running.
first := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
require.NoError(t, first.Verify(ctx()))
// A second block built on it, exactly as BuildBlock would if it followed the
// engine's preference: correct parent, correct height, and a root
// accumulated from applied state.
second := &Block{
ParentID_: first.ID(),
BlockHeight: first.BlockHeight + 1,
BlockTimestamp: first.BlockTimestamp,
StateRoot: advance(vm.state.Root(), key.signOpOver(t, digestOf(2)).digest()),
Operations: []*Operation{key.signOpOver(t, digestOf(2))},
vm: vm,
}
second.ID_ = second.computeID()
vm.chain.Track(first)
require.ErrorIs(t, second.Verify(ctx()), ErrStaleParent,
"a block whose parent has not been applied must be refused at Verify, "+
"not accepted and then found unapplicable")
// And the property that makes it safe: once the parent IS applied, the same
// shape verifies.
require.NoError(t, first.Accept(ctx()))
after := blockOver(t, vm, key.signOpOver(t, digestOf(2)))
require.NoError(t, after.Verify(ctx()))
}
// The builder holds the same rule, so this node never proposes a block its own
// peers — or its own Accept — would refuse. One predicate, both ends.
func TestTheBuilderWillNotBuildOnAnUnappliedParent(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 8)
key.register(t, vm)
vm.stage(key.signOpOver(t, digestOf(1)))
first, err := vm.BuildBlock(ctx())
require.NoError(t, err)
require.NoError(t, first.Verify(ctx()))
// The engine prefers the block it just verified; it is not applied.
require.NoError(t, vm.SetPreference(ctx(), first.ID()))
vm.stage(key.signOpOver(t, digestOf(2)))
_, err = vm.BuildBlock(ctx())
require.ErrorIs(t, err, ErrStaleParent)
// Neither ceremony is lost. A taken operation leaves the queue only when the
// block carrying it is durable, so the first block's is still there too —
// and the next proposal, once the parent lands, carries the second.
require.Equal(t, 2, vm.staged.Len())
require.NoError(t, first.Accept(ctx()))
require.Equal(t, 1, vm.staged.Len())
next, err := vm.BuildBlock(ctx())
require.NoError(t, err)
require.NoError(t, next.Verify(ctx()))
require.NoError(t, next.Accept(ctx()))
require.Zero(t, vm.staged.Len())
}
// A sibling of the applied tip — same parent, different operations — is refused
// for the same reason. Height and parent link both check out; the parent's
// post-state is one the chain has moved past.
func TestASiblingOfTheTipCannotRewindTheChain(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 9)
key.register(t, vm)
tipParent, _ := vm.chain.Tip()
rootBefore := vm.state.Root()
landed := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
require.NoError(t, landed.Verify(ctx()))
require.NoError(t, landed.Accept(ctx()))
sibling := &Block{
ParentID_: tipParent,
BlockHeight: landed.BlockHeight,
BlockTimestamp: landed.BlockTimestamp,
StateRoot: advance(rootBefore, key.signOpOver(t, digestOf(2)).digest()),
Operations: []*Operation{key.signOpOver(t, digestOf(2))},
vm: vm,
}
sibling.ID_ = sibling.computeID()
require.ErrorIs(t, sibling.Verify(ctx()), ErrStaleParent)
// And the height index still names the block that was actually accepted, so
// a bootstrapping peer is not served an orphan as canonical.
at, err := vm.GetBlockIDAtHeight(ctx(), landed.BlockHeight)
require.NoError(t, err)
require.Equal(t, landed.ID(), at)
}
// -----------------------------------------------------------------------------
// Shape: height, timestamp, emptiness, size
// -----------------------------------------------------------------------------
func TestGenesisIsNotVerifiableAsATransition(t *testing.T) {
vm := newVM(t)
require.Error(t, vm.genesisBlock.Verify(ctx()),
"height 0 registers nothing and signs nothing; verifying it as a transition would verify an empty claim")
}
func TestABlockWithNoVMVerifiesNothing(t *testing.T) {
require.Error(t, (&Block{BlockHeight: 1}).Verify(ctx()))
}
func TestAnUnknownParentIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 10)
key.register(t, vm)
blk := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
blk.ParentID_[0] ^= 0xff
blk.ID_ = blk.computeID()
require.ErrorContains(t, blk.Verify(ctx()), "unknown parent")
}
func TestHeightMustBeParentPlusOne(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 11)
key.register(t, vm)
for _, h := range []uint64{0, 1, 3, 99} {
blk := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
blk.BlockHeight = h
blk.ID_ = blk.computeID()
require.Error(t, blk.Verify(ctx()), "height %d must not verify against a tip at %d", h, blk.BlockHeight)
}
}
// The timestamp floor is monotonic, which is exactly why it needs a ceiling.
//
// Without one, a single proposer dates a block a year out, every validator
// accepts it, and the floor every later block must clear is a year away: the
// chain produces nothing until the clock catches up. The damage from bounding
// it is one rejected block.
func TestATimestampFarAheadIsRefusedBecauseTheFloorIsMonotonic(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 12)
key.register(t, vm)
blk := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
blk.BlockTimestamp = time.Now().Add(365 * 24 * time.Hour).Unix()
blk.ID_ = blk.computeID()
require.ErrorIs(t, blk.Verify(ctx()), ErrFutureBlock)
// Ordinary clock disagreement between honest validators still passes.
blk.BlockTimestamp = time.Now().Add(maxFutureSkew / 2).Unix()
blk.ID_ = blk.computeID()
require.NoError(t, blk.Verify(ctx()))
}
func TestATimestampBeforeItsParentIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 13)
key.register(t, vm)
first := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
first.BlockTimestamp = time.Now().Unix()
first.ID_ = first.computeID()
require.NoError(t, first.Verify(ctx()))
require.NoError(t, first.Accept(ctx()))
back := blockOver(t, vm, key.signOpOver(t, digestOf(2)))
back.BlockTimestamp = first.BlockTimestamp - 1
back.ID_ = back.computeID()
require.ErrorContains(t, back.Verify(ctx()), "precedes parent")
}
// The builder never emits a timestamp its own Verify would refuse: it clamps to
// the parent's floor rather than to a clock that may have run backwards.
func TestTheBuilderNeverEmitsATimestampItsOwnVerifyRefuses(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 14)
key.register(t, vm)
// A parent dated at the ceiling — the latest a peer's block could legally be.
first := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
first.BlockTimestamp = time.Now().Add(maxFutureSkew - time.Second).Unix()
first.ID_ = first.computeID()
require.NoError(t, first.Verify(ctx()))
require.NoError(t, first.Accept(ctx()))
vm.stage(key.signOpOver(t, digestOf(2)))
built, err := vm.BuildBlock(ctx())
require.NoError(t, err)
require.NoError(t, built.Verify(ctx()),
"the proposer built a block its own rules refuse")
require.GreaterOrEqual(t, built.(*Block).BlockTimestamp, first.BlockTimestamp)
}
func TestAnEmptyBlockIsNotATransition(t *testing.T) {
vm := newVM(t)
blk := blockOver(t, vm)
require.ErrorContains(t, blk.Verify(ctx()), "empty block")
}
// The operation count is a consensus rule, so it is a constant and Verify
// enforces it. Each sign operation costs every validator an ECDSA verification;
// an unbounded block is an unbounded verification cost that a proposer chooses
// and the whole network pays.
func TestABlockCannotMakeTheNetworkVerifyUnboundedWork(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 15)
key.register(t, vm)
ops := make([]*Operation, 0, maxOpsPerBlock+1)
for i := 0; i <= maxOpsPerBlock; i++ {
ops = append(ops, key.signOpOver(t, digestOf(byte(i))))
}
blk := blockOver(t, vm, ops...)
require.ErrorIs(t, blk.Verify(ctx()), ErrBlockTooLarge)
// And the builder holds the same bound, so a node with more staged than a
// block may carry proposes a block that fits.
for _, op := range ops {
vm.stage(op)
}
require.Equal(t, len(ops), vm.staged.Len())
built, err := vm.BuildBlock(ctx())
require.NoError(t, err)
require.Len(t, built.(*Block).Operations, maxOpsPerBlock)
require.NoError(t, built.Verify(ctx()))
}
// -----------------------------------------------------------------------------
// Operations
// -----------------------------------------------------------------------------
func TestAnOperationWithoutIdentityIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 16)
key.register(t, vm)
for name, mangle := range map[string]func(*Operation){
"no ceremony id": func(op *Operation) { op.CeremonyID = "" },
"no key id": func(op *Operation) { op.KeyID = "" },
"unknown type": func(op *Operation) { op.Type = "reshare" },
} {
op := key.signOpOver(t, digestOf(1))
mangle(op)
blk := blockOver(t, vm, op)
require.ErrorIsf(t, blk.Verify(ctx()), ErrInvalidOperation, "%s", name)
}
}
// A ceremony id is derived from its task, so the same id twice is the same task
// twice. For a bridge release that is the same funds released twice, and the
// two ways to attempt it — twice in one block, or once in each of two — are
// refused by the same rule reading two different places.
func TestARecordedCeremonyCannotBeRecordedAgain(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 17)
key.register(t, vm)
op := key.signOpOver(t, digestOf(1))
twiceInOne := blockOver(t, vm, op, op)
require.ErrorIs(t, twiceInOne.Verify(ctx()), ErrInvalidOperation)
require.ErrorContains(t, twiceInOne.Verify(ctx()), "appears twice")
landed := blockOver(t, vm, op)
require.NoError(t, landed.Verify(ctx()))
require.NoError(t, landed.Accept(ctx()))
again := blockOver(t, vm, op)
require.ErrorIs(t, again.Verify(ctx()), ErrCeremonyExists)
}
// -----------------------------------------------------------------------------
// Keygen
// -----------------------------------------------------------------------------
// A keygen's ceremony id must be the one its registration derives.
//
// Left unchecked the id is whatever the proposer writes, and a recorded id is
// permanently unusable — verifyOperation refuses any ceremony already in the
// log. Signing ids are derived from public inputs (the key, the digest, the
// quorum), so a proposer can name a future release in advance, register a
// keygen under that id, and censor that exact release forever. Nothing about
// the key it registers has to be wrong.
func TestAKeygenCannotClaimAFutureReleasesCeremonyId(t *testing.T) {
vm := newVM(t)
victim := newCustody(t, "vault", quorum.MustNew(3, 5), 18)
victim.register(t, vm)
// The release the attacker wants to stop, named before it happens.
release := digestOf(0x42)
target := ceremonyID(victim.rec.KeyID, release, quorumFor(victim.rec, release))
// A perfectly valid registration of an unrelated key — right policy, right
// address, real proof of possession — wearing the release's id.
squatter := newCustody(t, "unrelated", quorum.MustNew(3, 5), 19)
op := squatter.keygenOp(t)
op.CeremonyID = target
blk := blockOver(t, vm, op)
require.ErrorIs(t, blk.Verify(ctx()), ErrInvalidOperation)
require.ErrorContains(t, blk.Verify(ctx()), "not derived from this registration")
// With the id it actually derives, the same registration is fine — so what
// was refused is the squatting, not the key.
require.NoError(t, blockOver(t, vm, squatter.keygenOp(t)).Verify(ctx()))
}
// A keygen's signer set is its participant set. A DKG has no K-subset — every
// party runs every round — so any other set is a claim about who generated a
// custody key that nothing produced.
//
// The ceremony log is the replicated evidence of exactly that, and it is what
// an audit, an attribution or a slashing argument reads.
func TestAKeygenCannotFabricateWhoGeneratedTheKey(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 20)
for name, signers := range map[string][]party.ID{
"a quorum instead of the committee": key.rec.Participants[:3],
"strangers": {"zz-nobody"},
"nobody": nil,
"the committee plus a stranger": append(append([]party.ID(nil), key.rec.Participants...), "zz-nobody"),
} {
op := key.keygenOp(t)
op.Signers = signers
blk := blockOver(t, vm, op)
require.ErrorIsf(t, blk.Verify(ctx()), ErrInvalidOperation, "%s", name)
}
// And the honest one lands, carrying the true set into the log.
key.register(t, vm)
rec, err := vm.Ceremony(key.keygenOp(t).CeremonyID)
require.NoError(t, err)
require.Equal(t, key.rec.Participants, rec.Signers)
}
// The address is recomputed from the group key, so a proposer cannot publish a
// custody address the group key does not control. Funds sent to a wrong address
// are unspendable and there is no recovery.
func TestTheCustodyAddressIsRecomputedNotBelieved(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 21)
other := newCustody(t, "other", quorum.MustNew(3, 5), 22)
key.rec.Address = other.rec.Address
op := key.keygenOp(t) // signs the commit over the mangled record, so the PoP is real
blk := blockOver(t, vm, op)
require.ErrorIs(t, blk.Verify(ctx()), ErrInvalidOperation)
require.ErrorContains(t, blk.Verify(ctx()), "is not keccak(pubkey)")
}
// It must be Keccak-256, not SHA-256: the address is what holds bridged funds
// on every EVM chain and what an external ecrecover produces from a signature
// by this key.
func TestTheCustodyAddressIsTheOneAnExternalEcrecoverProduces(t *testing.T) {
key := newCustody(t, "vault", quorum.MustNew(3, 5), 23)
digest := digestOf(5)
sig := key.sign(t, digest)
pub, err := luxcrypto.Ecrecover(digest, sig)
require.NoError(t, err)
require.Equal(t, key.rec.Address, publicKeyToAddress(pub),
"the registered address is not the account an external chain sees signing")
}
func TestAKeygenWithoutARecordRegistersNothing(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 24)
op := key.keygenOp(t)
op.Key = nil
require.ErrorIs(t, blockOver(t, vm, op).Verify(ctx()), ErrInvalidOperation)
}
func TestAKeygenWhoseRecordNamesADifferentKeyIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 25)
op := key.keygenOp(t)
op.KeyID = "somewhere-else"
require.ErrorIs(t, blockOver(t, vm, op).Verify(ctx()), ErrInvalidOperation)
}
func TestAKeygenWhoseDigestIsNotItsOwnCommitmentIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 26)
op := key.keygenOp(t)
op.Digest = digestOf(9)
op.Artifact = key.sign(t, op.Digest) // a real signature, over the wrong thing
require.ErrorIs(t, blockOver(t, vm, op).Verify(ctx()), ErrInvalidOperation)
}
// Proof of possession: the registering key signs its own commitment. Without
// it a proposer registers a public key it does not hold — a rogue-key
// registration that names an attacker's address as the chain's custodian.
func TestAKeyMustProveItCanSignItsOwnRegistration(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 27)
impostor := newCustody(t, "vault", quorum.MustNew(3, 5), 28)
op := key.keygenOp(t)
op.Artifact = impostor.sign(t, op.Digest) // signed by a key that is not this one
require.ErrorIs(t, blockOver(t, vm, op).Verify(ctx()), ErrBadArtifact)
}
func TestAKeyIsRegisteredOnce(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 29)
key.register(t, vm)
require.ErrorIs(t, blockOver(t, vm, key.keygenOp(t)).Verify(ctx()), ErrCeremonyExists)
// And under a fresh ceremony id — a genuinely different registration of the
// same id — the key registry itself refuses.
again := newCustody(t, "vault", quorum.MustNew(4, 5), 30)
require.ErrorIs(t, blockOver(t, vm, again.keygenOp(t)).Verify(ctx()), ErrKeyExists)
}
func TestTheSameKeyCannotBeRegisteredTwiceInOneBlock(t *testing.T) {
vm := newVM(t)
a := newCustody(t, "vault", quorum.MustNew(3, 5), 31)
b := newCustody(t, "vault", quorum.MustNew(4, 5), 32)
require.ErrorIs(t, blockOver(t, vm, a.keygenOp(t), b.keygenOp(t)).Verify(ctx()), ErrKeyExists)
}
// A key registered earlier in the same block can be used later in it, which is
// what lets a chain register and immediately sign without waiting a height.
func TestAKeyRegisteredInABlockIsUsableInThatBlock(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 33)
blk := blockOver(t, vm, key.keygenOp(t), key.signOpOver(t, digestOf(1)))
require.NoError(t, blk.Verify(ctx()))
require.NoError(t, blk.Accept(ctx()))
rec, err := vm.Key("vault")
require.NoError(t, err)
require.Equal(t, key.rec.GroupPublicKey, rec.GroupPublicKey)
}
// One honest participant is enough to stop a mis-declared key. The proof of
// possession alone cannot bind the degree — a single party holding the whole
// secret could also produce one — so a validator that holds a share for the key
// compares the record against what its own share says and contradicts it.
func TestOneParticipantContradictsAMisdeclaredKey(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 34)
// This node holds a share whose group key is a different key entirely.
stranger := newCustody(t, "stranger", quorum.MustNew(3, 5), 35)
key.hold(vm, testShare(t, stranger.rec.GroupPublicKey, key.rec.Degree()))
require.ErrorContains(t, blockOver(t, vm, key.keygenOp(t)).Verify(ctx()),
"our share belongs to")
// And a share of the right key generated at a different degree.
key.hold(vm, testShare(t, key.rec.GroupPublicKey, key.rec.Degree()+1))
require.ErrorContains(t, blockOver(t, vm, key.keygenOp(t)).Verify(ctx()),
"our share has degree")
// Agreement lets it through.
key.hold(vm, testShare(t, key.rec.GroupPublicKey, key.rec.Degree()))
require.NoError(t, blockOver(t, vm, key.keygenOp(t)).Verify(ctx()))
}
// A node that holds no share for the key has nothing to compare and says so by
// admitting the block on the evidence that does not need participation.
func TestANonParticipantVerifiesWithoutAShare(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 36)
require.Empty(t, vm.shares)
require.NoError(t, blockOver(t, vm, key.keygenOp(t)).Verify(ctx()))
}
// An unreadable share is a corrupted store, not a reason to admit the block.
func TestAnUnreadableShareRefusesRatherThanAbstains(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 37)
key.hold(vm, &heldShare{kind: KindCGGMP21})
require.ErrorContains(t, blockOver(t, vm, key.keygenOp(t)).Verify(ctx()), "reading own share")
}
// -----------------------------------------------------------------------------
// Sign
// -----------------------------------------------------------------------------
func TestSigningWithAnUnregisteredKeyIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "never-registered", quorum.MustNew(3, 5), 38)
require.ErrorIs(t, blockOver(t, vm, key.signOpOver(t, digestOf(1))).Verify(ctx()), ErrUnknownKey)
}
func TestADigestThatIsNotThirtyTwoBytesIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 39)
key.register(t, vm)
for _, n := range []int{0, 31, 33, 64} {
op := key.signOpOver(t, digestOf(1))
op.Digest = make([]byte, n)
require.ErrorIsf(t, blockOver(t, vm, op).Verify(ctx()), ErrInvalidOperation, "digest of %d bytes", n)
}
}
// The signer set must satisfy the key's own policy, and it must be a real set
// of real participants — counted the way the policy counts.
//
// K signers, not K-1: a set the size of the polynomial degree cannot produce a
// signature, so a record claiming one did is either a lie or a wrong-degree key.
func TestAQuorumSmallerThanThePolicyIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 40)
key.register(t, vm)
digest := digestOf(1)
short := quorumFor(key.rec, digest)[:2]
op := &Operation{
Type: OpTypeSign,
CeremonyID: ceremonyID(key.rec.KeyID, digest, short),
KeyID: key.rec.KeyID,
Digest: digest,
Artifact: key.sign(t, digest),
Signers: short,
}
require.ErrorIs(t, blockOver(t, vm, op).Verify(ctx()), ErrQuorumTooSmall)
}
// One signer counted K times is not K signers. Without canonicality, a quorum
// is a number a proposer can reach alone — by repeating a name, or by spelling
// one name several ways.
func TestOneSignerRepeatedIsNotAQuorum(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 41)
key.register(t, vm)
digest := digestOf(1)
one := quorumFor(key.rec, digest)[0]
for name, signers := range map[string][]party.ID{
"repeated": {one, one, one},
"unsorted": {"pc", "pa", "pb"},
"strangers": {"za", "zb", "zc"},
} {
op := &Operation{
Type: OpTypeSign,
CeremonyID: ceremonyID(key.rec.KeyID, digest, signers),
KeyID: key.rec.KeyID,
Digest: digest,
Artifact: key.sign(t, digest),
Signers: signers,
}
require.ErrorIsf(t, blockOver(t, vm, op).Verify(ctx()), ErrInvalidOperation, "%s", name)
}
}
// The ceremony id must be the one this exact task derives. That is what makes
// the id unforgeable rather than a proposer-chosen label, and it is what makes
// replay detection mean anything: two spellings of one task would be two log
// entries, and for a release, two releases.
func TestASigningCeremonyIdIsDerivedFromItsTask(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 42)
key.register(t, vm)
op := key.signOpOver(t, digestOf(1))
op.CeremonyID = "mpc/whatever-i-like"
require.ErrorContains(t, blockOver(t, vm, op).Verify(ctx()), "not derived from this task")
}
// The check that needs no participation and no trust: does the signature verify
// under the registered group key, over the digest the operation names?
func TestASignatureForOneMessageDoesNotVerifyForAnother(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 43)
key.register(t, vm)
honest := key.signOpOver(t, digestOf(1))
other := key.signOpOver(t, digestOf(2))
// A real signature by the real custody key, moved onto a different message.
forged := &Operation{
Type: OpTypeSign,
CeremonyID: honest.CeremonyID,
KeyID: honest.KeyID,
Digest: honest.Digest,
Artifact: other.Artifact,
Signers: honest.Signers,
}
require.ErrorIs(t, blockOver(t, vm, forged).Verify(ctx()), ErrBadArtifact)
}
func TestASignatureByAKeyThatIsNotTheCustodianIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 44)
key.register(t, vm)
impostor := newCustody(t, "vault", quorum.MustNew(3, 5), 45)
op := key.signOpOver(t, digestOf(1))
op.Artifact = impostor.sign(t, op.Digest)
require.ErrorIs(t, blockOver(t, vm, op).Verify(ctx()), ErrBadArtifact)
}
// -----------------------------------------------------------------------------
// The root
// -----------------------------------------------------------------------------
// Every block carries the state it reaches, and every validator recomputes it.
// Two validators that would diverge cannot both accept.
func TestAClaimedRootThatIsNotTheReachedRootIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 46)
key.register(t, vm)
blk := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
blk.StateRoot[0] ^= 0xff
blk.ID_ = blk.computeID()
require.ErrorIs(t, blk.Verify(ctx()), ErrRootMismatch)
}
// The root binds the CHAIN, so a block built on one chain does not verify on
// another. The genesis root is seeded from the chain id, and every later root
// is a hash chain over it, so an identical operation history on a different
// chain reaches a different root.
func TestABlockDoesNotCrossFromOneChainToAnother(t *testing.T) {
a, b := newVM(t), newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 47)
require.NotEqual(t, a.StateRoot(), b.StateRoot(),
"two chains with empty state must not share a root, or a block from one applies to the other")
blk := blockOver(t, a, key.keygenOp(t))
require.NoError(t, blk.Verify(ctx()))
// The same bytes, handed to the other chain.
crossed, err := b.ParseBlock(ctx(), blk.Bytes())
require.NoError(t, err)
require.Error(t, crossed.Verify(ctx()),
"a block built on chain A must not verify on chain B")
}
// Operation digests are domain-separated by kind, so a keygen digest can never
// be replayed as a sign digest even when every other field coincides.
func TestAKeygenDigestIsNotASignDigest(t *testing.T) {
key := newCustody(t, "vault", quorum.MustNew(3, 5), 48)
kg := key.keygenOp(t)
sign := &Operation{
Type: OpTypeSign,
CeremonyID: kg.CeremonyID,
KeyID: kg.KeyID,
Digest: kg.Digest,
Artifact: kg.Artifact,
Signers: kg.Signers,
}
require.NotEqual(t, kg.digest(), sign.digest())
}
// -----------------------------------------------------------------------------
// Accept and Reject
// -----------------------------------------------------------------------------
func TestARejectedBlockLeavesItsCeremoniesStaged(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 49)
key.register(t, vm)
op := key.signOpOver(t, digestOf(1))
vm.stage(op)
built, err := vm.BuildBlock(ctx())
require.NoError(t, err)
require.NoError(t, built.Reject(ctx()))
require.Equal(t, uint8(choices.Rejected), built.(*Block).Status(), "a rejected block reports rejected")
require.Equal(t, choices.Rejected, built.(*Block).ChoicesStatus())
require.Equal(t, 1, vm.staged.Len(),
"rejection means the block did not land, not that the ceremony was invalid")
// So the ceremony reaches the next block instead.
next, err := vm.BuildBlock(ctx())
require.NoError(t, err)
require.NoError(t, next.Verify(ctx()))
require.NoError(t, next.Accept(ctx()))
require.Zero(t, vm.staged.Len())
}
func TestAnAcceptedBlockPublishesItsRootAndReleasesItsCeremonies(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 50)
key.register(t, vm)
op := key.signOpOver(t, digestOf(1))
vm.stage(op)
built, err := vm.BuildBlock(ctx())
require.NoError(t, err)
require.NoError(t, built.Verify(ctx()))
require.NoError(t, built.Accept(ctx()))
require.Equal(t, built.(*Block).StateRoot, vm.StateRoot())
require.Zero(t, vm.staged.Len())
require.Equal(t, uint8(choices.Accepted), built.(*Block).Status())
tip, err := vm.LastAccepted(ctx())
require.NoError(t, err)
require.Equal(t, built.ID(), tip)
rec, err := vm.Ceremony(op.CeremonyID)
require.NoError(t, err)
require.Equal(t, built.(*Block).BlockHeight, rec.Height,
"a recorded ceremony names the height that recorded it")
}
// -----------------------------------------------------------------------------
// verifyGroupSignature, in isolation
// -----------------------------------------------------------------------------
func TestGroupSignatureShapesAreRefusedBeforeTheCurveIsTouched(t *testing.T) {
key := newCustody(t, "vault", quorum.MustNew(3, 5), 51)
digest := digestOf(1)
sig := key.sign(t, digest)
pub := key.rec.GroupPublicKey
require.NoError(t, verifyGroupSignature(pub, digest, sig))
for name, call := range map[string]func() error{
"short signature": func() error { return verifyGroupSignature(pub, digest, sig[:64]) },
"long signature": func() error { return verifyGroupSignature(pub, digest, append(sig, 0)) },
"short digest": func() error { return verifyGroupSignature(pub, digest[:31], sig) },
"uncompressed key": func() error { return verifyGroupSignature(uncompressedXY(pub), digest, sig) },
"empty key": func() error { return verifyGroupSignature(nil, digest, sig) },
"wrong key same size": func() error { return verifyGroupSignature(flip(pub), digest, sig) },
} {
require.Errorf(t, call(), "%s must not verify", name)
}
}
// The recovery byte is not verified against — only r‖s — so a wrong v cannot
// make a good signature fail, and cannot make a bad one pass.
func TestTheRecoveryByteChangesNoVerdict(t *testing.T) {
key := newCustody(t, "vault", quorum.MustNew(3, 5), 52)
digest := digestOf(1)
good := key.sign(t, digest)
bad := key.sign(t, digestOf(2))
bad = append(append([]byte(nil), bad[:64]...), good[64])
for v := 0; v < 256; v++ {
g := append(append([]byte(nil), good[:64]...), byte(v))
require.NoErrorf(t, verifyGroupSignature(key.rec.GroupPublicKey, digest, g), "v=%d", v)
b := append(append([]byte(nil), bad[:64]...), byte(v))
require.Errorf(t, verifyGroupSignature(key.rec.GroupPublicKey, digest, b), "v=%d", v)
}
}
// -----------------------------------------------------------------------------
// Public-key normalisation
// -----------------------------------------------------------------------------
func TestEveryAcceptedKeyEncodingDerivesOneAddress(t *testing.T) {
key := newCustody(t, "vault", quorum.MustNew(3, 5), 53)
compressed := key.rec.GroupPublicKey
xy := uncompressedXY(compressed)
require.Len(t, xy, 64)
want := publicKeyToAddress(compressed)
require.Equal(t, want, publicKeyToAddress(xy))
require.Equal(t, want, publicKeyToAddress(append([]byte{0x04}, xy...)))
}
func TestAKeyThatIsNotAPointDerivesNoAddress(t *testing.T) {
for name, b := range map[string][]byte{
"empty": nil,
"short": make([]byte, 20),
"33 bytes, off curve": func() []byte { b := make([]byte, 33); b[0] = 0x02; return b }(),
"65 bytes, bad tag": make([]byte, 65),
"66 bytes": make([]byte, 66),
} {
require.Nilf(t, uncompressedXY(b), "%s", name)
require.Nilf(t, publicKeyToAddress(b), "%s: callers must treat nil as a hard failure", name)
}
}
// -----------------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------------
func flip(b []byte) []byte {
out := append([]byte(nil), b...)
out[len(out)-1] ^= 0xff
return out
}
// testShare is a share whose two publicly-derivable facts — the group key it
// belongs to and the degree it was generated at — are the ones a test names.
//
// One party in Public makes the Lagrange coefficient 1, so PublicPoint() is
// exactly that party's point. The real cross-check reads these two values out
// of a real CMP config; what is under test is what it DOES with them.
func testShare(t *testing.T, groupPub []byte, degree int) *heldShare {
t.Helper()
grp := curve.Secp256k1{}
pt := grp.NewPoint()
require.NoError(t, pt.UnmarshalBinary(groupPub))
return &heldShare{
kind: KindCGGMP21,
cmp: &cmpconfig.Config{
Group: grp,
ID: "pa",
Threshold: degree,
Public: map[party.ID]*cmpconfig.Public{"pa": {ECDSA: pt}},
},
}
}
+122 -14
View File
@@ -36,6 +36,7 @@ import (
"testing"
"time"
"github.com/luxfi/chains/ownership"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
@@ -290,13 +291,13 @@ func TestBridgeCustody_KeygenSignAndRecordOverGossip(t *testing.T) {
var recip [20]byte
copy(recip[:], []byte("recipient-0xabc0123"))
req := BridgeReleaseRequest{
KeyID: keyID,
SrcChainID: 200201, // Zoo EVM
DstChainID: 97368, // Lux testnet M-Chain route
Asset: asset,
Amount: 1_000_000,
Recipient: recip,
Nonce: 7,
KeyID: keyID,
SrcChainID: 200201, // Zoo EVM
DstChainID: 97368, // Lux testnet M-Chain route
Asset: asset,
Amount: 1_000_000,
Recipient: recip,
Nonce: 7,
}
// Every validator is asked; only the policy's K-subset signs. With a 3-of-5
@@ -504,13 +505,13 @@ func TestBridgeCustody_ThreeOfFive(t *testing.T) {
var recip [20]byte
copy(recip[:], []byte("recipient-3of5"))
req := BridgeReleaseRequest{
KeyID: keyID,
SrcChainID: 200201,
DstChainID: 97368,
Asset: asset,
Amount: 4_200_000,
Recipient: recip,
Nonce: 11,
KeyID: keyID,
SrcChainID: 200201,
DstChainID: 97368,
Asset: asset,
Amount: 4_200_000,
Recipient: recip,
Nonce: 11,
}
// Every validator is asked; the ones outside this task's quorum decline with
@@ -564,3 +565,110 @@ func TestBridgeCustody_ThreeOfFive(t *testing.T) {
t.Logf("3-of-5 custody: key %s addr 0x%x | %d of %d validators signed, %d declined | signature verifies | state root %x",
keyID, rec.Address, len(signed), n, declined, vms[0].StateRoot())
}
// =============================================================================
// The attestation domains, over the same real ceremony path
// =============================================================================
// Every attestation M-Chain issues is produced by the SAME ceremony, over a
// domain-bound payload, and verified against the SAME registry.
//
// The four domains differ only in how they compute their subject and their
// commitment — which is the only thing that actually differs between an oracle
// commit, a session completion, an epoch beacon and an ownership claim. This
// runs all four through a real 2-of-3 committee over the real gossip transport,
// so what is being held is that the shared path works for each of them, not
// that four separate paths happen to agree.
func TestEveryAttestationDomainRunsTheOneCeremonyPath(t *testing.T) {
const n = 3
const keyID = "attesting-custody"
policy := quorum.MustNew(2, 3)
nodes := make([]ids.NodeID, n)
for i := range nodes {
nodes[i] = ids.GenerateTestNodeID()
}
vs := committeeState(nodes)
fab := newMemFabric()
vms := make([]*VM, n)
for i, nid := range nodes {
vms[i] = newFabricVM(t, fab, nid, vs)
}
ctx, cancel := ceremonyContext(t)
defer cancel()
runOnAll(t, ctx, vms, func(ctx context.Context, vm *VM) (*Operation, error) {
return vm.StartKeygenWithPolicy(ctx, keyID, policy, authenticated(vm, "B-Chain"))
})
commitBlock(t, ctx, vms[0], vms)
var subject, root [32]byte
copy(subject[:], []byte("request-0x01"))
copy(root[:], []byte("commitment-root"))
// Each domain, asked of every validator; only the task's quorum signs.
for name, ask := range map[string]func(context.Context, *VM) (*QuantumAttestation, error){
"oracle write": func(c context.Context, vm *VM) (*QuantumAttestation, error) {
return vm.AttestOracleCommit(c, authenticated(vm, "B-Chain"), keyID, subject, 0, root, 11)
},
"oracle read": func(c context.Context, vm *VM) (*QuantumAttestation, error) {
return vm.AttestOracleCommit(c, authenticated(vm, "B-Chain"), keyID, subject, 1, root, 12)
},
"session complete": func(c context.Context, vm *VM) (*QuantumAttestation, error) {
return vm.AttestSessionComplete(c, authenticated(vm, "B-Chain"), keyID, subject, root, root, root, 13)
},
"epoch beacon": func(c context.Context, vm *VM) (*QuantumAttestation, error) {
return vm.AttestEpochBeacon(c, authenticated(vm, "B-Chain"), keyID, 14, root)
},
} {
signed, declined := runOnQuorum(t, ctx, vms, ask)
require.Lenf(t, signed, policy.K, "%s: exactly K validators sign", name)
require.Equalf(t, policy.N-policy.K, declined, "%s: the rest decline", name)
att := signed[0]
for _, a := range signed {
require.Equalf(t, att.Signature, a.Signature, "%s: all honest signers derive one signature", name)
require.Equalf(t, att.AttestationID, a.AttestationID, "%s: and one attestation id", name)
}
require.Equalf(t, policy, att.Policy, "%s: the attestation carries the key's own policy", name)
require.Lenf(t, att.Signers, policy.K, "%s", name)
require.NotEmptyf(t, att.CeremonyID, "%s: the attestation names the ceremony that produced it", name)
// Every validator verifies it, including the ones that did not sign.
for i, vm := range vms {
require.NoErrorf(t, vm.VerifyAttestation(att), "%s: validator %d rejected it", name, i)
}
}
// Two attestations over the same payload by different quorums are
// distinguishable, because the id binds the ceremony that signed it.
first, _ := runOnQuorum(t, ctx, vms, func(c context.Context, vm *VM) (*QuantumAttestation, error) {
return vm.AttestOracleCommit(c, authenticated(vm, "B-Chain"), keyID, subject, 0, root, 21)
})
second, _ := runOnQuorum(t, ctx, vms, func(c context.Context, vm *VM) (*QuantumAttestation, error) {
return vm.AttestOracleCommit(c, authenticated(vm, "B-Chain"), keyID, subject, 0, root, 22)
})
require.NotEqual(t, first[0].AttestationID, second[0].AttestationID)
// And an ownership claim rides the same path, returning the portable form a
// verifier consumes with no callback to M.
claim := ownership.Claim{Chain: 1, Token: 7, Block: 31}
copy(claim.Node[:], []byte("node-0x0123456789abc"))
owned, declined := runOnQuorum(t, ctx, vms, func(c context.Context, vm *VM) (*ownership.Attestation, error) {
return vm.AttestNFTOwnership(c, authenticated(vm, "B-Chain"), keyID, claim)
})
require.Len(t, owned, policy.K)
require.Equal(t, policy.N-policy.K, declined)
require.Equal(t, policy.K, owned[0].Quorum)
require.Equal(t, policy.K, owned[0].Signers)
require.Equal(t, claim.Block, owned[0].Epoch,
"the claim's block is the epoch, so equivocation means two facts about one node at one block")
require.NoError(t, owned[0].Authorizes(claim.Node))
// A node may not borrow a peer's entitlement.
var other [20]byte
copy(other[:], []byte("someone-else-0x9876"))
require.Error(t, owned[0].Authorizes(other))
}
+3 -3
View File
@@ -92,9 +92,9 @@ func TestCaller_PayloadCannotNameItself(t *testing.T) {
func TestUnboundLabelIsNotCounted(t *testing.T) {
real := ids.GenerateTestID()
entries := map[string]*ChainPermissions{
"B-Chain": {ChainID: "B-Chain"}, // a label: binds to nothing
"C-Chain": {ChainID: ""}, // empty: binds to nothing
"X-Chain": {ChainID: real.String()}, // an actual chain id
"B-Chain": {ChainID: "B-Chain"}, // a label: binds to nothing
"C-Chain": {ChainID: ""}, // empty: binds to nothing
"X-Chain": {ChainID: real.String()}, // an actual chain id
}
bound := 0
for _, p := range entries {
+274 -3
View File
@@ -4,17 +4,47 @@
package cert
import (
"errors"
"strings"
"testing"
"github.com/luxfi/chains/mpcvm/types"
)
type stubVerifier struct{ lane types.CertLane }
// dispatch records what a stubVerifier was handed, so a test can assert not
// only what Verify returned but whether the verifier ran at all.
type dispatch struct {
n int
subject [32]byte
share types.Share
payload []byte
}
type stubVerifier struct {
lane types.CertLane
err error
seen *dispatch
}
func (s stubVerifier) Lane() types.CertLane { return s.lane }
func (s stubVerifier) Verify(_ [32]byte, _ types.Share, _ []byte) error {
return nil
func (s stubVerifier) Verify(subject [32]byte, share types.Share, payload []byte) error {
if s.seen != nil {
s.seen.n++
s.seen.subject, s.seen.share, s.seen.payload = subject, share, payload
}
return s.err
}
func threeParty(t *testing.T, id types.CeremonyID) *types.ParticipantSet {
t.Helper()
set, err := types.NewParticipantSet(id, []types.Participant{
{Node: types.NodeID{1}}, {Node: types.NodeID{2}}, {Node: types.NodeID{3}},
})
if err != nil {
t.Fatalf("participant set: %v", err)
}
return set
}
// Orthogonality: the M-Chain registry refuses F-Chain lanes.
@@ -87,3 +117,244 @@ func TestRegister_RejectsDoubleRegistration(t *testing.T) {
t.Fatal("expected second registration to fail")
}
}
// The first registration is the one that survives a second attempt.
//
// Rejecting the duplicate is only half the property. If the map were written
// before the existence check, a second verifier for an already-owned lane would
// silently displace the first while the caller saw an error and moved on — the
// chain would then dispatch that protocol's shares to whichever verifier
// registered last, which is boot order, not policy.
func TestRegister_DuplicateDoesNotDisplaceTheIncumbent(t *testing.T) {
r := NewRegistry(OwnerMChain)
incumbent := stubVerifier{lane: types.LaneMChainFROST, seen: &dispatch{}}
if err := r.Register(incumbent); err != nil {
t.Fatalf("first register: %v", err)
}
if err := r.Register(stubVerifier{lane: types.LaneMChainFROST, seen: &dispatch{}}); err == nil {
t.Fatal("expected second registration to fail")
}
got, err := r.Verifier(types.LaneMChainFROST)
if err != nil {
t.Fatalf("lookup after rejected duplicate: %v", err)
}
if got.(stubVerifier).seen != incumbent.seen {
t.Fatal("a rejected duplicate replaced the verifier already registered for the lane")
}
}
// A nil verifier is refused, not stored.
//
// Storing it would put a nil interface in the dispatch table, turning a
// boot-time configuration mistake into a nil dereference on the first share
// that names the lane — long after the operator could connect the two.
func TestRegister_RefusesNilVerifier(t *testing.T) {
r := NewRegistry(OwnerMChain)
if err := r.Register(nil); err == nil {
t.Fatal("a nil verifier must be refused")
}
if len(r.verifier) != 0 {
t.Fatal("a refused registration left an entry in the dispatch table")
}
}
// A registry with no owner registers nothing.
//
// Owner is the entire enforcement mechanism — Register consults it and nothing
// else. A registry built from a zero Owner (a struct literal that forgot the
// field, a chain wired before it knows which chain it is) must refuse every
// lane. Refusing none would hand a single process both chains' lanes, which is
// exactly the state the M/F split exists to prevent.
func TestRegistry_UnownedRegistryRefusesEveryLane(t *testing.T) {
r := NewRegistry(OwnerUnknown)
if r.Owner() != OwnerUnknown {
t.Fatalf("Owner() = %d, want OwnerUnknown", r.Owner())
}
for i := 0; i <= 255; i++ {
if err := r.Register(stubVerifier{lane: types.CertLane(i)}); err == nil {
t.Fatalf("lane %d: an owner-unset registry accepted a verifier", i)
}
}
}
// Owner partitions the whole lane space, and each registry gets exactly its half.
//
// Lanes 5..7 are M-Chain's, 8..9 are F-Chain's, and 0..4 belong to neither —
// they are Quasar's own consensus and attestation lanes. Sweeping all 256
// encodable lane values rather than the ten named ones is deliberate: the lane
// arrives on the wire as a byte, so the ownership predicate has to answer for
// values LP-134 never assigned.
func TestRegistry_OwnerPartitionsTheLaneSpace(t *testing.T) {
for _, owner := range []struct {
owner Owner
owns func(types.CertLane) bool
}{
{OwnerMChain, types.CertLane.IsMChain},
{OwnerFChain, types.CertLane.IsFChain},
} {
r := NewRegistry(owner.owner)
if r.Owner() != owner.owner {
t.Fatalf("Owner() = %d, want %d", r.Owner(), owner.owner)
}
accepted := 0
for i := 0; i <= 255; i++ {
lane := types.CertLane(i)
err := r.Register(stubVerifier{lane: lane})
if want := owner.owns(lane); (err == nil) != want {
t.Fatalf("owner %d lane %d: Register err=%v, owns=%v", owner.owner, lane, err, want)
}
if err == nil {
accepted++
}
}
if want := map[Owner]int{OwnerMChain: 3, OwnerFChain: 2}[owner.owner]; accepted != want {
t.Fatalf("owner %d accepted %d lanes, want %d", owner.owner, accepted, want)
}
}
}
// Lookup keeps refusing after boot, for every lane that was never registered.
//
// Register enforces ownership once, at boot. Dispatch happens per-share
// thereafter on a lane number that arrived on the wire. If Verifier resolved an
// unregistered lane to anything but an error — a zero value, a nil interface
// the caller goes on to invoke — the boot-time partition would stop binding
// precisely where an attacker gets to choose the input.
func TestRegistry_VerifierRefusesUnregisteredLanesAfterBoot(t *testing.T) {
r := NewRegistry(OwnerMChain)
if err := r.Register(stubVerifier{lane: types.LaneMChainCGGMP21}); err != nil {
t.Fatalf("register: %v", err)
}
for i := 0; i <= 255; i++ {
lane := types.CertLane(i)
v, err := r.Verifier(lane)
if lane == types.LaneMChainCGGMP21 {
if err != nil || v == nil {
t.Fatalf("registered lane %d: v=%v err=%v", lane, v, err)
}
continue
}
if err == nil {
t.Fatalf("lane %d resolved to a verifier on a registry that never registered it", lane)
}
if v != nil {
t.Fatalf("lane %d returned a verifier alongside an error", lane)
}
}
}
// A malformed envelope never reaches a verifier.
//
// A lane verifier is protocol code that trusts its inputs: it is handed a
// payload window and a share already established to name a real participant of
// this ceremony. Every envelope defect has to be caught by the registry, and
// the verifier must not run at all — a verifier that runs on a share whose
// ParticipantID is out of range is attributing a round contribution to a
// participant that does not exist, and one that runs on a share naming another
// ceremony is accepting a replay.
func TestRegistry_VerifyRefusesMalformedEnvelopeWithoutDispatching(t *testing.T) {
id := types.CeremonyID{0xC0}
set := threeParty(t, id)
arena := []byte("0123456789abcdef")
good := types.Share{
CeremonyID: id, ParticipantID: 2, Round: 1,
Lane: types.LaneMChainCGGMP21, PayloadOffset: 4, PayloadLen: 4,
}
mangle := func(f func(*types.Share)) types.Share {
s := good
f(&s)
return s
}
for _, tc := range []struct {
name string
share types.Share
set *types.ParticipantSet
}{
{"nil participant set", good, nil},
{"ceremony id of another ceremony", mangle(func(s *types.Share) { s.CeremonyID = types.CeremonyID{0xC1} }), set},
{"participant one past the set", mangle(func(s *types.Share) { s.ParticipantID = 3 }), set},
{"participant far out of range", mangle(func(s *types.Share) { s.ParticipantID = ^uint32(0) }), set},
{"round zero", mangle(func(s *types.Share) { s.Round = 0 }), set},
{"empty payload", mangle(func(s *types.Share) { s.PayloadLen = 0 }), set},
{"payload window past the arena", mangle(func(s *types.Share) { s.PayloadLen = 13 }), set},
{"payload offset past the arena", mangle(func(s *types.Share) { s.PayloadOffset = 17 }), set},
{"lane this chain does not own", mangle(func(s *types.Share) { s.Lane = types.LaneFChainTFHE }), set},
{"lane no chain owns", mangle(func(s *types.Share) { s.Lane = types.LaneBLS }), set},
} {
seen := &dispatch{}
r := NewRegistry(OwnerMChain)
if err := r.Register(stubVerifier{lane: types.LaneMChainCGGMP21, seen: seen}); err != nil {
t.Fatalf("%s: register: %v", tc.name, err)
}
if err := r.Verify([32]byte{}, tc.share, arena, tc.set); err == nil {
t.Errorf("%s: accepted", tc.name)
}
if seen.n != 0 {
t.Errorf("%s: the verifier ran %d times on a share the registry should have refused", tc.name, seen.n)
}
}
}
// Verify hands the verifier the subject and exactly the window the share names.
//
// The (offset, len) indirection is the whole share ABI: the verifier never
// decodes the envelope, it only sees the bytes the registry sliced out. An
// off-by-one here hands a protocol verifier a payload shifted by a byte, which
// fails as a bad signature rather than as a bounds bug.
func TestRegistry_VerifyDispatchesTheNamedPayloadWindow(t *testing.T) {
id := types.CeremonyID{0xC0}
set := threeParty(t, id)
arena := []byte("0123456789abcdef")
subject := [32]byte{0xEE}
seen := &dispatch{}
r := NewRegistry(OwnerMChain)
if err := r.Register(stubVerifier{lane: types.LaneMChainCoronaGen, seen: seen}); err != nil {
t.Fatalf("register: %v", err)
}
share := types.Share{
CeremonyID: id, ParticipantID: 0, Round: 2,
Lane: types.LaneMChainCoronaGen, PayloadOffset: 4, PayloadLen: 6,
}
if err := r.Verify(subject, share, arena, set); err != nil {
t.Fatalf("a well-formed share on a registered lane must dispatch: %v", err)
}
if seen.n != 1 {
t.Fatalf("verifier ran %d times, want exactly 1", seen.n)
}
if seen.subject != subject {
t.Fatalf("verifier got subject %x, want %x", seen.subject, subject)
}
if seen.share != share {
t.Fatalf("verifier got share %+v, want %+v", seen.share, share)
}
if got := string(seen.payload); got != "456789" {
t.Fatalf("verifier got payload %q, want %q", got, "456789")
}
}
// A verifier's refusal is the registry's refusal.
//
// The registry is a dispatcher, not a policy layer. If it swallowed a
// verifier's error the share would be recorded as validated with no protocol
// check behind it, which is the one outcome the lane exists to prevent.
func TestRegistry_VerifierRefusalSurfaces(t *testing.T) {
id := types.CeremonyID{0xC0}
set := threeParty(t, id)
refusal := errors.New("payload is not a valid round-1 commitment")
r := NewRegistry(OwnerMChain)
if err := r.Register(stubVerifier{lane: types.LaneMChainFROST, err: refusal}); err != nil {
t.Fatalf("register: %v", err)
}
share := types.Share{
CeremonyID: id, ParticipantID: 1, Round: 1,
Lane: types.LaneMChainFROST, PayloadOffset: 0, PayloadLen: 4,
}
if err := r.Verify([32]byte{}, share, []byte("abcd"), set); !errors.Is(err, refusal) {
t.Fatalf("Verify returned %v, want the verifier's own error", err)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import (
// Roots is the set of upstream roots a Quasar 3.0 certificate_subject
// binds, mirroring the QuasarRoundDescriptor in LP-134.
//
// All seven roots are required, including both MChain and FChain —
// All ten roots are required, including both MChain and FChain —
// this is the structural property that makes cross-chain replay
// impossible. The substrate does not let a caller skip a root: a zero
// value is still a value, and the chain that owns the root is
+149
View File
@@ -0,0 +1,149 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package cert
import (
"reflect"
"strings"
"testing"
)
// flip returns a copy of r with the named field's first byte set to v.
// Reflection rather than a hand-written switch: a root added to the struct is
// covered the moment it exists, which is the only way TestSubject_EveryRootIsBound
// can outlive the descriptor it was written against.
func flip(field int, v byte) Roots {
var r Roots
reflect.ValueOf(&r).Elem().Field(field).Index(0).SetUint(uint64(v))
return r
}
// Every field of Roots reaches the subject hash.
//
// A root BindSubject forgets to write is a cross-chain replay hole: two rounds
// that differ only in the forgotten root bind the same certificate_subject, so
// a certificate minted for one round is valid on the other. The M-Chain and
// F-Chain roots are the two that make this concrete — if fchain_fhe_root did
// not reach the hash, an M-Chain certificate would verify against an F-Chain
// round that never happened.
func TestSubject_EveryRootIsBound(t *testing.T) {
zero := BindSubject(Roots{})
rt := reflect.TypeOf(Roots{})
for i := 0; i < rt.NumField(); i++ {
if BindSubject(flip(i, 1)) == zero {
t.Errorf("root %s does not reach the subject hash", rt.Field(i).Name)
}
}
}
// The roots are distinguishable from one another, not just individually bound.
//
// BindSubject writes ten fixed-width roots back to back with no separators, so
// injectivity rests entirely on position. A copy-paste that wrote ParentBlock
// into StateRoot's slot would still let every root move the hash — the previous
// test would pass — while making the two roots interchangeable: a descriptor
// with the values swapped would bind the same subject.
func TestSubject_RootsAreNotInterchangeable(t *testing.T) {
rt := reflect.TypeOf(Roots{})
seen := make(map[[32]byte]string, rt.NumField())
for i := 0; i < rt.NumField(); i++ {
name := rt.Field(i).Name
s := BindSubject(flip(i, 0xAB))
if prior, dup := seen[s]; dup {
t.Errorf("roots %s and %s bind the same subject: they occupy one slot", prior, name)
}
seen[s] = name
}
}
// BindSubject is a function of the roots alone.
//
// The subject is recomputed independently by every node at cert-ingress time.
// If it carried any hidden input — a clock, a map iteration, an allocation
// address — nodes would disagree on a value consensus treats as canonical.
func TestSubject_BindIsDeterministic(t *testing.T) {
r := Roots{ParentBlock: [32]byte{1}, MChainCeremony: [32]byte{9}, FChainFHE: [32]byte{10}}
first := BindSubject(r)
for i := 0; i < 8; i++ {
if BindSubject(r) != first {
t.Fatal("BindSubject returned two different subjects for one descriptor")
}
}
}
// VerifySubject accepts the claim its own roots produce and nothing else.
//
// This is the ingress check: a certificate arrives carrying a subject, and the
// node recomputes it from the roots it holds. Accepting a mismatch would let a
// proposer bind a subject to a descriptor it did not derive from.
func TestSubject_VerifyAcceptsOnlyTheDerivedClaim(t *testing.T) {
r := Roots{
ParentBlock: [32]byte{0x11},
MChainCeremony: [32]byte{0x22},
FChainFHE: [32]byte{0x33},
}
if err := VerifySubject(BindSubject(r), r); err != nil {
t.Fatalf("the subject derived from these roots must verify against them: %v", err)
}
rt := reflect.TypeOf(Roots{})
for i := 0; i < rt.NumField(); i++ {
altered := r
f := reflect.ValueOf(&altered).Elem().Field(i)
f.Index(31).SetUint(uint64(f.Index(31).Uint()) ^ 1)
if err := VerifySubject(BindSubject(r), altered); err == nil {
t.Errorf("VerifySubject accepted a claim after %s changed", rt.Field(i).Name)
}
}
err := VerifySubject([32]byte{}, r)
if err == nil || !strings.Contains(err.Error(), "does not match descriptor roots") {
t.Fatalf("a claimed subject of all zeroes must be refused, got: %v", err)
}
}
// Each chain root is required on its own.
//
// LP-134 binds mchain_ceremony_root and fchain_fhe_root on every round, even
// the rounds where one chain finalizes nothing (its unchanged root satisfies
// the binding). A check that only fired when *both* were zero would let a round
// that omits one chain through, and the subject would then attest to a single
// chain while claiming to attest to two.
func TestSubject_RequireBothChainRootsIndependently(t *testing.T) {
nonzero := [32]byte{0x01}
if err := (Roots{MChainCeremony: nonzero, FChainFHE: nonzero}).RequireBothChains(); err != nil {
t.Fatalf("both roots present must be accepted: %v", err)
}
for _, tc := range []struct {
name string
r Roots
want string
}{
{"m-chain root missing", Roots{FChainFHE: nonzero}, "mchain_ceremony_root is zero"},
{"f-chain root missing", Roots{MChainCeremony: nonzero}, "fchain_fhe_root is zero"},
{"both missing", Roots{}, "mchain_ceremony_root is zero"},
} {
err := tc.r.RequireBothChains()
if err == nil {
t.Errorf("%s: accepted a descriptor that does not bind both chains", tc.name)
continue
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: want an error naming %q, got: %v", tc.name, tc.want, err)
}
}
}
// A root that is present but zero-valued elsewhere is not the chain roots'
// business. RequireBothChains guards exactly two fields; the other eight are
// legitimately zero at genesis, and a check that rejected them would stop the
// first round of every network from ever binding a subject.
func TestSubject_RequireBothChainsIgnoresTheOtherRoots(t *testing.T) {
r := Roots{MChainCeremony: [32]byte{1}, FChainFHE: [32]byte{1}}
if err := r.RequireBothChains(); err != nil {
t.Fatalf("eight zero roots beside two present chain roots must be accepted: %v", err)
}
}
+11 -101
View File
@@ -14,15 +14,16 @@ import (
"time"
)
// Client provides access to mpcvm services. Per LP-134, this serves
// M-Chain (MPC mode) and F-Chain (FHE mode); legacy T-Chain MPC routes here.
// Client reads one M-Chain node over its JSON-RPC endpoint: the custody
// registry, the ceremony log, the state root, and what the node itself is.
type Client struct {
endpoint string
chainID string // Requesting chain's ID
httpClient *http.Client
}
// NewClient creates a new T-Chain client
// NewClient builds a client bound to one endpoint, identifying itself as
// chainID for the RPCs that report per-chain quota.
func NewClient(endpoint, chainID string) *Client {
return &Client{
endpoint: endpoint,
@@ -128,65 +129,12 @@ type CeremonyInfo struct {
Height uint64 `json:"height,omitempty"`
}
// KeygenRequest contains parameters for key generation.
type KeygenRequest struct {
KeyID string `json:"keyId"`
// Policy is the quorum in operator form, "3-of-5". Empty means the chain's
// default.
Policy string `json:"policy,omitempty"`
}
// KeygenResponse is a COMPLETED key generation.
type KeygenResponse struct {
Ceremony CeremonyInfo `json:"ceremony"`
Key KeyInfo `json:"key"`
}
// Keygen runs a distributed key generation and returns when the key exists.
// There is no status to poll afterwards: the ceremony either produced a
// registered key or returned an error.
func (c *Client) Keygen(ctx context.Context, req KeygenRequest) (*KeygenResponse, error) {
params := map[string]interface{}{
"keyId": req.KeyID,
"requestedBy": c.chainID,
}
if req.Policy != "" {
params["policy"] = req.Policy
}
var result KeygenResponse
if err := c.call(ctx, "threshold_keygen", params, &result); err != nil {
return nil, err
}
return &result, nil
}
// SignRequest contains parameters for signing.
type SignRequest struct {
KeyID string `json:"keyId"`
// MessageHash is the exact 32 bytes to sign. The caller owns its signing
// domain; M-Chain signs what it is given and never re-hashes.
MessageHash []byte `json:"messageHash"`
}
// Sign runs a threshold signing ceremony and returns the finished signature.
//
// The call blocks for the duration of the ceremony. The returned CeremonyID is
// the durable handle: GetCeremony re-reads the same signature from replicated
// state once the block carrying it is accepted.
func (c *Client) Sign(ctx context.Context, req SignRequest) (*CeremonyInfo, error) {
params := map[string]interface{}{
"keyId": req.KeyID,
"messageHash": hex.EncodeToString(req.MessageHash),
"requestingChain": c.chainID,
}
var result CeremonyInfo
if err := c.call(ctx, "threshold_sign", params, &result); err != nil {
return nil, err
}
return &result, nil
}
// This client READS. It does not run ceremonies: a ceremony is requested BY a
// chain over the cross-chain transport that authenticates which chain is
// asking, and HTTP authenticates nobody. There were a Keygen and a Sign method
// here calling threshold_keygen and threshold_sign, which the server refuses
// unconditionally — two entry points that could only ever return an error, and
// an invitation to make the server stop refusing.
// GetCeremony reads one recorded ceremony from replicated state.
func (c *Client) GetCeremony(ctx context.Context, ceremonyID string) (*CeremonyInfo, error) {
@@ -293,44 +241,6 @@ func (c *Client) GetAddress(ctx context.Context, keyID string) ([]byte, error) {
return hex.DecodeString(addrHex)
}
// =============================================================================
// Protocol Information
// =============================================================================
// ProtocolInfo contains protocol information
type ProtocolInfo struct {
Name string `json:"name"`
Description string `json:"description"`
SupportedCurves []string `json:"supportedCurves"`
KeySize int `json:"keySize"`
SignatureSize int `json:"signatureSize"`
IsPostQuantum bool `json:"isPostQuantum"`
SupportsReshare bool `json:"supportsReshare"`
SupportsRefresh bool `json:"supportsRefresh"`
}
// GetProtocols retrieves all supported protocols
func (c *Client) GetProtocols(ctx context.Context) ([]ProtocolInfo, error) {
var result []ProtocolInfo
if err := c.call(ctx, "threshold_getProtocols", nil, &result); err != nil {
return nil, err
}
return result, nil
}
// GetProtocolInfo retrieves info for a specific protocol
func (c *Client) GetProtocolInfo(ctx context.Context, protocol string) (*ProtocolInfo, error) {
params := map[string]string{
"protocol": protocol,
}
var result ProtocolInfo
if err := c.call(ctx, "threshold_getProtocolInfo", params, &result); err != nil {
return nil, err
}
return &result, nil
}
// =============================================================================
// Network Information
// =============================================================================
@@ -371,7 +281,7 @@ type NetworkStats struct {
SignaturesByChain map[string]uint64 `json:"signaturesByChain"`
}
// GetStats retrieves T-Chain statistics
// GetStats retrieves this node's ceremony counters.
func (c *Client) GetStats(ctx context.Context) (*NetworkStats, error) {
var result NetworkStats
if err := c.call(ctx, "threshold_getStats", nil, &result); err != nil {
+32 -24
View File
@@ -1,11 +1,20 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Command plugin serves the M-Chain VM to a Lux node.
//
// The binary's FILENAME must be the CB58 of mpcvm.VMID
// (qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS) — that is how the node's
// plugin registry resolves a vmID from a CreateChainTx to an implementation.
// A binary installed under any other name is invisible and the chain silently
// never starts, which is why `version` prints the id it must be installed as:
// the fact an operator needs is in the place an operator already looks.
package main
import (
"context"
"fmt"
"io"
"os"
"github.com/luxfi/chains/mpcvm"
@@ -15,34 +24,33 @@ import (
"github.com/luxfi/vm/rpc"
)
// main serves the M-Chain VM as a plugin.
//
// The binary's FILENAME must be the CB58 of mpcvm.VMID
// (qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS) — that is how the node's
// plugin registry resolves a vmID from a CreateChainTx to an implementation.
// A binary installed under any other name is invisible and the chain silently
// never starts. See node/Dockerfile and TestVMID_IsCanonicalAndStable.
func main() {
if len(os.Args) > 1 && os.Args[1] == "version" {
fmt.Printf("mpcvm/%s\n", mpcvm.Version)
os.Exit(0)
os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
}
// run is main with the exit lifted out, so what the plugin does is reachable
// from a test and only the exit itself is not.
func run(ctx context.Context, args []string, out, errOut io.Writer) int {
if len(args) > 0 && args[0] == "version" {
fmt.Fprintf(out, "mpcvm/%s %s\n", mpcvm.Version, mpcvm.VMID)
return 0
}
if err := ulimit.Set(ulimit.DefaultFDLimit, log.Root()); err != nil {
fmt.Fprintf(os.Stderr, "failed to set fd limit: %s\n", err)
os.Exit(1)
return fail(errOut, err)
}
f := &mpcvm.Factory{}
raw, err := f.New(log.Root())
if err != nil {
fmt.Fprintf(os.Stderr, "factory error: %s\n", err)
os.Exit(1)
// Typed at the declaration: "this plugin serves M-Chain's ChainVM" is then
// a compile error to get wrong, rather than a runtime type assertion on a
// factory that returns interface{} and an error it never returns.
var vm chain.ChainVM = &mpcvm.VM{}
if err := rpc.Serve(ctx, log.Root(), vm); err != nil {
return fail(errOut, err)
}
return 0
}
vm := raw.(chain.ChainVM)
if err := rpc.Serve(context.Background(), log.Root(), vm); err != nil {
fmt.Fprintf(os.Stderr, "rpc.Serve error: %s\n", err)
os.Exit(1)
}
// fail reports why the plugin is not serving and gives the node a non-zero
// exit. A plugin that dies quietly presents as a chain that never starts.
func fail(w io.Writer, err error) int {
fmt.Fprintf(w, "mpcvm plugin: %s\n", err)
return 1
}
+22 -19
View File
@@ -22,8 +22,13 @@ package mpcvm
// The committee is the chain's own validator set (Committee below). Joining the
// signing ring is joining the validator set — there is no allowlist, no
// operator registry, and no key ceremony gated on an admin. A new validator
// participates in every key generated after it joins, and is reshared into
// existing keys by the resharing path.
// participates in every key generated after it joins.
//
// It does NOT join keys that already exist: there is no resharing ceremony, and
// a key's participant set is fixed at the DKG that created it. That is a real
// limit — a custody key survives only while K of its original N remain — and it
// is stated rather than implied, because a registry with a ReplaceKey and prose
// about "the resharing path" reads as though the ceremony exists.
//
// # Why this is the fix for off-chain signer clusters
//
@@ -42,10 +47,9 @@ import (
"errors"
"fmt"
"sort"
"time"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/quorum"
cmpconfig "github.com/luxfi/threshold/protocols/cmp/config"
@@ -101,8 +105,20 @@ func (h *heldShare) marshal() ([]byte, error) {
}
// parseHeldShare reads a node-private share back.
//
// The curve comes from the key's protocol, and it is not optional: the stored
// encoding holds curve points as bare bytes, so the decoder has to be told
// which curve to decode them on. It used to be handed nil, which the library
// refuses outright — so parseHeldShare could not succeed for ANY input, and
// every validator that actually held a share failed to start, with
// Initialize returning "load key shares: config must be initialized using
// EmptyConfig". A node that holds no share restarts fine, which is why the
// path that fails is the one only real committee members take.
func parseHeldShare(kind string, raw []byte) (*heldShare, error) {
cfg := cmpconfig.EmptyConfig(nil)
if kind != KindCGGMP21 {
return nil, fmt.Errorf("mpcvm: share for unknown protocol %q", kind)
}
cfg := cmpconfig.EmptyConfig(curve.Secp256k1{})
if err := cfg.UnmarshalBinary(raw); err != nil {
return nil, fmt.Errorf("mpcvm: share for kind %s: %w", kind, err)
}
@@ -318,7 +334,6 @@ func (vm *VM) runKeygen(ctx context.Context, keyID string, policy quorum.Policy,
Artifact: pop,
Signers: participants,
Key: rec,
Timestamp: time.Now().Unix(),
}
vm.stage(op)
@@ -400,7 +415,6 @@ func (vm *VM) runSign(ctx context.Context, keyID string, digest []byte, requesti
Digest: append([]byte(nil), digest...),
Artifact: sig,
Signers: signers,
Timestamp: time.Now().Unix(),
}
vm.stage(op)
@@ -471,14 +485,7 @@ func (vm *VM) runCMPKeygen(
// inbound gossip for that ceremony finds it. release tears it down.
func (vm *VM) ceremonyRouter(ctx context.Context, cid string, parties []party.ID) (MessageRouter, func(), error) {
if vm.sender == nil {
// Single-process operation with no p2p. The in-process mesh is the same
// MessageRouter boundary, so the executor path under test is the path
// that runs in production; only the wire underneath differs.
if vm.localMesh == nil {
return nil, nil, errors.New("mpcvm: no warp sender and no local mesh; cross-validator ceremonies unavailable")
}
mesh := vm.localMesh
return mesh.routerFor(cid, vm.partyID), func() { mesh.release(cid) }, nil
return nil, nil, errors.New("mpcvm: no warp sender; cross-validator ceremonies unavailable")
}
nodes, peers, err := resolveCommittee(parties)
if err != nil {
@@ -558,7 +565,3 @@ func containsParty(ps []party.ID, want party.ID) bool {
}
return false
}
// netID is the network (subnet) this chain validates. M-Chain runs on the
// primary network, so its committee is the primary validator set.
var _ = ids.Empty
+446
View File
@@ -0,0 +1,446 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// custody_test.go — the leaderless parts: who signs, which ceremony it is, and
// how both are derived rather than announced.
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/validatorstest"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/quorum"
)
// -----------------------------------------------------------------------------
// Ceremony ids
// -----------------------------------------------------------------------------
// A signing ceremony id is a function of the task and nothing else, so every
// validator that observes the same request lands on the same id with no
// coordination round and no coordinator.
func TestASigningCeremonyIdIsAFunctionOfItsTaskAlone(t *testing.T) {
key, digest := "vault", digestOf(1)
signers := parties(3)
base := ceremonyID(key, digest, signers)
require.Equal(t, base, ceremonyID(key, digest, signers))
require.Equal(t, base, ceremonyID(key, digest, []party.ID{"pc", "pa", "pb"}),
"the signer set is a set: enumerating it in a different order is the same task")
require.NotEqual(t, base, ceremonyID("other", digest, signers))
require.NotEqual(t, base, ceremonyID(key, digestOf(2), signers))
require.NotEqual(t, base, ceremonyID(key, digest, parties(4)))
require.Contains(t, base, "mpc/")
}
// A keygen has no message to bind, so it binds the key id, the policy and the
// committee: two nodes that disagree about any of those run different
// ceremonies rather than producing incompatible shares of the same session.
func TestAKeygenCeremonyIdBindsThePolicyAndTheCommittee(t *testing.T) {
base := keygenCeremonyID("vault", quorum.MustNew(3, 5), parties(5))
require.Equal(t, base, keygenCeremonyID("vault", quorum.MustNew(3, 5), parties(5)))
require.NotEqual(t, base, keygenCeremonyID("other", quorum.MustNew(3, 5), parties(5)))
require.NotEqual(t, base, keygenCeremonyID("vault", quorum.MustNew(4, 5), parties(5)))
require.NotEqual(t, base, keygenCeremonyID("vault", quorum.MustNew(3, 6), parties(5)))
require.NotEqual(t, base, keygenCeremonyID("vault", quorum.MustNew(3, 5), parties(4)))
}
// A keygen id and a signing id can never collide, even for identical inputs.
func TestAKeygenIdIsNeverASigningId(t *testing.T) {
require.NotEqual(t,
keygenCeremonyID("vault", quorum.MustNew(3, 5), parties(5)),
ceremonyID("vault", digestOf(1), parties(5)))
require.NotContains(t, ceremonyID("vault", digestOf(1), parties(5)), "keygen")
}
// -----------------------------------------------------------------------------
// Quorum selection
// -----------------------------------------------------------------------------
// The quorum for a task is a deterministic function of (key, digest), so every
// node — signer or not — computes the same subset and therefore the same
// ceremony id, with no election.
func TestEveryNodeComputesTheSameQuorumForATask(t *testing.T) {
rec := sampleKeyRecord()
rec.Policy = quorum.MustNew(3, 5)
rec.Participants = parties(5)
digest := digestOf(1)
first := quorumFor(rec, digest)
require.Len(t, first, 3, "a 3-of-5 key signs with three parties, not five")
require.True(t, sortedUnique(first))
for i := 0; i < 20; i++ {
require.Equal(t, first, quorumFor(rec, digest))
}
for _, p := range first {
require.Contains(t, rec.Participants, p)
}
}
// The selection spreads work across the committee instead of loading the same K
// parties for every transfer, and an adversary cannot choose which subset will
// sign a message it does not control.
func TestQuorumSelectionSpreadsAcrossTheCommittee(t *testing.T) {
rec := sampleKeyRecord()
rec.Policy = quorum.MustNew(3, 5)
rec.Participants = parties(5)
seen := map[party.ID]int{}
distinct := map[string]struct{}{}
for i := 0; i < 60; i++ {
q := quorumFor(rec, digestOf(byte(i)))
key := ""
for _, p := range q {
seen[p]++
key += string(p)
}
distinct[key] = struct{}{}
}
require.Len(t, seen, 5, "every committee member must be selected for some task")
require.Greater(t, len(distinct), 1, "a selection that always picked the same subset would not spread anything")
// And the same key with a different id ranks its parties differently, so a
// party's position is not a property of the party.
other := sampleKeyRecord()
other.KeyID = "another-vault"
other.Policy = rec.Policy
other.Participants = rec.Participants
differed := false
for i := 0; i < 20 && !differed; i++ {
differed = !samePartySet(quorumFor(rec, digestOf(byte(i))), quorumFor(other, digestOf(byte(i))))
}
require.True(t, differed, "the ranking must depend on the key, not only on the message")
}
// A committee smaller than the policy asks for yields whatever there is rather
// than indexing past the end.
func TestAQuorumCannotAskForMorePartiesThanThereAre(t *testing.T) {
rec := sampleKeyRecord()
rec.Policy = quorum.MustNew(5, 5)
rec.Participants = parties(2)
require.Len(t, quorumFor(rec, digestOf(1)), 2)
rec.Participants = nil
require.Empty(t, quorumFor(rec, digestOf(1)))
}
func TestPartyMembershipIsWhatItSays(t *testing.T) {
require.True(t, containsParty(parties(3), "pb"))
require.False(t, containsParty(parties(3), "pz"))
require.False(t, containsParty(nil, "pa"))
}
// -----------------------------------------------------------------------------
// Committee formation
// -----------------------------------------------------------------------------
// Seats go to stake, not to node ids. An id is a hash of the node's
// certificate, so ids can be AIMED: certificates are cheap to generate offline
// and one that sorts early is a matter of how many were tried. An order over
// ids alone would let a party choose its own seats — and under a small policy,
// a quorum of every key the chain generates.
func TestSeatsFollowStakeAndNotAGrindableId(t *testing.T) {
// Four validators. The two with the most stake sort LAST by id, so a
// selection that ordered by id would pick the two poorest.
rich := []ids.NodeID{{0xff, 1}, {0xff, 2}}
poor := []ids.NodeID{{0x00, 1}, {0x00, 2}}
weights := map[ids.NodeID]uint64{rich[0]: 1000, rich[1]: 999, poor[0]: 1, poor[1]: 1}
vm := openVM(t, memdb.New(), ids.GenerateTestID(), weightedValidators(weights), nil)
defer vm.Shutdown(ctx())
set, err := vm.custodySet(ctx(), 0, 2)
require.NoError(t, err)
require.Len(t, set, 2)
for _, r := range rich {
require.Contains(t, set, party.ID(r.String()))
}
for _, p := range poor {
require.NotContains(t, set, party.ID(p.String()),
"a grindable id must not buy a seat that stake did not")
}
require.True(t, sortedUnique(set), "the set is hashed into the ceremony id, so it must be canonical")
}
// Ties break lexicographically, which is safe once stake has chosen the set:
// grinding an id then buys a position among peers whose stake you already had
// to match.
func TestEqualStakeBreaksTiesTotallyAndIdentically(t *testing.T) {
nodes := []ids.NodeID{{1}, {2}, {3}, {4}}
weights := map[ids.NodeID]uint64{}
for _, n := range nodes {
weights[n] = 5
}
vs := weightedValidators(weights)
a := openVM(t, memdb.New(), ids.GenerateTestID(), vs, nil)
b := openVM(t, memdb.New(), ids.GenerateTestID(), vs, nil)
defer a.Shutdown(ctx())
defer b.Shutdown(ctx())
setA, err := a.custodySet(ctx(), 0, 3)
require.NoError(t, err)
setB, err := b.custodySet(ctx(), 0, 3)
require.NoError(t, err)
require.Equal(t, setA, setB, "every validator must derive the same custody set without negotiating one")
}
// A validator entry with no weight recorded counts as no stake rather than
// crashing the selection.
func TestAValidatorWithNoRecordedWeightHasNoStake(t *testing.T) {
present, absent := ids.NodeID{9}, ids.NodeID{1}
vs := &validatorstest.TestState{
GetValidatorSetF: func(context.Context, uint64, ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
return map[ids.NodeID]*validators.GetValidatorOutput{
present: {NodeID: present, Weight: 100},
absent: nil,
}, nil
},
}
vm := openVM(t, memdb.New(), ids.GenerateTestID(), vs, nil)
defer vm.Shutdown(ctx())
set, err := vm.custodySet(ctx(), 0, 1)
require.NoError(t, err)
require.Equal(t, []party.ID{party.ID(present.String())}, set)
}
// A policy that needs more parties than the chain has validators cannot be
// deployed, and says which number was short.
func TestAPolicyLargerThanTheValidatorSetIsRefused(t *testing.T) {
node := ids.GenerateTestNodeID()
vm := openVM(t, memdb.New(), ids.GenerateTestID(), oneValidator(node), nil)
defer vm.Shutdown(ctx())
_, err := vm.custodySet(ctx(), 0, 5)
require.ErrorIs(t, err, ErrPolicyTooLarge)
require.ErrorContains(t, err, "validator set has 1")
_, err = vm.StartKeygenWithPolicy(ctx(), "vault", quorum.MustNew(3, 5),
authenticatedFor(vm, "B-Chain", true))
require.ErrorIs(t, err, ErrPolicyTooLarge)
}
// The ceremony committee IS the validator set, in canonical order. There is no
// separate MPC roster to drift from it.
func TestTheCommitteeIsTheValidatorSetInCanonicalOrder(t *testing.T) {
nodes := []ids.NodeID{{3}, {1}, {2}}
weights := map[ids.NodeID]uint64{nodes[0]: 1, nodes[1]: 1, nodes[2]: 1}
vm := openVM(t, memdb.New(), ids.GenerateTestID(), weightedValidators(weights), nil)
defer vm.Shutdown(ctx())
committee, err := vm.Committee(ctx(), 0)
require.NoError(t, err)
require.Len(t, committee, 3)
require.True(t, sortedUnique(committee))
}
func TestANodeWithNoValidatorStateHasNoCommittee(t *testing.T) {
vm := newVM(t)
_, err := vm.Committee(ctx(), 0)
require.ErrorIs(t, err, ErrNoCommittee)
_, err = vm.custodySet(ctx(), 0, 1)
require.ErrorIs(t, err, ErrNoCommittee)
}
func TestAnEmptyValidatorSetIsNoCommittee(t *testing.T) {
vs := &validatorstest.TestState{
GetValidatorSetF: func(context.Context, uint64, ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
return nil, nil
},
}
vm := openVM(t, memdb.New(), ids.GenerateTestID(), vs, nil)
defer vm.Shutdown(ctx())
_, err := vm.Committee(ctx(), 0)
require.ErrorIs(t, err, ErrNoCommittee)
}
func TestAnUnreadableValidatorSetIsNamedNotEmpty(t *testing.T) {
vs := &validatorstest.TestState{
GetValidatorSetF: func(context.Context, uint64, ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
return nil, errFaulty
},
}
vm := openVM(t, memdb.New(), ids.GenerateTestID(), vs, nil)
defer vm.Shutdown(ctx())
_, err := vm.Committee(ctx(), 0)
require.ErrorIs(t, err, errFaulty)
_, err = vm.custodySet(ctx(), 0, 1)
require.ErrorIs(t, err, errFaulty)
}
// -----------------------------------------------------------------------------
// Ceremonies this node cannot run
// -----------------------------------------------------------------------------
// A node outside the custody set does not run the keygen: it will verify the
// registration like any other validator when the block arrives.
func TestANodeOutsideTheCustodySetDoesNotRunTheKeygen(t *testing.T) {
others := map[ids.NodeID]uint64{{7}: 10, {8}: 10, {9}: 10}
vm := openVM(t, memdb.New(), ids.GenerateTestID(), weightedValidators(others), nil)
defer vm.Shutdown(ctx())
_, err := vm.StartKeygenWithPolicy(ctx(), "vault", quorum.MustNew(2, 3),
authenticatedFor(vm, "B-Chain", true))
require.ErrorIs(t, err, ErrNotParticipant)
}
func TestAKeygenForAnAlreadyRegisteredKeyDoesNotRun(t *testing.T) {
self, peer := ids.NodeID{1}, ids.NodeID{2}
vm := openVM(t, memdb.New(), ids.GenerateTestID(),
weightedValidators(map[ids.NodeID]uint64{self: 10, peer: 10}), nil)
defer vm.Shutdown(ctx())
vm.partyID = party.ID(self.String())
key := newCustody(t, "vault", quorum.MustNew(3, 5), 110)
key.register(t, vm)
_, err := vm.StartKeygenWithPolicy(ctx(), "vault", quorum.MustNew(2, 2),
authenticatedFor(vm, "B-Chain", true))
require.ErrorIs(t, err, ErrKeyExists,
"a key id is registered once; a second ceremony for it must not even start")
}
func TestAnUndeployablePolicyNeverStartsACeremony(t *testing.T) {
vm := newVM(t)
_, err := vm.StartKeygenWithPolicy(ctx(), "vault", quorum.Policy{K: 1, N: 1},
authenticatedFor(vm, "B-Chain", true))
require.ErrorContains(t, err, "undeployable policy")
}
// Signing needs a registered key, a share of it, and a place in this task's
// quorum — each refused by its own named error so an operator can tell an
// outage from normal operation.
func TestSigningRefusalsAreDistinguishable(t *testing.T) {
vm := newVM(t)
by := authenticatedFor(vm, "B-Chain", false)
_, err := vm.RequestSignature(ctx(), by, "vault", digestOf(1))
require.ErrorIs(t, err, ErrUnknownKey)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 111)
key.register(t, vm)
_, err = vm.RequestSignature(ctx(), by, "vault", digestOf(1))
require.ErrorIs(t, err, ErrShareNotHeld)
// It holds a share, but it is not a participant of this key at all.
key.hold(vm, testShare(t, key.rec.GroupPublicKey, key.rec.Degree()))
_, err = vm.RequestSignature(ctx(), by, "vault", digestOf(1))
require.ErrorIs(t, err, ErrNotParticipant)
// It is a participant, but not in this task's quorum — normal, and not an
// error condition for the chain.
vm.partyID = outsideQuorum(t, key.rec, digestOf(1))
_, err = vm.RequestSignature(ctx(), by, "vault", digestOf(1))
require.ErrorIs(t, err, ErrNotInQuorum)
}
// M-Chain signs the exact 32 bytes it is given and never re-hashes: the caller
// owns its signing domain, and a chain that re-hashed would produce signatures
// over a preimage nobody authorised.
func TestMChainSignsWhatItIsGivenAndOnlyThirtyTwoBytes(t *testing.T) {
vm := newVM(t)
by := authenticatedFor(vm, "B-Chain", false)
for _, n := range []int{0, 31, 33} {
_, err := vm.RequestSignature(ctx(), by, "vault", make([]byte, n))
require.ErrorContainsf(t, err, "want 32", "a %d-byte digest must be refused", n)
}
}
// A ceremony that has already been recorded is refused before it runs: asking
// again for the identical task is the identical ceremony, and recording it
// twice would double-count whatever it authorised.
func TestARecordedCeremonyIsNotRunAgain(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 112)
key.register(t, vm)
key.hold(vm, testShare(t, key.rec.GroupPublicKey, key.rec.Degree()))
digest := digestOf(1)
op := key.signOpOver(t, digest)
blk := blockOver(t, vm, op)
require.NoError(t, blk.Verify(ctx()))
require.NoError(t, blk.Accept(ctx()))
vm.partyID = quorumFor(key.rec, digest)[0]
_, err := vm.RequestSignature(ctx(), authenticatedFor(vm, "B-Chain", false), "vault", digest)
require.ErrorIs(t, err, ErrCeremonyExists)
}
// A share that cannot sign is not a share: the signing path refuses an empty
// one rather than producing an artifact nothing verifies.
func TestAnEmptyShareSignsNothing(t *testing.T) {
vm := newVM(t)
rec := sampleKeyRecord()
_, err := vm.thresholdSign(ctx(), rec, nil, digestOf(1), parties(3))
require.ErrorIs(t, err, ErrShareNotHeld)
_, err = vm.thresholdSign(ctx(), rec, &heldShare{}, digestOf(1), parties(3))
require.ErrorIs(t, err, ErrShareNotHeld)
_, _, err = (&heldShare{}).groupKeyAndDegree()
require.ErrorContains(t, err, "empty share")
_, _, err = (*heldShare)(nil).groupKeyAndDegree()
require.ErrorContains(t, err, "empty share")
_, err = (&heldShare{}).marshal()
require.ErrorContains(t, err, "no share to store")
}
// A share of a protocol this chain does not run is refused rather than decoded
// on a guessed curve: the stored encoding holds curve points as bare bytes, so
// decoding it on the wrong curve is decoding the wrong key.
func TestAShareOfAnotherProtocolIsRefused(t *testing.T) {
raw, _, _ := realShare(t)
_, err := parseHeldShare("frost", raw)
require.ErrorContains(t, err, "unknown protocol")
got, err := parseHeldShare(KindCGGMP21, raw)
require.NoError(t, err)
require.Equal(t, KindCGGMP21, got.kind)
}
// -----------------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------------
func weightedValidators(weights map[ids.NodeID]uint64) *validatorstest.TestState {
return &validatorstest.TestState{
GetValidatorSetF: func(context.Context, uint64, ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
out := make(map[ids.NodeID]*validators.GetValidatorOutput, len(weights))
for n, w := range weights {
out[n] = &validators.GetValidatorOutput{NodeID: n, Weight: w}
}
return out, nil
},
}
}
// authenticatedFor is the Caller the transport would produce for a chain with
// the right named to it.
func authenticatedFor(vm *VM, name string, keygen bool) Caller {
return Caller{name: name, perms: &ChainPermissions{ChainID: name, CanSign: true, CanKeygen: keygen}}
}
// outsideQuorum names a participant of rec that this task did NOT select.
func outsideQuorum(t *testing.T, rec *KeyRecord, digest []byte) party.ID {
t.Helper()
chosen := quorumFor(rec, digest)
for _, p := range rec.Participants {
if !containsParty(chosen, p) {
return p
}
}
t.Fatal("every participant is in the quorum; the policy leaves nobody out")
return ""
}
+65 -369
View File
@@ -3,9 +3,22 @@
package mpcvm
// executor.go — the one place that knows the threshold library exists.
//
// M-Chain runs exactly one threshold protocol: CGGMP21 (cmp), the threshold
// ECDSA that produces signatures an external chain's ecrecover accepts. There
// is no protocol registry and no selection: a custody key's Kind names the
// protocol it was generated with, and cggmp21 is the only value that can be
// written. A registry whose other entries return "not yet implemented" is not
// a choice, it is a menu of failures, and it invites a caller to reach one.
//
// Everything above this file speaks in ceremonies; everything below it is
// github.com/luxfi/threshold. The MessageRouter is the seam: the executor
// drives a per-party protocol.Handler and the router moves that handler's
// opaque messages to the other parties (transport.go).
import (
"context"
"encoding/json"
"fmt"
"sync"
@@ -18,20 +31,26 @@ import (
"github.com/luxfi/threshold/protocols/cmp"
cmpconfig "github.com/luxfi/threshold/protocols/cmp/config"
"github.com/luxfi/threshold/protocols/frost"
frostconfig "github.com/luxfi/threshold/protocols/frost/keygen"
"github.com/luxfi/threshold/protocols/lss"
lssconfig "github.com/luxfi/threshold/protocols/lss/config"
)
// ProtocolExecutor manages MPC protocol execution using the threshold library.
// It provides the bridge between ThresholdVM session management and the
// actual MPC protocol implementations.
// MessageRouter carries one ceremony's protocol messages between parties. It
// never interprets them: the cryptography is the threshold library's, the
// delivery is the router's, and neither knows the other's business.
type MessageRouter interface {
// Send sends a message to the party it addresses, or broadcasts it to the
// whole ceremony when To is empty.
Send(msg *protocol.Message) error
// Receive yields messages addressed to this party.
Receive() <-chan *protocol.Message
}
// ProtocolExecutor drives threshold protocols to completion over a router.
type ProtocolExecutor struct {
pool *pool.Pool
logger log.Logger
// Active handlers for message routing
// handlers are the ceremonies running right now, keyed by session id, so a
// ceremony can be torn down by name when it finishes or its context dies.
mu sync.RWMutex
handlers map[string]*protocol.Handler
}
@@ -45,105 +64,7 @@ func NewProtocolExecutor(workerPool *pool.Pool, logger log.Logger) *ProtocolExec
}
}
// =============================================================================
// StartFunc Generators - Create protocol start functions
// =============================================================================
// LSSKeygenStartFunc returns a StartFunc for LSS key generation.
func (pe *ProtocolExecutor) LSSKeygenStartFunc(
selfID party.ID,
participants []party.ID,
threshold int,
) protocol.StartFunc {
return lss.Keygen(curve.Secp256k1{}, selfID, participants, threshold, pe.pool)
}
// LSSSignStartFunc returns a StartFunc for LSS signing.
func (pe *ProtocolExecutor) LSSSignStartFunc(
config *lssconfig.Config,
signers []party.ID,
messageHash []byte,
) protocol.StartFunc {
return lss.Sign(config, signers, messageHash, pe.pool)
}
// LSSReshareStartFunc returns a StartFunc for LSS resharing.
func (pe *ProtocolExecutor) LSSReshareStartFunc(
config *lssconfig.Config,
newParticipants []party.ID,
newThreshold int,
) protocol.StartFunc {
return lss.Reshare(config, newParticipants, newThreshold, pe.pool)
}
// LSSRefreshStartFunc returns a StartFunc for LSS key refresh.
func (pe *ProtocolExecutor) LSSRefreshStartFunc(config *lssconfig.Config) protocol.StartFunc {
return lss.Refresh(config, pe.pool)
}
// CMPKeygenStartFunc returns a StartFunc for CMP key generation.
func (pe *ProtocolExecutor) CMPKeygenStartFunc(
selfID party.ID,
participants []party.ID,
threshold int,
) protocol.StartFunc {
return cmp.Keygen(curve.Secp256k1{}, selfID, participants, threshold, pe.pool)
}
// CMPSignStartFunc returns a StartFunc for CMP signing.
func (pe *ProtocolExecutor) CMPSignStartFunc(
config *cmpconfig.Config,
signers []party.ID,
messageHash []byte,
) protocol.StartFunc {
return cmp.Sign(config, signers, messageHash, pe.pool)
}
// CMPRefreshStartFunc returns a StartFunc for CMP key refresh.
func (pe *ProtocolExecutor) CMPRefreshStartFunc(config *cmpconfig.Config) protocol.StartFunc {
return cmp.Refresh(config, pe.pool)
}
// FROSTKeygenStartFunc returns a StartFunc for FROST key generation.
func (pe *ProtocolExecutor) FROSTKeygenStartFunc(
selfID party.ID,
participants []party.ID,
threshold int,
) protocol.StartFunc {
return frost.Keygen(curve.Secp256k1{}, selfID, participants, threshold)
}
// FROSTKeygenTaprootStartFunc returns a StartFunc for FROST Taproot key generation.
func (pe *ProtocolExecutor) FROSTKeygenTaprootStartFunc(
selfID party.ID,
participants []party.ID,
threshold int,
) protocol.StartFunc {
return frost.KeygenTaproot(selfID, participants, threshold)
}
// FROSTSignStartFunc returns a StartFunc for FROST signing.
func (pe *ProtocolExecutor) FROSTSignStartFunc(
config *frostconfig.Config,
signers []party.ID,
messageHash []byte,
) protocol.StartFunc {
return frost.Sign(config, signers, messageHash)
}
// FROSTRefreshStartFunc returns a StartFunc for FROST key refresh.
func (pe *ProtocolExecutor) FROSTRefreshStartFunc(
config *frostconfig.Config,
participants []party.ID,
) protocol.StartFunc {
return frost.Refresh(config, participants)
}
// =============================================================================
// Handler Management
// =============================================================================
// CreateHandler creates a new protocol handler for a session.
// CreateHandler starts one ceremony and registers it under sessionID.
func (pe *ProtocolExecutor) CreateHandler(
ctx context.Context,
sessionID string,
@@ -152,13 +73,13 @@ func (pe *ProtocolExecutor) CreateHandler(
handler, err := protocol.NewHandler(
ctx,
pe.logger,
nil, // No metrics registry for now
nil, // no metrics registry
startFunc,
[]byte(sessionID),
protocol.DefaultConfig(),
)
if err != nil {
return nil, fmt.Errorf("failed to create handler: %w", err)
return nil, fmt.Errorf("mpcvm: create ceremony handler: %w", err)
}
pe.mu.Lock()
@@ -168,15 +89,7 @@ func (pe *ProtocolExecutor) CreateHandler(
return handler, nil
}
// GetHandler retrieves an active handler by session ID.
func (pe *ProtocolExecutor) GetHandler(sessionID string) (*protocol.Handler, bool) {
pe.mu.RLock()
defer pe.mu.RUnlock()
handler, ok := pe.handlers[sessionID]
return handler, ok
}
// RemoveHandler removes a handler from active tracking.
// RemoveHandler stops a ceremony and forgets it. Idempotent.
func (pe *ProtocolExecutor) RemoveHandler(sessionID string) {
pe.mu.Lock()
defer pe.mu.Unlock()
@@ -186,118 +99,52 @@ func (pe *ProtocolExecutor) RemoveHandler(sessionID string) {
}
}
// AcceptMessage routes an incoming message to the appropriate handler.
func (pe *ProtocolExecutor) AcceptMessage(sessionID string, msg *protocol.Message) error {
// Live reports how many ceremonies this executor is driving. It is what a
// leaked handler shows up in.
func (pe *ProtocolExecutor) Live() int {
pe.mu.RLock()
handler, ok := pe.handlers[sessionID]
pe.mu.RUnlock()
if !ok {
return fmt.Errorf("no active handler for session: %s", sessionID)
}
handler.Accept(msg)
return nil
defer pe.mu.RUnlock()
return len(pe.handlers)
}
// =============================================================================
// High-Level Protocol Operations
// =============================================================================
// RunLSSKeygen executes a complete LSS key generation protocol.
// This is a convenience method that creates a handler and waits for completion.
// For multi-party scenarios, use CreateHandler and manage message routing manually.
func (pe *ProtocolExecutor) RunLSSKeygen(
ctx context.Context,
sessionID string,
selfID party.ID,
participants []party.ID,
threshold int,
messageRouter MessageRouter,
) (*lssconfig.Config, error) {
startFunc := pe.LSSKeygenStartFunc(selfID, participants, threshold)
return runProtocol[*lssconfig.Config](ctx, pe, sessionID, startFunc, messageRouter)
}
// RunLSSSign executes a complete LSS signing protocol.
func (pe *ProtocolExecutor) RunLSSSign(
ctx context.Context,
sessionID string,
config *lssconfig.Config,
signers []party.ID,
messageHash []byte,
messageRouter MessageRouter,
) (*ECDSASignature, error) {
startFunc := pe.LSSSignStartFunc(config, signers, messageHash)
return runProtocol[*ECDSASignature](ctx, pe, sessionID, startFunc, messageRouter)
}
// RunCMPKeygen executes a complete CMP key generation protocol.
// RunCMPKeygen runs one distributed key generation to completion. threshold is
// the polynomial degree t, never the signer count: see quorum.Policy.Degree.
func (pe *ProtocolExecutor) RunCMPKeygen(
ctx context.Context,
sessionID string,
selfID party.ID,
participants []party.ID,
threshold int,
messageRouter MessageRouter,
router MessageRouter,
) (*cmpconfig.Config, error) {
startFunc := pe.CMPKeygenStartFunc(selfID, participants, threshold)
return runProtocol[*cmpconfig.Config](ctx, pe, sessionID, startFunc, messageRouter)
start := cmp.Keygen(curve.Secp256k1{}, selfID, participants, threshold, pe.pool)
return runProtocol[*cmpconfig.Config](ctx, pe, sessionID, start, router)
}
// RunCMPSign executes a complete CMP signing protocol.
// RunCMPSign runs one signing ceremony to completion and returns the canonical
// Ethereum r‖s‖v encoding.
func (pe *ProtocolExecutor) RunCMPSign(
ctx context.Context,
sessionID string,
config *cmpconfig.Config,
signers []party.ID,
messageHash []byte,
messageRouter MessageRouter,
router MessageRouter,
) (*ECDSASignature, error) {
startFunc := pe.CMPSignStartFunc(config, signers, messageHash)
// cmp.Sign yields *ecdsa.Signature (R point, S scalar). The prior code
// asserted the protocol result straight to *ECDSASignature, which never
// matches → "unexpected result type". Assert the real type, then convert.
sig, err := runProtocol[*ecdsa.Signature](ctx, pe, sessionID, startFunc, messageRouter)
start := cmp.Sign(config, signers, messageHash, pe.pool)
// cmp.Sign yields *ecdsa.Signature (R point, S scalar). Asserting the
// protocol result straight to *ECDSASignature never matches — it produced
// "unexpected result type" on every signature. Assert the real type, then
// convert.
sig, err := runProtocol[*ecdsa.Signature](ctx, pe, sessionID, start, router)
if err != nil {
return nil, err
}
return ecdsaSigToWrapper(sig)
}
// RunCMPRefresh executes a complete CMP key refresh protocol.
func (pe *ProtocolExecutor) RunCMPRefresh(
ctx context.Context,
sessionID string,
config *cmpconfig.Config,
messageRouter MessageRouter,
) (*cmpconfig.Config, error) {
startFunc := pe.CMPRefreshStartFunc(config)
return runProtocol[*cmpconfig.Config](ctx, pe, sessionID, startFunc, messageRouter)
}
// RunFROSTKeygen executes a complete FROST key generation protocol.
func (pe *ProtocolExecutor) RunFROSTKeygen(
ctx context.Context,
sessionID string,
selfID party.ID,
participants []party.ID,
threshold int,
messageRouter MessageRouter,
) (*frostconfig.Config, error) {
startFunc := pe.FROSTKeygenStartFunc(selfID, participants, threshold)
return runProtocol[*frostconfig.Config](ctx, pe, sessionID, startFunc, messageRouter)
}
// MessageRouter defines the interface for routing MPC messages between parties.
type MessageRouter interface {
// Send sends a message to the specified party (or broadcasts if To is empty)
Send(msg *protocol.Message) error
// Receive returns a channel for receiving incoming messages
Receive() <-chan *protocol.Message
}
// runProtocol is a generic helper that runs a protocol to completion.
// runProtocol drives one ceremony to its result, moving messages both ways over
// the router for as long as it runs.
func runProtocol[T any](
ctx context.Context,
pe *ProtocolExecutor,
@@ -313,11 +160,10 @@ func runProtocol[T any](
}
defer pe.RemoveHandler(sessionID)
// Start message routing goroutines
done := make(chan struct{})
var routerErr error
// Outgoing messages
// Outgoing: everything this party emits goes to the other parties.
go func() {
defer close(done)
for msg := range handler.Listen() {
@@ -328,39 +174,32 @@ func runProtocol[T any](
}
}()
// Incoming messages
// Incoming: everything the other parties emit reaches this handler.
go func() {
for msg := range router.Receive() {
handler.Accept(msg)
}
}()
// Wait for protocol completion
result, err := handler.WaitForResult()
if err != nil {
return zero, fmt.Errorf("protocol failed: %w", err)
return zero, fmt.Errorf("mpcvm: ceremony %s: %w", sessionID, err)
}
// Wait for message routing to complete
<-done
if routerErr != nil {
return zero, fmt.Errorf("message routing failed: %w", routerErr)
return zero, fmt.Errorf("mpcvm: ceremony %s transport: %w", sessionID, routerErr)
}
// Type assert the result
typedResult, ok := result.(T)
typed, ok := result.(T)
if !ok {
return zero, fmt.Errorf("unexpected result type: %T", result)
return zero, fmt.Errorf("mpcvm: ceremony %s produced %T, want %T", sessionID, result, zero)
}
return typedResult, nil
return typed, nil
}
// =============================================================================
// Signature Types
// =============================================================================
// ECDSASignature wraps ECDSA signature from threshold library.
// ECDSASignature is a threshold ECDSA signature in the one encoding M-Chain
// stores and every verifier reads: r(32) ‖ s(32) ‖ v(1).
type ECDSASignature struct {
R []byte
S []byte
@@ -374,155 +213,12 @@ func ecdsaSigToWrapper(sig *ecdsa.Signature) (*ECDSASignature, error) {
if sig == nil {
return nil, fmt.Errorf("mpcvm: nil signature")
}
// SigEthereum builds a fixed 65-byte array and checks both component lengths
// on the way in, so a successful return is 65 bytes by construction. A
// length check here would restate that and could never fire.
eth, err := sig.SigEthereum()
if err != nil {
return nil, fmt.Errorf("mpcvm: encode signature: %w", err)
}
if len(eth) != 65 {
return nil, fmt.Errorf("mpcvm: unexpected signature length %d (want 65)", len(eth))
}
return &ECDSASignature{R: eth[0:32], S: eth[32:64], V: eth[64]}, nil
}
// SchnorrSignature wraps Schnorr signature from FROST.
type SchnorrSignature struct {
R []byte
Z []byte
}
// =============================================================================
// Key Share Wrappers - Implement KeyShare interface for each protocol
// =============================================================================
// LSSKeyShare wraps lssconfig.Config to implement KeyShare.
type LSSKeyShare struct {
Config *lssconfig.Config
}
// PublicKey returns the group public key.
func (s *LSSKeyShare) PublicKey() []byte {
point, err := s.Config.PublicPoint()
if err != nil {
return nil
}
bytes, _ := point.MarshalBinary()
return bytes
}
// PartyID returns this party's ID.
func (s *LSSKeyShare) PartyID() party.ID {
return s.Config.ID
}
// Threshold returns the threshold t.
func (s *LSSKeyShare) Threshold() int {
return s.Config.Threshold
}
// TotalParties returns total parties n.
func (s *LSSKeyShare) TotalParties() int {
return len(s.Config.Public)
}
// Generation returns the key generation number.
func (s *LSSKeyShare) Generation() uint64 {
return s.Config.Generation
}
// Protocol returns which protocol this share is for.
func (s *LSSKeyShare) Protocol() Protocol {
return ProtocolLSS
}
// Serialize converts the share to bytes for storage.
func (s *LSSKeyShare) Serialize() ([]byte, error) {
return s.Config.MarshalJSON()
}
// CMPKeyShare wraps cmpconfig.Config to implement KeyShare.
type CMPKeyShare struct {
Config *cmpconfig.Config
}
// PublicKey returns the group public key.
func (s *CMPKeyShare) PublicKey() []byte {
point := s.Config.PublicPoint()
bytes, _ := point.MarshalBinary()
return bytes
}
// PartyID returns this party's ID.
func (s *CMPKeyShare) PartyID() party.ID {
return s.Config.ID
}
// Threshold returns the threshold t.
func (s *CMPKeyShare) Threshold() int {
return s.Config.Threshold
}
// TotalParties returns total parties n.
func (s *CMPKeyShare) TotalParties() int {
return len(s.Config.Public)
}
// Generation returns the key generation number.
func (s *CMPKeyShare) Generation() uint64 {
return 0 // CMP doesn't have generation tracking
}
// Protocol returns which protocol this share is for.
func (s *CMPKeyShare) Protocol() Protocol {
return ProtocolCGGMP21
}
// Serialize converts the share to bytes for storage.
func (s *CMPKeyShare) Serialize() ([]byte, error) {
return s.Config.MarshalBinary()
}
// FROSTKeyShare wraps frostconfig.Config to implement KeyShare.
type FROSTKeyShare struct {
Config *frostconfig.Config
}
// PublicKey returns the group public key.
func (s *FROSTKeyShare) PublicKey() []byte {
bytes, _ := s.Config.PublicKey.MarshalBinary()
return bytes
}
// PartyID returns this party's ID.
func (s *FROSTKeyShare) PartyID() party.ID {
return s.Config.ID
}
// Threshold returns the threshold t.
func (s *FROSTKeyShare) Threshold() int {
return s.Config.Threshold
}
// TotalParties returns total parties n.
func (s *FROSTKeyShare) TotalParties() int {
// VerificationShares is *party.PointMap, access Points field
if s.Config.VerificationShares == nil {
return 0
}
return len(s.Config.VerificationShares.Points)
}
// Generation returns the key generation number.
func (s *FROSTKeyShare) Generation() uint64 {
return 0 // FROST doesn't have generation tracking
}
// Protocol returns which protocol this share is for.
func (s *FROSTKeyShare) Protocol() Protocol {
return ProtocolFrost
}
// Serialize converts the share to bytes for storage.
func (s *FROSTKeyShare) Serialize() ([]byte, error) {
// FROST config doesn't have built-in marshal, use JSON
return json.Marshal(s.Config)
}
+226
View File
@@ -0,0 +1,226 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// executor_test.go — the one place that knows the threshold library exists.
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/log"
"github.com/luxfi/threshold/pkg/ecdsa"
"github.com/luxfi/threshold/pkg/math/curve"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/pool"
"github.com/luxfi/threshold/pkg/protocol"
"github.com/luxfi/threshold/protocols/cmp"
)
func newExecutor(t *testing.T) *ProtocolExecutor {
t.Helper()
pl := pool.NewPool(2)
t.Cleanup(pl.TearDown)
return NewProtocolExecutor(pl, log.NewTestLogger(log.ErrorLevel))
}
// A ceremony is registered while it runs and gone when it ends, so a leaked
// handler — one whose goroutines are still holding a worker pool and a party's
// round state — is visible rather than silent.
func TestACeremonyIsTrackedWhileItRunsAndNotAfter(t *testing.T) {
pe := newExecutor(t)
require.Zero(t, pe.Live())
start := cmp.Keygen(curve.Secp256k1{}, "pa", []party.ID{"pa", "pb"}, 1, pe.pool)
handler, err := pe.CreateHandler(ctx(), "mpc/one", start)
require.NoError(t, err)
require.NotNil(t, handler)
require.Equal(t, 1, pe.Live())
pe.RemoveHandler("mpc/one")
require.Zero(t, pe.Live())
pe.RemoveHandler("mpc/one")
require.Zero(t, pe.Live(), "tearing down a ceremony twice is not an error")
pe.RemoveHandler("mpc/never-existed")
}
// A ceremony that cannot be started is reported as such rather than registered
// as a ceremony that is running.
func TestACeremonyThatCannotStartIsNotTracked(t *testing.T) {
pe := newExecutor(t)
// A start function for a party that is not in its own participant set: the
// library refuses before the first round.
start := cmp.Keygen(curve.Secp256k1{}, "stranger", []party.ID{"pa", "pb"}, 1, pe.pool)
_, err := pe.CreateHandler(ctx(), "mpc/doomed", start)
require.ErrorContains(t, err, "create ceremony handler")
require.Zero(t, pe.Live())
}
// A ceremony whose transport fails is reported as a transport failure, named
// with the ceremony, rather than as a protocol failure or a timeout.
func TestATransportFailureIsAttributedToTheTransport(t *testing.T) {
pe := newExecutor(t)
ctxT, cancel := context.WithTimeout(ctx(), 30*time.Second)
defer cancel()
start := cmp.Keygen(curve.Secp256k1{}, "pa", []party.ID{"pa", "pb"}, 1, pe.pool)
_, err := runProtocol[*ecdsa.Signature](ctxT, pe, "mpc/broken", start, &brokenRouter{})
require.Error(t, err)
require.Contains(t, err.Error(), "mpc/broken")
}
// A ceremony that produces something other than what the caller asked for is an
// error naming both types, not a zero value the caller reads as success.
func TestAnUnexpectedCeremonyResultIsNamed(t *testing.T) {
pe := newExecutor(t)
ctxT, cancel := context.WithTimeout(ctx(), 60*time.Second)
defer cancel()
ids := []party.ID{"pa", "pb"}
net, routers := newMeshRouters(ids)
defer net.close()
done := make(chan struct{})
go func() {
defer close(done)
other := NewProtocolExecutor(pe.pool, log.NewTestLogger(log.ErrorLevel))
start := cmp.Keygen(curve.Secp256k1{}, "pb", ids, 1, other.pool)
_, _ = runProtocol[*ecdsa.Signature](ctxT, other, "mpc/typed", start, routers["pb"])
}()
start := cmp.Keygen(curve.Secp256k1{}, "pa", ids, 1, pe.pool)
_, err := runProtocol[*ecdsa.Signature](ctxT, pe, "mpc/typed", start, routers["pa"])
require.ErrorContains(t, err, "produced *config.Config, want *ecdsa.Signature")
<-done
}
// A ceremony whose context is already dead does not run.
func TestACeremonyWithNoTimeLeftDoesNotRun(t *testing.T) {
pe := newExecutor(t)
dead, cancel := context.WithCancel(ctx())
cancel()
net, routers := newMeshRouters([]party.ID{"pa", "pb"})
defer net.close()
_, err := pe.RunCMPKeygen(dead, "mpc/dead", "pa", []party.ID{"pa", "pb"}, 1, routers["pa"])
require.Error(t, err)
}
// -----------------------------------------------------------------------------
// The signature encoding
// -----------------------------------------------------------------------------
// The one encoding M-Chain stores and every verifier reads: r(32) ‖ s(32) ‖
// v(1), low-S normalised, so it verifies under luxfi/crypto and under an
// on-chain ecrecover without either side re-deriving anything.
func TestASignatureIsStoredInTheEncodingEveryVerifierReads(t *testing.T) {
_, groupPub, degree := realShare(t)
require.Equal(t, 1, degree)
require.Len(t, groupPub, 33)
pe := newExecutor(t)
ctxT, cancel := context.WithTimeout(ctx(), 120*time.Second)
defer cancel()
sig := signWithRealShare(t, ctxT, pe, digestOf(1))
require.Len(t, sig.R, 32)
require.Len(t, sig.S, 32)
raw := append(append([]byte(nil), sig.R...), sig.S...)
require.True(t, secp256k1.VerifySignature(groupPub, digestOf(1), raw),
"a signature the executor produced must verify under the key the DKG produced")
require.NoError(t, verifyGroupSignature(groupPub,
digestOf(1), append(raw, sig.V)),
"and under the same check a block applies")
}
// A ceremony that produced nothing is reported as nothing, not dereferenced.
// The type assertion in runProtocol admits a typed nil, and SigEthereum has a
// value receiver, so encoding one would fault the VM rather than fail a
// ceremony.
func TestACeremonyThatProducedNothingIsNamedNotDereferenced(t *testing.T) {
_, err := ecdsaSigToWrapper(nil)
require.ErrorContains(t, err, "nil signature")
}
// -----------------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------------
// brokenRouter refuses to carry anything, which is how a ceremony fails when
// the network under it does.
type brokenRouter struct{}
func (brokenRouter) Send(*protocol.Message) error { return errors.New("carrier pigeon lost") }
func (brokenRouter) Receive() <-chan *protocol.Message { return make(chan *protocol.Message) }
// signWithRealShare runs a signing ceremony across the committee that generated
// the package's one real key, and returns the signature every signer derived.
func signWithRealShare(t *testing.T, c context.Context, pe *ProtocolExecutor, digest []byte) *ECDSASignature {
t.Helper()
cfgs := realConfigs(t)
signers := make([]party.ID, 0, len(cfgs))
for id := range cfgs {
signers = append(signers, id)
}
signers = canonicalParties(signers)
net, routers := newMeshRouters(signers)
defer net.close()
sigs := make([]*ECDSASignature, len(signers))
errs := make([]error, len(signers))
var wg sync.WaitGroup
for i, id := range signers {
wg.Add(1)
go func(i int, id party.ID) {
defer wg.Done()
exec := NewProtocolExecutor(pe.pool, log.NewTestLogger(log.ErrorLevel))
sigs[i], errs[i] = exec.RunCMPSign(c, "mpc/sign", cfgs[id], signers, digest, routers[id])
}(i, id)
}
wg.Wait()
for i, err := range errs {
require.NoErrorf(t, err, "signer %s", signers[i])
}
for i := 1; i < len(sigs); i++ {
require.Equal(t, sigs[0].R, sigs[i].R, "every honest signer derives the identical signature")
require.Equal(t, sigs[0].S, sigs[i].S)
}
return sigs[0]
}
// A signing ceremony that cannot start is reported as a failed ceremony, not as
// a signature that failed to encode.
func TestASigningCeremonyThatCannotStartIsNamed(t *testing.T) {
pe := newExecutor(t)
dead, cancel := context.WithCancel(ctx())
cancel()
cfgs := realConfigs(t)
signers := make([]party.ID, 0, len(cfgs))
for id := range cfgs {
signers = append(signers, id)
}
signers = canonicalParties(signers)
net, routers := newMeshRouters(signers)
defer net.close()
_, err := pe.RunCMPSign(dead, "mpc/dead-sign", cfgs[signers[0]], signers, digestOf(1), routers[signers[0]])
require.ErrorContains(t, err, "mpc/dead-sign")
// And a ceremony whose start function refuses is reported before any round.
_, err = pe.RunCMPKeygen(ctx(), "mpc/no-self", "stranger", signers, 1, routers[signers[0]])
require.ErrorContains(t, err, "create ceremony handler")
}
+11 -5
View File
@@ -5,7 +5,6 @@ package mpcvm
import (
"github.com/luxfi/constants"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms"
)
@@ -27,10 +26,17 @@ var _ vms.Factory = (*Factory)(nil)
// node/node/vms.go all say mpcvm.
var VMID = constants.MPCVMID
// Assert at compile time that the alias really is the shared constant, so a
// future edit cannot silently reintroduce a private literal.
var _ = map[bool]struct{}{VMID == ids.ID{'m', 'p', 'c', 'v', 'm'}: {}}
// The value is held by TestVMID_Bytes and TestVMID_IsCanonicalAndStable, which
// compare it to the literal bytes and to the CB58 the plugin binary is named
// after.
//
// It used to also carry `var _ = map[bool]struct{}{VMID == ids.ID{...}: {}}`,
// labelled a compile-time assertion. It is not one: a map literal with a
// single non-constant key compiles whatever the key evaluates to — only a
// DUPLICATE constant key is a compile error. So it held for every possible
// value of VMID, including a wrong one, while reading as proof. A check that
// cannot fail is worse than no check, because it stops anyone writing the one
// that can.
// Factory creates M-Chain VM instances.
type Factory struct{}
+451
View File
@@ -0,0 +1,451 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// fault_test.go — what a validator does when the disk under it fails.
//
// A custody chain's worst failure is not a rejected block; it is a node that
// keeps going on state it could not read. Every path below is driven by a real
// store that refuses one key, so the failure is the one that actually happens
// in the field: the read or the write, not a mocked-out layer that never had a
// disk.
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/quorum"
vmcore "github.com/luxfi/vm"
)
// breakOn wraps the VM's state store so one key fails, and restores it after
// the test. The store is reached directly because a fault in the middle of a
// block is exactly what has no other entry point.
func breakOn(t *testing.T, vm *VM, db database.Database) {
t.Helper()
was := vm.state.db
vm.state.db = db
t.Cleanup(func() { vm.state.db = was })
}
// -----------------------------------------------------------------------------
// The registry and the ceremony log
// -----------------------------------------------------------------------------
// Every read of the registry either answers or says why. There is no path that
// turns an unreadable key into "no such key", because that is the answer that
// lets a chain forget who holds the funds.
func TestAnUnreadableRegistryIsNeverAnEmptyOne(t *testing.T) {
s, raw := newState(t)
rec := sampleKeyRecord()
require.NoError(t, s.PutKey(rec))
keyOf := func(prefix, id string) []byte { return append([]byte(prefix), id...) }
s.db = &faultyDB{Database: raw, failGet: keyOf("c/key/", rec.KeyID)}
_, err := s.GetKey(rec.KeyID)
require.ErrorIs(t, err, errFaulty)
_, err = s.HasKey(rec.KeyID)
require.ErrorIs(t, err, errFaulty)
require.ErrorIs(t, s.PutKey(rec), errFaulty, "a registration must not land on a store that cannot be read")
s.db = &faultyDB{Database: raw, failPut: keyOf("c/key/", "fresh")}
fresh := sampleKeyRecord()
fresh.KeyID = "fresh"
require.ErrorIs(t, s.PutKey(fresh), errFaulty)
s.db = &faultyIterDB{Database: raw}
_, err = s.Keys()
require.Error(t, err)
}
func TestAnUnreadableCeremonyLogIsNeverAnEmptyOne(t *testing.T) {
s, raw := newState(t)
cer := &CeremonyRecord{ID: "mpc/one", Kind: OpTypeSign, KeyID: "vault"}
require.NoError(t, s.PutCeremony(cer))
s.db = &faultyDB{Database: raw, failGet: []byte("c/ceremony/mpc/one")}
_, err := s.GetCeremony("mpc/one")
require.ErrorIs(t, err, errFaulty)
require.ErrorIs(t, s.PutCeremony(cer), errFaulty)
s.db = &faultyDB{Database: raw, failPut: []byte("c/ceremony/mpc/two")}
require.ErrorIs(t, s.PutCeremony(&CeremonyRecord{ID: "mpc/two"}), errFaulty)
s.db = &faultyIterDB{Database: raw}
_, err = s.Ceremonies()
require.Error(t, err)
}
func TestAnUnreadableShareIsNeverAnAbsentOne(t *testing.T) {
s, raw := newState(t)
require.NoError(t, s.PutShare("vault", []byte("share")))
s.db = &faultyDB{Database: raw, failGet: []byte("n/share/vault")}
_, err := s.GetShare("vault")
require.ErrorIs(t, err, errFaulty)
_, err = s.HasShare("vault")
require.ErrorIs(t, err, errFaulty)
}
func TestAnUnreadableRootIsAnError(t *testing.T) {
s, raw := newState(t)
s.db = &faultyDB{Database: raw, failGet: keyRoot}
require.ErrorIs(t, s.ReadRoot(), errFaulty)
}
// -----------------------------------------------------------------------------
// Applying a block onto a failing store
// -----------------------------------------------------------------------------
// A block that cannot be written is refused whole. Nothing it staged survives,
// the root does not move, and the ceremony stays queued — so the block is
// applicable again once the store is.
func TestABlockOntoAFailingStoreLeavesNothingBehind(t *testing.T) {
for name, breaking := range map[string]func(*VM) database.Database{
"the registry write fails": func(vm *VM) database.Database {
return &faultyDB{Database: vm.state.db, failPut: []byte("c/key/vault")}
},
"the ceremony write fails": func(vm *VM) database.Database {
return &faultyDB{Database: vm.state.db, failPut: []byte("c/ceremony/")}
},
"the registry read fails": func(vm *VM) database.Database {
return &faultyDB{Database: vm.state.db, failGet: []byte("c/key/vault")}
},
} {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 130)
op := key.keygenOp(t)
blk := blockOver(t, vm, op)
require.NoErrorf(t, blk.Verify(ctx()), "%s: the block is valid before the store fails", name)
rootBefore := vm.state.Root()
tipBefore, heightBefore := vm.chain.Tip()
broken := breaking(vm)
if f, ok := broken.(*faultyDB); ok && string(f.failPut) == "c/ceremony/" {
f.failPut = []byte("c/ceremony/" + op.CeremonyID)
}
breakOn(t, vm, broken)
require.Errorf(t, blk.Accept(ctx()), "%s: a block that cannot be written must not be accepted", name)
vm.state.db = vm.chain.View()
require.Equalf(t, rootBefore, vm.state.Root(), "%s: the root must not have moved", name)
_, err := vm.state.GetKey("vault")
require.Errorf(t, err, "%s: no key may survive a block the chain refused", name)
tip, height := vm.chain.Tip()
require.Equalf(t, tipBefore, tip, "%s", name)
require.Equalf(t, heightBefore, height, "%s", name)
}
}
// A block whose operation cannot be checked against the registry is refused,
// rather than admitted because the check could not run.
func TestAnUncheckableOperationIsRefusedNotAdmitted(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 131)
blk := blockOver(t, vm, key.keygenOp(t))
breakOn(t, vm, &faultyDB{Database: vm.state.db, failGet: []byte("c/key/vault")})
require.ErrorIs(t, blk.Verify(ctx()), errFaulty)
}
// A record whose structure would fail validation is refused at Verify, before
// it can reach the registry — the same rule PutKey holds, one step earlier so
// the block is named rather than the write.
func TestAStructurallyInvalidRecordIsRefusedAtVerify(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 132)
op := key.keygenOp(t)
op.Key.Kind = "" // a key bound to no protocol
require.Error(t, blockOver(t, vm, op).Verify(ctx()))
}
// A group key that is 33 bytes and not a point derives no address, and a
// registration that cannot derive its own custody address is refused rather
// than registered with an empty one.
func TestAKeyThatIsNotAPointRegistersNoAddress(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 133)
op := key.keygenOp(t)
notAPoint := make([]byte, 33)
notAPoint[0] = 0x02 // a well-formed prefix over an x that is not on the curve
op.Key.GroupPublicKey = notAPoint
require.Nil(t, publicKeyToAddress(notAPoint))
require.ErrorIs(t, blockOver(t, vm, op).Verify(ctx()), ErrInvalidOperation)
}
// -----------------------------------------------------------------------------
// Booting onto a failing store
// -----------------------------------------------------------------------------
// A node whose state cannot be read does not start, and a node whose genesis
// cannot be committed does not start either — the second is what makes the
// genesis root durable, so a node that skipped it would come back deriving a
// root rather than reading one.
func TestANodeThatCannotReadOrSeedItsStateDoesNotStart(t *testing.T) {
vm := &VM{}
require.ErrorIs(t, vm.Initialize(ctx(), vmcore.Init{
Runtime: &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), ChainID: ids.GenerateTestID()},
DB: &faultyDB{Database: memdb.New(), failGet: keyRoot},
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
}), errFaulty)
// The genesis root is staged through the view and made durable by one
// commit; a store that will not flush must not report a started chain.
refusing := &refusingDB{Database: memdb.New(), refuse: true}
vm = &VM{}
require.ErrorIs(t, vm.Initialize(ctx(), vmcore.Init{
Runtime: &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), ChainID: ids.GenerateTestID()},
DB: refusing,
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
}), errRefused)
}
// A tip pointer that names a block the store does not hold is a corrupted
// store, not an empty chain.
func TestATipNamingNoBlockDoesNotStart(t *testing.T) {
db := memdb.New()
require.NoError(t, db.Put([]byte("chain/tip"), idBytes(ids.GenerateTestID())))
vm := &VM{}
err := vm.Initialize(ctx(), vmcore.Init{
Runtime: &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), ChainID: ids.GenerateTestID()},
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
})
require.ErrorContains(t, err, "open chain state")
}
// A share that is on disk but unreadable stops the boot, rather than being
// skipped as "not a participant" — those are different facts and only one of
// them is safe to assume.
func TestAnUnreadableShareIsNotMistakenForNotParticipating(t *testing.T) {
raw, pub, degree := realShare(t)
rec := custodyFor(t, "vault", quorum.MustNew(degree+1, degree+2), pub)
db, chainID := memdb.New(), ids.GenerateTestID()
seedRegistryAndShare(t, db, chainID, rec, raw)
vm := &VM{}
err := vm.Initialize(ctx(), vmcore.Init{
Runtime: &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), ChainID: chainID},
DB: &faultyDB{Database: db, failGet: []byte("n/share/vault")},
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
})
require.ErrorIs(t, err, errFaulty)
}
// A keygen ceremony that cannot read the registry does not proceed to generate
// a key it might not be allowed to register.
func TestAKeygenThatCannotReadTheRegistryDoesNotRun(t *testing.T) {
self, peer := ids.NodeID{1}, ids.NodeID{2}
vm := openVM(t, memdb.New(), ids.GenerateTestID(),
weightedValidators(map[ids.NodeID]uint64{self: 10, peer: 10}), nil)
defer vm.Shutdown(ctx())
vm.partyID = party.ID(self.String())
breakOn(t, vm, &faultyDB{Database: vm.state.db, failGet: []byte("c/key/vault")})
_, err := vm.StartKeygenWithPolicy(ctx(), "vault", quorum.MustNew(2, 2),
authenticatedFor(vm, "B-Chain", true))
require.ErrorIs(t, err, errFaulty)
}
// The engine is told the node is unhealthy, and the HTTP probe says the same
// thing from the same call.
func TestAnUnreadableRegistryIsReportedAsUnhealthyEverywhere(t *testing.T) {
vm := newVM(t)
breakOn(t, vm, &faultyIterDB{Database: vm.state.db})
_, err := vm.HealthCheck(ctx())
require.Error(t, err)
w := httptest.NewRecorder()
vm.handleHealth(w, httptest.NewRequest(http.MethodGet, "/health", nil))
require.Equal(t, http.StatusServiceUnavailable, w.Code)
}
// -----------------------------------------------------------------------------
// Failures over the wire
// -----------------------------------------------------------------------------
// Every list method reports the failure rather than an empty list, and every
// one of them classifies it the same way.
func TestAnUnreadableStateIsReportedOverRPCNotHiddenAsEmpty(t *testing.T) {
vm := newVM(t)
breakOn(t, vm, &faultyIterDB{Database: vm.state.db})
_, srv := serve(t, vm)
for _, method := range []string{"mpc_listKeys", "mpc_listCeremonies", "threshold_getInfo"} {
got := call(t, srv, method, nil)
require.NotNilf(t, got.Error, "%s must report the fault", method)
require.Equalf(t, RPCErrorInternal, got.Error.Code, "%s", method)
}
}
// One classifier, exercised over every failure it names. A method that already
// classified its own failure keeps that code; anything else gets this table's.
func TestOneClassifierNamesEveryFailure(t *testing.T) {
for _, tc := range []struct {
err error
want int
}{
{ErrUnauthorizedChain, RPCErrorUnauthorized},
{ErrQuotaExceeded, RPCErrorQuotaExceeded},
{ErrUnknownKey, RPCErrorKeyNotFound},
{ErrShareNotHeld, RPCErrorKeyNotFound},
{errFaulty, RPCErrorInternal},
{&RPCError{Code: RPCErrorCeremonyNotFound, Message: "already classified"}, RPCErrorCeremonyNotFound},
} {
require.Equal(t, tc.want, asRPCError(tc.err).Code, "%v", tc.err)
}
// And through a wrap, because the VM wraps its errors with context.
wrapped := &RPCError{Code: RPCErrorQuotaExceeded, Message: "over"}
require.Equal(t, RPCErrorQuotaExceeded, asRPCError(wrapError(wrapped)).Code)
}
// A client whose server refuses reports the refusal on every method, rather
// than a zero value the caller reads as an answer.
func TestEveryClientMethodReportsARefusal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"the disk fell off"}}`))
}))
defer srv.Close()
c := NewClient(srv.URL, "B-Chain")
for name, call := range map[string]func() error{
"GetCeremony": func() error { _, err := c.GetCeremony(ctx(), "mpc/x"); return err },
"ListCeremonies": func() error { _, err := c.ListCeremonies(ctx()); return err },
"StateRoot": func() error { _, err := c.StateRoot(ctx()); return err },
"ListKeys": func() error { _, err := c.ListKeys(ctx()); return err },
"GetKey": func() error { _, err := c.GetKey(ctx(), "vault"); return err },
"GetPublicKey": func() error { _, err := c.GetPublicKey(ctx(), "vault"); return err },
"GetAddress": func() error { _, err := c.GetAddress(ctx(), "vault"); return err },
"GetInfo": func() error { _, err := c.GetInfo(ctx()); return err },
"GetStats": func() error { _, err := c.GetStats(ctx()); return err },
"GetQuota": func() error { _, err := c.GetQuota(ctx()); return err },
"Health": func() error { _, err := c.Health(ctx()); return err },
} {
require.ErrorContainsf(t, call(), "the disk fell off", "%s swallowed the refusal", name)
}
}
// A request that cannot even be encoded is reported as such, rather than sent
// as something else.
func TestARequestThatCannotBeEncodedIsNotSent(t *testing.T) {
c := NewClient("http://127.0.0.1:1", "B-Chain")
err := c.call(ctx(), "threshold_getInfo", make(chan int), nil)
require.ErrorContains(t, err, "failed to marshal request")
}
// A response that stops mid-body is a failure, not a short answer.
func TestATruncatedResponseIsAFailureNotAShortAnswer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "4096")
_, _ = w.Write([]byte(`{"jsonrpc":"2.0"`))
}))
defer srv.Close()
_, err := NewClient(srv.URL, "B-Chain").GetInfo(ctx())
require.ErrorContains(t, err, "failed to read response")
}
// wrapError puts an error behind another, which is what the VM's own error
// paths do and what errors.As must see through.
func wrapError(err error) error { return &wrapped{err} }
type wrapped struct{ err error }
func (w *wrapped) Error() string { return "context: " + w.err.Error() }
func (w *wrapped) Unwrap() error { return w.err }
func idBytes(id ids.ID) []byte { return id[:] }
// -----------------------------------------------------------------------------
// A node whose store holds bytes that are not records
// -----------------------------------------------------------------------------
// Enumerating the registry decodes every entry, and an entry that does not
// decode fails the enumeration rather than being skipped. A skipped custody key
// is a key the chain believes it does not hold.
func TestAnUndecodableEntryFailsTheEnumeration(t *testing.T) {
s, raw := newState(t)
require.NoError(t, s.PutKey(sampleKeyRecord()))
require.NoError(t, raw.Put([]byte("c/key/junk"), []byte("not a key record")))
_, err := s.Keys()
require.Error(t, err)
require.NoError(t, s.PutCeremony(&CeremonyRecord{ID: "mpc/one"}))
require.NoError(t, raw.Put([]byte("c/ceremony/junk"), []byte("not a ceremony")))
_, err = s.Ceremonies()
require.Error(t, err)
}
// -----------------------------------------------------------------------------
// A ceremony with no transport
// -----------------------------------------------------------------------------
// A node with no p2p cannot run a ceremony, and every entry point says so with
// the same reason rather than hanging or producing a one-party artifact.
func TestACeremonyWithNoTransportIsRefusedAtEveryEntryPoint(t *testing.T) {
self, peer := ids.NodeID{1}, ids.NodeID{2}
vm := openVM(t, memdb.New(), ids.GenerateTestID(),
weightedValidators(map[ids.NodeID]uint64{self: 10, peer: 10}), nil)
defer vm.Shutdown(ctx())
vm.partyID = party.ID(self.String())
// Keygen reaches the ceremony and finds no way to carry it.
_, err := vm.StartKeygenWithPolicy(ctx(), "vault", quorum.MustNew(2, 2),
authenticatedFor(vm, "B-Chain", true))
require.ErrorContains(t, err, "no warp sender")
// And so does signing, once the key and the share are in place.
key := newCustody(t, "vault", quorum.MustNew(2, 2), 140)
key.rec.Participants = []party.ID{party.ID(self.String()), party.ID(peer.String())}
key.rec.Participants = canonicalParties(key.rec.Participants)
key.register(t, vm)
key.hold(vm, testShare(t, key.rec.GroupPublicKey, key.rec.Degree()))
digest := digestOf(1)
vm.partyID = quorumFor(key.rec, digest)[0]
_, err = vm.RequestSignature(ctx(), authenticatedFor(vm, "B-Chain", false), "vault", digest)
require.ErrorContains(t, err, "no warp sender")
}
// A signer that is not a validator id cannot be routed to, and the ceremony
// says which one rather than stalling on a round nobody receives.
func TestASignerThatIsNotAValidatorIdIsNamedNotAwaited(t *testing.T) {
self := ids.GenerateTestNodeID()
fab := newMemFabric()
vm := newFabricVM(t, fab, self, oneValidator(self))
defer vm.Shutdown(ctx())
// A key whose committee is names, not node ids — which the block rules
// permit, because routing is the transport's business and not consensus'.
key := newCustody(t, "vault", quorum.MustNew(2, 3), 141)
key.register(t, vm)
key.hold(vm, testShare(t, key.rec.GroupPublicKey, key.rec.Degree()))
digest := digestOf(1)
vm.partyID = quorumFor(key.rec, digest)[0]
_, err := vm.RequestSignature(ctx(), authenticatedFor(vm, "B-Chain", false), "vault", digest)
require.ErrorContains(t, err, "is not a node id")
}
+14 -9
View File
@@ -7,13 +7,18 @@ import (
"github.com/luxfi/node/vms/types/fee"
)
// gateUserTx refuses every caller — M-Chain accepts no user txs. It is a
// committee-driven service VM: keygen and signing requests come from validators
// through consensus, not a user mempool. Any service entry that exposes itself
// as user-callable MUST route through this gate, so the refusal is explicit
// rather than a path nobody happened to write.
func (vm *VM) gateUserTx() error { return vm.fee.Admit(0) }
// Fee is what M-Chain charges to admit a user transaction, which is nothing,
// because it admits none: it is a committee-driven service VM, and keygen and
// signing requests reach it from validators through consensus rather than from
// a mempool. Initialize pins the closed sentinel and the node's boot-time
// nodefee.Validate reads it back through here.
//
// There was also a gateUserTx() wrapper, described as the gate every
// user-callable entry MUST route through. Nothing routed through it, because
// M-Chain has no user-callable entry — a refusal nobody can reach is a
// statement, and this is where the statement belongs.
func (vm *VM) Fee() fee.Policy { return vm.fee.Policy() }
// FeePolicy exposes the chain's declared fee policy for diagnostics and the
// boot-time Validate gate.
func (vm *VM) FeePolicy() fee.Policy { return vm.fee.Policy() }
// AdmitUserTx is the refusal itself, so "M-Chain takes no user transactions"
// is a value that can be exercised rather than a comment.
func (vm *VM) AdmitUserTx(paid uint64) error { return vm.fee.Admit(paid) }
+39 -15
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
@@ -7,27 +7,51 @@ import (
"errors"
"testing"
"github.com/luxfi/chains/chain"
"github.com/luxfi/log"
"github.com/luxfi/node/vms/types/fee"
)
func TestThresholdVM_FeePolicy_IsNoUserTxSentinel(t *testing.T) {
v := &VM{log: log.NewNoOpLogger(), fee: chain.Closed()}
if v.FeePolicy() == nil {
t.Fatal("FeePolicy() = nil; want NoUserTxPolicy")
// M-Chain declares the closed policy: it is a committee-driven service VM and
// there is no mempool to admit anything into. The node's boot-time
// nodefee.Validate reads this declaration, and it passes for the sentinel with
// no minimum fee — which for any OTHER policy would be flagged as a zero-fee
// user-facing chain.
func TestMChainDeclaresItTakesNoUserTransactions(t *testing.T) {
vm := newVM(t)
policy := vm.Fee()
if policy == nil {
t.Fatal("Fee() = nil; a chain that declares no policy is a chain with no declared cost")
}
if _, ok := v.FeePolicy().(fee.NoUserTxPolicy); !ok {
t.Fatalf("FeePolicy() = %T, want NoUserTxPolicy", v.FeePolicy())
if _, ok := policy.(fee.NoUserTxPolicy); !ok {
t.Fatalf("Fee() = %T, want NoUserTxPolicy", policy)
}
if err := fee.Validate(v.FeePolicy()); err != nil {
t.Errorf("fee.Validate(NoUserTxPolicy) = %v, want nil", err)
if err := fee.Validate(policy); err != nil {
t.Fatalf("the node's boot gate rejects M-Chain's own declaration: %v", err)
}
}
func TestThresholdVM_FeePolicy_RejectsAllUserTx(t *testing.T) {
v := &VM{log: log.NewNoOpLogger(), fee: chain.Closed()}
if err := v.gateUserTx(); !errors.Is(err, fee.ErrChainAcceptsNoUserTxs) {
t.Fatalf("gateUserTx() = %v, want ErrChainAcceptsNoUserTxs", err)
// Refusal does not depend on how much is offered. A policy that admitted a
// large enough payment would make "M-Chain has no user entry" a matter of
// price.
func TestNoPaymentBuysAdmission(t *testing.T) {
vm := newVM(t)
for _, paid := range []uint64{0, 1, 1_000_000, 1 << 62} {
if err := vm.AdmitUserTx(paid); !errors.Is(err, fee.ErrChainAcceptsNoUserTxs) {
t.Errorf("AdmitUserTx(%d) = %v, want ErrChainAcceptsNoUserTxs", paid, err)
}
}
}
// A VM that never ran Initialize holds the zero Fee, and the zero Fee admits
// nothing. That is the direction the default must fail in: a chain that forgot
// to declare a policy refuses every caller rather than admitting every caller.
func TestAnUndeclaredPolicyRefusesEveryone(t *testing.T) {
var vm VM
if err := vm.AdmitUserTx(0); !errors.Is(err, fee.ErrChainAcceptsNoUserTxs) {
t.Fatalf("the zero Fee admitted a caller: %v", err)
}
if vm.Fee() != nil {
t.Fatalf("the zero Fee reports a policy: %v", vm.Fee())
}
}
+174
View File
@@ -6,6 +6,7 @@
package fhe
import (
"io"
"testing"
"github.com/luxfi/lattice/v7/ring"
@@ -203,3 +204,176 @@ func BenchmarkBatchPolyMulAccel(b *testing.B) {
b.ReportMetric(float64(batchSize*b.N)/b.Elapsed().Seconds(), "muls/sec")
}
// ---------------------------------------------------------------------------
// What the accelerator does without a GPU behind it
// ---------------------------------------------------------------------------
// discardLogger is a real logger over a writer that keeps nothing. It matters
// that it is real: the accelerator skips its whole reporting block when the
// logger IsZero, which log.Noop is, so a Noop logger leaves that path unrun.
func discardLogger() log.Logger { return log.NewWriter(io.Discard) }
// ring8192 is the smallest ring that clears the accelerator's own GPU
// thresholds (N >= 8192), so a batch large enough to be offered to a GPU can be
// built.
func ring8192(t testing.TB) *ring.Ring {
t.Helper()
r, err := ring.NewRing(8192, []uint64{0x7fffffffe0001})
require.NoError(t, err)
return r
}
func filled(t testing.TB, r *ring.Ring, n int) []ring.Poly {
t.Helper()
out := make([]ring.Poly, n)
for i := range out {
out[i] = r.NewPoly()
for j := range out[i].Coeffs[0] {
out[i].Coeffs[0][j] = uint64(i*7 + j)
}
}
return out
}
// TestAcceleratorReportsCPUWithoutAGPU holds what the accelerator says about
// itself on a host with no GPU backend: not enabled, and a backend name that
// says so rather than naming a device that is not there. Callers pick batch
// sizes off this.
func TestAcceleratorReportsCPUWithoutAGPU(t *testing.T) {
accel, err := NewFHEAccelerator(discardLogger())
require.NoError(t, err)
require.False(t, accel.IsEnabled())
require.Equal(t, "CPU (GPU not available)", accel.Backend())
require.Equal(t, FHEStats{}, accel.Stats())
// Asking for the CPU backend explicitly reaches the same place.
cpu, err := NewFHEAcceleratorWithOptions(discardLogger(), FHEOptions{Backend: "cpu"})
require.NoError(t, err)
require.False(t, cpu.IsEnabled())
require.Equal(t, "CPU (GPU not available)", cpu.Backend())
}
// TestAcceleratorMatchesTheCPUReference holds the property the whole
// accelerator exists to preserve: whichever path a batch takes, the
// coefficients that come out are the ones the ring's own NTT produces. A
// wrong-but-fast transform is worse than no acceleration at all.
func TestAcceleratorMatchesTheCPUReference(t *testing.T) {
accel, err := NewFHEAccelerator(discardLogger())
require.NoError(t, err)
r := ring8192(t)
// Batch sizes straddling every threshold in the three batch paths: 0, the
// polymul/inverse cutoff at 4, and the forward cutoff at 64.
for _, n := range []int{0, 1, 4, 8, 64} {
batch := filled(t, r, n)
reference := filled(t, r, n)
for i := range reference {
r.NTT(reference[i], reference[i])
}
require.NoError(t, accel.BatchNTTForward(r, batch))
for i := range batch {
require.Equal(t, reference[i].Coeffs[0], batch[i].Coeffs[0], "n=%d poly %d", n, i)
}
require.NoError(t, accel.BatchNTTInverse(r, batch))
original := filled(t, r, n)
for i := range batch {
require.Equal(t, original[i].Coeffs[0], batch[i].Coeffs[0], "n=%d poly %d round trip", n, i)
}
out := filled(t, r, n)
require.NoError(t, accel.BatchPolyMul(r, batch, original, out))
for i := range out {
want := r.NewPoly()
r.MulCoeffsBarrett(batch[i], original[i], want)
require.Equal(t, want.Coeffs[0], out[i].Coeffs[0], "n=%d poly %d", n, i)
}
}
}
// TestBatchPolyMulRefusesMismatchedBatches holds that three slices of different
// lengths are refused rather than multiplied up to the shortest. Silently
// truncating would leave the tail of the output holding whatever it held
// before, which downstream reads as a valid product.
func TestBatchPolyMulRefusesMismatchedBatches(t *testing.T) {
accel, err := NewFHEAccelerator(discardLogger())
require.NoError(t, err)
r := ring8192(t)
a, b, out := filled(t, r, 4), filled(t, r, 3), filled(t, r, 4)
require.ErrorContains(t, accel.BatchPolyMul(r, a, b, out), "batch size mismatch")
require.ErrorContains(t, accel.BatchPolyMul(r, a, out, b), "batch size mismatch")
}
// TestEnabledAcceleratorWithoutASessionFallsBackToCPU holds the guard that
// stands between "GPU acceleration is on" and "there is a session to run it
// on". Close() leaves exactly that state -- enabled cleared, session cleared --
// and the batch paths check the session again after taking the lock, because
// Close can land between the enabled check and the dispatch. Without the guard
// a batch on a closed accelerator dereferences nil in the middle of a block.
func TestEnabledAcceleratorWithoutASessionFallsBackToCPU(t *testing.T) {
accel, err := NewFHEAccelerator(discardLogger())
require.NoError(t, err)
r := ring8192(t)
// The state a host with a GPU has, minus the session.
accel.enabled = true
for _, n := range []int{4, 64} {
batch := filled(t, r, n)
reference := filled(t, r, n)
for i := range reference {
r.NTT(reference[i], reference[i])
}
require.NoError(t, accel.BatchNTTForward(r, batch))
for i := range batch {
require.Equal(t, reference[i].Coeffs[0], batch[i].Coeffs[0], "n=%d poly %d", n, i)
}
require.NoError(t, accel.BatchNTTInverse(r, batch))
require.NoError(t, accel.BatchPolyMul(r, batch, batch, filled(t, r, n)))
}
// It still says it is enabled, and names its backend from the session it
// does not have.
require.True(t, accel.IsEnabled())
require.Equal(t, "CPU (GPU not available)", accel.Backend())
}
// TestCloseDisablesTheAccelerator holds that Close leaves the accelerator
// usable and CPU-backed rather than half-torn-down: the session it borrowed
// belongs to the accel package, so Close drops the reference instead of
// releasing a session other holders are still using.
func TestCloseDisablesTheAccelerator(t *testing.T) {
accel, err := NewFHEAccelerator(discardLogger())
require.NoError(t, err)
accel.enabled = true
accel.Close()
require.False(t, accel.IsEnabled())
require.Nil(t, accel.session)
require.Equal(t, "CPU (GPU not available)", accel.Backend())
// Close is idempotent, and ClearCache has nothing to clear.
accel.Close()
accel.ClearCache()
r := ring8192(t)
require.NoError(t, accel.BatchNTTForward(r, filled(t, r, 8)))
}
// TestGlobalAcceleratorIsOne holds that the process-wide accelerator is built
// once and shared. Two accelerators would keep two sets of statistics and, on a
// GPU host, two claims on the same device.
func TestGlobalAcceleratorIsOne(t *testing.T) {
first, err := GetFHEAccelerator()
require.NoError(t, err)
require.NotNil(t, first)
second, err := GetFHEAccelerator()
require.NoError(t, err)
require.Same(t, first, second)
}
+11 -10
View File
@@ -1,5 +1,3 @@
//go:build cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
@@ -650,10 +648,10 @@ func TestContributeShareAlreadyComplete(t *testing.T) {
require := require.New(t)
logger := log.Noop()
// Use very low threshold for easier testing
// Smallest quorum the constructor accepts, so two shares finish the session.
params, _ := ckks.NewParametersFromLiteral(ckks.ExampleParameters128BitLogN14LogQP438)
config := ThresholdConfig{
Threshold: 1,
Threshold: 2,
TotalParties: 2,
CKKSParams: params,
LogBound: 128,
@@ -684,18 +682,21 @@ func TestContributeShareAlreadyComplete(t *testing.T) {
err = integration.InitiateDecryption(sessionID, requestID, ctBytes)
require.NoError(err)
// Generate a share
shareBytes, err := integration.GenerateShare(sessionID)
// Two shares reach the threshold of 2
first, err := integration.GenerateShare(sessionID)
require.NoError(err)
complete, err := integration.ContributeShare(sessionID, ids.GenerateTestNodeID(), first)
require.NoError(err)
require.False(complete)
// Contribute share - threshold is 1 so this should complete
nodeID := ids.GenerateTestNodeID()
complete, err := integration.ContributeShare(sessionID, nodeID, shareBytes)
second, err := integration.GenerateShare(sessionID)
require.NoError(err)
complete, err = integration.ContributeShare(sessionID, ids.GenerateTestNodeID(), second)
require.NoError(err)
require.True(complete)
// Try to contribute again - should return true (already complete)
complete, err = integration.ContributeShare(sessionID, ids.GenerateTestNodeID(), shareBytes)
complete, err = integration.ContributeShare(sessionID, ids.GenerateTestNodeID(), second)
require.NoError(err)
require.True(complete)
}
+54 -12
View File
@@ -43,6 +43,48 @@ func DefaultThresholdConfig() ThresholdConfig {
}
}
// checkThreshold rejects a (threshold, total) pair that is not a quorum.
// Both decryption paths finalize a session on shareCount >= threshold, so the
// pair is the only thing standing between a ciphertext and its plaintext:
// a threshold below 1 finalizes on the first share to arrive, a threshold of 1
// over more than one party lets any single party decrypt alone -- which is the
// one property a t-of-n scheme exists to deny -- and a threshold above the
// party count can never be met, so every request hangs until it expires.
func checkThreshold(threshold, total int) error {
if total < 1 {
return fmt.Errorf("%w: committee of %d parties", ErrInvalidThreshold, total)
}
least := 2
if total == 1 {
least = 1
}
if threshold < least || threshold > total {
return fmt.Errorf("%w: %d of %d parties, want %d to %d", ErrInvalidThreshold, threshold, total, least, total)
}
return nil
}
// parseCiphertext deserializes ciphertext bytes that arrived from off this
// chain. rlwe's decoder sizes its allocations from lengths it reads out of the
// input, so a crafted payload reaches make() with a length out of range and
// panics instead of returning an error -- sixteen bytes are enough. Both
// callers run on a goroutine that services cross-chain requests and neither
// recovers, so that panic ends the process. Every other malformed input already
// comes back as an error here; this makes the crafted one do the same.
func parseCiphertext(params ckks.Parameters, data []byte) (ct *rlwe.Ciphertext, err error) {
defer func() {
if r := recover(); r != nil {
ct, err = nil, fmt.Errorf("unmarshal ciphertext: %v", r)
}
}()
ct = rlwe.NewCiphertext(params.Parameters, 1, params.MaxLevel())
if err := ct.UnmarshalBinary(data); err != nil {
return nil, fmt.Errorf("unmarshal ciphertext: %w", err)
}
return ct, nil
}
// ThresholdFHEIntegration integrates threshold FHE with ThresholdVM
type ThresholdFHEIntegration struct {
logger log.Logger
@@ -87,6 +129,10 @@ type DecryptionSession struct {
// NewThresholdFHEIntegration creates a new integration instance
func NewThresholdFHEIntegration(logger log.Logger, config ThresholdConfig, partyID int) (*ThresholdFHEIntegration, error) {
if err := checkThreshold(config.Threshold, config.TotalParties); err != nil {
return nil, err
}
service := NewFHEDecryptionService(logger)
// Create E2S protocol
@@ -139,9 +185,9 @@ func (i *ThresholdFHEIntegration) InitiateDecryption(
}
// Deserialize ciphertext
ct := rlwe.NewCiphertext(i.params.Parameters, 1, i.params.MaxLevel())
if err := ct.UnmarshalBinary(ciphertextBytes); err != nil {
return fmt.Errorf("unmarshal ciphertext: %w", err)
ct, err := parseCiphertext(i.params, ciphertextBytes)
if err != nil {
return err
}
i.sessions[sessionID] = &DecryptionSession{
@@ -246,9 +292,7 @@ func (i *ThresholdFHEIntegration) ContributeShare(
// Check if we have enough shares
if session.ShareCount >= i.config.Threshold {
if err := i.completeDecryption(session); err != nil {
return false, fmt.Errorf("complete decryption: %w", err)
}
i.completeDecryption(session)
return true, nil
}
@@ -256,7 +300,7 @@ func (i *ThresholdFHEIntegration) ContributeShare(
}
// completeDecryption finishes decryption when threshold is reached
func (i *ThresholdFHEIntegration) completeDecryption(session *DecryptionSession) error {
func (i *ThresholdFHEIntegration) completeDecryption(session *DecryptionSession) {
i.logger.Info("Threshold reached, completing decryption",
"sessionID", session.ID,
"shares", session.ShareCount,
@@ -283,10 +327,10 @@ func (i *ThresholdFHEIntegration) completeDecryption(session *DecryptionSession)
values := make([]complex128, len(recoveredShare.Value))
scale := new(big.Float).SetPrec(256).SetFloat64(math.Pow(2, float64(i.params.DefaultScale().Log2())))
// Every entry is allocated by multiparty.NewAdditiveShareBigint, which
// fills the slice with new(big.Int); GetShare only writes into them. There
// is no nil to skip.
for idx, v := range recoveredShare.Value {
if v == nil {
continue
}
fv := new(big.Float).SetPrec(256).SetInt(v)
fv.Quo(fv, scale)
realVal, _ := fv.Float64()
@@ -302,8 +346,6 @@ func (i *ThresholdFHEIntegration) completeDecryption(session *DecryptionSession)
"requestID", session.RequestID.Hex(),
"resultLen", len(session.Result),
)
return nil
}
// GetSessionResult retrieves the result of a completed session
+86 -27
View File
@@ -1,5 +1,3 @@
//go:build cgo
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
@@ -420,7 +418,7 @@ func TestContributeShareSuccess(t *testing.T) {
params, _ := ckks.NewParametersFromLiteral(ckks.ExampleParameters128BitLogN14LogQP438)
config := ThresholdConfig{
Threshold: 1, // Low threshold for testing
Threshold: 2, // Smallest quorum the constructor accepts
TotalParties: 3,
CKKSParams: params,
LogBound: 128,
@@ -437,13 +435,17 @@ func TestContributeShareSuccess(t *testing.T) {
err := integration.InitiateDecryption(sessionID, requestID, ctBytes)
require.NoError(err)
shareBytes, err := integration.GenerateShare(sessionID)
first, err := integration.GenerateShare(sessionID)
require.NoError(err)
complete, err := integration.ContributeShare(sessionID, ids.GenerateTestNodeID(), first)
require.NoError(err)
require.False(complete) // one of two
nodeID := ids.GenerateTestNodeID()
complete, err := integration.ContributeShare(sessionID, nodeID, shareBytes)
second, err := integration.GenerateShare(sessionID)
require.NoError(err)
require.True(complete) // threshold is 1
complete, err = integration.ContributeShare(sessionID, ids.GenerateTestNodeID(), second)
require.NoError(err)
require.True(complete)
}
func TestContributeShareInvalidSession(t *testing.T) {
@@ -499,7 +501,7 @@ func TestContributeShareSessionAlreadyComplete(t *testing.T) {
params, _ := ckks.NewParametersFromLiteral(ckks.ExampleParameters128BitLogN14LogQP438)
config := ThresholdConfig{
Threshold: 1, // Completes with 1 share
Threshold: 2, // Completes on the second share
TotalParties: 3,
CKKSParams: params,
LogBound: 128,
@@ -516,18 +518,21 @@ func TestContributeShareSessionAlreadyComplete(t *testing.T) {
err := integration.InitiateDecryption(sessionID, requestID, ctBytes)
require.NoError(err)
shareBytes, err := integration.GenerateShare(sessionID)
// Complete the session
first, err := integration.GenerateShare(sessionID)
require.NoError(err)
_, err = integration.ContributeShare(sessionID, ids.GenerateTestNodeID(), first)
require.NoError(err)
// Complete the session
nodeID1 := ids.GenerateTestNodeID()
complete, err := integration.ContributeShare(sessionID, nodeID1, shareBytes)
second, err := integration.GenerateShare(sessionID)
require.NoError(err)
complete, err := integration.ContributeShare(sessionID, ids.GenerateTestNodeID(), second)
require.NoError(err)
require.True(complete)
// Try to contribute to already complete session
nodeID2 := ids.GenerateTestNodeID()
complete, err = integration.ContributeShare(sessionID, nodeID2, shareBytes)
nodeID3 := ids.GenerateTestNodeID()
complete, err = integration.ContributeShare(sessionID, nodeID3, second)
require.NoError(err)
require.True(complete) // returns true because already complete
}
@@ -623,7 +628,7 @@ func TestGetSessionResultSuccess(t *testing.T) {
params, _ := ckks.NewParametersFromLiteral(ckks.ExampleParameters128BitLogN14LogQP438)
config := ThresholdConfig{
Threshold: 1,
Threshold: 2,
TotalParties: 2,
CKKSParams: params,
LogBound: 128,
@@ -640,12 +645,12 @@ func TestGetSessionResultSuccess(t *testing.T) {
err := integration.InitiateDecryption(sessionID, requestID, ctBytes)
require.NoError(err)
shareBytes, err := integration.GenerateShare(sessionID)
require.NoError(err)
nodeID := ids.GenerateTestNodeID()
_, err = integration.ContributeShare(sessionID, nodeID, shareBytes)
require.NoError(err)
for i := 0; i < 2; i++ {
shareBytes, shareErr := integration.GenerateShare(sessionID)
require.NoError(shareErr)
_, err = integration.ContributeShare(sessionID, ids.GenerateTestNodeID(), shareBytes)
require.NoError(err)
}
result, complete, err := integration.GetSessionResult(sessionID)
require.NoError(err)
@@ -912,7 +917,7 @@ func TestMultipleSessionsParallel(t *testing.T) {
params, _ := ckks.NewParametersFromLiteral(ckks.ExampleParameters128BitLogN14LogQP438)
config := ThresholdConfig{
Threshold: 1,
Threshold: 2,
TotalParties: 2,
CKKSParams: params,
LogBound: 128,
@@ -933,12 +938,14 @@ func TestMultipleSessionsParallel(t *testing.T) {
// Complete each session
for _, sessionID := range sessionIDs {
shareBytes, err := integration.GenerateShare(sessionID)
require.NoError(err)
var complete bool
for i := 0; i < 2; i++ {
shareBytes, err := integration.GenerateShare(sessionID)
require.NoError(err)
nodeID := ids.GenerateTestNodeID()
complete, err := integration.ContributeShare(sessionID, nodeID, shareBytes)
require.NoError(err)
complete, err = integration.ContributeShare(sessionID, ids.GenerateTestNodeID(), shareBytes)
require.NoError(err)
}
require.True(complete)
}
@@ -1038,3 +1045,55 @@ func BenchmarkDecodeComplexValues(b *testing.B) {
_ = decodeComplexValuesFromBytes(encoded)
}
}
// TestNewThresholdFHEIntegrationRejectsUnusableParameters holds that a
// parameter set the E2S protocol cannot be built over is refused at
// construction, not at the first decryption request.
func TestNewThresholdFHEIntegrationRejectsUnusableParameters(t *testing.T) {
integration, err := NewThresholdFHEIntegration(log.Noop(), ThresholdConfig{
Threshold: 1, TotalParties: 1, CKKSParams: ckks.Parameters{}, LogBound: 128,
}, 0)
require.ErrorContains(t, err, "E2S protocol")
require.Nil(t, integration)
}
// TestGenerateShareRefusesAnOversizedMask holds that a LogBound too large for
// the ciphertext's level is refused. The mask is what hides this party's share
// of the plaintext from everyone else; a mask wider than the modulus does not
// wrap around harmlessly, it makes the share unrecoverable, so the protocol
// refuses rather than emitting a share nobody can combine.
func TestGenerateShareRefusesAnOversizedMask(t *testing.T) {
params, err := ckks.NewParametersFromLiteral(ckks.ExampleParameters128BitLogN14LogQP438)
require.NoError(t, err)
integration, err := NewThresholdFHEIntegration(log.Noop(), ThresholdConfig{
Threshold: 2, TotalParties: 3, CKKSParams: params, LogBound: 100_000,
}, 0)
require.NoError(t, err)
ctBytes, sk := newTestCiphertext(t, params)
integration.SetSecretKey(sk)
require.NoError(t, integration.InitiateDecryption("s", common.HexToHash("0x1"), ctBytes))
_, err = integration.GenerateShare("s")
require.ErrorContains(t, err, "generate share")
}
// TestContributeShareRejectsBytesThatAreNotAShare holds that a peer's gossip is
// deserialized before it is counted, so garbage cannot occupy one of the
// threshold's slots and stall the session at count-1 forever.
func TestContributeShareRejectsBytesThatAreNotAShare(t *testing.T) {
integration := newTestIntegration(t, nil)
ctBytes, sk := newTestCiphertext(t, integration.params)
integration.SetSecretKey(sk)
require.NoError(t, integration.InitiateDecryption("s", common.HexToHash("0x1"), ctBytes))
complete, err := integration.ContributeShare("s", ids.GenerateTestNodeID(), []byte("junk"))
require.ErrorContains(t, err, "unmarshal public share")
require.False(t, complete)
integration.sessionsMu.RLock()
count := integration.sessions["s"].ShareCount
integration.sessionsMu.RUnlock()
require.Zero(t, count)
}
+54 -26
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/database"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
@@ -697,6 +698,15 @@ func (lm *LifecycleManager) completeDKGLocked() (*deferredCallback, error) {
lm.currentDKG.Status = DKGCompleted
lm.currentDKG.CompletedAt = time.Now().Unix()
// A transition waiting on this ceremony can now be finalized. This is the
// only place that advances it: shouldFinalizeTransition finalizes only from
// TransitionFinalizingPhase, so without this the transition stays in
// TransitionDKGPhase forever, currentTransition never clears, and
// shouldStartTransition then refuses every later epoch as well.
if lm.currentTransition != nil && lm.currentTransition.Status == TransitionDKGPhase {
lm.currentTransition.Status = TransitionFinalizingPhase
}
lm.logger.Info("DKG ceremony completed",
"epoch", lm.currentDKG.Epoch,
"public_key_len", len(publicKey))
@@ -817,10 +827,14 @@ func (lm *LifecycleManager) startTransitionLocked() error {
participants[i] = m.NodeID
}
// Start DKG for new epoch
// Start DKG for new epoch. A committee too small for the configured
// threshold falls back to the smallest quorum above two thirds, the same
// rule DefaultThreshold encodes: 2*100/3+1 is 67, the configured default.
// Without the +1 the fallback is 2*n/3, which is 66 of 100 -- and 2 of 4,
// half the committee.
threshold := lm.config.DefaultThreshold
if threshold > len(participants) {
threshold = (len(participants) * 2) / 3 // 2/3 majority
threshold = (len(participants)*2)/3 + 1
}
return lm.startDKGLocked(newEpoch, participants, threshold)
@@ -999,41 +1013,55 @@ func (lm *LifecycleManager) SetSlashCallback(onSlash func(nodeID ids.NodeID, rea
lm.onSlash = onSlash
}
// loadState loads persisted lifecycle state
// loadState restores a ceremony or a transition that was in flight when the
// process last stopped.
//
// A record that is absent means there was nothing in flight. Anything else --
// an unreadable database, bytes that do not decode -- is reported, because
// continuing leaves the manager with no ceremony and no transition, which is
// indistinguishable from a clean start: the epoch the committee was halfway
// through would simply never finish, and nothing would say so.
func (lm *LifecycleManager) loadState() error {
if lm.registry == nil || lm.registry.db == nil {
return nil
}
// Load any active DKG state
currentEpoch := lm.registry.GetCurrentEpoch()
dkgKey := append([]byte("lifecycle:dkg:"), encodeUint64(currentEpoch)...)
if dkgData, err := lm.registry.db.Get(dkgKey); err == nil && len(dkgData) > 0 {
dkgData, err := lm.registry.db.Get(append([]byte("lifecycle:dkg:"), encodeUint64(currentEpoch)...))
if err != nil && !errors.Is(err, database.ErrNotFound) {
return fmt.Errorf("read DKG state: %w", err)
}
if len(dkgData) > 0 {
var dkg DKGState
if err := json.Unmarshal(dkgData, &dkg); err == nil {
// Only restore if DKG is still in progress
if dkg.Status != DKGCompleted && dkg.Status != DKGFailed && dkg.Status != DKGAborted {
lm.currentDKG = &dkg
lm.logger.Info("Restored DKG state from database",
"epoch", dkg.Epoch,
"status", dkg.Status.String())
}
if err := json.Unmarshal(dkgData, &dkg); err != nil {
return fmt.Errorf("decode DKG state: %w", err)
}
// Only restore if DKG is still in progress
if dkg.Status != DKGCompleted && dkg.Status != DKGFailed && dkg.Status != DKGAborted {
lm.currentDKG = &dkg
lm.logger.Info("Restored DKG state from database",
"epoch", dkg.Epoch,
"status", dkg.Status.String())
}
}
// Load any active transition state
transitionKey := append([]byte("lifecycle:transition:"), encodeUint64(currentEpoch+1)...)
if transitionData, err := lm.registry.db.Get(transitionKey); err == nil && len(transitionData) > 0 {
transitionData, err := lm.registry.db.Get(append([]byte("lifecycle:transition:"), encodeUint64(currentEpoch+1)...))
if err != nil && !errors.Is(err, database.ErrNotFound) {
return fmt.Errorf("read transition state: %w", err)
}
if len(transitionData) > 0 {
var transition TransitionState
if err := json.Unmarshal(transitionData, &transition); err == nil {
// Only restore if transition is still in progress
if transition.Status != TransitionCompleted && transition.Status != TransitionFailed {
lm.currentTransition = &transition
lm.logger.Info("Restored transition state from database",
"fromEpoch", transition.FromEpoch,
"toEpoch", transition.ToEpoch,
"status", transition.Status.String())
}
if err := json.Unmarshal(transitionData, &transition); err != nil {
return fmt.Errorf("decode transition state: %w", err)
}
// Only restore if transition is still in progress
if transition.Status != TransitionCompleted && transition.Status != TransitionFailed {
lm.currentTransition = &transition
lm.logger.Info("Restored transition state from database",
"fromEpoch", transition.FromEpoch,
"toEpoch", transition.ToEpoch,
"status", transition.Status.String())
}
}
+749
View File
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/luxfi/crypto/bls"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
@@ -1087,3 +1088,751 @@ func TestSlashMemberNotExists(t *testing.T) {
// If the member doesn't exist, it may or may not return an error
_ = err // Accept either behavior
}
// ---------------------------------------------------------------------------
// Epoch and ceremony transitions
// ---------------------------------------------------------------------------
// seatedCommittee returns a lifecycle manager over a faulty-capable database
// with an active epoch 1 whose committee holds n members, which is the state
// every transition test starts from.
func seatedCommittee(t *testing.T, n int) (*LifecycleManager, *Registry, *faultyDB) {
t.Helper()
registry, db := newFaultyRegistry(t)
members := make([]CommitteeMember, n)
for i := range members {
members[i] = CommitteeMember{NodeID: ids.GenerateTestNodeID(), PublicKey: []byte{byte(i)}, Weight: 10, Index: i}
}
require.NoError(t, registry.SetEpoch(1, &EpochInfo{
Epoch: 1,
StartTime: 0, // read as a block height by shouldStartTransition
Committee: members,
Threshold: n - 1,
PublicKey: []byte("epoch-1"),
Status: EpochActive,
}))
lm := NewLifecycleManager(registry, &LifecycleConfig{
EpochDuration: 100,
GracePeriod: 10,
MinCommitteeSize: 2,
MaxCommitteeSize: 10,
DefaultThreshold: 67,
DKGTimeout: time.Minute,
}, log.Noop())
return lm, registry, db
}
// driveDKG carries the manager's current ceremony from commit phase to
// completed by having every participant submit a commitment and a share.
func driveDKG(t *testing.T, lm *LifecycleManager, commitment []byte) {
t.Helper()
participants := append([]ids.NodeID(nil), lm.GetDKGState().Participants...)
for _, node := range participants {
require.NoError(t, lm.SubmitDKGCommitment(node, commitment))
}
for _, node := range participants {
if lm.GetDKGState().Status != DKGSharePhase {
break
}
require.NoError(t, lm.SubmitDKGShare(node, []byte("share")))
}
require.Equal(t, DKGCompleted, lm.GetDKGState().Status)
}
// TestTransitionFallbackThresholdIsAQuorum holds that a committee too small for
// the configured threshold still gets a quorum. The fallback is the rule
// DefaultThreshold encodes -- 2*100/3+1 is 67 -- so applying it to any n must
// land strictly above two thirds. Without the +1 the same expression gives 66
// of 100, contradicting the package's own default, and 2 of 4: exactly half a
// committee able to open every ciphertext the epoch protects.
func TestTransitionFallbackThresholdIsAQuorum(t *testing.T) {
for _, size := range []int{4, 5, 6, 9, 10} {
lm, _, _ := seatedCommittee(t, size)
require.NoError(t, lm.ForceEpochTransition())
threshold := lm.GetDKGState().Threshold
require.Greater(t, threshold*3, size*2,
"a %d-member committee got a threshold of %d, which is not above two thirds", size, threshold)
require.LessOrEqual(t, threshold, size)
}
}
// TestTransitionReachesFinalizingWhenItsCeremonyCompletes holds the step that
// carries a transition from waiting on a DKG to ready to finalize.
// shouldFinalizeTransition only ever finalizes from TransitionFinalizingPhase,
// so if nothing sets it the transition stays in the DKG phase forever,
// currentTransition never clears, and shouldStartTransition then refuses every
// later epoch too: one stalled rotation freezes the key schedule for good.
func TestTransitionReachesFinalizingWhenItsCeremonyCompletes(t *testing.T) {
lm, registry, _ := seatedCommittee(t, 4)
require.NoError(t, lm.ForceEpochTransition())
require.Equal(t, TransitionDKGPhase, lm.GetTransitionState().Status)
driveDKG(t, lm, make([]byte, 32))
require.Equal(t, TransitionFinalizingPhase, lm.GetTransitionState().Status)
// Past the grace period, the next block finalizes it.
lm.mu.Lock()
lm.currentTransition.StartedAt = 0
lm.mu.Unlock()
var from, to uint64
lm.SetCallbacks(func(oldEpoch, newEpoch uint64) { from, to = oldEpoch, newEpoch }, nil, nil)
require.NoError(t, lm.OnBlock(500))
require.False(t, lm.IsTransitioning())
require.Nil(t, lm.GetDKGState())
require.Equal(t, uint64(2), registry.GetCurrentEpoch())
require.Equal(t, uint64(1), from)
require.Equal(t, uint64(2), to)
ended, err := registry.GetEpoch(1)
require.NoError(t, err)
require.Equal(t, EpochEnded, ended.Status)
require.NotZero(t, ended.EndTime)
}
// TestOnBlockStartsATransitionWhenTheEpochIsOver holds the automatic half of
// the rotation: past the epoch duration, a block opens a transition and its
// ceremony, and a second block does not open a second one.
func TestOnBlockStartsATransitionWhenTheEpochIsOver(t *testing.T) {
lm, _, _ := seatedCommittee(t, 4)
require.NoError(t, lm.OnBlock(50))
require.False(t, lm.IsTransitioning(), "before the epoch duration nothing starts")
require.NoError(t, lm.OnBlock(150))
require.True(t, lm.IsTransitioning())
ceremony := lm.GetDKGState().CeremonyID
require.NoError(t, lm.OnBlock(151))
require.Equal(t, ceremony, lm.GetDKGState().CeremonyID, "a transition in flight is not restarted")
}
// TestOnBlockFailsACeremonyThatRanOutOfTime holds that a DKG past its timeout
// is marked failed and takes its transition down with it. A ceremony left in
// commit phase forever blocks every later ceremony, because startDKGLocked
// refuses to start one while another is in progress.
func TestOnBlockFailsACeremonyThatRanOutOfTime(t *testing.T) {
lm, _, _ := seatedCommittee(t, 4)
lm.config.DKGTimeout = time.Nanosecond
require.NoError(t, lm.ForceEpochTransition())
require.Equal(t, DKGCommitPhase, lm.GetDKGState().Status)
require.NoError(t, lm.OnBlock(10))
require.Equal(t, DKGFailed, lm.GetDKGState().Status)
require.Equal(t, "DKG ceremony timed out", lm.GetDKGState().Error)
require.NotZero(t, lm.GetDKGState().CompletedAt)
transition := lm.GetTransitionState()
require.Equal(t, TransitionFailed, transition.Status)
require.Equal(t, "DKG ceremony timed out", transition.Error)
// A failed ceremony no longer blocks the next one.
require.NoError(t, lm.OnBlock(11))
require.Equal(t, DKGFailed, lm.GetDKGState().Status)
}
// TestOnBlockRotatesKeysOnSchedule holds that key rotation opens a ceremony for
// the epoch it is already in, rather than advancing the epoch. Rotation and
// transition are different operations and must not be confused: one refreshes
// the key inside an epoch, the other ends the epoch.
func TestOnBlockRotatesKeysOnSchedule(t *testing.T) {
lm, registry, _ := seatedCommittee(t, 4)
lm.config.EpochDuration = 1_000_000 // no transition in range
lm.config.KeyRotationBlocks = 25
require.NoError(t, lm.OnBlock(24))
require.Nil(t, lm.GetDKGState())
require.NoError(t, lm.OnBlock(25))
require.NotNil(t, lm.GetDKGState())
require.Equal(t, uint64(1), lm.GetDKGState().Epoch, "rotation stays in the current epoch")
require.False(t, lm.IsTransitioning())
require.Equal(t, uint64(1), registry.GetCurrentEpoch())
}
// TestKeyRotationSurfacesRegistryFailures holds that a rotation over an
// unreadable store fails instead of opening a ceremony with no participants.
func TestKeyRotationSurfacesRegistryFailures(t *testing.T) {
lm, _, db := seatedCommittee(t, 4)
lm.config.EpochDuration = 1_000_000
lm.config.KeyRotationBlocks = 25
db.refuseGet = always
require.ErrorIs(t, lm.OnBlock(25), errFault)
require.Nil(t, lm.GetDKGState())
}
// TestKeyRotationNeedsTheEpochThreshold holds that rotation reads its threshold
// from the epoch record; without one there is no threshold to rotate under.
func TestKeyRotationNeedsTheEpochThreshold(t *testing.T) {
registry, _ := newFaultyRegistry(t)
lm := NewLifecycleManager(registry, &LifecycleConfig{
EpochDuration: 1_000_000, MinCommitteeSize: 0, MaxCommitteeSize: 10,
DefaultThreshold: 2, DKGTimeout: time.Minute, KeyRotationBlocks: 25,
}, log.Noop())
require.Error(t, lm.OnBlock(25))
require.Nil(t, lm.GetDKGState())
}
// TestTransitionSurfacesRegistryFailures holds that every store failure along
// the transition path is reported. A transition that half-applies leaves the
// epoch pointer and the epoch records disagreeing about which committee holds
// the current key.
func TestTransitionSurfacesRegistryFailures(t *testing.T) {
t.Run("committee unreadable at start", func(t *testing.T) {
lm, _, db := seatedCommittee(t, 4)
db.refuseGet = always
require.ErrorIs(t, lm.ForceEpochTransition(), errFault)
})
t.Run("new epoch unwritable at finalize", func(t *testing.T) {
lm, _, db := seatedCommittee(t, 4)
require.NoError(t, lm.ForceEpochTransition())
driveDKG(t, lm, make([]byte, 32))
lm.mu.Lock()
lm.currentTransition.StartedAt = 0
lm.mu.Unlock()
db.refusePut = always
require.ErrorIs(t, lm.OnBlock(500), errFault)
require.True(t, lm.IsTransitioning(), "a failed finalize leaves the transition in place")
})
}
// TestFinalizeRequiresACompletedCeremony holds that an epoch cannot be advanced
// onto a key that was never generated. Finalizing on a ceremony still in commit
// phase would install an empty public key as the epoch key.
func TestFinalizeRequiresACompletedCeremony(t *testing.T) {
lm, registry, _ := seatedCommittee(t, 4)
require.NoError(t, lm.ForceEpochTransition())
lm.mu.Lock()
lm.currentTransition.Status = TransitionFinalizingPhase
lm.currentTransition.StartedAt = 0
lm.mu.Unlock()
require.ErrorIs(t, lm.OnBlock(500), ErrDKGNotStarted)
require.Equal(t, uint64(1), registry.GetCurrentEpoch())
}
// TestMemberChangesSurfaceRegistryFailures holds that registering, removing and
// slashing all report a store that will not answer. Slashing is the one that
// matters most: SlashMember treats a nil error as a completed eviction and then
// emits the penalty event, so a swallowed failure penalizes a validator that is
// still seated and still holding its key share.
func TestMemberChangesSurfaceRegistryFailures(t *testing.T) {
t.Run("register cannot read the committee", func(t *testing.T) {
lm, _, db := seatedCommittee(t, 4)
db.refuseGet = always
require.ErrorIs(t, lm.RegisterMember(ids.GenerateTestNodeID(), []byte("pk"), 1), errFault)
})
t.Run("register cannot write the member", func(t *testing.T) {
lm, _, db := seatedCommittee(t, 4)
db.refusePut = always
require.ErrorIs(t, lm.RegisterMember(ids.GenerateTestNodeID(), []byte("pk"), 1), errFault)
})
t.Run("remove cannot write", func(t *testing.T) {
lm, registry, db := seatedCommittee(t, 4)
members, err := registry.GetCommittee()
require.NoError(t, err)
db.refusePut = always
require.ErrorIs(t, lm.RemoveMember(members[0].NodeID), errFault)
})
t.Run("remove cannot re-read the committee", func(t *testing.T) {
lm, registry, db := seatedCommittee(t, 4)
members, err := registry.GetCommittee()
require.NoError(t, err)
// The removal itself lands; the size check afterwards cannot read.
require.NoError(t, lm.RemoveMember(members[0].NodeID))
db.refuseGet = always
require.ErrorIs(t, lm.RemoveMember(members[1].NodeID), errFault)
})
t.Run("slash does not fire its penalty when the eviction failed", func(t *testing.T) {
lm, registry, db := seatedCommittee(t, 4)
members, err := registry.GetCommittee()
require.NoError(t, err)
slashed := 0
lm.SetSlashCallback(func(ids.NodeID, string) { slashed++ })
db.refusePut = always
require.NoError(t, lm.SlashMember(members[0].NodeID, "equivocation"))
require.Zero(t, slashed, "a failed eviction must not emit the penalty")
db.refusePut = nil
require.NoError(t, lm.SlashMember(members[0].NodeID, "equivocation"))
require.Equal(t, 1, slashed)
})
}
// TestInitiateEpochSurfacesAFailedWrite holds that genesis reports a store that
// could not record it, rather than returning as though epoch 1 exists.
func TestInitiateEpochSurfacesAFailedWrite(t *testing.T) {
registry, db := newFaultyRegistry(t)
lm := NewLifecycleManager(registry, DefaultLifecycleConfig(), log.Noop())
members := make([]CommitteeMember, 4)
for i := range members {
members[i] = CommitteeMember{NodeID: ids.GenerateTestNodeID()}
}
db.refusePut = always
require.ErrorIs(t, lm.InitiateEpoch(members, 3, []byte("pk")), errFault)
}
// TestGetCommitteeWeightSurfacesAnUnreadableCommittee holds that a weight of
// zero is only reported when the committee genuinely weighs nothing. Any caller
// comparing a weight against a stake threshold reads a swallowed failure as
// "the committee has no stake".
func TestGetCommitteeWeightSurfacesAnUnreadableCommittee(t *testing.T) {
lm, _, db := seatedCommittee(t, 4)
weight, err := lm.GetCommitteeWeight()
require.NoError(t, err)
require.Equal(t, uint64(40), weight)
db.refuseGet = always
weight, err = lm.GetCommitteeWeight()
require.ErrorIs(t, err, errFault)
require.Zero(t, weight)
}
// TestDKGCallbacksFireOutsideTheLock holds that every ceremony event reaches its
// callback. These are how the ceremony is gossiped to the other participants,
// so a callback that never fires is a ceremony no peer ever hears about; they
// are invoked after the mutex is released, which is why a callback that calls
// back into the manager does not deadlock.
func TestDKGCallbacksFireOutsideTheLock(t *testing.T) {
lm, _, _ := seatedCommittee(t, 4)
var started, phaseChanged, completed int
var startedEpoch, completedEpoch uint64
var startedID, phaseID [32]byte
var phase DKGStatus
lm.SetDKGCallbacks(
func(ceremonyID [32]byte, epoch uint64, participants []ids.NodeID) {
started++
startedID, startedEpoch = ceremonyID, epoch
require.Len(t, participants, 4)
require.NotNil(t, lm.GetDKGState(), "a callback may read the manager back")
},
func(ceremonyID [32]byte, newPhase DKGStatus) {
phaseChanged++
phaseID, phase = ceremonyID, newPhase
},
)
lm.SetCallbacks(nil, nil, func(epoch uint64, publicKey []byte) {
completed++
completedEpoch = epoch
require.NotEmpty(t, publicKey)
})
participants := []ids.NodeID{
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
}
require.NoError(t, lm.StartDKG(7, participants, 3))
require.Equal(t, 1, started)
require.Equal(t, uint64(7), startedEpoch)
require.Equal(t, lm.GetDKGState().CeremonyID, startedID)
driveDKG(t, lm, make([]byte, 32))
require.Equal(t, 1, phaseChanged)
require.Equal(t, DKGSharePhase, phase)
require.Equal(t, startedID, phaseID)
require.Equal(t, 1, completed)
require.Equal(t, uint64(7), completedEpoch)
}
// TestSubmitDKGShareRefusesEveryWayItCanBeWrong holds the three refusals that
// stand between a ceremony and a forged key share: the wrong phase, a node that
// is not a participant, and a participant that never committed. The commitment
// is what binds a share to a party, so accepting a share without one lets a
// participant substitute a share chosen after seeing everyone else's.
func TestSubmitDKGShareRefusesEveryWayItCanBeWrong(t *testing.T) {
lm, _, _ := seatedCommittee(t, 4)
participants := []ids.NodeID{
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
}
require.ErrorIs(t, lm.SubmitDKGShare(participants[0], []byte("s")), ErrDKGNotStarted)
require.ErrorIs(t, lm.SubmitDKGCommitment(participants[0], []byte("c")), ErrDKGNotStarted)
require.NoError(t, lm.StartDKG(2, participants, 3))
// Still in commit phase.
require.ErrorContains(t, lm.SubmitDKGShare(participants[0], []byte("s")), "not in share phase")
require.ErrorContains(t, lm.SubmitDKGCommitment(ids.GenerateTestNodeID(), []byte("c")), "not a DKG participant")
for _, node := range participants {
require.NoError(t, lm.SubmitDKGCommitment(node, make([]byte, 32)))
}
require.Equal(t, DKGSharePhase, lm.GetDKGState().Status)
// The commit phase is over.
require.ErrorContains(t, lm.SubmitDKGCommitment(participants[0], []byte("c")), "not in commit phase")
// A stranger cannot contribute a share.
require.ErrorIs(t, lm.SubmitDKGShare(ids.GenerateTestNodeID(), []byte("s")), ErrNotParticipant)
// A participant whose commitment was dropped cannot contribute either.
lm.mu.Lock()
delete(lm.currentDKG.Commitments, participants[0].String())
lm.mu.Unlock()
require.ErrorIs(t, lm.SubmitDKGShare(participants[0], []byte("s")), ErrMissingCommitment)
}
// TestAggregatePublicKeyUsesCurveArithmeticWhenItCan holds which of the two
// aggregation paths runs. BLS-formatted commitments must aggregate on the
// curve; anything else falls back to an XOR that is reproducible but carries no
// cryptographic meaning, so which path ran is the difference between an epoch
// key and a checksum.
func TestAggregatePublicKeyUsesCurveArithmeticWhenItCan(t *testing.T) {
lm, _, _ := seatedCommittee(t, 4)
keys := make([][]byte, 3)
pubKeys := make([]*bls.PublicKey, 3)
for i := range keys {
sk, err := bls.NewSecretKey()
require.NoError(t, err)
pubKeys[i] = sk.PublicKey()
keys[i] = bls.PublicKeyToCompressedBytes(pubKeys[i])
require.Len(t, keys[i], bls.PublicKeyLen)
}
require.NoError(t, lm.StartDKG(2, []ids.NodeID{
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
}, 3))
lm.mu.Lock()
for i, key := range keys {
lm.currentDKG.Commitments[string(rune('a'+i))] = key
}
aggregated := lm.aggregatePublicKey()
lm.mu.Unlock()
want, err := bls.AggregatePublicKeys(pubKeys)
require.NoError(t, err)
require.Equal(t, bls.PublicKeyToCompressedBytes(want), aggregated)
require.Len(t, aggregated, bls.PublicKeyLen)
}
// TestAggregatePublicKeyFallsBackWhenCommitmentsAreNotKeys holds the other
// path: commitments that are the right length but not curve points, and
// commitments that are too short to be keys at all, both land on the XOR
// fallback rather than producing a bad key or a panic.
func TestAggregatePublicKeyFallsBackWhenCommitmentsAreNotKeys(t *testing.T) {
lm, _, _ := seatedCommittee(t, 4)
require.NoError(t, lm.StartDKG(2, []ids.NodeID{
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
}, 3))
notAPoint := make([]byte, bls.PublicKeyLen)
for i := range notAPoint {
notAPoint[i] = 0xAB
}
short := make([]byte, 32)
short[0] = 0x0f
lm.mu.Lock()
lm.currentDKG.Commitments["a"] = notAPoint
lm.currentDKG.Commitments["b"] = short
aggregated := lm.aggregatePublicKey()
lm.mu.Unlock()
require.Len(t, aggregated, 32, "the fallback is a 32-byte XOR, not a curve point")
require.NotEqual(t, make([]byte, 32), aggregated)
}
// TestAggregatePublicKeyRefusesAnIdentityAggregate holds the rogue-key case: a
// commitment and its negation sum to the point at infinity, and an identity
// aggregate public key makes verification accept anything. The aggregation must
// refuse it and fall back rather than publish it as the epoch key.
func TestAggregatePublicKeyRefusesAnIdentityAggregate(t *testing.T) {
lm, _, _ := seatedCommittee(t, 4)
require.NoError(t, lm.StartDKG(2, []ids.NodeID{
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
}, 3))
sk, err := bls.NewSecretKey()
require.NoError(t, err)
key := bls.PublicKeyToCompressedBytes(sk.PublicKey())
// Flipping the compressed sign bit names the negation of the same point.
negated := append([]byte(nil), key...)
negated[0] ^= 0x20
_, err = bls.PublicKeyFromCompressedBytes(negated)
require.NoError(t, err, "the negation is still a valid point")
_, err = bls.AggregatePublicKeys(mustParseKeys(t, key, negated))
require.Error(t, err, "a key and its negation must not aggregate")
lm.mu.Lock()
lm.currentDKG.Commitments["a"] = key
lm.currentDKG.Commitments["b"] = negated
aggregated := lm.aggregatePublicKey()
lm.mu.Unlock()
require.Len(t, aggregated, 32, "the identity aggregate is refused and the fallback runs")
}
func mustParseKeys(t *testing.T, raw ...[]byte) []*bls.PublicKey {
t.Helper()
keys := make([]*bls.PublicKey, len(raw))
for i, b := range raw {
key, err := bls.PublicKeyFromCompressedBytes(b)
require.NoError(t, err)
keys[i] = key
}
return keys
}
// TestStartRestoresACeremonyInFlight holds crash recovery: a ceremony that was
// mid-flight comes back, and one that had already finished does not. Restoring
// a finished ceremony would re-run the transition onto a key the committee has
// already moved past.
func TestStartRestoresACeremonyInFlight(t *testing.T) {
registry, _ := newFaultyRegistry(t)
config := &LifecycleConfig{
EpochDuration: 100, GracePeriod: 10, MinCommitteeSize: 2,
MaxCommitteeSize: 10, DefaultThreshold: 2, DKGTimeout: time.Minute,
}
first := NewLifecycleManager(registry, config, log.Noop())
require.NoError(t, first.StartDKG(0, []ids.NodeID{
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
}, 2))
first.mu.Lock()
first.currentTransition = &TransitionState{FromEpoch: 0, ToEpoch: 1, Status: TransitionDKGPhase}
require.NoError(t, first.persistStateLocked())
first.mu.Unlock()
restored := NewLifecycleManager(registry, config, log.Noop())
require.NoError(t, restored.Start())
require.NotNil(t, restored.GetDKGState())
require.Equal(t, DKGCommitPhase, restored.GetDKGState().Status)
require.NotNil(t, restored.GetTransitionState())
require.Equal(t, uint64(1), restored.GetTransitionState().ToEpoch)
// A finished ceremony and a finished transition are not restored.
first.mu.Lock()
first.currentDKG.Status = DKGCompleted
first.currentTransition.Status = TransitionCompleted
require.NoError(t, first.persistStateLocked())
first.mu.Unlock()
fresh := NewLifecycleManager(registry, config, log.Noop())
require.NoError(t, fresh.Start())
require.Nil(t, fresh.GetDKGState())
require.Nil(t, fresh.GetTransitionState())
}
// TestStartRefusesUnreadableState holds that a manager whose recovery record
// cannot be read or decoded refuses to start. Starting anyway looks exactly
// like a clean slate: the ceremony the committee was midway through would never
// finish, and nothing would say so.
func TestStartRefusesUnreadableState(t *testing.T) {
registry, db := newFaultyRegistry(t)
config := DefaultLifecycleConfig()
db.refuseGet = always
err := NewLifecycleManager(registry, config, log.Noop()).Start()
require.ErrorIs(t, err, errFault)
require.ErrorContains(t, err, "read DKG state", "the first unreadable record is the one reported")
db.refuseGet = nil
require.NoError(t, registry.db.Put(append([]byte("lifecycle:dkg:"), encodeUint64(0)...), []byte("{not json")))
require.ErrorContains(t, NewLifecycleManager(registry, config, log.Noop()).Start(), "decode DKG state")
require.NoError(t, registry.db.Delete(append([]byte("lifecycle:dkg:"), encodeUint64(0)...)))
require.NoError(t, registry.db.Put(append([]byte("lifecycle:transition:"), encodeUint64(1)...), []byte("{not json")))
require.ErrorContains(t, NewLifecycleManager(registry, config, log.Noop()).Start(), "decode transition state")
}
// TestStartWithoutAStoreIsANoOp holds that a manager with no registry starts
// clean, which is the shape every unit test that does not need persistence uses.
func TestStartWithoutAStoreIsANoOp(t *testing.T) {
lm := NewLifecycleManager(nil, nil, log.Noop())
require.NoError(t, lm.Start())
require.NoError(t, lm.persistState())
lm.Stop()
}
// TestPersistSurfacesAFailedWrite holds that a recovery record that did not
// land is reported. The ceremony persisted here is the only thing that survives
// a restart, so a silent failure is a ceremony that quietly cannot be resumed.
func TestPersistSurfacesAFailedWrite(t *testing.T) {
registry, db := newFaultyRegistry(t)
lm := NewLifecycleManager(registry, DefaultLifecycleConfig(), log.Noop())
require.NoError(t, lm.StartDKG(1, []ids.NodeID{
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
}, 3))
db.refusePut = always
require.ErrorIs(t, lm.persistState(), errFault)
lm.mu.Lock()
lm.currentDKG = nil
lm.currentTransition = &TransitionState{FromEpoch: 1, ToEpoch: 2}
lm.mu.Unlock()
require.ErrorIs(t, lm.persistState(), errFault)
}
// TestAbortDKGRecordsTheReason holds that an abandoned ceremony keeps why it
// was abandoned, and that the record survives to the store: a ceremony that
// vanishes without a reason is indistinguishable from one that never started.
func TestAbortDKGRecordsTheReason(t *testing.T) {
registry, db := newFaultyRegistry(t)
lm := NewLifecycleManager(registry, DefaultLifecycleConfig(), log.Noop())
require.NoError(t, lm.StartDKG(1, []ids.NodeID{
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
ids.GenerateTestNodeID(), ids.GenerateTestNodeID(),
}, 3))
db.refusePut = always // a failed persist must not lose the in-memory abort
require.NoError(t, lm.AbortDKG("participants unreachable"))
require.Equal(t, DKGAborted, lm.GetDKGState().Status)
require.Equal(t, "participants unreachable", lm.GetDKGState().Error)
require.NotZero(t, lm.GetDKGState().CompletedAt)
}
// TestGetStatusReportsCeremonyAndProgress holds that the status a client reads
// names the current ceremony, the current transition, and how far through its
// epoch the chain is -- with progress clamped at 1 rather than running past it
// once an epoch overruns its duration.
func TestGetStatusReportsCeremonyAndProgress(t *testing.T) {
lm, _, _ := seatedCommittee(t, 4)
require.NoError(t, lm.OnBlock(50))
status, err := lm.GetStatus()
require.NoError(t, err)
require.Equal(t, uint64(1), status.CurrentEpoch)
require.Equal(t, uint64(50), status.CurrentBlock)
require.Equal(t, 4, status.CommitteeSize)
require.Equal(t, 3, status.Threshold)
require.False(t, status.IsTransitioning)
require.Empty(t, status.DKGStatus)
require.InDelta(t, 0.5, status.EpochProgress, 1e-9)
require.NoError(t, lm.ForceEpochTransition())
require.NoError(t, lm.OnBlock(400))
status, err = lm.GetStatus()
require.NoError(t, err)
require.True(t, status.IsTransitioning)
require.Equal(t, DKGCommitPhase.String(), status.DKGStatus)
require.Equal(t, TransitionDKGPhase.String(), status.TransitionStatus)
require.Equal(t, 1.0, status.EpochProgress, "progress is clamped once the epoch overruns")
}
// TestRemoveMemberSurfacesAFailedSizeCheck holds that the committee-size check
// after a removal reports a store it could not read. The removal itself has
// already landed at that point, so swallowing the failure means the manager
// stops noticing that the committee has fallen below its minimum -- the one
// warning that says the remaining members can no longer form a quorum.
func TestRemoveMemberSurfacesAFailedSizeCheck(t *testing.T) {
lm, registry, db := seatedCommittee(t, 4)
members, err := registry.GetCommittee()
require.NoError(t, err)
// The removal reads the epoch once and succeeds; the size check reads it
// again and cannot.
db.refuseGet = afterFirst()
require.ErrorIs(t, lm.RemoveMember(members[0].NodeID), errFault)
db.refuseGet = nil
remaining, err := registry.GetCommittee()
require.NoError(t, err)
require.Len(t, remaining, 3, "the removal itself landed")
}
// TestRemoveMemberWarnsBelowTheMinimum holds that a committee dropping under
// its configured minimum is reported rather than silently accepted; below it
// there are not enough members left to reach any threshold.
func TestRemoveMemberWarnsBelowTheMinimum(t *testing.T) {
lm, registry, _ := seatedCommittee(t, 4)
lm.config.MinCommitteeSize = 4
members, err := registry.GetCommittee()
require.NoError(t, err)
require.NoError(t, lm.RemoveMember(members[0].NodeID))
remaining, err := registry.GetCommittee()
require.NoError(t, err)
require.Len(t, remaining, 3)
}
// TestCeremonyRunsOnWhenItsRecoveryRecordCannotBeWritten holds that a store
// that will not accept the recovery record does not abort the ceremony or the
// transition in memory. The record is a crash-recovery convenience; losing it
// costs a resume, while aborting a live ceremony costs the epoch.
func TestCeremonyRunsOnWhenItsRecoveryRecordCannotBeWritten(t *testing.T) {
lm, registry, db := seatedCommittee(t, 4)
db.refusePut = under("lifecycle:")
require.NoError(t, lm.ForceEpochTransition())
require.True(t, lm.IsTransitioning())
driveDKG(t, lm, make([]byte, 32))
require.Equal(t, TransitionFinalizingPhase, lm.GetTransitionState().Status)
lm.mu.Lock()
lm.currentTransition.StartedAt = 0
lm.mu.Unlock()
require.NoError(t, lm.OnBlock(500))
require.Equal(t, uint64(2), registry.GetCurrentEpoch(), "the epoch still advanced")
}
// TestFinalizeStopsWhenTheNewEpochCannotBeWritten holds that a transition whose
// new epoch record fails to store is reported instead of half-applied. The old
// epoch has already been marked ended by then, so continuing would leave the
// chain with no active epoch at all.
func TestFinalizeStopsWhenTheNewEpochCannotBeWritten(t *testing.T) {
lm, registry, db := seatedCommittee(t, 4)
require.NoError(t, lm.ForceEpochTransition())
driveDKG(t, lm, make([]byte, 32))
lm.mu.Lock()
lm.currentTransition.StartedAt = 0
lm.mu.Unlock()
db.refusePut = under(string(append(epochPrefix, encodeUint64(2)...)))
require.ErrorIs(t, lm.OnBlock(500), errFault)
require.True(t, lm.IsTransitioning())
require.Equal(t, uint64(1), registry.GetCurrentEpoch())
}
// TestStartRefusesAnUnreadableTransitionRecord holds that the transition half
// of recovery is checked as carefully as the ceremony half: a ceremony that
// reads back fine does not excuse a transition record that does not.
func TestStartRefusesAnUnreadableTransitionRecord(t *testing.T) {
registry, db := newFaultyRegistry(t)
db.refuseGet = under("lifecycle:transition:")
require.ErrorContains(t,
NewLifecycleManager(registry, DefaultLifecycleConfig(), log.Noop()).Start(),
"read transition state")
}
+24 -5
View File
@@ -508,11 +508,19 @@ func (r *Registry) GetCurrentEpoch() uint64 {
return r.epoch
}
// GetCommittee returns the committee for the current epoch
// GetCommittee returns the committee for the current epoch.
// Only a missing epoch record means "no committee yet"; any other failure is
// returned. Reporting a read failure as an empty committee hands the lifecycle
// manager a zero-member set that reads as a legitimate answer -- it would
// register every new member at index 0, weigh the committee at zero, and open
// a DKG with no participants.
func (r *Registry) GetCommittee() ([]CommitteeMember, error) {
info, err := r.GetEpoch(r.GetCurrentEpoch())
if errors.Is(err, database.ErrNotFound) {
return []CommitteeMember{}, nil
}
if err != nil {
return []CommitteeMember{}, nil // Return empty slice if no epoch configured
return nil, err
}
return info.Committee, nil
}
@@ -527,12 +535,18 @@ func (r *Registry) AddCommitteeMember(member *CommitteeMember) error {
data, err := r.db.Get(key)
var info EpochInfo
if err == nil {
switch {
case err == nil:
if err := json.Unmarshal(data, &info); err != nil {
return fmt.Errorf("failed to unmarshal epoch info: %w", err)
}
} else {
case errors.Is(err, database.ErrNotFound):
info = EpochInfo{Epoch: epoch, Status: EpochActive, Threshold: 67}
default:
// Starting from a blank EpochInfo here would write the new member into
// an epoch record holding only that member, dropping the committee the
// read failed to return.
return fmt.Errorf("failed to read epoch info: %w", err)
}
// Check if member already exists
@@ -574,9 +588,14 @@ func (r *Registry) RemoveCommitteeMember(nodeID ids.NodeID) error {
epoch := r.epoch
key := append(epochPrefix, encodeUint64(epoch)...)
data, err := r.db.Get(key)
if err != nil {
if errors.Is(err, database.ErrNotFound) {
return nil // No epoch, nothing to remove
}
if err != nil {
// Returning nil here reports "removed" for a member still in the
// committee -- SlashMember reads that as a completed eviction.
return fmt.Errorf("failed to read epoch info: %w", err)
}
var info EpochInfo
if err := json.Unmarshal(data, &info); err != nil {
+337
View File
@@ -4,9 +4,12 @@
package fhe
import (
"bytes"
"errors"
"testing"
"time"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/stretchr/testify/require"
@@ -524,3 +527,337 @@ func TestRegistrySetAndGetEpoch(t *testing.T) {
require.NoError(t, registry.SetEpoch(100, epochInfo))
require.Equal(t, uint64(100), registry.GetCurrentEpoch())
}
// ---------------------------------------------------------------------------
// What the registry does when the database does not cooperate
// ---------------------------------------------------------------------------
// errFault is what a database returns when it is neither working nor absent.
// Telling those two apart is the whole point of the tests below: "not found"
// means the record does not exist, anything else means the answer is unknown,
// and a store that conflates them reports emptiness it has not established.
var errFault = errors.New("disk fell over")
// faultyDB is a database that consults a hook before each read and each write.
// A hook returning an error refuses that one operation, so a test can place a
// failure on a particular key, on the nth call, or on everything, without the
// fake growing a knob per shape.
type faultyDB struct {
database.Database
refuseGet func(key []byte) error
refusePut func(key []byte) error
}
func (f *faultyDB) Get(key []byte) ([]byte, error) {
if f.refuseGet != nil {
if err := f.refuseGet(key); err != nil {
return nil, err
}
}
return f.Database.Get(key)
}
func (f *faultyDB) Put(key, value []byte) error {
if f.refusePut != nil {
if err := f.refusePut(key); err != nil {
return err
}
}
return f.Database.Put(key, value)
}
// always refuses every key, which is what a dead disk looks like.
func always(key []byte) error { return errFault }
// under refuses only the keys a subsystem owns, which is how a test fails one
// record while the rest of a multi-write operation succeeds.
func under(prefix string) func([]byte) error {
return func(key []byte) error {
if bytes.HasPrefix(key, []byte(prefix)) {
return errFault
}
return nil
}
}
// afterFirst lets one call through and refuses the rest, which separates two
// reads of the same key inside one operation.
func afterFirst() func([]byte) error {
seen := 0
return func([]byte) error {
seen++
if seen > 1 {
return errFault
}
return nil
}
}
// newFaultyRegistry returns a registry over a working database plus the switch
// that breaks it, so a test can populate state first and fail afterwards.
func newFaultyRegistry(t *testing.T) (*Registry, *faultyDB) {
t.Helper()
db := &faultyDB{Database: memdb.New()}
registry, err := NewRegistry(db)
require.NoError(t, err)
return registry, db
}
// corrupt writes bytes that are not JSON under a key, which is what a torn
// write or a format change leaves behind.
func corrupt(t *testing.T, r *Registry, prefix []byte, suffix []byte) {
t.Helper()
require.NoError(t, r.db.Put(append(prefix, suffix...), []byte("{not json")))
}
// TestNewRegistryRefusesAnUnreadableEpochPointer holds that a registry whose
// stored epoch cannot be read fails to open. Starting at epoch 0 instead would
// silently rewind the chain's key schedule and register new ciphertexts under
// an epoch whose committee has long since rotated out.
func TestNewRegistryRefusesAnUnreadableEpochPointer(t *testing.T) {
registry, err := NewRegistry(&faultyDB{Database: memdb.New(), refuseGet: always})
require.ErrorIs(t, err, errFault)
require.Nil(t, registry)
}
// TestRegistryReportsUnreadableRecords holds that every read distinguishes
// "absent" from "unknown". Each getter maps a missing record to its own
// not-found error, and everything else has to come back as the failure it was.
func TestRegistryReportsUnreadableRecords(t *testing.T) {
registry, db := newFaultyRegistry(t)
db.refuseGet = always
_, err := registry.GetCiphertextMeta([32]byte{1})
require.ErrorIs(t, err, errFault)
require.NotErrorIs(t, err, ErrCiphertextNotFound)
_, err = registry.GetDecryptRequest([32]byte{1})
require.ErrorIs(t, err, errFault)
require.NotErrorIs(t, err, ErrRequestNotFound)
_, err = registry.GetPermit([32]byte{1})
require.ErrorIs(t, err, errFault)
require.NotErrorIs(t, err, ErrPermitNotFound)
_, err = registry.GetSession("s")
require.ErrorIs(t, err, errFault)
require.NotErrorIs(t, err, ErrSessionNotFound)
_, err = registry.GetEpoch(1)
require.ErrorIs(t, err, errFault)
require.ErrorIs(t, registry.UpdateDecryptRequest([32]byte{1}, RequestCompleted, [32]byte{}, ""), errFault)
}
// TestRegistryReportsCorruptRecords holds that bytes that are not the record
// they claim to be are reported, not returned as a zero-valued record. A
// zero-valued CiphertextMeta names owner 0x00..00, which is an address an
// attacker can plausibly control.
func TestRegistryReportsCorruptRecords(t *testing.T) {
registry := newTestRegistry(t)
corrupt(t, registry, ciphertextPrefix, make([]byte, 32))
_, err := registry.GetCiphertextMeta([32]byte{})
require.ErrorContains(t, err, "unmarshal ciphertext meta")
corrupt(t, registry, decryptRequestPrefix, make([]byte, 32))
_, err = registry.GetDecryptRequest([32]byte{})
require.ErrorContains(t, err, "unmarshal decrypt request")
require.ErrorContains(t,
registry.UpdateDecryptRequest([32]byte{}, RequestCompleted, [32]byte{}, ""),
"unmarshal decrypt request")
corrupt(t, registry, permitPrefix, make([]byte, 32))
_, err = registry.GetPermit([32]byte{})
require.ErrorContains(t, err, "unmarshal permit")
corrupt(t, registry, sessionPrefix, []byte("s"))
_, err = registry.GetSession("s")
require.ErrorContains(t, err, "unmarshal session")
corrupt(t, registry, epochPrefix, encodeUint64(0))
_, err = registry.GetEpoch(0)
require.ErrorContains(t, err, "unmarshal epoch info")
_, err = registry.GetCommittee()
require.ErrorContains(t, err, "unmarshal epoch info")
require.ErrorContains(t, registry.AddCommitteeMember(&CommitteeMember{}), "unmarshal epoch info")
require.ErrorContains(t, registry.RemoveCommitteeMember(ids.GenerateTestNodeID()), "unmarshal epoch info")
}
// TestRegistryReportsFailedWrites holds that a write that did not land is
// reported. A silent failure here loses a permit, a request or an epoch while
// the caller carries on as though it were stored.
func TestRegistryReportsFailedWrites(t *testing.T) {
registry, db := newFaultyRegistry(t)
db.refusePut = always
require.ErrorIs(t, registry.RegisterCiphertext(&CiphertextMeta{}), errFault)
require.ErrorIs(t, registry.CreateDecryptRequest(&DecryptRequest{}), errFault)
require.ErrorIs(t, registry.CreatePermit(&Permit{}), errFault)
require.ErrorIs(t, registry.SaveSession(&SessionState{SessionID: "s"}), errFault)
require.ErrorIs(t, registry.SetEpoch(1, &EpochInfo{}), errFault)
require.ErrorIs(t, registry.AddCommitteeMember(&CommitteeMember{NodeID: ids.GenerateTestNodeID()}), errFault)
}
// TestGetCommitteeDistinguishesEmptyFromUnreadable is the property that a
// committee of nobody is a fact about the chain, not a symptom of a broken
// disk. Reporting a read failure as an empty committee is a silent-empty: the
// lifecycle manager would weigh the committee at zero, index every new member
// at 0 on top of the existing ones, and open a DKG with no participants -- all
// without an error anywhere.
func TestGetCommitteeDistinguishesEmptyFromUnreadable(t *testing.T) {
registry, db := newFaultyRegistry(t)
// No epoch configured yet: genuinely empty.
members, err := registry.GetCommittee()
require.NoError(t, err)
require.Empty(t, members)
db.refuseGet = always
members, err = registry.GetCommittee()
require.ErrorIs(t, err, errFault)
require.Nil(t, members)
_, err = registry.GetCommitteeMember(ids.GenerateTestNodeID())
require.ErrorIs(t, err, errFault)
}
// TestAddCommitteeMemberKeepsTheCommitteeItCannotRead holds that a failed read
// does not become a fresh epoch record. The old code fell back to a blank
// EpochInfo on any error, so one unreadable Get replaced the whole committee
// with the single member being added -- and then wrote that over the real one.
func TestAddCommitteeMemberKeepsTheCommitteeItCannotRead(t *testing.T) {
registry, db := newFaultyRegistry(t)
seated := make([]CommitteeMember, 4)
for i := range seated {
seated[i] = CommitteeMember{NodeID: ids.GenerateTestNodeID(), Weight: 10, Index: i}
}
require.NoError(t, registry.SetEpoch(1, &EpochInfo{Epoch: 1, Committee: seated, Threshold: 3}))
db.refuseGet = always
require.ErrorIs(t, registry.AddCommitteeMember(&CommitteeMember{NodeID: ids.GenerateTestNodeID()}), errFault)
db.refuseGet = nil
after, err := registry.GetCommittee()
require.NoError(t, err)
require.Len(t, after, 4, "a failed read must not shrink the committee")
}
// TestRemoveCommitteeMemberReportsAnUnreadableEpoch holds that "removed" is
// only reported when a removal happened. SlashMember treats a nil error as a
// completed eviction and then emits the slashing event, so a swallowed read
// failure penalizes a validator that is still seated and still holding a key
// share.
func TestRemoveCommitteeMemberReportsAnUnreadableEpoch(t *testing.T) {
registry, db := newFaultyRegistry(t)
node := ids.GenerateTestNodeID()
require.NoError(t, registry.SetEpoch(1, &EpochInfo{
Epoch: 1,
Committee: []CommitteeMember{{NodeID: node}},
}))
db.refuseGet = always
require.ErrorIs(t, registry.RemoveCommitteeMember(node), errFault)
db.refuseGet = nil
members, err := registry.GetCommittee()
require.NoError(t, err)
require.Len(t, members, 1, "the member was never removed")
db.refusePut = always
require.ErrorIs(t, registry.RemoveCommitteeMember(node), errFault)
}
// TestAddCommitteeMemberSeatsTheFirstMemberWithoutAnEpoch holds that the first
// member can be registered before any epoch record exists, which is how a
// committee is bootstrapped.
func TestAddCommitteeMemberSeatsTheFirstMemberWithoutAnEpoch(t *testing.T) {
registry := newTestRegistry(t)
node := ids.GenerateTestNodeID()
require.NoError(t, registry.AddCommitteeMember(&CommitteeMember{NodeID: node, Weight: 7}))
members, err := registry.GetCommittee()
require.NoError(t, err)
require.Len(t, members, 1)
require.Equal(t, node, members[0].NodeID)
// And the epoch it created is active with the default threshold.
info, err := registry.GetEpoch(0)
require.NoError(t, err)
require.Equal(t, EpochActive, info.Status)
require.Equal(t, 67, info.Threshold)
}
// TestRemoveCommitteeMemberIsSilentWithoutAnEpoch holds that removing from a
// committee that was never seated is not an error -- there is nothing to
// remove, and that is a fact, not a failure.
func TestRemoveCommitteeMemberIsSilentWithoutAnEpoch(t *testing.T) {
registry := newTestRegistry(t)
require.NoError(t, registry.RemoveCommitteeMember(ids.GenerateTestNodeID()))
}
// TestVerifyPermitBindsToHandleGranteeOperationAndExpiry holds all four gates a
// permit carries. Each one alone is the difference between a permit and a
// blanket key: the handle stops it opening a different ciphertext, the grantee
// stops it being presented by someone else, the operation bitmask stops a
// compute grant being spent on a decryption, and the expiry stops it living
// forever.
func TestVerifyPermitBindsToHandleGranteeOperationAndExpiry(t *testing.T) {
registry := newTestRegistry(t)
handle := [32]byte{1}
grantee := [20]byte{2}
permitID := [32]byte{3}
require.NoError(t, registry.CreatePermit(&Permit{
PermitID: permitID,
Handle: handle,
Grantee: grantee,
Operations: PermitOpCompute,
Expiry: time.Now().Add(time.Hour).Unix(),
}))
require.NoError(t, registry.VerifyPermit(permitID, handle, grantee, PermitOpCompute))
require.ErrorIs(t, registry.VerifyPermit(permitID, [32]byte{9}, grantee, PermitOpCompute), ErrPermitInvalid)
require.ErrorIs(t, registry.VerifyPermit(permitID, handle, [20]byte{9}, PermitOpCompute), ErrPermitInvalid)
require.ErrorIs(t, registry.VerifyPermit(permitID, handle, grantee, PermitOpDecrypt), ErrPermitInvalid)
require.ErrorIs(t, registry.VerifyPermit([32]byte{9}, handle, grantee, PermitOpCompute), ErrPermitNotFound)
}
// TestPermitExpiryIsInclusiveAtTheDeadline holds where the expiry boundary
// falls: the check is `now > expiry`, so a permit is still valid during the
// second it names and dead from the next one. Both sides are pinned because a
// boundary that moves by one second changes which requests a rotation admits.
func TestPermitExpiryIsInclusiveAtTheDeadline(t *testing.T) {
registry := newTestRegistry(t)
handle := [32]byte{1}
grantee := [20]byte{2}
now := time.Now().Unix()
live := [32]byte{10}
require.NoError(t, registry.CreatePermit(&Permit{
PermitID: live, Handle: handle, Grantee: grantee,
Operations: PermitOpDecrypt, Expiry: now,
}))
require.NoError(t, registry.VerifyPermit(live, handle, grantee, PermitOpDecrypt),
"a permit is valid through the second it expires at")
dead := [32]byte{11}
require.NoError(t, registry.CreatePermit(&Permit{
PermitID: dead, Handle: handle, Grantee: grantee,
Operations: PermitOpDecrypt, Expiry: now - 1,
}))
require.ErrorIs(t, registry.VerifyPermit(dead, handle, grantee, PermitOpDecrypt), ErrPermitExpired)
forever := [32]byte{12}
require.NoError(t, registry.CreatePermit(&Permit{
PermitID: forever, Handle: handle, Grantee: grantee,
Operations: PermitOpDecrypt, Expiry: 0,
}))
require.NoError(t, registry.VerifyPermit(forever, handle, grantee, PermitOpDecrypt),
"expiry 0 means no expiry")
}
+13 -13
View File
@@ -440,6 +440,10 @@ func NewThresholdDecryptor(
logBound uint,
broadcastShare func(sessionID string, share []byte) error,
) (*ThresholdDecryptor, error) {
if err := checkThreshold(threshold, totalParties); err != nil {
return nil, err
}
e2sProtocol, err := mpckks.NewEncToShareProtocol(params, params.Xe())
if err != nil {
return nil, fmt.Errorf("create E2S protocol: %w", err)
@@ -470,9 +474,9 @@ func (d *ThresholdDecryptor) Decrypt(ctx context.Context, sessionID string, ciph
}
// Parse ciphertext
ct := rlwe.NewCiphertext(d.params.Parameters, 1, d.params.MaxLevel())
if err := ct.UnmarshalBinary(ciphertextBytes); err != nil {
return nil, fmt.Errorf("unmarshal ciphertext: %w", err)
ct, err := parseCiphertext(d.params, ciphertextBytes)
if err != nil {
return nil, err
}
// Allocate shares
@@ -571,11 +575,7 @@ func (d *ThresholdDecryptor) AddShare(sessionID string, partyID int, shareBytes
// Check if we have enough shares
if session.shareCount >= d.threshold {
result, err := d.completeDecryption(session)
if err != nil {
return fmt.Errorf("complete decryption: %w", err)
}
session.result = result
session.result = d.completeDecryption(session)
session.complete = true
close(session.completedChan)
}
@@ -584,7 +584,7 @@ func (d *ThresholdDecryptor) AddShare(sessionID string, partyID int, shareBytes
}
// completeDecryption finishes decryption when threshold is reached
func (d *ThresholdDecryptor) completeDecryption(session *decryptorSession) ([]byte, error) {
func (d *ThresholdDecryptor) completeDecryption(session *decryptorSession) []byte {
d.logger.Info("Threshold reached, completing decryption",
"sessionID", session.sessionID,
"shares", session.shareCount,
@@ -610,10 +610,10 @@ func (d *ThresholdDecryptor) completeDecryption(session *decryptorSession) ([]by
values := make([]complex128, len(recoveredShare.Value))
scale := new(big.Float).SetPrec(256).SetFloat64(math.Pow(2, float64(d.params.DefaultScale().Log2())))
// Every entry is allocated by multiparty.NewAdditiveShareBigint, which
// fills the slice with new(big.Int); GetShare only writes into them. There
// is no nil to skip.
for i, v := range recoveredShare.Value {
if v == nil {
continue
}
fv := new(big.Float).SetPrec(256).SetInt(v)
fv.Quo(fv, scale)
realVal, _ := fv.Float64()
@@ -628,7 +628,7 @@ func (d *ThresholdDecryptor) completeDecryption(session *decryptorSession) ([]by
"resultLen", len(result),
)
return result, nil
return result
}
// encodeComplexValues converts complex values to bytes for output
+764
View File
@@ -5,6 +5,9 @@ package fhe
import (
"context"
"encoding/binary"
"errors"
"math"
"math/big"
"sync"
"testing"
@@ -12,6 +15,8 @@ import (
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/lattice/v7/core/rlwe"
"github.com/luxfi/lattice/v7/schemes/ckks"
"github.com/luxfi/log"
"github.com/luxfi/warp"
"github.com/stretchr/testify/require"
@@ -827,3 +832,762 @@ func TestRelayerNetworkIDAndChainIDs(t *testing.T) {
require.Equal(chainID, relayer.chainID)
require.Equal(zChainID, relayer.zChainID)
}
// ---------------------------------------------------------------------------
// Threshold decryptor: what the quorum actually gates
// ---------------------------------------------------------------------------
// testParams is the CKKS parameter set every decryptor test shares, so a share
// produced in one test body deserializes in another.
func testParams(t testing.TB) ckks.Parameters {
t.Helper()
p, err := ckks.NewParametersFromLiteral(ckks.ExampleParameters128BitLogN14LogQP438)
require.NoError(t, err)
return p
}
// encrypt returns a ciphertext holding values and the secret key that opens it.
func encrypt(t testing.TB, p ckks.Parameters, values []complex128) ([]byte, *rlwe.SecretKey) {
t.Helper()
kgen := rlwe.NewKeyGenerator(p.Parameters)
sk := kgen.GenSecretKeyNew()
slots := make([]complex128, p.MaxSlots())
copy(slots, values)
pt := ckks.NewPlaintext(p, p.MaxLevel())
require.NoError(t, ckks.NewEncoder(p).Encode(slots, pt))
ct, err := ckks.NewEncryptor(p, kgen.GenPublicKeyNew(sk)).EncryptNew(pt)
require.NoError(t, err)
b, err := ct.MarshalBinary()
require.NoError(t, err)
return b, sk
}
// oneShare runs a single-party decryptor far enough to emit one well-formed
// E2S share for ciphertext ct, and returns those bytes.
func oneShare(t testing.TB, p ckks.Parameters, ct []byte, sk *rlwe.SecretKey) []byte {
t.Helper()
var out []byte
d, err := NewThresholdDecryptor(log.Noop(), p, 1, 1, 0, 128, func(_ string, s []byte) error {
out = append([]byte(nil), s...)
return nil
})
require.NoError(t, err)
d.SetSecretKey(sk)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err = d.Decrypt(ctx, "emit", ct)
require.NoError(t, err)
require.NotEmpty(t, out)
return out
}
// TestNewThresholdDecryptorRejectsNonQuorum holds the property that a t-of-n
// decryptor is refused whenever t is not a quorum over n. Every completion test
// in both decryption paths is `shares >= threshold`, so an unchecked threshold
// is the whole access control: t=0 finalizes on the first share to arrive and
// t=1 over a hundred parties lets any single one of them decrypt alone, which
// is the one thing a threshold scheme exists to prevent. t>n can never be met,
// so requests hang until they expire rather than failing at configuration time.
func TestNewThresholdDecryptorRejectsNonQuorum(t *testing.T) {
p := testParams(t)
for _, bad := range []struct{ threshold, total int }{
{0, 100}, {1, 100}, {101, 100}, {67, 0}, {0, 0}, {-5, -5}, {2, 1},
} {
d, err := NewThresholdDecryptor(log.Noop(), p, bad.threshold, bad.total, 0, 128, nil)
require.ErrorIs(t, err, ErrInvalidThreshold, "%d of %d", bad.threshold, bad.total)
require.Nil(t, d)
}
// A lone party is the degenerate case: there is no quorum to break.
d, err := NewThresholdDecryptor(log.Noop(), p, 1, 1, 0, 128, nil)
require.NoError(t, err)
require.NotNil(t, d)
// And the smallest real quorum, plus the configured default.
for _, ok := range []struct{ threshold, total int }{{2, 2}, {2, 3}, {67, 100}} {
d, err := NewThresholdDecryptor(log.Noop(), p, ok.threshold, ok.total, 0, 128, nil)
require.NoError(t, err, "%d of %d", ok.threshold, ok.total)
require.NotNil(t, d)
}
}
// TestNewThresholdFHEIntegrationRejectsNonQuorum holds the same property on the
// other decryption path. ContributeShare completes on
// ShareCount >= config.Threshold, so a ThresholdConfig with Threshold 0 is the
// same total break as a decryptor with threshold 0, reached through a different
// constructor.
func TestNewThresholdFHEIntegrationRejectsNonQuorum(t *testing.T) {
p := testParams(t)
for _, bad := range []struct{ threshold, total int }{{0, 100}, {1, 100}, {101, 100}, {5, 0}} {
i, err := NewThresholdFHEIntegration(log.Noop(), ThresholdConfig{
Threshold: bad.threshold, TotalParties: bad.total, CKKSParams: p, LogBound: 128,
}, 0)
require.ErrorIs(t, err, ErrInvalidThreshold, "%d of %d", bad.threshold, bad.total)
require.Nil(t, i)
}
}
// TestNewThresholdDecryptorRejectsUnusableParameters holds that a parameter set
// the E2S protocol cannot be built over is refused at construction rather than
// at the first decryption, when a request is already in flight.
func TestNewThresholdDecryptorRejectsUnusableParameters(t *testing.T) {
d, err := NewThresholdDecryptor(log.Noop(), ckks.Parameters{}, 1, 1, 0, 128, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "E2S protocol")
require.Nil(t, d)
}
// TestAddShareCountsPartiesNotSubmissions holds that a party's second share is
// ignored. Counting submissions instead of parties would let one validator
// reach any threshold by resubmitting, which is the cheapest possible attack on
// a t-of-n scheme: no key material, no peers, one message repeated.
//
// The decryptor is 3-of-5 and the same party id submits three times from inside
// the broadcast callback, before Decrypt adds its own share. Two distinct
// parties are seen, so the session stops at two and the context deadline is
// what ends the wait.
func TestAddShareCountsPartiesNotSubmissions(t *testing.T) {
p := testParams(t)
ct, sk := encrypt(t, p, []complex128{complex(42, 0)})
foreign := oneShare(t, p, ct, sk)
var d *ThresholdDecryptor
d, err := NewThresholdDecryptor(log.Noop(), p, 3, 5, 0, 128, func(sessionID string, _ []byte) error {
for i := 0; i < 3; i++ {
require.NoError(t, d.AddShare(sessionID, 4, foreign))
}
return nil
})
require.NoError(t, err)
d.SetSecretKey(sk)
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
_, err = d.Decrypt(ctx, "repeat", ct)
require.ErrorIs(t, err, context.DeadlineExceeded)
d.sessionsMu.RLock()
session := d.sessions["repeat"]
d.sessionsMu.RUnlock()
require.Equal(t, 2, session.shareCount, "one party submitting three times must count once")
require.False(t, session.complete)
}
// TestContributeShareCountsNodesNotSubmissions is the same property on the
// integration path, which dedupes on NodeID instead of an int party id.
//
// The two paths disagree on what a repeat submission means: AddShare returns
// nil, ContributeShare returns an error. Both refuse to count it, which is the
// property that matters; the divergence is in the report, not in the fix.
func TestContributeShareCountsNodesNotSubmissions(t *testing.T) {
p := testParams(t)
integration, err := NewThresholdFHEIntegration(log.Noop(), ThresholdConfig{
Threshold: 3, TotalParties: 5, CKKSParams: p, LogBound: 128,
}, 0)
require.NoError(t, err)
ct, sk := encrypt(t, p, []complex128{complex(42, 0)})
integration.SetSecretKey(sk)
require.NoError(t, integration.InitiateDecryption("s", common.HexToHash("0x1"), ct))
share, err := integration.GenerateShare("s")
require.NoError(t, err)
node := ids.GenerateTestNodeID()
complete, err := integration.ContributeShare("s", node, share)
require.NoError(t, err)
require.False(t, complete)
for i := 0; i < 3; i++ {
complete, err = integration.ContributeShare("s", node, share)
require.Error(t, err, "a repeat submission must not be counted")
require.False(t, complete)
}
integration.sessionsMu.RLock()
count := integration.sessions["s"].ShareCount
integration.sessionsMu.RUnlock()
require.Equal(t, 1, count)
}
// TestAddShareAcceptsForeignSharesUnderInventedPartyIDs is the finding this
// package most needs on record: shares are counted, never verified.
//
// AddShare (relayer.go:556) deserializes the bytes and nothing else. It does
// not check that the share was produced by the claimed party, that the claimed
// party is in the committee, or that the share was produced for this session's
// ciphertext -- and partyID is an int the caller chooses. So one share, made by
// an unrelated key for an unrelated ciphertext, replayed under two party ids
// that generated nothing, carries a 3-of-5 session to completion.
//
// Deleting the deserialization check would not fail this test. Only adding a
// real check would, which is the point.
func TestAddShareAcceptsForeignSharesUnderInventedPartyIDs(t *testing.T) {
p := testParams(t)
// A share for a completely unrelated ciphertext under an unrelated key.
other, otherKey := encrypt(t, p, []complex128{complex(1, 0)})
foreign := oneShare(t, p, other, otherKey)
// The session under attack decrypts a different ciphertext.
target, targetKey := encrypt(t, p, []complex128{complex(42, 0)})
var d *ThresholdDecryptor
d, err := NewThresholdDecryptor(log.Noop(), p, 3, 5, 0, 128, func(sessionID string, _ []byte) error {
require.NoError(t, d.AddShare(sessionID, 7, foreign))
require.NoError(t, d.AddShare(sessionID, 8, foreign))
return nil
})
require.NoError(t, err)
d.SetSecretKey(targetKey)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := d.Decrypt(ctx, "victim", target)
require.NoError(t, err, "the session finalized on two forged shares and one real one")
require.NotEmpty(t, result)
d.sessionsMu.RLock()
session := d.sessions["victim"]
d.sessionsMu.RUnlock()
require.True(t, session.complete)
require.Equal(t, 3, session.shareCount)
}
// TestContributeShareAcceptsForeignShares is the same absence of verification on
// the integration path (integration.go:231): a share generated for one session
// is accepted into another, for a different ciphertext, from a node id that
// generated nothing.
func TestContributeShareAcceptsForeignShares(t *testing.T) {
p := testParams(t)
integration, err := NewThresholdFHEIntegration(log.Noop(), ThresholdConfig{
Threshold: 2, TotalParties: 5, CKKSParams: p, LogBound: 128,
}, 0)
require.NoError(t, err)
first, key := encrypt(t, p, []complex128{complex(1, 0)})
second, _ := encrypt(t, p, []complex128{complex(42, 0)})
integration.SetSecretKey(key)
require.NoError(t, integration.InitiateDecryption("first", common.HexToHash("0x1"), first))
require.NoError(t, integration.InitiateDecryption("second", common.HexToHash("0x2"), second))
// A share made for session "first" and its ciphertext.
foreign, err := integration.GenerateShare("first")
require.NoError(t, err)
// Session "second" needs its own share for OwnSecretShare, then accepts the
// foreign one from a node that contributed nothing to it.
own, err := integration.GenerateShare("second")
require.NoError(t, err)
complete, err := integration.ContributeShare("second", ids.GenerateTestNodeID(), own)
require.NoError(t, err)
require.False(t, complete)
complete, err = integration.ContributeShare("second", ids.GenerateTestNodeID(), foreign)
require.NoError(t, err, "a share bound to no session is accepted by any session")
require.True(t, complete)
}
// TestDecryptReturnsCoefficientShareNotPlaintext records what the threshold
// decryptor actually hands back today, so the gap is visible rather than
// assumed away.
//
// relayer.go:607 recovers a coefficient-domain additive share and
// relayer.go:610-624 reads its entries as if they were CKKS slot values;
// ckks.Encoder.Decode is never called, and the other parties' additive shares
// are never collected, so no reconstruction sum is ever formed. Encrypt the
// ramp slot[i] = i over 8192 slots and the first entry comes back 4095.5 -- the
// mean of 0..8191, which is coefficient 0 of the encoding. Slot 0 holds 0.
// Two runs over the identical ciphertext and key also disagree, because the
// value carries fresh smudging noise rather than a decrypted message.
//
// integration.go:280-297 is the same code on the other path. This test fails as
// soon as either the decode or the cross-party sum lands, which is intended.
func TestDecryptReturnsCoefficientShareNotPlaintext(t *testing.T) {
p := testParams(t)
ramp := make([]complex128, p.MaxSlots())
for i := range ramp {
ramp[i] = complex(float64(i), 0)
}
ct, sk := encrypt(t, p, ramp)
run := func() []float64 {
d, err := NewThresholdDecryptor(log.Noop(), p, 1, 1, 0, 128, nil)
require.NoError(t, err)
d.SetSecretKey(sk)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := d.Decrypt(ctx, "ramp", ct)
require.NoError(t, err)
require.Len(t, out, 8*16)
values := make([]float64, len(out)/16)
for i := range values {
values[i] = math.Float64frombits(binary.LittleEndian.Uint64(out[i*16 : i*16+8]))
}
return values
}
first := run()
require.InDelta(t, 4095.5, first[0], 0.5, "entry 0 is the encoding's coefficient 0, the mean of the ramp")
require.Greater(t, math.Abs(first[0]-real(ramp[0])), 1.0, "entry 0 is not slot 0")
require.NotEqual(t, first, run(), "a decryption is a function of the ciphertext and the key")
}
// TestDecryptRequiresSecretKey holds that a decryptor with no key share refuses
// rather than registering a session that can never produce a share.
func TestDecryptRequiresSecretKey(t *testing.T) {
p := testParams(t)
d, err := NewThresholdDecryptor(log.Noop(), p, 2, 3, 0, 128, nil)
require.NoError(t, err)
ct, _ := encrypt(t, p, []complex128{complex(1, 0)})
_, err = d.Decrypt(context.Background(), "s", ct)
require.Error(t, err)
require.Contains(t, err.Error(), "secret key")
d.sessionsMu.RLock()
_, exists := d.sessions["s"]
d.sessionsMu.RUnlock()
require.False(t, exists, "a refused request must not leave a session behind")
}
// malformed is the set of byte strings a peer can put where a ciphertext
// belongs. The last two matter most: rlwe's decoder reads element and vector
// lengths straight out of the input and hands them to make(), so a crafted
// sixteen bytes reaches "makeslice: len out of range" -- a panic, on the
// goroutine that services cross-chain decryption requests, with nothing above
// it to recover. Both entry points must return an error for every one of these.
var malformed = [][]byte{
nil,
{},
[]byte("invalid"),
[]byte("not a ciphertext"),
{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
}
// TestDecryptRejectsMalformedCiphertext holds that bytes that are not a
// ciphertext are refused -- not survived, refused -- before a session exists,
// so a malformed cross-chain request neither ends the process nor parks a
// session in the map until it restarts.
func TestDecryptRejectsMalformedCiphertext(t *testing.T) {
p := testParams(t)
d, err := NewThresholdDecryptor(log.Noop(), p, 2, 3, 0, 128, nil)
require.NoError(t, err)
d.SetSecretKey(rlwe.NewKeyGenerator(p.Parameters).GenSecretKeyNew())
for i, bad := range malformed {
_, err = d.Decrypt(context.Background(), "s", bad)
require.ErrorContains(t, err, "unmarshal ciphertext", "input %d", i)
d.sessionsMu.RLock()
_, exists := d.sessions["s"]
d.sessionsMu.RUnlock()
require.False(t, exists, "input %d", i)
}
}
// TestInitiateDecryptionRejectsMalformedCiphertext holds the same refusal on
// the integration path, which deserializes the same untrusted bytes.
func TestInitiateDecryptionRejectsMalformedCiphertext(t *testing.T) {
p := testParams(t)
integration, err := NewThresholdFHEIntegration(log.Noop(), ThresholdConfig{
Threshold: 2, TotalParties: 3, CKKSParams: p, LogBound: 128,
}, 0)
require.NoError(t, err)
for i, bad := range malformed {
require.ErrorContains(t,
integration.InitiateDecryption("s", common.HexToHash("0x1"), bad),
"unmarshal ciphertext", "input %d", i)
integration.sessionsMu.RLock()
_, exists := integration.sessions["s"]
integration.sessionsMu.RUnlock()
require.False(t, exists, "input %d", i)
}
}
// TestDecryptSurvivesBroadcastFailure holds that a validator whose gossip fails
// still participates locally. Returning the broadcast error instead would mean
// a single unreachable peer aborts this node's contribution to every session.
func TestDecryptSurvivesBroadcastFailure(t *testing.T) {
p := testParams(t)
ct, sk := encrypt(t, p, []complex128{complex(7, 0)})
d, err := NewThresholdDecryptor(log.Noop(), p, 1, 1, 0, 128, func(string, []byte) error {
return errors.New("no peers reachable")
})
require.NoError(t, err)
d.SetSecretKey(sk)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := d.Decrypt(ctx, "s", ct)
require.NoError(t, err)
require.NotEmpty(t, out)
}
// TestDecryptGivesUpWithoutQuorum holds that a session that never reaches its
// threshold ends in ErrInsufficientShares rather than blocking a relayer
// goroutine forever. The wait is the decryptor's own 30 second bound, so this
// test costs that long and is the only place that bound is observed.
func TestDecryptGivesUpWithoutQuorum(t *testing.T) {
t.Parallel()
p := testParams(t)
ct, sk := encrypt(t, p, []complex128{complex(7, 0)})
d, err := NewThresholdDecryptor(log.Noop(), p, 2, 3, 0, 128, nil)
require.NoError(t, err)
d.SetSecretKey(sk)
start := time.Now()
_, err = d.Decrypt(context.Background(), "lonely", ct)
require.ErrorIs(t, err, ErrInsufficientShares)
require.Greater(t, time.Since(start), 29*time.Second)
}
// TestAddShareRejectsUnknownSessionAndMalformedShare holds the two refusals
// AddShare owes its caller: a share for a session this node never opened, and
// bytes that are not a share at all.
func TestAddShareRejectsUnknownSessionAndMalformedShare(t *testing.T) {
p := testParams(t)
ct, sk := encrypt(t, p, []complex128{complex(7, 0)})
var d *ThresholdDecryptor
d, err := NewThresholdDecryptor(log.Noop(), p, 3, 5, 0, 128, func(sessionID string, _ []byte) error {
require.ErrorContains(t, d.AddShare(sessionID, 2, []byte("junk")), "unmarshal share")
return nil
})
require.NoError(t, err)
d.SetSecretKey(sk)
require.ErrorContains(t, d.AddShare("never-opened", 1, []byte{}), "not found")
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
_, err = d.Decrypt(ctx, "s", ct)
require.ErrorIs(t, err, context.DeadlineExceeded)
d.sessionsMu.RLock()
count := d.sessions["s"].shareCount
d.sessionsMu.RUnlock()
require.Equal(t, 1, count, "the malformed share must not be counted")
}
// TestAddShareIgnoresSharesAfterCompletion holds that a share arriving late --
// after the quorum already closed the session -- is dropped rather than
// re-running the aggregation over a result that has already been reported.
func TestAddShareIgnoresSharesAfterCompletion(t *testing.T) {
p := testParams(t)
ct, sk := encrypt(t, p, []complex128{complex(7, 0)})
late := oneShare(t, p, ct, sk)
d, err := NewThresholdDecryptor(log.Noop(), p, 1, 1, 0, 128, nil)
require.NoError(t, err)
d.SetSecretKey(sk)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
first, err := d.Decrypt(ctx, "s", ct)
require.NoError(t, err)
require.NoError(t, d.AddShare("s", 99, late))
d.sessionsMu.RLock()
session := d.sessions["s"]
d.sessionsMu.RUnlock()
require.Equal(t, 1, session.shareCount)
require.Equal(t, first, session.result, "a completed session's result must not move")
}
// ---------------------------------------------------------------------------
// Relayer: request in, fulfillment out
// ---------------------------------------------------------------------------
// singleParty is a decryptor that finishes on its own share, so a relayer test
// observes the request pipeline rather than a quorum forming.
func singleParty(t testing.TB, p ckks.Parameters, sk *rlwe.SecretKey) *ThresholdDecryptor {
t.Helper()
d, err := NewThresholdDecryptor(log.Noop(), p, 1, 1, 0, 128, nil)
require.NoError(t, err)
d.SetSecretKey(sk)
return d
}
// TestRelayerFulfillsRequestOverWarp holds the whole C-Chain round trip: a
// submitted request is decrypted, recorded as fulfilled, and answered with one
// signed Warp envelope carrying the fulfillDecryption call for that request id.
// Each half is load-bearing -- a result recorded but never sent leaves the
// calling contract waiting forever, and an envelope sent for the wrong request
// id fulfills the wrong caller.
func TestRelayerFulfillsRequestOverWarp(t *testing.T) {
p := testParams(t)
ct, sk := encrypt(t, p, []complex128{complex(42, 0)})
handle := common.HexToHash("0x0badc0de")
storage := NewInMemoryCiphertextStorage()
require.NoError(t, storage.Put(handle, ct))
sent := make(chan *warp.Envelope, 1)
r := NewRelayer(log.Noop(), singleParty(t, p, sk), storage, 1,
ids.GenerateTestID(), ids.GenerateTestID(), &mockSigner{},
func(_ context.Context, env *warp.Envelope) error {
sent <- env
return nil
})
require.NoError(t, r.Start(context.Background()))
defer r.Stop()
requestID := common.HexToHash("0xfeed")
require.NoError(t, r.SubmitRequest(context.Background(), &DecryptionRequest{
RequestID: requestID,
CiphertextHash: handle,
SourceChainID: ids.GenerateTestID(),
}))
select {
case env := <-sent:
require.NotNil(t, env)
// The addressed call wraps the ABI call; the request id is at a fixed
// offset from the selector inside it.
require.Contains(t, string(env.Message.Payload), string(requestID.Bytes()))
case <-time.After(30 * time.Second):
t.Fatal("no fulfillment was sent")
}
result, fulfilled, err := r.GetResult(requestID)
require.NoError(t, err)
require.True(t, fulfilled)
require.NotEmpty(t, result)
}
// TestRelayerLeavesFailedRequestPending holds that a request whose ciphertext
// cannot be fetched stays pending and unfulfilled. Marking it fulfilled with an
// empty result would answer the calling contract with a plaintext of nothing.
func TestRelayerLeavesFailedRequestPending(t *testing.T) {
p := testParams(t)
_, sk := encrypt(t, p, []complex128{complex(42, 0)})
r := NewRelayer(log.Noop(), singleParty(t, p, sk), NewInMemoryCiphertextStorage(), 1,
ids.GenerateTestID(), ids.GenerateTestID(), &mockSigner{},
func(context.Context, *warp.Envelope) error {
t.Error("a failed decryption must not send a fulfillment")
return nil
})
require.NoError(t, r.Start(context.Background()))
defer r.Stop()
requestID := common.HexToHash("0xdead")
require.NoError(t, r.SubmitRequest(context.Background(), &DecryptionRequest{
RequestID: requestID,
CiphertextHash: common.HexToHash("0xnothinghere"),
}))
require.Eventually(t, func() bool {
_, fulfilled, err := r.GetResult(requestID)
return err == nil && !fulfilled
}, 5*time.Second, 10*time.Millisecond)
// Still pending after the pipeline has certainly drained.
time.Sleep(100 * time.Millisecond)
_, fulfilled, err := r.GetResult(requestID)
require.NoError(t, err)
require.False(t, fulfilled)
}
// TestRelayerReportsUndecryptableCiphertext holds that stored bytes that are
// not a ciphertext leave the request pending instead of ending the results
// goroutine. The bytes come from storage, which is written by peers.
func TestRelayerReportsUndecryptableCiphertext(t *testing.T) {
p := testParams(t)
_, sk := encrypt(t, p, []complex128{complex(42, 0)})
handle := common.HexToHash("0x1")
storage := NewInMemoryCiphertextStorage()
require.NoError(t, storage.Put(handle, []byte("not a ciphertext")))
r := NewRelayer(log.Noop(), singleParty(t, p, sk), storage, 1,
ids.GenerateTestID(), ids.GenerateTestID(), &mockSigner{}, nil)
require.NoError(t, r.Start(context.Background()))
defer r.Stop()
requestID := common.HexToHash("0xbeef")
require.NoError(t, r.SubmitRequest(context.Background(), &DecryptionRequest{
RequestID: requestID,
CiphertextHash: handle,
}))
time.Sleep(200 * time.Millisecond)
_, fulfilled, err := r.GetResult(requestID)
require.NoError(t, err)
require.False(t, fulfilled)
}
// TestHandleResultDropsResultsWithNoLiveRequest holds the two cases where a
// result has nowhere to go: no such request, and a request already answered.
// Sending a second fulfillment for a request the gateway has already settled is
// a replay, so the second one has to be dropped here.
func TestHandleResultDropsResultsWithNoLiveRequest(t *testing.T) {
sends := 0
r := NewRelayer(log.Noop(), nil, NewInMemoryCiphertextStorage(), 1,
ids.GenerateTestID(), ids.GenerateTestID(), &mockSigner{},
func(context.Context, *warp.Envelope) error {
sends++
return nil
})
// Unknown request.
r.handleResult(context.Background(), &DecryptionResult{
RequestID: common.HexToHash("0x1"),
Plaintext: []byte("x"),
})
// Already fulfilled.
known := common.HexToHash("0x2")
r.mu.Lock()
r.pendingRequests[known] = &DecryptionRequest{RequestID: known, Fulfilled: true, Result: []byte("first")}
r.mu.Unlock()
r.handleResult(context.Background(), &DecryptionResult{RequestID: known, Plaintext: []byte("second")})
result, fulfilled, err := r.GetResult(known)
require.NoError(t, err)
require.True(t, fulfilled)
require.Equal(t, []byte("first"), result, "an answered request keeps its first result")
require.Zero(t, sends)
}
// TestSendFulfillmentSurfacesSigningAndTransportFailures holds that a
// fulfillment that could not be signed or could not be handed to the network is
// reported as an error. Swallowing either makes a lost cross-chain answer look
// like a delivered one.
func TestSendFulfillmentSurfacesSigningAndTransportFailures(t *testing.T) {
req := &DecryptionRequest{RequestID: common.HexToHash("0x1"), Result: []byte("plaintext")}
newRelayer := func(signer warp.Signer, onMessage func(context.Context, *warp.Envelope) error) *Relayer {
return NewRelayer(log.Noop(), nil, NewInMemoryCiphertextStorage(), 1,
ids.GenerateTestID(), ids.GenerateTestID(), signer, onMessage)
}
failing := &mockSigner{signFunc: func(*warp.Message) ([]byte, error) {
return nil, errors.New("bls key unavailable")
}}
require.ErrorContains(t,
newRelayer(failing, nil).sendFulfillment(context.Background(), req),
"build signed warp message")
short := &mockSigner{signFunc: func(*warp.Message) ([]byte, error) { return []byte{1, 2, 3}, nil }}
require.ErrorContains(t,
newRelayer(short, nil).sendFulfillment(context.Background(), req),
"build signed warp message")
require.ErrorContains(t,
newRelayer(&mockSigner{}, func(context.Context, *warp.Envelope) error {
return errors.New("peer unreachable")
}).sendFulfillment(context.Background(), req),
"send warp message")
// No transport configured is not a failure: the envelope is built and the
// node simply has nowhere to hand it.
require.NoError(t, newRelayer(&mockSigner{}, nil).sendFulfillment(context.Background(), req))
}
// TestFulfillmentCallEncodesAsSolidityBytes holds the ABI layout of
// fulfillDecryption(bytes32,bytes). The three words after the selector are the
// request id, the offset to the byte array, and its length; a wrong offset or
// length makes the gateway read a different span of the call data and settle
// the request with whatever it finds there.
func TestFulfillmentCallEncodesAsSolidityBytes(t *testing.T) {
requestID := common.HexToHash("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef")
for _, result := range [][]byte{{}, []byte("plaintext"), make([]byte, 64), make([]byte, 65)} {
data := encodeFulfillmentCall(requestID, result)
padded := ((len(result) + 31) / 32) * 32
require.Len(t, data, 4+32+32+32+padded)
require.Equal(t, []byte{0x8a, 0x6d, 0x3a, 0xf9}, data[0:4])
require.Equal(t, requestID.Bytes(), data[4:36])
// Head word 2 is the offset from the start of the arguments to the
// length word, which is exactly two words in.
require.Equal(t, uint64(64), binary.BigEndian.Uint64(data[60:68]))
require.Zero(t, binary.BigEndian.Uint64(data[36:44]), "the offset word's high half must be zero")
require.Equal(t, uint64(len(result)), binary.BigEndian.Uint64(data[92:100]))
require.Equal(t, result, data[100:100+len(result)])
}
}
// TestDecryptRefusesAnOversizedMask holds that a decryptor configured with a
// mask wider than the ciphertext's modulus fails the request rather than
// emitting a share the other parties cannot combine.
func TestDecryptRefusesAnOversizedMask(t *testing.T) {
p := testParams(t)
ct, sk := encrypt(t, p, []complex128{complex(1, 0)})
d, err := NewThresholdDecryptor(log.Noop(), p, 2, 3, 0, 100_000, nil)
require.NoError(t, err)
d.SetSecretKey(sk)
_, err = d.Decrypt(context.Background(), "s", ct)
require.ErrorContains(t, err, "generate share")
}
// TestFulfillmentFailureLeavesTheResultRecorded holds that a fulfillment the
// node could not send does not undo the decryption. The result stays readable
// through GetResult, which is what an operator retries from; discarding it
// would mean re-running the whole quorum to answer the same request.
func TestFulfillmentFailureLeavesTheResultRecorded(t *testing.T) {
failing := &mockSigner{signFunc: func(*warp.Message) ([]byte, error) {
return nil, errors.New("bls key unavailable")
}}
r := NewRelayer(log.Noop(), nil, NewInMemoryCiphertextStorage(), 1,
ids.GenerateTestID(), ids.GenerateTestID(), failing, nil)
requestID := common.HexToHash("0x1")
r.mu.Lock()
r.pendingRequests[requestID] = &DecryptionRequest{RequestID: requestID}
r.mu.Unlock()
r.handleResult(context.Background(), &DecryptionResult{
RequestID: requestID,
Plaintext: []byte("plaintext"),
})
result, fulfilled, err := r.GetResult(requestID)
require.NoError(t, err)
require.True(t, fulfilled)
require.Equal(t, []byte("plaintext"), result)
}
// TestExpiredRequestsAreSweptOnTheTimer holds that the relayer's periodic sweep
// actually runs, not just that doCleanup works when called by hand: an unanswered
// request that is never swept holds its slot in pendingRequests for the life of
// the process, and the queue behind it is bounded at 100. The sweep interval is
// a fixed minute inside cleanupExpired, so this test waits one.
func TestExpiredRequestsAreSweptOnTheTimer(t *testing.T) {
t.Parallel()
r := NewRelayer(log.Noop(), nil, NewInMemoryCiphertextStorage(), 1,
ids.GenerateTestID(), ids.GenerateTestID(), nil, nil)
r.requestTimeout = time.Millisecond
requestID := common.HexToHash("0x1")
r.mu.Lock()
r.pendingRequests[requestID] = &DecryptionRequest{
RequestID: requestID,
Timestamp: time.Now().Add(-time.Hour),
}
r.mu.Unlock()
require.NoError(t, r.Start(context.Background()))
defer r.Stop()
require.Eventually(t, func() bool {
_, _, err := r.GetResult(requestID)
return errors.Is(err, ErrRequestNotFound)
}, 90*time.Second, time.Second, "the periodic sweep never removed the expired request")
}
+321
View File
@@ -6,6 +6,7 @@ package fhe
import (
"context"
"encoding/hex"
"errors"
"testing"
"time"
@@ -1311,3 +1312,323 @@ func TestFHEServiceRegisterCiphertextWithChainID(t *testing.T) {
require.NoError(err)
require.NotEmpty(reply.Handle)
}
// ---------------------------------------------------------------------------
// Who the RPC surface will answer, and what it refuses
// ---------------------------------------------------------------------------
// callerFrom is an Authenticator that reports a fixed address, or a failure to
// establish one, which is the difference between an authenticated caller and an
// anonymous connection.
type callerFrom struct {
address [20]byte
err error
}
func (c callerFrom) GetCallerAddress(context.Context) ([20]byte, error) {
if c.err != nil {
return [20]byte{}, c.err
}
return c.address, nil
}
// TestWithAuthenticatorInstallsTheCallerCheck holds that the option reaches the
// service, because every ownership check below is a no-op without it.
func TestWithAuthenticatorInstallsTheCallerCheck(t *testing.T) {
auth := callerFrom{address: [20]byte{1}}
service := NewFHEService(nil, nil, log.NewNoOpLogger(), ids.GenerateTestID(), WithAuthenticator(auth))
require.NotNil(t, service)
require.Equal(t, auth, service.auth)
// Without the option there is no authenticator, and the constructor says so
// rather than failing: the RPC surface is usable unauthenticated.
require.Nil(t, NewFHEService(nil, nil, log.NewNoOpLogger(), ids.GenerateTestID()).auth)
}
// TestRegisterCiphertextRequiresTheOwnerToBeTheCaller holds that a ciphertext
// is registered only by the address it names as owner. Without it any caller
// can claim a handle under someone else's address, and CreatePermit then treats
// that address as entitled to grant access to it.
func TestRegisterCiphertextRequiresTheOwnerToBeTheCaller(t *testing.T) {
owner := [20]byte{0xaa}
handle := hex.EncodeToString(make([]byte, 32))
register := func(auth Authenticator) error {
service := newTestFHEService(t)
service.auth = auth
return service.RegisterCiphertext(context.Background(), &RegisterCiphertextArgs{
Handle: handle,
Owner: hex.EncodeToString(owner[:]),
}, &RegisterCiphertextReply{})
}
require.NoError(t, register(callerFrom{address: owner}))
err := register(callerFrom{address: [20]byte{0xbb}})
require.ErrorIs(t, err, ErrUnauthorized)
err = register(callerFrom{err: errors.New("no client certificate")})
require.ErrorIs(t, err, ErrAuthRequired)
}
// TestCreatePermitRequiresTheGrantorToBeTheCallerAndTheOwner holds both halves
// of granting access: the caller must be the grantor they name, and that
// grantor must own the ciphertext. Dropping either turns permit creation into
// an open door -- anyone could mint a permit over anyone's ciphertext.
func TestCreatePermitRequiresTheGrantorToBeTheCallerAndTheOwner(t *testing.T) {
owner := [20]byte{0xaa}
stranger := [20]byte{0xbb}
handle := make([]byte, 32)
handle[0] = 7
service := newTestFHEService(t)
require.NoError(t, service.registry.RegisterCiphertext(&CiphertextMeta{
Handle: [32]byte(handle), Owner: owner,
}))
args := &CreatePermitArgs{
Handle: hex.EncodeToString(handle),
Grantee: hex.EncodeToString(stranger[:]),
Grantor: hex.EncodeToString(owner[:]),
Operations: PermitOpDecrypt,
}
service.auth = callerFrom{address: owner}
require.NoError(t, service.CreatePermit(context.Background(), args, &CreatePermitReply{}))
service.auth = callerFrom{address: stranger}
require.ErrorIs(t, service.CreatePermit(context.Background(), args, &CreatePermitReply{}), ErrUnauthorized)
service.auth = callerFrom{err: errors.New("no client certificate")}
require.ErrorIs(t, service.CreatePermit(context.Background(), args, &CreatePermitReply{}), ErrAuthRequired)
// The grantor must also be the owner, even when it is the caller.
service.auth = callerFrom{address: stranger}
require.ErrorContains(t, service.CreatePermit(context.Background(), &CreatePermitArgs{
Handle: hex.EncodeToString(handle),
Grantee: hex.EncodeToString(owner[:]),
Grantor: hex.EncodeToString(stranger[:]),
}, &CreatePermitReply{}), "not the ciphertext owner")
}
// TestRequestDecryptRefusesWithoutAValidPermit holds that a decryption request
// is admitted only when the permit names this ciphertext, this callback and the
// decrypt operation. This is the only gate on the request path: RequestDecrypt
// does not consult the authenticator at all, so the permit is what stands
// between a stored ciphertext and a settlement sent to the grantee.
func TestRequestDecryptRefusesWithoutAValidPermit(t *testing.T) {
service := newTestFHEService(t)
handle := [32]byte{9}
grantee := [20]byte{0xcc}
require.NoError(t, service.registry.RegisterCiphertext(&CiphertextMeta{Handle: handle, Owner: [20]byte{1}}))
permitID := [32]byte{4}
require.NoError(t, service.registry.CreatePermit(&Permit{
PermitID: permitID, Handle: handle, Grantee: grantee,
Operations: PermitOpCompute, // not decrypt
}))
args := &RequestDecryptArgs{
CiphertextHandle: hex.EncodeToString(handle[:]),
PermitID: hex.EncodeToString(permitID[:]),
Callback: hex.EncodeToString(grantee[:]),
CallbackSelector: "deadbeef",
}
err := service.RequestDecrypt(context.Background(), args, &RequestDecryptReply{})
require.ErrorIs(t, err, ErrPermitInvalid)
// A permit for a different ciphertext is refused for this one.
otherPermit := [32]byte{5}
require.NoError(t, service.registry.CreatePermit(&Permit{
PermitID: otherPermit, Handle: [32]byte{99}, Grantee: grantee,
Operations: PermitOpDecrypt,
}))
args.PermitID = hex.EncodeToString(otherPermit[:])
require.ErrorIs(t, service.RequestDecrypt(context.Background(), args, &RequestDecryptReply{}), ErrPermitInvalid)
// The right permit is admitted.
good := [32]byte{6}
require.NoError(t, service.registry.CreatePermit(&Permit{
PermitID: good, Handle: handle, Grantee: grantee,
Operations: PermitOpDecrypt,
}))
args.PermitID = hex.EncodeToString(good[:])
reply := &RequestDecryptReply{}
require.NoError(t, service.RequestDecrypt(context.Background(), args, reply))
require.Len(t, reply.RequestID, 64)
require.Equal(t, RequestPending.String(), reply.Status)
}
// TestRequestDecryptCarriesTheNamedSourceChain holds that a caller-supplied
// source chain is parsed and recorded, and that an unparseable one is refused
// rather than silently replaced by this chain -- the fulfillment is addressed
// back to whatever is recorded here.
func TestRequestDecryptCarriesTheNamedSourceChain(t *testing.T) {
service := newTestFHEService(t)
handle := [32]byte{9}
grantee := [20]byte{0xcc}
permitID := [32]byte{4}
require.NoError(t, service.registry.RegisterCiphertext(&CiphertextMeta{Handle: handle}))
require.NoError(t, service.registry.CreatePermit(&Permit{
PermitID: permitID, Handle: handle, Grantee: grantee, Operations: PermitOpDecrypt,
}))
args := &RequestDecryptArgs{
CiphertextHandle: hex.EncodeToString(handle[:]),
PermitID: hex.EncodeToString(permitID[:]),
Callback: hex.EncodeToString(grantee[:]),
CallbackSelector: "deadbeef",
SourceChain: "not-a-chain-id",
}
require.ErrorContains(t, service.RequestDecrypt(context.Background(), args, &RequestDecryptReply{}),
"invalid source chain")
source := ids.GenerateTestID()
args.SourceChain = source.String()
args.Expiry = time.Now().Add(time.Hour).Unix()
reply := &RequestDecryptReply{}
require.NoError(t, service.RequestDecrypt(context.Background(), args, reply))
raw, err := hex.DecodeString(reply.RequestID)
require.NoError(t, err)
stored, err := service.registry.GetDecryptRequest([32]byte(raw))
require.NoError(t, err)
require.Equal(t, source, stored.SourceChain)
require.Equal(t, args.Expiry, stored.Expiry)
}
// TestUninitializedServiceRefusesEveryCall holds that a service with no
// registry answers ErrNotInitialized everywhere instead of dereferencing nil.
// These methods are reachable over the wire the moment the RPC handler is
// registered, which can happen before the registry is attached.
func TestUninitializedServiceRefusesEveryCall(t *testing.T) {
service := &FHEService{logger: log.NewNoOpLogger()}
ctx := context.Background()
require.ErrorIs(t, service.GetPublicParams(ctx, &GetPublicParamsArgs{}, &GetPublicParamsReply{}), ErrNotInitialized)
require.ErrorIs(t, service.GetCommittee(ctx, &GetCommitteeArgs{}, &GetCommitteeReply{}), ErrNotInitialized)
require.ErrorIs(t, service.RegisterCiphertext(ctx, &RegisterCiphertextArgs{}, &RegisterCiphertextReply{}), ErrNotInitialized)
require.ErrorIs(t, service.GetCiphertextMeta(ctx, &GetCiphertextMetaArgs{}, &GetCiphertextMetaReply{}), ErrNotInitialized)
require.ErrorIs(t, service.RequestDecrypt(ctx, &RequestDecryptArgs{}, &RequestDecryptReply{}), ErrNotInitialized)
require.ErrorIs(t, service.GetDecryptResult(ctx, &GetDecryptResultArgs{}, &GetDecryptResultReply{}), ErrNotInitialized)
require.ErrorIs(t, service.GetRequestReceipt(ctx, &GetRequestReceiptArgs{}, &GetRequestReceiptReply{}), ErrNotInitialized)
require.ErrorIs(t, service.CreatePermit(ctx, &CreatePermitArgs{}, &CreatePermitReply{}), ErrNotInitialized)
require.ErrorIs(t, service.VerifyPermit(ctx, &VerifyPermitArgs{}, &VerifyPermitReply{}), ErrNotInitialized)
}
// TestGetPublicParamsNeedsAnEpoch holds that the parameters a client encrypts
// against are reported only when there is an epoch to report them for. Serving
// a zero threshold and an empty public key would have clients encrypt to
// nothing.
func TestGetPublicParamsNeedsAnEpoch(t *testing.T) {
registry, err := NewRegistry(memdb.New())
require.NoError(t, err)
service := &FHEService{registry: registry, logger: log.NewNoOpLogger()}
require.ErrorContains(t,
service.GetPublicParams(context.Background(), &GetPublicParamsArgs{}, &GetPublicParamsReply{}),
"failed to get epoch info")
}
// TestGetCiphertextMetaRejectsMalformedHandles holds that a handle that is not
// 32 hex-encoded bytes is refused before it reaches storage, so a short handle
// cannot be zero-extended into a different, existing one.
func TestGetCiphertextMetaRejectsMalformedHandles(t *testing.T) {
service := newTestFHEService(t)
for _, handle := range []string{"", "zz", hex.EncodeToString(make([]byte, 31)), hex.EncodeToString(make([]byte, 33))} {
require.ErrorIs(t,
service.GetCiphertextMeta(context.Background(), &GetCiphertextMetaArgs{Handle: handle}, &GetCiphertextMetaReply{}),
ErrInvalidHandle, "handle %q", handle)
}
}
// TestRegistryFailuresReachTheRPCCaller holds that a store that cannot write is
// reported to the caller rather than answered with a success. A client that
// believes a ciphertext is registered will go on to request its decryption.
func TestRegistryFailuresReachTheRPCCaller(t *testing.T) {
registry, db := newFaultyRegistry(t)
service := &FHEService{registry: registry, logger: log.NewNoOpLogger(), chainID: ids.GenerateTestID()}
handle := make([]byte, 32)
require.NoError(t, registry.RegisterCiphertext(&CiphertextMeta{Handle: [32]byte(handle)}))
permitID := [32]byte{1}
require.NoError(t, registry.CreatePermit(&Permit{
PermitID: permitID, Handle: [32]byte(handle), Operations: PermitOpDecrypt,
}))
db.refusePut = always
require.ErrorContains(t, service.RegisterCiphertext(context.Background(), &RegisterCiphertextArgs{
Handle: hex.EncodeToString(handle),
Owner: hex.EncodeToString(make([]byte, 20)),
}, &RegisterCiphertextReply{}), "failed to register ciphertext")
require.ErrorContains(t, service.RequestDecrypt(context.Background(), &RequestDecryptArgs{
CiphertextHandle: hex.EncodeToString(handle),
PermitID: hex.EncodeToString(permitID[:]),
Callback: hex.EncodeToString(make([]byte, 20)),
CallbackSelector: "deadbeef",
}, &RequestDecryptReply{}), "failed to create request")
require.ErrorContains(t, service.CreatePermit(context.Background(), &CreatePermitArgs{
Handle: hex.EncodeToString(handle),
Grantee: hex.EncodeToString(make([]byte, 20)),
Grantor: hex.EncodeToString(make([]byte, 20)),
}, &CreatePermitReply{}), "failed to create permit")
}
// TestGetDecryptResultReportsTheResultHandleOnlyWhenCompleted holds that the
// result handle appears exactly when the request is completed. Reporting the
// zero handle on a pending request would have a caller fetch ciphertext 0x00.
func TestGetDecryptResultReportsTheResultHandleOnlyWhenCompleted(t *testing.T) {
service := newTestFHEService(t)
requestID := [32]byte{7}
require.NoError(t, service.registry.CreateDecryptRequest(&DecryptRequest{RequestID: requestID}))
reply := &GetDecryptResultReply{}
require.NoError(t, service.GetDecryptResult(context.Background(),
&GetDecryptResultArgs{RequestID: hex.EncodeToString(requestID[:])}, reply))
require.Equal(t, RequestPending.String(), reply.Status)
require.Empty(t, reply.ResultHandle)
resultHandle := [32]byte{0xab}
require.NoError(t, service.registry.UpdateDecryptRequest(requestID, RequestCompleted, resultHandle, ""))
reply = &GetDecryptResultReply{}
require.NoError(t, service.GetDecryptResult(context.Background(),
&GetDecryptResultArgs{RequestID: hex.EncodeToString(requestID[:])}, reply))
require.Equal(t, RequestCompleted.String(), reply.Status)
require.Equal(t, hex.EncodeToString(resultHandle[:]), reply.ResultHandle)
require.NotZero(t, reply.CompletedAt)
}
// TestBatchCallsAreBoundedAndFailAsAUnit holds two properties of the batch
// entry points: neither will accept more than MaxBatchSize, and a batch of
// requests fails at the first bad member rather than half-applying. The read
// batch is deliberately the other way around -- one unknown id must not hide
// the results of the others -- so both shapes are pinned here.
func TestBatchCallsAreBoundedAndFailAsAUnit(t *testing.T) {
service := newTestFHEService(t)
ctx := context.Background()
oversized := make([]RequestDecryptArgs, MaxBatchSize+1)
require.ErrorIs(t, service.RequestDecryptBatch(ctx, &RequestDecryptBatchArgs{Requests: oversized},
&RequestDecryptBatchReply{}), ErrBatchTooLarge)
oversizedIDs := make([]string, MaxBatchSize+1)
require.ErrorIs(t, service.GetDecryptBatchResult(ctx, &GetDecryptBatchResultArgs{RequestIDs: oversizedIDs},
&GetDecryptBatchResultReply{}), ErrBatchTooLarge)
require.ErrorContains(t, service.RequestDecryptBatch(ctx, &RequestDecryptBatchArgs{
Requests: []RequestDecryptArgs{{CiphertextHandle: "not hex"}},
}, &RequestDecryptBatchReply{}), "request 0 failed")
// A read batch reports per-entry failures inside the results.
reply := &GetDecryptBatchResultReply{}
require.NoError(t, service.GetDecryptBatchResult(ctx,
&GetDecryptBatchResultArgs{RequestIDs: []string{hex.EncodeToString(make([]byte, 32))}}, reply))
require.Len(t, reply.Results, 1)
require.NotEmpty(t, reply.Results[0].Error)
}
+21 -5
View File
@@ -569,7 +569,11 @@ func ParseFHEKeyRotationV1(data []byte) (*FHEKeyRotationV1, error) {
// Payload Dispatcher
// =====================
// ParsePayload parses any FHE Warp payload and returns the type and data
// ParsePayload parses any FHE Warp payload and returns the type and data.
// On any error the payload is a true nil. Handing back the typed pointer
// alongside the error boxes a nil *FHEDecryptResultV1 into a non-nil
// interface, and a caller testing `if payload != nil` then admits a payload
// that failed to parse.
func ParsePayload(data []byte) (uint8, interface{}, error) {
if len(data) < 2 {
return 0, nil, ErrPayloadTooShort
@@ -594,16 +598,28 @@ func ParsePayload(data []byte) (uint8, interface{}, error) {
return payloadType, req, nil
case PayloadTypeFHEDecryptResultV1:
res, err := ParseFHEDecryptResultV1(data)
return payloadType, res, err
if err != nil {
return payloadType, nil, err
}
return payloadType, res, nil
case PayloadTypeFHEReencryptRequestV1:
req, err := ParseFHEReencryptRequestV1(data)
return payloadType, req, err
if err != nil {
return payloadType, nil, err
}
return payloadType, req, nil
case PayloadTypeFHETaskResultV1:
res, err := ParseFHETaskResultV1(data)
return payloadType, res, err
if err != nil {
return payloadType, nil, err
}
return payloadType, res, nil
case PayloadTypeFHEKeyRotationV1:
rot, err := ParseFHEKeyRotationV1(data)
return payloadType, rot, err
if err != nil {
return payloadType, nil, err
}
return payloadType, rot, nil
default:
return payloadType, nil, ErrInvalidPayloadType
}
+137
View File
@@ -768,3 +768,140 @@ func TestParsePayloadAcceptsNoExpiryRequest(t *testing.T) {
require.Equal(PayloadTypeFHEDecryptRequestV1, payloadType)
require.NotNil(parsed)
}
// TestParsePayloadReturnsNilPayloadOnError holds that a failed parse hands back
// a nil payload, not a nil pointer boxed in a non-nil interface. Four of the
// five branches used to return the typed pointer alongside the error, so a
// caller writing `if payload != nil { use(payload) }` -- the ordinary Go shape
// -- accepted a decrypt result that never parsed and read zero values out of
// it: a zero request id, a zero epoch, an empty plaintext, all of which look
// like a legitimate settlement.
func TestParsePayloadReturnsNilPayloadOnError(t *testing.T) {
for _, payloadType := range []uint8{
PayloadTypeFHEDecryptRequestV1,
PayloadTypeFHEDecryptResultV1,
PayloadTypeFHEReencryptRequestV1,
PayloadTypeFHETaskResultV1,
PayloadTypeFHEKeyRotationV1,
} {
truncated := make([]byte, 10)
truncated[0] = PayloadVersionV1
truncated[1] = payloadType
gotType, payload, err := ParsePayload(truncated)
require.ErrorIs(t, err, ErrPayloadTooShort, "type 0x%02x", payloadType)
require.Equal(t, payloadType, gotType)
// The comparison is against the interface, not require.Nil: a nil
// *FHEDecryptResultV1 inside an interface satisfies require.Nil, and it
// is exactly the value a caller's `!= nil` accepts.
require.True(t, payload == nil,
"type 0x%02x returned a %T alongside an error", payloadType, payload)
}
}
// TestParsePayloadRejectsTruncatedDecryptRequest holds that the dispatcher
// reports the underlying parse failure rather than a valid-looking request. The
// request branch is the only one that also validates expiry, so it is the only
// one where a parse error and a policy error arrive through the same return.
func TestParsePayloadRejectsTruncatedDecryptRequest(t *testing.T) {
full := (&FHEDecryptRequestV1{RequestID: [32]byte{1}}).Bytes()
gotType, payload, err := ParsePayload(full[:201])
require.ErrorIs(t, err, ErrPayloadTooShort)
require.Equal(t, PayloadTypeFHEDecryptRequestV1, gotType)
require.True(t, payload == nil)
// One more byte and it parses.
_, payload, err = ParsePayload(full)
require.NoError(t, err)
require.NotNil(t, payload)
}
// TestFixedWidthFieldsSurviveTheExactMinimumLength holds that every fixed-width
// field is inside the length floor each parser checks. A field that reached
// past it would be zero-padded by copy() without any error, producing a
// well-formed envelope naming the wrong ciphertext or the wrong chain -- the
// classic way a wire format silently lies.
func TestFixedWidthFieldsSurviveTheExactMinimumLength(t *testing.T) {
request := &FHEDecryptRequestV1{
RequestID: [32]byte{0xff},
CiphertextHandle: [32]byte{31: 0xff},
PermitID: [32]byte{0xaa, 31: 0xbb},
SourceChainID: ids.ID{31: 0xcc},
Epoch: ^uint64(0),
Nonce: ^uint64(0),
Expiry: 0,
Requester: [20]byte{19: 0xdd},
Callback: [20]byte{0xee},
CallbackSelector: [4]byte{0xde, 0xad, 0xbe, 0xef},
GasLimit: ^uint32(0),
}
encoded := request.Bytes()
require.Len(t, encoded, 202, "the wire size is the parser's length floor")
parsed, err := ParseFHEDecryptRequestV1(encoded)
require.NoError(t, err)
require.Equal(t, request, parsed)
result := &FHEDecryptResultV1{
RequestID: [32]byte{31: 0x11},
ResultHandle: [32]byte{0x22},
SourceChainID: ids.ID{31: 0x33},
Epoch: ^uint64(0),
Status: DecryptStatusDenied,
CommitteeSignature: [32]byte{31: 0x44},
}
encodedResult := result.Bytes()
require.Len(t, encodedResult, 143)
parsedResult, err := ParseFHEDecryptResultV1(encodedResult)
require.NoError(t, err)
require.Equal(t, result.RequestID, parsedResult.RequestID)
require.Equal(t, result.ResultHandle, parsedResult.ResultHandle)
require.Equal(t, result.SourceChainID, parsedResult.SourceChainID)
require.Equal(t, result.Epoch, parsedResult.Epoch)
require.Equal(t, result.Status, parsedResult.Status)
require.Equal(t, result.CommitteeSignature, parsedResult.CommitteeSignature)
require.Empty(t, parsedResult.Plaintext)
task := &FHETaskResultV1{
TaskID: [32]byte{31: 0x55},
ResultHandle: [32]byte{0x66},
SourceChainID: ids.ID{31: 0x77},
Epoch: ^uint64(0),
Status: TaskStatusTimeout,
Callback: [20]byte{19: 0x88},
CallbackSelector: [4]byte{0xca, 0xfe, 0xba, 0xbe},
Signature: [32]byte{31: 0x99},
}
encodedTask := task.Bytes()
require.Len(t, encodedTask, 163)
parsedTask, err := ParseFHETaskResultV1(encodedTask)
require.NoError(t, err)
require.Equal(t, task, parsedTask)
}
// TestVariableLengthPayloadsIgnoreTrailingBytes records that a declared length
// shorter than the bytes that follow it parses, and the surplus is dropped. Two
// different byte strings therefore decode to the same payload. That is safe
// here only because the Warp envelope is signed over the whole message, so the
// surplus cannot be added after signing -- if that ever stops being true, this
// is where the malleability lives.
func TestVariableLengthPayloadsIgnoreTrailingBytes(t *testing.T) {
result := &FHEDecryptResultV1{Plaintext: []byte("abc")}
withSurplus := append(result.Bytes(), 0xde, 0xad, 0xbe, 0xef)
parsed, err := ParseFHEDecryptResultV1(withSurplus)
require.NoError(t, err)
require.Equal(t, []byte("abc"), parsed.Plaintext)
rotation := &FHEKeyRotationV1{NewPublicKey: []byte("pk")}
parsedRotation, err := ParseFHEKeyRotationV1(append(rotation.Bytes(), 0xff))
require.NoError(t, err)
require.Equal(t, []byte("pk"), parsedRotation.NewPublicKey)
reencrypt := &FHEReencryptRequestV1{RecipientPublicKey: []byte("pk")}
parsedReencrypt, err := ParseFHEReencryptRequestV1(append(reencrypt.Bytes(), 0xff))
require.NoError(t, err)
require.Equal(t, []byte("pk"), parsedReencrypt.RecipientPublicKey)
}
+274
View File
@@ -0,0 +1,274 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// gossip_test.go — the cross-validator transport, in isolation.
//
// The transport moves opaque bytes and interprets no cryptography, so what
// there is to hold here is: an envelope round-trips, a broadcast reaches every
// committee member and nobody else, a directed message reaches one peer, and a
// session that is closing cannot be made to send on a closed channel.
import (
"context"
"errors"
"sync"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
"github.com/luxfi/math/set"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/protocol"
"github.com/luxfi/warp"
)
// recordingSender records what the node was asked to gossip and to whom.
type recordingSender struct {
mu sync.Mutex
sent []sentGossip
err error
}
type sentGossip struct {
to set.Set[ids.NodeID]
msg []byte
}
func (s *recordingSender) SendGossip(_ context.Context, cfg warp.SendConfig, msg []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return s.err
}
s.sent = append(s.sent, sentGossip{to: cfg.NodeIDs, msg: msg})
return nil
}
func (s *recordingSender) SendRequest(context.Context, set.Set[ids.NodeID], uint32, []byte) error {
return nil
}
func (s *recordingSender) SendResponse(context.Context, ids.NodeID, uint32, []byte) error { return nil }
func (s *recordingSender) SendError(context.Context, ids.NodeID, uint32, int32, string) error {
return nil
}
func (s *recordingSender) last() sentGossip {
s.mu.Lock()
defer s.mu.Unlock()
return s.sent[len(s.sent)-1]
}
func (s *recordingSender) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.sent)
}
var _ warp.Sender = (*recordingSender)(nil)
// -----------------------------------------------------------------------------
// Envelopes
// -----------------------------------------------------------------------------
// A ceremony message survives the wire unchanged, tagged with the session it
// belongs to, so a validator running several ceremonies at once demultiplexes
// them to the right handler.
func TestACeremonyMessageSurvivesTheWireWithItsSession(t *testing.T) {
msg := &protocol.Message{
From: "pa",
To: "pb",
Protocol: "cmp/sign",
RoundNumber: 3,
Data: []byte("round data"),
Broadcast: true,
}
env, err := marshalEnvelope("mpc/session", msg)
require.NoError(t, err)
session, got, err := unmarshalEnvelope(env)
require.NoError(t, err)
require.Equal(t, "mpc/session", session)
require.Equal(t, msg.From, got.From)
require.Equal(t, msg.To, got.To)
require.Equal(t, msg.Protocol, got.Protocol)
require.Equal(t, msg.RoundNumber, got.RoundNumber)
require.Equal(t, msg.Data, got.Data)
require.Equal(t, msg.Broadcast, got.Broadcast)
}
func TestBytesThatAreNotAnEnvelopeAreRefused(t *testing.T) {
_, _, err := unmarshalEnvelope([]byte("not json"))
require.ErrorContains(t, err, "unmarshal envelope")
// A well-formed envelope whose payload is not a ceremony message.
env, err := marshalEnvelope("s", &protocol.Message{From: "pa"})
require.NoError(t, err)
broken := append([]byte(nil), env...)
broken[len(broken)-4] ^= 0xff
_, _, err = unmarshalEnvelope(broken)
require.Error(t, err)
}
// -----------------------------------------------------------------------------
// Sending
// -----------------------------------------------------------------------------
// A broadcast is an explicit multicast to the ceremony's committee, never a
// validator sample: an MPC round is sound only if EVERY listed signer receives
// every broadcast, and sampling a subset silently starves a signer and stalls
// the round.
func TestABroadcastReachesEveryCommitteeMemberAndNobodyElse(t *testing.T) {
self := ids.GenerateTestNodeID()
peers := []ids.NodeID{ids.GenerateTestNodeID(), ids.GenerateTestNodeID()}
sender := &recordingSender{}
net := newWarpAppNetwork(sender, self, append([]ids.NodeID{self}, peers...))
require.NoError(t, net.Broadcast(ctx(), []byte("round one")))
got := sender.last()
require.Equal(t, 2, got.to.Len(), "self is excluded; every other committee member is addressed")
for _, p := range peers {
require.True(t, got.to.Contains(p))
}
require.False(t, got.to.Contains(self))
require.Equal(t, []byte("round one"), got.msg)
}
// A committee of one has nobody to broadcast to, and says so by sending
// nothing rather than by sending to everyone.
func TestABroadcastToACommitteeOfOneSendsNothing(t *testing.T) {
self := ids.GenerateTestNodeID()
sender := &recordingSender{}
net := newWarpAppNetwork(sender, self, []ids.NodeID{self})
require.NoError(t, net.Broadcast(ctx(), []byte("alone")))
require.Zero(t, sender.count())
}
func TestADirectedMessageReachesOnePeerAndNeverThisNode(t *testing.T) {
self, peer := ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
sender := &recordingSender{}
net := newWarpAppNetwork(sender, self, []ids.NodeID{self, peer})
require.NoError(t, net.SendTo(ctx(), peer, []byte("for you")))
got := sender.last()
require.Equal(t, 1, got.to.Len())
require.True(t, got.to.Contains(peer))
require.NoError(t, net.SendTo(ctx(), self, []byte("for me")))
require.Equal(t, 1, sender.count(), "a node does not gossip to itself")
}
// A node with no p2p cannot carry a ceremony, and says so rather than dropping
// rounds silently.
func TestANodeWithNoSenderCarriesNoCeremony(t *testing.T) {
net := newWarpAppNetwork(nil, ids.GenerateTestNodeID(), []ids.NodeID{ids.GenerateTestNodeID()})
require.ErrorContains(t, net.Broadcast(ctx(), nil), "no warp sender")
require.ErrorContains(t, net.SendTo(ctx(), ids.GenerateTestNodeID(), nil), "no warp sender")
vm := newVM(t)
_, _, err := vm.ceremonyRouter(ctx(), "mpc/x", parties(3))
require.ErrorContains(t, err, "no warp sender")
}
// -----------------------------------------------------------------------------
// The router
// -----------------------------------------------------------------------------
func TestTheRouterBroadcastsOrAddressesByWhatTheMessageSays(t *testing.T) {
self, peer := ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
sender := &recordingSender{}
net := newWarpAppNetwork(sender, self, []ids.NodeID{self, peer})
router := newGossipRouter(ctx(), "mpc/s", party.ID(self.String()), net,
map[party.ID]ids.NodeID{party.ID(peer.String()): peer})
require.NoError(t, router.Send(&protocol.Message{From: party.ID(self.String())}))
require.Equal(t, 1, sender.count())
require.NoError(t, router.Send(&protocol.Message{From: party.ID(self.String()), To: party.ID(peer.String())}))
require.Equal(t, 2, sender.count())
require.True(t, sender.last().to.Contains(peer))
require.NoError(t, router.Send(nil), "nothing to send is not an error")
require.Equal(t, 2, sender.count())
}
// A message addressed to a party this ceremony has no validator for is an
// error, not a silent drop: the round will stall either way, and only one of
// those says why.
func TestAMessageForAPartyWithNoValidatorIsNamed(t *testing.T) {
sender := &recordingSender{}
self := ids.GenerateTestNodeID()
net := newWarpAppNetwork(sender, self, []ids.NodeID{self})
router := newGossipRouter(ctx(), "mpc/s", party.ID(self.String()), net, nil)
err := router.Send(&protocol.Message{From: party.ID(self.String()), To: "nobody"})
require.ErrorContains(t, err, "no validator for party nobody")
}
func TestATransportFailureIsReportedNotSwallowed(t *testing.T) {
sender := &recordingSender{err: errors.New("network down")}
self, peer := ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
net := newWarpAppNetwork(sender, self, []ids.NodeID{self, peer})
router := newGossipRouter(ctx(), "mpc/s", party.ID(self.String()), net,
map[party.ID]ids.NodeID{party.ID(peer.String()): peer})
require.ErrorContains(t, router.Send(&protocol.Message{From: party.ID(self.String())}), "network down")
require.ErrorContains(t, router.Send(&protocol.Message{
From: party.ID(self.String()), To: party.ID(peer.String()),
}), "network down")
}
// A router drops a message rather than blocking consensus on a full inbox, and
// never sends on a closed channel however late a peer's gossip arrives.
func TestALateMessageNeverSendsOnAClosedSession(t *testing.T) {
router := newGossipRouter(ctx(), "mpc/s", "self", nil, nil)
router.Deliver(nil)
router.Deliver(&protocol.Message{From: "self"})
require.Empty(t, router.Receive(), "a router does not deliver this node's own message back to it")
router.Deliver(&protocol.Message{From: "pa"})
require.Len(t, router.Receive(), 1)
<-router.Receive()
router.Close()
router.Close() // idempotent
require.NotPanics(t, func() { router.Deliver(&protocol.Message{From: "pa"}) },
"a peer still gossiping as the session tears down must not crash this node")
}
func TestAFullInboxDropsRatherThanBlocks(t *testing.T) {
router := newGossipRouter(ctx(), "mpc/s", "self", nil, nil)
for i := 0; i < cap(router.inbox)+50; i++ {
router.Deliver(&protocol.Message{From: "pa", RoundNumber: 1})
}
require.Equal(t, cap(router.inbox), len(router.Receive()),
"blocking here would block the consensus goroutine that called Gossip")
}
// -----------------------------------------------------------------------------
// party.ID is the NodeID string
// -----------------------------------------------------------------------------
// The committee and the peer set are the same value in two spellings, so the
// mapping from a signer back to the validator that runs it is the exact
// inverse, with no side table to drift.
func TestASignerResolvesToTheValidatorThatRunsIt(t *testing.T) {
nodes := []ids.NodeID{ids.GenerateTestNodeID(), ids.GenerateTestNodeID()}
signers := []party.ID{party.ID(nodes[0].String()), party.ID(nodes[1].String())}
resolved, peers, err := resolveCommittee(signers)
require.NoError(t, err)
require.Equal(t, nodes, resolved)
for i, s := range signers {
require.Equal(t, nodes[i], peers[s])
}
}
func TestASignerThatIsNotANodeIdIsNamed(t *testing.T) {
_, _, err := resolveCommittee([]party.ID{"not-a-node-id"})
require.ErrorContains(t, err, "is not a node id")
}
+394
View File
@@ -0,0 +1,394 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build cgo
package mpcvm
// gpu_plugin_test.go — the dlopen bridge, driven against a real plugin.
//
// The GPU bridge is a runtime overlay: it dlopens a backend plugin, dlsyms four
// launchers, and hands them host pointers to the wire structs. What can be
// wrong here is not the arithmetic inside a kernel — that is the kernel's
// business — but the BRIDGE: whether a missing symbol is noticed, whether the
// counts the launcher receives are the ones the caller passed, and whether a
// launcher's non-zero return reaches the caller as an error instead of as a
// zero count the caller reads as success.
//
// So the plugin under test is a real shared object, compiled here, whose
// launchers ECHO what they were handed back through the output buffers the ABI
// already gives them. That exercises the same dlopen, the same dlsym and the
// same call shims a CUDA plugin would, with the kernel replaced by a mirror. No
// GPU is required and none is simulated: the arithmetic is not what this file
// is about, and pretending otherwise would be a test that passes when the
// bridge and the mirror are wrong in the same way.
//
// The mirror's contract, which the tests below read back:
//
// rc — every launcher returns desc->Mode (compute_leaves, which gets
// no descriptor, returns the low half of ceremonies[0].CeremonyID)
// *capp — ceremony_count *napp — next_contribution_id (truncated)
// *ra — ceremony_count *fin — key_share_count
// *fai — next_share_id (truncated)
// result — Status=7, ActiveCeremonyCount=ceremony_count,
// KeyShareCount=key_share_count; state.CurrentEpoch += 1
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
const stubSource = `
#include <stdint.h>
#include <string.h>
// The descriptor's Mode field, at offset 32, carries the return code the
// caller wants back — so one plugin covers the success and the failure path
// without a second build.
static int rc_of(const void* desc) { return (int)*(const uint32_t*)((const char*)desc + 32); }
int lux_cuda_mpcvm_ceremony_apply(
const void* desc, const void* cops, const void* nops,
void* cer, void* con, void* capp, void* napp,
uint32_t cc, uint32_t nc, uint64_t next_cont, void* stream)
{
(void)cops; (void)nops; (void)cer; (void)con; (void)stream;
*(uint32_t*)capp = cc;
*(uint32_t*)napp = (uint32_t)next_cont;
return rc_of(desc);
}
int lux_cuda_mpcvm_ceremony_sweep(
const void* desc, void* cer, void* ks, void* con,
void* ra, void* fin, void* fai,
uint32_t cc, uint32_t kc, uint32_t nc, uint64_t next_share, void* stream)
{
(void)cer; (void)ks; (void)con; (void)nc; (void)stream;
*(uint32_t*)ra = cc;
*(uint32_t*)fin = kc;
*(uint32_t*)fai = (uint32_t)next_share;
return rc_of(desc);
}
int lux_cuda_mpcvm_compute_leaves(
const void* cer, const void* ks, const void* con,
void* cl, void* sl, void* nl,
void* ac, void* fc, void* fac, void* sc,
void* cm, void* sm, void* nm,
uint32_t cc, uint32_t kc, uint32_t nc, void* stream)
{
(void)ks; (void)con; (void)stream;
memset(cl, 0xAA, (size_t)cc * 32);
memset(sl, 0xBB, (size_t)kc * 32);
memset(nl, 0xCC, (size_t)nc * 32);
memset(cm, 1, cc); memset(sm, 1, kc); memset(nm, 1, nc);
*(uint32_t*)ac = cc; *(uint32_t*)fc = kc; *(uint32_t*)fac = nc; *(uint32_t*)sc = kc;
// No descriptor reaches this launcher; the first ceremony slot carries the
// return code instead.
return (int)*(const uint32_t*)cer;
}
int lux_cuda_mpcvm_compose_root(
const void* desc,
const void* cl, const void* sl, const void* nl,
const void* cm, const void* sm, const void* nm,
const void* ac, const void* fc, const void* fac, const void* sc,
void* state, void* result,
uint32_t cc, uint32_t kc, uint32_t nc, void* stream)
{
(void)cl; (void)sl; (void)nl; (void)cm; (void)sm; (void)nm;
(void)fc; (void)fac; (void)cc; (void)kc; (void)nc; (void)stream;
uint32_t* out = (uint32_t*)result;
out[0] = 7; // Status
out[5] = *(const uint32_t*)ac; // ActiveCeremonyCount
out[6] = *(const uint32_t*)sc; // KeyShareCount
((uint64_t*)state)[0] += 1; // CurrentEpoch advances in place
return rc_of(desc);
}
`
var (
stubOnce sync.Once
stubPath string
stubErr error
)
// stubPlugin compiles the mirror plugin once. A C compiler is present by
// construction: this file only builds under cgo, and cgo needed one.
func stubPlugin(t *testing.T) string {
t.Helper()
stubOnce.Do(func() {
dir, err := os.MkdirTemp("", "mpcvm-gpu-stub")
if err != nil {
stubErr = err
return
}
src := filepath.Join(dir, "stub.c")
if err := os.WriteFile(src, []byte(stubSource), 0o600); err != nil {
stubErr = err
return
}
out := filepath.Join(dir, "libluxgpu_backend_cuda.so")
if combined, err := exec.Command(compiler(), "-shared", "-fPIC", "-o", out, src).CombinedOutput(); err != nil {
stubErr = fmt.Errorf("%s: %w: %s", compiler(), err, combined)
return
}
stubPath = out
})
require.NoError(t, stubErr)
return stubPath
}
func compiler() string {
if cc := os.Getenv("CC"); cc != "" {
return cc
}
return "cc"
}
// loadStub resolves the mirror plugin through the production loader.
func loadStub(t *testing.T) *GPUBackend {
t.Helper()
b := tryLoadPlugin(GPUBackendCUDA, stubPlugin(t))
require.NotNil(t, b, "the loader did not resolve a plugin exporting all four launchers")
require.Equal(t, GPUBackendCUDA, b.Kind)
require.Equal(t, stubPlugin(t), b.Path)
require.True(t, b.IsAvailable())
return b
}
// round is a descriptor whose Mode carries the return code the mirror echoes.
func round(rc uint32) *GPUMPCVMRoundDescriptor {
return &GPUMPCVMRoundDescriptor{ChainID: 1, Round: 2, Mode: rc}
}
// -----------------------------------------------------------------------------
// Resolution
// -----------------------------------------------------------------------------
// A plugin is used only when the launchers it must export actually resolve. A
// library that dlopens but exports nothing is not a backend, and adopting one
// would call a null pointer on the first round.
func TestALibraryWithoutTheLaunchersIsNotABackend(t *testing.T) {
require.Nil(t, tryLoadPlugin(GPUBackendCUDA, "libc.so.6", "libSystem.B.dylib"),
"a library missing ceremony_apply/ceremony_sweep must not be adopted as a backend")
}
func TestAPluginThatIsNotThereIsNotABackend(t *testing.T) {
require.Nil(t, tryLoadPlugin(GPUBackendCUDA, "/nonexistent/a.so", "/nonexistent/b.so", "/nonexistent/c.so"))
require.Nil(t, tryLoadPlugin(GPUBackendCUDA))
require.Nil(t, tryLoadPlugin(GPUBackendCUDA, "", "", ""))
require.Nil(t, tryLoadPlugin(GPUBackendNone, "anything"),
"a backend with no launcher prefix names no symbols to resolve")
}
// The probe walks the substrate priority order and adopts the first backend
// that resolves. A host with no plugin adopts none and runs on the CPU
// reference, rather than adopting a broken one.
func TestTheProbeAdoptsTheFirstBackendThatResolves(t *testing.T) {
t.Setenv("LUX_GPU_PLUGIN_DIR", filepath.Dir(stubPlugin(t)))
t.Setenv("LUXCPP_PREFIX", "/nonexistent")
got := probeGPUBackend()
require.NotNil(t, got)
require.Equal(t, GPUBackendCUDA, got.Kind, "cuda is first in the substrate priority order")
t.Setenv("LUX_GPU_PLUGIN_DIR", "/nonexistent")
require.Nil(t, probeGPUBackend(), "a host with no plugin runs on the CPU reference")
}
// Every backend has candidate filenames, ordered: install path, then build
// tree, then the bare leaf for the dynamic linker's own search.
func TestEveryBackendHasCandidateFilenames(t *testing.T) {
t.Setenv("LUXCPP_PREFIX", "/opt/luxcpp")
t.Setenv("LUX_GPU_PLUGIN_DIR", "/build/plugins")
for _, kind := range []GPUBackendKind{
GPUBackendCUDA, GPUBackendHIP, GPUBackendMetal, GPUBackendVulkan, GPUBackendWebGPU,
} {
got := candidatesFor(kind)
require.NotEmptyf(t, got, "%s has no candidate filenames", kind)
require.Containsf(t, got[0], "/opt/luxcpp/lib/", "%s must try the install path first", kind)
require.Truef(t, hasPrefixDir(got, "/build/plugins"), "%s must try the build tree", kind)
require.Containsf(t, got, filepath.Base(got[0]), "%s must fall back to the bare leaf name", kind)
require.NotEmptyf(t, kind.String(), "%s has no launcher prefix", kind)
}
require.Empty(t, candidatesFor(GPUBackendNone))
require.Empty(t, backendDylibLeaves(GPUBackendNone))
// With no explicit prefix, the home-relative install tree stands in.
t.Setenv("LUXCPP_PREFIX", "")
t.Setenv("LUX_GPU_PLUGIN_DIR", "")
t.Setenv("HOME", "/home/somebody")
require.Contains(t, candidatesFor(GPUBackendCUDA)[0], "/home/somebody/work/luxcpp/install/lib/")
// With neither, only the bare leaves remain.
t.Setenv("HOME", "")
require.Equal(t, backendDylibLeaves(GPUBackendCUDA), candidatesFor(GPUBackendCUDA))
}
// -----------------------------------------------------------------------------
// The four launchers
// -----------------------------------------------------------------------------
// Each method hands the launcher the counts and buffers the caller passed, and
// returns what the launcher wrote back into them.
func TestTheBridgePassesTheCallersCountsAndReadsBackTheResult(t *testing.T) {
b := loadStub(t)
desc := round(0)
ceremonies := make([]GPUCeremony, 4)
contributions := make([]GPUContribution, 3)
shares := make([]GPUKeyShare, 2)
applied, err := b.CeremonyApply(desc, []GPUCeremonyOp{{CeremonyID: 9}}, ceremonies)
require.NoError(t, err)
require.Equal(t, uint32(len(ceremonies)), applied, "the ceremony table's length reaches the launcher")
applied, err = b.ContributionApply(desc, []GPUContributionOp{{CeremonyID: 9}}, ceremonies, contributions, 41)
require.NoError(t, err)
require.Equal(t, uint32(41), applied, "the contribution-id counter reaches the launcher")
advance, finalized, failed, err := b.KeyShareApply(desc, ceremonies, shares, contributions, 77)
require.NoError(t, err)
require.Equal(t, uint32(len(ceremonies)), advance)
require.Equal(t, uint32(len(shares)), finalized)
require.Equal(t, uint32(77), failed, "the share-id counter reaches the launcher")
state := &GPUMPCVMState{CurrentEpoch: 5}
result, err := b.MPCTransition(desc, ceremonies, shares, contributions, state)
require.NoError(t, err)
require.Equal(t, uint32(7), result.Status)
require.Equal(t, uint32(len(ceremonies)), result.ActiveCeremonyCount,
"the leaf pass's counts reach the fold pass")
require.Equal(t, uint32(len(shares)), result.KeyShareCount)
require.Equal(t, uint64(6), state.CurrentEpoch, "the substrate state advances in place")
}
// An empty op stream is a legal call: the ceremony-admin and contribution
// dispatches share one launcher and differ only in which stream is populated.
func TestAnEmptyOpStreamStillDispatches(t *testing.T) {
b := loadStub(t)
desc := round(0)
ceremonies := make([]GPUCeremony, 2)
contributions := make([]GPUContribution, 2)
_, err := b.CeremonyApply(desc, nil, ceremonies)
require.NoError(t, err)
_, err = b.ContributionApply(desc, nil, ceremonies, contributions, 0)
require.NoError(t, err)
}
// A launcher that fails is an error, not a zero count. A caller that read the
// count without the error would apply nothing and believe it applied
// everything.
func TestALauncherFailureIsAnErrorNotAZeroCount(t *testing.T) {
b := loadStub(t)
desc := round(4) // 4 = dispatch, in the launcher's own vocabulary
ceremonies := make([]GPUCeremony, 1)
contributions := make([]GPUContribution, 1)
shares := make([]GPUKeyShare, 1)
_, err := b.CeremonyApply(desc, []GPUCeremonyOp{{}}, ceremonies)
require.ErrorContains(t, err, "rc=4")
_, err = b.ContributionApply(desc, []GPUContributionOp{{}}, ceremonies, contributions, 0)
require.ErrorContains(t, err, "rc=4")
_, _, _, err = b.KeyShareApply(desc, ceremonies, shares, contributions, 0)
require.ErrorContains(t, err, "rc=4")
// Both halves of the transition report separately, so an operator knows
// which pass failed.
_, err = b.MPCTransition(desc, ceremonies, shares, contributions, &GPUMPCVMState{})
require.ErrorContains(t, err, "compose_root: launcher rc=4")
ceremonies[0].CeremonyID = 5 // the leaf pass's return code
_, err = b.MPCTransition(round(0), ceremonies, shares, contributions, &GPUMPCVMState{})
require.ErrorContains(t, err, "compute_leaves: launcher rc=5")
}
// An empty arena is refused before any pointer is taken. Taking &slice[0] of an
// empty slice panics, so this is what stands between an empty round and a
// crash.
func TestAnEmptyArenaIsRefusedBeforeAPointerIsTaken(t *testing.T) {
b := loadStub(t)
desc := round(0)
ceremonies := make([]GPUCeremony, 1)
contributions := make([]GPUContribution, 1)
shares := make([]GPUKeyShare, 1)
_, err := b.CeremonyApply(nil, nil, ceremonies)
require.ErrorContains(t, err, "nil desc")
_, err = b.CeremonyApply(desc, nil, nil)
require.ErrorContains(t, err, "empty ceremonies")
_, err = b.ContributionApply(nil, nil, ceremonies, contributions, 0)
require.ErrorContains(t, err, "empty arena")
_, err = b.ContributionApply(desc, nil, nil, contributions, 0)
require.ErrorContains(t, err, "empty arena")
_, err = b.ContributionApply(desc, nil, ceremonies, nil, 0)
require.ErrorContains(t, err, "empty arena")
_, _, _, err = b.KeyShareApply(nil, ceremonies, shares, contributions, 0)
require.ErrorContains(t, err, "empty arena")
_, _, _, err = b.KeyShareApply(desc, nil, shares, contributions, 0)
require.ErrorContains(t, err, "empty arena")
_, _, _, err = b.KeyShareApply(desc, ceremonies, nil, contributions, 0)
require.ErrorContains(t, err, "empty arena")
_, _, _, err = b.KeyShareApply(desc, ceremonies, shares, nil, 0)
require.ErrorContains(t, err, "empty arena")
_, err = b.MPCTransition(nil, ceremonies, shares, contributions, &GPUMPCVMState{})
require.ErrorContains(t, err, "empty arena")
_, err = b.MPCTransition(desc, ceremonies, shares, contributions, nil)
require.ErrorContains(t, err, "empty arena")
_, err = b.MPCTransition(desc, nil, shares, contributions, &GPUMPCVMState{})
require.ErrorContains(t, err, "empty arena")
_, err = b.MPCTransition(desc, ceremonies, nil, contributions, &GPUMPCVMState{})
require.ErrorContains(t, err, "empty arena")
_, err = b.MPCTransition(desc, ceremonies, shares, nil, &GPUMPCVMState{})
require.ErrorContains(t, err, "empty arena")
}
// A plugin exporting the two required launchers but not the two the whole
// transition needs is adopted for what it can do and refuses the rest by name,
// so an early port is usable rather than silently wrong.
func TestAPartialPluginIsUsedForWhatItExports(t *testing.T) {
b := loadStub(t)
partial := *b
partial.fnComputeLeaves = nil
require.True(t, partial.IsAvailable())
_, err := partial.MPCTransition(round(0),
make([]GPUCeremony, 1), make([]GPUKeyShare, 1), make([]GPUContribution, 1), &GPUMPCVMState{})
require.ErrorIs(t, err, ErrGPUNotAvailable)
require.ErrorContains(t, err, "compute_leaves or compose_root missing")
partial = *b
partial.fnComposeRoot = nil
_, err = partial.MPCTransition(round(0),
make([]GPUCeremony, 1), make([]GPUKeyShare, 1), make([]GPUContribution, 1), &GPUMPCVMState{})
require.ErrorIs(t, err, ErrGPUNotAvailable)
}
// hasPrefixDir reports whether any candidate lives under dir.
func hasPrefixDir(candidates []string, dir string) bool {
for _, c := range candidates {
if strings.HasPrefix(c, dir+string(filepath.Separator)) {
return true
}
}
return false
}
// A caller holding no backend at all asks the same question and gets the same
// answer, rather than dereferencing nothing.
func TestNoBackendIsNotAnAvailableOne(t *testing.T) {
require.False(t, (*GPUBackend)(nil).IsAvailable())
require.False(t, (&GPUBackend{}).IsAvailable(), "a zero backend has dlopened nothing")
}
+288
View File
@@ -0,0 +1,288 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// harness_test.go — the fixtures the state-transition tests are built on.
//
// # Why a plain secp256k1 key stands in for a threshold key
//
// The output of a CGGMP21 ceremony is an ordinary secp256k1 ECDSA signature
// under an ordinary secp256k1 public key. That is the entire point of using
// threshold ECDSA for bridge custody: an external chain's ecrecover cannot tell
// a threshold signature from a single-key one, and neither can Block.Verify,
// which calls the same secp256k1.VerifySignature.
//
// So a fixture that holds the group secret in one place produces artifacts that
// are byte-indistinguishable from real ceremony output, and lets the block
// rules be tested against thousands of cases in milliseconds instead of one
// case per DKG. What the fixture does NOT prove is that the ceremony produces
// such a signature — that is bridge_transport_test.go, which runs the real
// distributed key generation and the real signing rounds across five VMs over
// the real gossip transport, and is the reason this shortcut is a shortcut and
// not a stub.
//
// The fixture holding the whole secret is also what lets these tests forge: a
// test that can sign anything can produce the artifact an ATTACKER would need,
// which is how "the block rules refuse it" becomes a claim about an adversary
// rather than about a typo.
import (
"context"
"crypto/ecdsa"
"sync"
"testing"
"github.com/stretchr/testify/require"
luxcrypto "github.com/luxfi/crypto"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/pool"
"github.com/luxfi/threshold/pkg/quorum"
cmpconfig "github.com/luxfi/threshold/protocols/cmp/config"
vmcore "github.com/luxfi/vm"
)
// custody is a registered custody key whose secret this test holds, so a test
// can produce the artifact a ceremony would have produced — or the artifact an
// attacker would need.
type custody struct {
rec *KeyRecord
sec *ecdsa.PrivateKey
}
// newCustody builds a key record under policy with n canonical participants,
// derived deterministically from seed so a test's expectations are stable.
func newCustody(t *testing.T, keyID string, policy quorum.Policy, seed byte) *custody {
t.Helper()
var d [32]byte
d[31] = seed
d[0] = 1 // keep the scalar well inside the curve order
sec, err := luxcrypto.ToECDSA(d[:])
require.NoError(t, err)
pub := secp256k1.CompressPubkey(sec.PublicKey.X, sec.PublicKey.Y)
require.Len(t, pub, 33)
addr := publicKeyToAddress(pub)
require.Len(t, addr, 20)
return &custody{
rec: &KeyRecord{
KeyID: keyID,
Kind: KindCGGMP21,
Policy: policy,
Participants: parties(policy.N),
GroupPublicKey: pub,
Address: addr,
},
sec: sec,
}
}
// sign produces the 65-byte r‖s‖v artifact over a 32-byte digest — the same
// encoding thresholdSign returns and the same one verifyGroupSignature checks.
func (c *custody) sign(t *testing.T, digest []byte) []byte {
t.Helper()
sig, err := luxcrypto.Sign(digest, c.sec)
require.NoError(t, err)
require.Len(t, sig, 65)
return sig
}
// keygenOp is the operation that registers this key: the proof of possession
// over its own commit digest, under the derived ceremony id.
func (c *custody) keygenOp(t *testing.T) *Operation {
t.Helper()
commit := KeyCommitDigest(c.rec)
return &Operation{
Type: OpTypeKeygen,
CeremonyID: keygenCeremonyID(c.rec.KeyID, c.rec.Policy, c.rec.Participants),
KeyID: c.rec.KeyID,
Digest: commit[:],
Artifact: c.sign(t, commit[:]),
Signers: c.rec.Participants,
Key: c.rec,
}
}
// signOpOver is a completed signing ceremony over digest by the quorum this
// task selects, with the ceremony id that task derives.
func (c *custody) signOpOver(t *testing.T, digest []byte) *Operation {
t.Helper()
signers := quorumFor(c.rec, digest)
return &Operation{
Type: OpTypeSign,
CeremonyID: ceremonyID(c.rec.KeyID, digest, signers),
KeyID: c.rec.KeyID,
Digest: digest,
Artifact: c.sign(t, digest),
Signers: signers,
}
}
// parties returns n canonical party ids. They are not node ids — nothing in the
// block rules parses them as one; resolveCommittee is the only place that does
// and it is the transport's business, not consensus'.
func parties(n int) []party.ID {
out := make([]party.ID, n)
for i := range out {
out[i] = party.ID([]byte{'p', byte('a' + i)})
}
return out
}
// digestOf is a 32-byte message digest, distinct per seed.
func digestOf(seed byte) []byte {
d := make([]byte, 32)
d[0] = seed
d[31] = seed ^ 0xff
return d
}
// newVM opens a VM on a fresh in-memory database, initialized exactly as the
// node initializes it. No field is reached into afterwards: a fixture that
// hand-assembled a VM would be testing a state the production path cannot
// produce.
func newVM(t *testing.T) *VM {
t.Helper()
vm := newVMOn(t, memdb.New(), nil)
t.Cleanup(func() { _ = vm.Shutdown(context.Background()) })
return vm
}
// newVMOn opens a VM over a caller-supplied database and config, for the tests
// that need to restart a node on the state it left or to fail a write.
func newVMOn(t *testing.T, db database.Database, config []byte) *VM {
t.Helper()
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), vmcore.Init{
Runtime: &runtime.Runtime{
NodeID: ids.GenerateTestNodeID(),
ChainID: ids.GenerateTestID(),
},
DB: db,
Config: config,
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
}))
return vm
}
// register puts a custody key into consensus state through the block path, so
// every test that needs a registered key gets one that a block actually
// registered rather than one written behind the state machine's back.
func (c *custody) register(t *testing.T, vm *VM) *Block {
t.Helper()
blk := blockOver(t, vm, c.keygenOp(t))
require.NoError(t, blk.Verify(context.Background()))
require.NoError(t, blk.Accept(context.Background()))
return blk
}
// hold gives this node the key's own share, which is what makes it a
// participant for crossCheckOwnShare. It is set directly because the only
// honest way to obtain one is to run the DKG, and what is under test here is
// what the cross-check does with a share, not how one is produced.
func (c *custody) hold(vm *VM, share *heldShare) {
vm.mu.Lock()
defer vm.mu.Unlock()
vm.shares[c.rec.KeyID] = share
}
// -----------------------------------------------------------------------------
// One real key share, generated once for the whole package
// -----------------------------------------------------------------------------
var (
realOnce sync.Once
realCfgs map[party.ID]*cmpconfig.Config
realRaw []byte
realPub []byte
realDegree int
realErr error
)
// realShare returns the node-private encoding of a share a genuine two-party
// DKG produced, plus the group key and degree it belongs to.
//
// The share store round-trips through the threshold library's own encoding, and
// that encoding carries a Paillier secret whose primes are validated on the way
// back in. So a hand-assembled config cannot be stored and re-read, and the only
// honest way to test what a restarted node does with a stored share is to store
// one a ceremony produced. It is generated once — key generation is the
// expensive part of this protocol, and the tests below differ in what they do
// with the share, not in which share it is.
func realShare(t *testing.T) (raw, groupPub []byte, degree int) {
t.Helper()
realOnce.Do(func() {
const t0 = 1 // polynomial degree: any 2 of the 2 parties reconstruct
ids := []party.ID{"pa", "pb"}
pl := pool.NewPool(2)
defer pl.TearDown()
net, routers := newMeshRouters(ids)
defer net.close()
var wg sync.WaitGroup
cfgs := make([]*cmpconfig.Config, len(ids))
errs := make([]error, len(ids))
for i, id := range ids {
wg.Add(1)
go func(i int, id party.ID) {
defer wg.Done()
pe := NewProtocolExecutor(pl, log.NewTestLogger(log.ErrorLevel))
cfgs[i], errs[i] = pe.RunCMPKeygen(context.Background(), "fixture", id, ids, t0, routers[id])
}(i, id)
}
wg.Wait()
for _, err := range errs {
if err != nil {
realErr = err
return
}
}
realCfgs = make(map[party.ID]*cmpconfig.Config, len(ids))
for i, id := range ids {
realCfgs[id] = cfgs[i]
}
if realRaw, realErr = cfgs[0].MarshalBinary(); realErr != nil {
return
}
realPub, realErr = cfgs[0].PublicPoint().MarshalBinary()
realDegree = cfgs[0].Threshold
})
require.NoError(t, realErr)
return realRaw, realPub, realDegree
}
// custodyFor builds a registry entry for a group key generated elsewhere, so a
// stored share and the record it is filed under agree — or, by choosing a
// different policy, disagree in exactly one way.
func custodyFor(t *testing.T, keyID string, policy quorum.Policy, groupPub []byte) *KeyRecord {
t.Helper()
addr := publicKeyToAddress(groupPub)
require.Len(t, addr, 20)
return &KeyRecord{
KeyID: keyID,
Kind: KindCGGMP21,
Policy: policy,
Participants: parties(policy.N),
GroupPublicKey: groupPub,
Address: addr,
}
}
// realConfigs returns every party's share of the package's one real key, so a
// signing ceremony can actually be run across the committee that generated it.
func realConfigs(t *testing.T) map[party.ID]*cmpconfig.Config {
t.Helper()
realShare(t)
return realCfgs
}
+104
View File
@@ -0,0 +1,104 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// mesh_test.go — an in-memory MessageRouter for the executor tests.
//
// It lives here rather than beside the gossip router because it is scaffolding:
// nothing in production has a second transport, and one that shipped in the
// binary would be a second way for a ceremony to run. The fan-out matches the
// real one (broadcast to everyone but the sender, or a directed send by To) so
// the executor path under test is the executor path that runs.
import (
"sync"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/protocol"
)
// mesh is a shared in-memory bus for one ceremony inside one process.
type mesh struct {
mu sync.RWMutex
inboxes map[party.ID]chan *protocol.Message
inFlight sync.RWMutex
closed bool
}
func newMesh(parties []party.ID) *mesh {
m := &mesh{inboxes: make(map[party.ID]chan *protocol.Message, len(parties))}
for _, id := range parties {
m.inboxes[id] = make(chan *protocol.Message, 1024)
}
return m
}
func (m *mesh) send(msg *protocol.Message) {
if msg == nil {
return
}
m.inFlight.RLock()
defer m.inFlight.RUnlock()
if m.closed {
return
}
m.mu.RLock()
var targets []chan *protocol.Message
if msg.To == "" {
for id, ch := range m.inboxes {
if id != msg.From {
targets = append(targets, ch)
}
}
} else if ch, ok := m.inboxes[msg.To]; ok {
targets = append(targets, ch)
}
m.mu.RUnlock()
for _, ch := range targets {
ch <- msg
}
}
// close shuts every inbox once in-flight sends have drained, so a router loop
// ranging over its inbox unwinds without racing a send.
func (m *mesh) close() {
m.inFlight.Lock()
defer m.inFlight.Unlock()
if m.closed {
return
}
m.closed = true
m.mu.Lock()
defer m.mu.Unlock()
for _, ch := range m.inboxes {
close(ch)
}
}
// meshRouter is one party's view of the mesh.
type meshRouter struct {
self party.ID
net *mesh
}
func (r *meshRouter) Send(msg *protocol.Message) error { r.net.send(msg); return nil }
func (r *meshRouter) Receive() <-chan *protocol.Message {
r.net.mu.RLock()
defer r.net.mu.RUnlock()
return r.net.inboxes[r.self]
}
// newMeshRouters builds a mesh plus one router per party. Call net.close() once
// every party's ceremony has returned.
func newMeshRouters(parties []party.ID) (*mesh, map[party.ID]*meshRouter) {
net := newMesh(parties)
routers := make(map[party.ID]*meshRouter, len(parties))
for _, id := range parties {
routers[id] = &meshRouter{self: id, net: net}
}
return net, routers
}
var _ MessageRouter = (*meshRouter)(nil)
-48
View File
@@ -1,48 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package cggmp21 declares the M-Chain CGGMP21 protocol surface.
//
// This package contains interfaces only. The concrete implementation
// (key generation, pre-signing, signing, verification, identifiable
// abort) lives in chains/mchain/protocol/cggmp21/. The substrate
// imports this package; M-Chain implements it.
package cggmp21
import (
"context"
"github.com/luxfi/chains/mpcvm/types"
)
// Verifier validates a CGGMP21 share payload. It is registered into
// the M-Chain LaneRegistry as the LaneMChainCGGMP21 verifier.
type Verifier interface {
// VerifyShare checks that the per-round payload is a well-formed
// CGGMP21 commitment / signature share. It does not produce or
// hold any secret material.
VerifyShare(subject [32]byte, share types.Share, payload []byte) error
// VerifyFinal checks that the aggregated proof is a valid
// secp256k1 ECDSA signature under the group public key encoded in
// the proof.
VerifyFinal(subject [32]byte, proof types.Proof) error
}
// Driver runs the protocol on a participant's behalf. Only validators
// selected into the ceremony's participant set instantiate a Driver;
// everyone else only verifies.
type Driver interface {
// Round1 emits the participant's round-1 payload (commitments).
Round1(ctx context.Context, ceremony types.CeremonyID) ([]byte, error)
// Round2 emits the participant's round-2 payload (encrypted
// shares), given the round-1 commitments from the rest of the
// participant set.
Round2(ctx context.Context, ceremony types.CeremonyID, round1 [][]byte) ([]byte, error)
// Finalize aggregates all round-2 payloads into the final proof.
// Returns ErrIdentifiableAbort if a participant is detected
// cheating; the abort proof identifies the cheater.
Finalize(ctx context.Context, ceremony types.CeremonyID, round2 [][]byte) (types.Proof, error)
}
-34
View File
@@ -1,34 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package corona_general declares the M-Chain general-purpose
// Corona (Module-LWE) threshold protocol surface.
//
// This is **not** consensus-Corona. Consensus uses Corona at
// fixed parameters as the post-quantum cert lane (Q-Chain runs that
// ceremony, see LP-073). General-purpose Corona is for app-level
// threshold signing under a different parameter set; it lives on
// M-Chain.
//
// Interface-only; implementation in chains/mchain/protocol/corona/.
package corona_general
import (
"context"
"github.com/luxfi/chains/mpcvm/types"
)
// Verifier validates Corona-general share payloads. Registered as
// the LaneMChainCoronaGen verifier in the M-Chain LaneRegistry.
type Verifier interface {
VerifyShare(subject [32]byte, share types.Share, payload []byte) error
VerifyFinal(subject [32]byte, proof types.Proof) error
}
// Driver runs the 2-round lattice protocol for a selected participant.
type Driver interface {
Round1(ctx context.Context, ceremony types.CeremonyID) ([]byte, error)
Round2(ctx context.Context, ceremony types.CeremonyID, round1 [][]byte) ([]byte, error)
Finalize(ctx context.Context, ceremony types.CeremonyID, round2 [][]byte) (types.Proof, error)
}
-32
View File
@@ -1,32 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package frost declares the M-Chain FROST protocol surface.
//
// FROST is a 2-round Schnorr threshold scheme over Ed25519. This
// package is interface-only; the implementation lives in
// chains/mchain/protocol/frost/.
package frost
import (
"context"
"github.com/luxfi/chains/mpcvm/types"
)
// Verifier validates FROST share payloads. Registered as the
// LaneMChainFROST verifier in the M-Chain LaneRegistry.
type Verifier interface {
VerifyShare(subject [32]byte, share types.Share, payload []byte) error
VerifyFinal(subject [32]byte, proof types.Proof) error
}
// Driver runs FROST on a selected participant's behalf.
type Driver interface {
// Round1 emits the participant's nonce commitments (D_i, E_i).
Round1(ctx context.Context, ceremony types.CeremonyID) ([]byte, error)
// Round2 emits the signature share z_i.
Round2(ctx context.Context, ceremony types.CeremonyID, round1 [][]byte) ([]byte, error)
// Finalize aggregates {z_i} into the single Ed25519 (R, z).
Finalize(ctx context.Context, ceremony types.CeremonyID, round2 [][]byte) (types.Proof, error)
}
-47
View File
@@ -1,47 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package tfhe_keygen declares the cross-chain TFHE bootstrap-key
// generation surface.
//
// The ceremony **runs on M-Chain** (it is an MPC ceremony of FROST
// shape over the TFHE secret-key polynomial), but its **output is
// consumed by F-Chain** as the new bootstrap key. The handoff is
// represented in the substrate as a single Ceremony with kind
// KindTFHEKeygen and Lane=LaneMChainFROST at finalize, then ingested
// on F-Chain via Lane=LaneFChainBootstrap that wraps the upstream
// artifact.
//
// Interface-only; implementations split between
// chains/mchain/protocol/tfhe_keygen/ (the producer) and
// chains/fchain/protocol/tfhe_bootstrap/ (the consumer).
package tfhe_keygen
import (
"context"
"github.com/luxfi/chains/mpcvm/types"
)
// Producer is implemented by M-Chain. It runs the MPC ceremony that
// produces a TFHE evaluation key.
type Producer interface {
Round1(ctx context.Context, ceremony types.CeremonyID) ([]byte, error)
Round2(ctx context.Context, ceremony types.CeremonyID, round1 [][]byte) ([]byte, error)
// Finalize emits a Proof whose Payload is the serialized TFHE
// evaluation key plus bootstrap material.
Finalize(ctx context.Context, ceremony types.CeremonyID, round2 [][]byte) (types.Proof, error)
}
// Consumer is implemented by F-Chain. It accepts the M-Chain proof,
// re-verifies under the M-Chain ceremony root, and binds the new key
// into fchain_fhe_root.
type Consumer interface {
// Ingest verifies the upstream proof and installs the bootstrap
// key for use by F-Chain's FHE compute pipeline (LP-013).
Ingest(ctx context.Context, mchainSubject [32]byte, proof types.Proof) error
// VerifyHandoff is the per-share verifier on F-Chain's
// LaneFChainBootstrap. It validates that a F-Chain bootstrap
// share correctly references an M-Chain TFHE-keygen ceremony.
VerifyHandoff(subject [32]byte, share types.Share, payload []byte) error
}
File diff suppressed because it is too large Load Diff
-651
View File
@@ -1,651 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/big"
"time"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/pool"
cmpconfig "github.com/luxfi/threshold/protocols/cmp/config"
lssconfig "github.com/luxfi/threshold/protocols/lss/config"
)
// Protocol represents a threshold signing protocol
type Protocol string
const (
// ECDSA Threshold Protocols
ProtocolLSS Protocol = "lss" // Lux Secret Sharing - optimized for Lux
ProtocolCGGMP21 Protocol = "cggmp21" // Canetti-Gennaro-Goldfeder-Makriyannis-Peled 2021
// BLS Threshold (for validators)
ProtocolBLS Protocol = "bls" // BLS threshold signatures
// Post-Quantum Threshold
ProtocolCorona Protocol = "corona" // Post-quantum lattice-based threshold
// Experimental
ProtocolFrost Protocol = "frost" // FROST (Flexible Round-Optimized Schnorr Threshold)
ProtocolEDDSA Protocol = "eddsa" // EdDSA threshold (Ed25519)
)
// ProtocolConfig contains configuration for a specific protocol
type ProtocolConfig struct {
Protocol Protocol `json:"protocol"`
Threshold int `json:"threshold"` // t: number of parties required
TotalParties int `json:"totalParties"` // n: total parties
Curve string `json:"curve"` // secp256k1, ed25519, bls12-381, etc.
Options ProtocolOptions `json:"options"`
}
// ProtocolOptions contains protocol-specific options
type ProtocolOptions struct {
// LSS Options
LSSGeneration uint64 `json:"lssGeneration,omitempty"` // LSS generation number
// CGGMP21 Options
CMPPrecompute bool `json:"cmpPrecompute,omitempty"` // Enable precomputation for faster signing
// BLS Options
BLSScheme string `json:"blsScheme,omitempty"` // basic, min-pk, min-sig
// Corona Options
CoronaSecurityLevel int `json:"coronaSecurityLevel,omitempty"` // 128, 192, 256
// General Options
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
RetryOnFailure bool `json:"retryOnFailure,omitempty"`
}
// ProtocolHandler defines the interface for all threshold protocols.
// Real implementations are in github.com/luxfi/threshold (LSS, CMP, FROST).
// Use ProtocolExecutor in executor.go for actual protocol execution.
type ProtocolHandler interface {
// Keygen generates a new threshold key
Keygen(ctx context.Context, partyID party.ID, partyIDs []party.ID, threshold int) (KeyShare, error)
// Sign creates a threshold signature
Sign(ctx context.Context, share KeyShare, message []byte, signers []party.ID) (Signature, error)
// Verify verifies a threshold signature
Verify(pubKey []byte, message []byte, signature Signature) (bool, error)
// Reshare reshares the key to a new set of parties
Reshare(ctx context.Context, share KeyShare, newPartyIDs []party.ID, newThreshold int) (KeyShare, error)
// Refresh refreshes the key shares without changing the public key
Refresh(ctx context.Context, share KeyShare) (KeyShare, error)
// Name returns the protocol name
Name() Protocol
// SupportedCurves returns the curves this protocol supports
SupportedCurves() []string
}
// KeyShare represents a threshold key share (abstract)
type KeyShare interface {
// PublicKey returns the group public key
PublicKey() []byte
// PartyID returns this party's ID
PartyID() party.ID
// Threshold returns the threshold t
Threshold() int
// TotalParties returns total parties n
TotalParties() int
// Generation returns the key generation number
Generation() uint64
// Protocol returns which protocol this share is for
Protocol() Protocol
// Serialize converts the share to bytes for storage
Serialize() ([]byte, error)
}
// Signature represents a threshold signature (abstract)
type Signature interface {
// Bytes returns the raw signature bytes
Bytes() []byte
// R returns R component (for ECDSA)
R() *big.Int
// S returns S component (for ECDSA)
S() *big.Int
// V returns recovery ID (for ECDSA/Ethereum)
V() byte
// Protocol returns which protocol created this signature
Protocol() Protocol
}
// ProtocolRegistry manages available protocols
type ProtocolRegistry struct {
handlers map[Protocol]ProtocolHandler
pool *pool.Pool
}
// NewProtocolRegistry creates a new protocol registry
func NewProtocolRegistry(workerPool *pool.Pool) *ProtocolRegistry {
reg := &ProtocolRegistry{
handlers: make(map[Protocol]ProtocolHandler),
pool: workerPool,
}
// Register all supported protocols
reg.Register(&LSSHandler{pool: workerPool})
reg.Register(&CGGMP21Handler{pool: workerPool})
reg.Register(&BLSHandler{})
reg.Register(&CoronaHandler{})
return reg
}
// Register adds a protocol handler
func (r *ProtocolRegistry) Register(handler ProtocolHandler) {
r.handlers[handler.Name()] = handler
}
// Get retrieves a protocol handler
func (r *ProtocolRegistry) Get(protocol Protocol) (ProtocolHandler, error) {
handler, ok := r.handlers[protocol]
if !ok {
return nil, fmt.Errorf("unsupported protocol: %s", protocol)
}
return handler, nil
}
// Available returns all available protocols
func (r *ProtocolRegistry) Available() []Protocol {
protocols := make([]Protocol, 0, len(r.handlers))
for p := range r.handlers {
protocols = append(protocols, p)
}
return protocols
}
// =============================================================================
// LSS Handler - Lux Secret Sharing
// Implemented in github.com/luxfi/threshold/protocols/lss
// Use ProtocolExecutor.LSSKeygenStartFunc() for actual execution
// =============================================================================
// LSSHandler implements ProtocolHandler for LSS
type LSSHandler struct {
pool *pool.Pool
}
// lssKeyShare wraps LSS config to implement KeyShare
type lssKeyShare struct {
config *lssconfig.Config
pubKey []byte
partyID party.ID
thresh int
total int
gen uint64
}
func (s *lssKeyShare) PublicKey() []byte {
return s.pubKey
}
func (s *lssKeyShare) PartyID() party.ID {
return s.partyID
}
func (s *lssKeyShare) Threshold() int {
return s.thresh
}
func (s *lssKeyShare) TotalParties() int {
return s.total
}
func (s *lssKeyShare) Generation() uint64 {
return s.gen
}
func (s *lssKeyShare) Protocol() Protocol {
return ProtocolLSS
}
func (s *lssKeyShare) Serialize() ([]byte, error) {
// Serialize key share data to JSON format for storage
data := struct {
PublicKey []byte `json:"publicKey"`
PartyID string `json:"partyId"`
Threshold int `json:"threshold"`
TotalParties int `json:"totalParties"`
Generation uint64 `json:"generation"`
Protocol string `json:"protocol"`
}{
PublicKey: s.pubKey,
PartyID: string(s.partyID),
Threshold: s.thresh,
TotalParties: s.total,
Generation: s.gen,
Protocol: string(ProtocolLSS),
}
return json.Marshal(data)
}
func (h *LSSHandler) Name() Protocol {
return ProtocolLSS
}
func (h *LSSHandler) SupportedCurves() []string {
return []string{"secp256k1"}
}
func (h *LSSHandler) Keygen(ctx context.Context, partyID party.ID, partyIDs []party.ID, threshold int) (KeyShare, error) {
// LSS keygen requires multi-party session coordination - use VM.StartKeygen() which
// handles the protocol.StartFunc and session management across distributed parties.
return nil, errors.New("LSS keygen requires protocol session integration - use VM.StartKeygen instead")
}
func (h *LSSHandler) Sign(ctx context.Context, share KeyShare, message []byte, signers []party.ID) (Signature, error) {
// LSS signing requires multi-party session coordination - use VM.RequestSignature() which
// handles the protocol.StartFunc and session management across distributed parties.
return nil, errors.New("LSS sign requires protocol session integration - use VM.RequestSignature instead")
}
// verifySecp256k1ECDSA performs secp256k1 ECDSA signature verification with proper validation.
// pubKey must be 33 bytes (compressed) or 65 bytes (uncompressed).
// message must be exactly 32 bytes (pre-hashed digest).
// signature must be exactly 64 bytes in [R || S] format.
func verifySecp256k1ECDSA(pubKey []byte, message []byte, signature Signature) (bool, error) {
// Validate public key format (compressed: 33 bytes, uncompressed: 65 bytes)
if len(pubKey) != 33 && len(pubKey) != 65 {
return false, fmt.Errorf("invalid public key length: expected 33 or 65, got %d", len(pubKey))
}
// Validate compressed public key prefix (0x02 or 0x03 for compressed, 0x04 for uncompressed)
if len(pubKey) == 33 && pubKey[0] != 0x02 && pubKey[0] != 0x03 {
return false, errors.New("invalid compressed public key prefix")
}
if len(pubKey) == 65 && pubKey[0] != 0x04 {
return false, errors.New("invalid uncompressed public key prefix")
}
// ECDSA requires 32-byte hash for message
if len(message) != 32 {
return false, fmt.Errorf("message must be 32-byte hash, got %d bytes", len(message))
}
// Validate signature
if signature == nil {
return false, errors.New("signature is nil")
}
sigBytes := signature.Bytes()
if len(sigBytes) != 64 {
return false, fmt.Errorf("signature must be exactly 64 bytes, got %d", len(sigBytes))
}
// Verify using secp256k1 ECDSA
return secp256k1.VerifySignature(pubKey, message, sigBytes), nil
}
func (h *LSSHandler) Verify(pubKey []byte, message []byte, signature Signature) (bool, error) {
return verifySecp256k1ECDSA(pubKey, message, signature)
}
func (h *LSSHandler) Reshare(ctx context.Context, share KeyShare, newPartyIDs []party.ID, newThreshold int) (KeyShare, error) {
return nil, errors.New("LSS reshare requires protocol session integration - use VM.ReshareKey instead")
}
func (h *LSSHandler) Refresh(ctx context.Context, share KeyShare) (KeyShare, error) {
return nil, errors.New("LSS refresh requires protocol session integration - use VM.RefreshKey instead")
}
// =============================================================================
// CGGMP21 Handler (CMP)
// Implemented in github.com/luxfi/threshold/protocols/cmp
// Use ProtocolExecutor.CMPKeygenStartFunc() for actual execution
// =============================================================================
// CGGMP21Handler implements ProtocolHandler for CGGMP21/CMP
type CGGMP21Handler struct {
pool *pool.Pool
executor *ProtocolExecutor
router MessageRouter
}
// SetExecutor sets the protocol executor for the handler
func (h *CGGMP21Handler) SetExecutor(executor *ProtocolExecutor) {
h.executor = executor
}
// SetMessageRouter sets the message router for multi-party communication
func (h *CGGMP21Handler) SetMessageRouter(router MessageRouter) {
h.router = router
}
func (h *CGGMP21Handler) Name() Protocol {
return ProtocolCGGMP21
}
func (h *CGGMP21Handler) SupportedCurves() []string {
return []string{"secp256k1"}
}
func (h *CGGMP21Handler) Keygen(ctx context.Context, partyID party.ID, partyIDs []party.ID, threshold int) (KeyShare, error) {
if h.executor == nil {
return nil, errors.New("CGGMP21 executor not configured")
}
if h.router == nil {
return nil, errors.New("CGGMP21 message router not configured")
}
sessionID := fmt.Sprintf("cmp-keygen-%d", time.Now().UnixNano())
config, err := h.executor.RunCMPKeygen(ctx, sessionID, partyID, partyIDs, threshold, h.router)
if err != nil {
return nil, fmt.Errorf("CMP keygen failed: %w", err)
}
return &cmpKeyShare{
config: config,
pubKey: nil, // Will be set from config.PublicPoint()
partyID: partyID,
thresh: threshold,
total: len(partyIDs),
}, nil
}
func (h *CGGMP21Handler) Sign(ctx context.Context, share KeyShare, message []byte, signers []party.ID) (Signature, error) {
if h.executor == nil {
return nil, errors.New("CGGMP21 executor not configured")
}
if h.router == nil {
return nil, errors.New("CGGMP21 message router not configured")
}
// Get CMP config from share - support both cmpKeyShare and CMPKeyShare from executor
var cmpConfig *cmpconfig.Config
switch s := share.(type) {
case *cmpKeyShare:
if s.config == nil {
return nil, errors.New("CMP config not available in key share")
}
cmpConfig = s.config
case *CMPKeyShare:
if s.Config == nil {
return nil, errors.New("CMP config not available in CMPKeyShare")
}
cmpConfig = s.Config
default:
return nil, fmt.Errorf("invalid key share type for CMP signing: %T", share)
}
sessionID := fmt.Sprintf("cmp-sign-%d", time.Now().UnixNano())
ecdsaSig, err := h.executor.RunCMPSign(ctx, sessionID, cmpConfig, signers, message, h.router)
if err != nil {
// Check if it's a timeout error
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("CMP signing timed out: %w", err)
}
return nil, fmt.Errorf("CMP signing failed: %w", err)
}
return &cmpSignature{
r: ecdsaSig.R,
s: ecdsaSig.S,
v: ecdsaSig.V,
protocol: ProtocolCGGMP21,
}, nil
}
func (h *CGGMP21Handler) Verify(pubKey []byte, message []byte, signature Signature) (bool, error) {
return verifySecp256k1ECDSA(pubKey, message, signature)
}
func (h *CGGMP21Handler) Reshare(ctx context.Context, share KeyShare, newPartyIDs []party.ID, newThreshold int) (KeyShare, error) {
return nil, errors.New("CGGMP21 reshare not yet implemented - use refresh instead")
}
func (h *CGGMP21Handler) Refresh(ctx context.Context, share KeyShare) (KeyShare, error) {
if h.executor == nil {
return nil, errors.New("CGGMP21 executor not configured")
}
if h.router == nil {
return nil, errors.New("CGGMP21 message router not configured")
}
cmpShare, ok := share.(*cmpKeyShare)
if !ok {
return nil, errors.New("invalid key share type for CMP refresh")
}
if cmpShare.config == nil {
return nil, errors.New("CMP config not available for refresh")
}
sessionID := fmt.Sprintf("cmp-refresh-%d", time.Now().UnixNano())
newConfig, err := h.executor.RunCMPRefresh(ctx, sessionID, cmpShare.config, h.router)
if err != nil {
return nil, fmt.Errorf("CMP refresh failed: %w", err)
}
return &cmpKeyShare{
config: newConfig,
pubKey: cmpShare.pubKey,
partyID: cmpShare.partyID,
thresh: cmpShare.thresh,
total: cmpShare.total,
}, nil
}
// cmpKeyShare wraps CMP config to implement KeyShare interface
type cmpKeyShare struct {
config *cmpconfig.Config
pubKey []byte
partyID party.ID
thresh int
total int
}
func (s *cmpKeyShare) PublicKey() []byte {
return s.pubKey
}
func (s *cmpKeyShare) PartyID() party.ID {
return s.partyID
}
func (s *cmpKeyShare) Threshold() int {
return s.thresh
}
func (s *cmpKeyShare) TotalParties() int {
return s.total
}
func (s *cmpKeyShare) Generation() uint64 {
return 0 // CMP doesn't track generation
}
func (s *cmpKeyShare) Protocol() Protocol {
return ProtocolCGGMP21
}
func (s *cmpKeyShare) Serialize() ([]byte, error) {
// Serialize key share data to JSON format for storage
data := struct {
PublicKey []byte `json:"publicKey"`
PartyID string `json:"partyId"`
Threshold int `json:"threshold"`
TotalParties int `json:"totalParties"`
Protocol string `json:"protocol"`
}{
PublicKey: s.pubKey,
PartyID: string(s.partyID),
Threshold: s.thresh,
TotalParties: s.total,
Protocol: string(ProtocolCGGMP21),
}
return json.Marshal(data)
}
// cmpSignature implements the Signature interface for CMP
type cmpSignature struct {
r []byte
s []byte
v byte
protocol Protocol
}
func (sig *cmpSignature) Bytes() []byte {
return append(sig.r, sig.s...)
}
func (sig *cmpSignature) R() *big.Int {
return new(big.Int).SetBytes(sig.r)
}
func (sig *cmpSignature) S() *big.Int {
return new(big.Int).SetBytes(sig.s)
}
func (sig *cmpSignature) V() byte {
return sig.v
}
func (sig *cmpSignature) Protocol() Protocol {
return sig.protocol
}
// =============================================================================
// BLS Handler
// =============================================================================
// BLSHandler implements ProtocolHandler for BLS threshold signatures
type BLSHandler struct{}
func (h *BLSHandler) Name() Protocol {
return ProtocolBLS
}
func (h *BLSHandler) SupportedCurves() []string {
return []string{"bls12-381"}
}
func (h *BLSHandler) Keygen(ctx context.Context, partyID party.ID, partyIDs []party.ID, threshold int) (KeyShare, error) {
return nil, errors.New("BLS keygen not yet implemented")
}
func (h *BLSHandler) Sign(ctx context.Context, share KeyShare, message []byte, signers []party.ID) (Signature, error) {
return nil, errors.New("BLS sign not yet implemented")
}
func (h *BLSHandler) Verify(pubKey []byte, message []byte, signature Signature) (bool, error) {
return false, errors.New("BLS verify not yet implemented")
}
func (h *BLSHandler) Reshare(ctx context.Context, share KeyShare, newPartyIDs []party.ID, newThreshold int) (KeyShare, error) {
return nil, errors.New("BLS reshare not supported")
}
func (h *BLSHandler) Refresh(ctx context.Context, share KeyShare) (KeyShare, error) {
return nil, errors.New("BLS refresh not supported")
}
// =============================================================================
// Corona Handler (Post-Quantum)
// =============================================================================
// CoronaHandler implements ProtocolHandler for post-quantum threshold signatures
type CoronaHandler struct{}
func (h *CoronaHandler) Name() Protocol {
return ProtocolCorona
}
func (h *CoronaHandler) SupportedCurves() []string {
return []string{"lattice"}
}
func (h *CoronaHandler) Keygen(ctx context.Context, partyID party.ID, partyIDs []party.ID, threshold int) (KeyShare, error) {
return nil, errors.New("Corona keygen not yet implemented")
}
func (h *CoronaHandler) Sign(ctx context.Context, share KeyShare, message []byte, signers []party.ID) (Signature, error) {
return nil, errors.New("Corona sign not yet implemented")
}
func (h *CoronaHandler) Verify(pubKey []byte, message []byte, signature Signature) (bool, error) {
return false, errors.New("Corona verify not yet implemented")
}
func (h *CoronaHandler) Reshare(ctx context.Context, share KeyShare, newPartyIDs []party.ID, newThreshold int) (KeyShare, error) {
return nil, errors.New("Corona reshare not supported")
}
func (h *CoronaHandler) Refresh(ctx context.Context, share KeyShare) (KeyShare, error) {
return nil, errors.New("Corona refresh not supported")
}
// =============================================================================
// Protocol Information
// =============================================================================
// Note: ProtocolInfo type is defined in client.go
// GetProtocolInfo returns information about all supported protocols
func GetProtocolInfo() []ProtocolInfo {
return []ProtocolInfo{
{
Name: string(ProtocolLSS),
Description: "Lux Secret Sharing - Optimized threshold ECDSA for Lux blockchain",
SupportedCurves: []string{"secp256k1"},
KeySize: 256,
SignatureSize: 64,
IsPostQuantum: false,
SupportsReshare: true,
SupportsRefresh: true,
},
{
Name: string(ProtocolCGGMP21),
Description: "CGGMP21 - State-of-the-art threshold ECDSA protocol",
SupportedCurves: []string{"secp256k1"},
KeySize: 256,
SignatureSize: 64,
IsPostQuantum: false,
SupportsReshare: true,
SupportsRefresh: true,
},
{
Name: string(ProtocolBLS),
Description: "BLS threshold signatures - Aggregatable signatures for validators",
SupportedCurves: []string{"bls12-381"},
KeySize: 381,
SignatureSize: 96,
IsPostQuantum: false,
SupportsReshare: false,
SupportsRefresh: false,
},
{
Name: string(ProtocolCorona),
Description: "Corona - Post-quantum lattice-based threshold signatures",
SupportedCurves: []string{"lattice"},
KeySize: 2048, // Varies by security level
SignatureSize: 2420, // Dilithium3 size
IsPostQuantum: true,
SupportsReshare: false,
SupportsRefresh: false,
},
}
}
+12 -99
View File
@@ -10,8 +10,6 @@ import (
"errors"
"fmt"
"net/http"
"github.com/luxfi/threshold/pkg/quorum"
)
// RPCRequest represents a JSON-RPC request
@@ -54,7 +52,6 @@ const (
RPCErrorQuotaExceeded = -32003
RPCErrorCeremonyNotFound = -32004
RPCErrorKeyNotFound = -32005
RPCErrorProtocolNotFound = -32006
)
// createRPCHandler creates the JSON-RPC handler
@@ -75,11 +72,7 @@ func (vm *VM) createRPCHandler() http.Handler {
result, err := vm.handleRPCMethod(r.Context(), req.Method, req.Params)
if err != nil {
rpcErr, ok := err.(*RPCError)
if !ok {
rpcErr = &RPCError{Code: RPCErrorInternal, Message: err.Error()}
}
writeRPCResponse(w, req.ID, nil, rpcErr)
writeRPCResponse(w, req.ID, nil, asRPCError(err))
return
}
@@ -141,12 +134,6 @@ func (vm *VM) handleRPCMethod(ctx context.Context, method string, params json.Ra
case "mpc_getStateRoot":
return vm.rpcGetStateRoot()
// Protocol Information
case "threshold_getProtocols":
return vm.rpcGetProtocols()
case "threshold_getProtocolInfo":
return vm.rpcGetProtocolInfo(params)
// Network Information
case "threshold_getInfo":
return vm.rpcGetInfo()
@@ -172,10 +159,18 @@ func (vm *VM) handleRPCMethod(ctx context.Context, method string, params json.Ra
}
}
// asRPCError maps a VM error to its JSON-RPC code. Written once so every method
// classifies the same failure the same way, and matched with errors.Is because
// the VM wraps its errors with context.
// asRPCError maps an error to its JSON-RPC code. It is the ONE classifier: the
// handler runs it over whatever a method returned, so a method that already
// classified its own failure keeps that code and one that did not gets the code
// this table gives it. Two classifiers would eventually disagree about the same
// failure.
//
// Matching is by errors.Is/As because the VM wraps its errors with context.
func asRPCError(err error) *RPCError {
var already *RPCError
if errors.As(err, &already) {
return already
}
switch {
case errors.Is(err, ErrUnauthorizedChain):
return &RPCError{Code: RPCErrorUnauthorized, Message: err.Error()}
@@ -192,35 +187,6 @@ func asRPCError(err error) *RPCError {
// Ceremony RPCs
// =============================================================================
// KeygenParams contains parameters for key generation.
type KeygenParams struct {
KeyID string `json:"keyId"`
RequestedBy string `json:"requestedBy"` // Chain ID; must be authorised to keygen
// Policy is the quorum in operator form, "3-of-5". Omit it to use the
// chain's default. It is deliberately not a pair of numbers: a caller
// cannot express the quorum ambiguously, and the polynomial degree is
// derived from it inside the ceremony rather than passed alongside it.
Policy quorum.Policy `json:"policy,omitempty"`
}
// KeygenResult is a COMPLETED key generation: the ceremony that ran and the
// key it registered. There is no status to poll — a keygen that returns has a
// key, and one that fails returns an error.
type KeygenResult struct {
Ceremony CeremonyInfo `json:"ceremony"`
Key KeyInfo `json:"key"`
}
// SignParams contains parameters for signing.
type SignParams struct {
KeyID string `json:"keyId"`
// MessageHash is the exact 32 bytes to sign, hex encoded. M-Chain does not
// hash on the caller's behalf: the caller owns its signing domain, and a
// chain that re-hashed would sign a preimage nobody authorised.
MessageHash string `json:"messageHash"`
RequestingChain string `json:"requestingChain"`
}
// =============================================================================
// Custody registry RPCs (replicated state)
// =============================================================================
@@ -365,50 +331,6 @@ func ceremonyInfoOf(c *CeremonyRecord) CeremonyInfo {
return info
}
// pendingRecord is the ceremony record a completed operation WILL be written
// as, once the block carrying it is accepted. Height is zero until then: the
// ceremony has happened, but the chain has not yet placed it at a height.
func pendingRecord(op *Operation) *CeremonyRecord {
return &CeremonyRecord{
ID: op.CeremonyID,
Kind: op.Type,
KeyID: op.KeyID,
Digest: op.Digest,
Signers: op.Signers,
Artifact: op.Artifact,
RequestingChain: op.RequestingChain,
}
}
// =============================================================================
// Protocol Information RPCs
// =============================================================================
func (vm *VM) rpcGetProtocols() ([]ProtocolInfo, error) {
return GetProtocolInfo(), nil
}
// GetProtocolInfoParams contains parameters for getting protocol info
type GetProtocolInfoParams struct {
Protocol string `json:"protocol"`
}
func (vm *VM) rpcGetProtocolInfo(params json.RawMessage) (*ProtocolInfo, error) {
var p GetProtocolInfoParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, &RPCError{Code: RPCErrorInvalidParams, Message: "invalid parameters"}
}
protocols := GetProtocolInfo()
for _, info := range protocols {
if string(info.Name) == p.Protocol {
return &info, nil
}
}
return nil, &RPCError{Code: RPCErrorProtocolNotFound, Message: "protocol not found"}
}
// =============================================================================
// Network Information RPCs
// =============================================================================
@@ -584,12 +506,3 @@ func (vm *VM) rpcHealthCheck(ctx context.Context) (map[string]interface{}, error
}
return result, nil
}
// Helper functions
func stripHexPrefix(s string) string {
if len(s) >= 2 && s[:2] == "0x" {
return s[2:]
}
return s
}
+482
View File
@@ -0,0 +1,482 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// rpc_test.go — the read surface, driven end to end.
//
// Every test here goes through the real http.Handler and, where a Client method
// exists, through the real Client over a real socket. A test that called the
// rpc* methods directly would prove the projection and nothing about the
// dispatch, the encoding or the error codes a caller actually sees.
import (
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/threshold/pkg/quorum"
)
// serve puts the VM's real handler behind a real socket and returns a Client
// pointed at it.
func serve(t *testing.T, vm *VM) (*Client, *httptest.Server) {
t.Helper()
mux := http.NewServeMux()
handlers, err := vm.CreateHandlers(ctx())
require.NoError(t, err)
for path, h := range handlers {
mux.Handle(path, h)
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return NewClient(srv.URL, "B-Chain"), srv
}
// call posts one JSON-RPC request and returns the decoded response.
func call(t *testing.T, srv *httptest.Server, method string, params any) RPCResponse {
t.Helper()
body, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
require.NoError(t, err)
resp, err := http.Post(srv.URL+"/rpc", "application/json", strings.NewReader(string(body)))
require.NoError(t, err)
defer resp.Body.Close()
var out RPCResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
return out
}
// -----------------------------------------------------------------------------
// Dispatch
// -----------------------------------------------------------------------------
// Ceremonies are requested BY a chain, over the transport that authenticates
// which chain. HTTP authenticates nobody, so this endpoint refuses rather than
// offering a way in that reads a chain's name out of a request body.
func TestCeremoniesAreNotReachableOverAnEndpointThatAuthenticatesNobody(t *testing.T) {
vm := newVM(t)
_, srv := serve(t, vm)
for _, method := range []string{"threshold_keygen", "threshold_sign"} {
got := call(t, srv, method, map[string]any{"keyId": "vault"})
require.NotNil(t, got.Error, "%s must not be reachable here", method)
require.Equal(t, RPCErrorUnauthorized, got.Error.Code)
require.Contains(t, got.Error.Message, "authenticates no caller")
}
}
func TestAnUnknownMethodIsNamedNotIgnored(t *testing.T) {
vm := newVM(t)
_, srv := serve(t, vm)
got := call(t, srv, "threshold_doTheThing", nil)
require.NotNil(t, got.Error)
require.Equal(t, RPCErrorMethodNotFound, got.Error.Code)
require.Contains(t, got.Error.Message, "threshold_doTheThing")
}
func TestOnlyAPostCarriesARequest(t *testing.T) {
vm := newVM(t)
_, srv := serve(t, vm)
resp, err := http.Get(srv.URL + "/rpc")
require.NoError(t, err)
defer resp.Body.Close()
var out RPCResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
require.NotNil(t, out.Error)
require.Equal(t, RPCErrorInvalidRequest, out.Error.Code)
}
func TestABodyThatIsNotJSONIsRefused(t *testing.T) {
vm := newVM(t)
_, srv := serve(t, vm)
resp, err := http.Post(srv.URL+"/rpc", "application/json", strings.NewReader("{not json"))
require.NoError(t, err)
defer resp.Body.Close()
var out RPCResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
require.Equal(t, RPCErrorInvalidRequest, out.Error.Code)
}
// Every read method that takes parameters refuses ones it cannot decode, with
// the code that says so.
func TestUndecodableParametersAreNamedAsSuch(t *testing.T) {
vm := newVM(t)
_, srv := serve(t, vm)
for _, method := range []string{
"mpc_getKey", "mpc_getCeremony", "threshold_getPublicKey", "threshold_getAddress",
"threshold_getQuota", "threshold_getChainPermissions",
} {
got := call(t, srv, method, []int{1, 2, 3})
require.NotNilf(t, got.Error, "%s", method)
require.Equalf(t, RPCErrorInvalidParams, got.Error.Code, "%s", method)
}
}
// A VM error carries its own code, and the mapping is written once so every
// method classifies the same failure the same way.
func TestAFailureIsClassifiedTheSameWayByEveryMethod(t *testing.T) {
vm := newVM(t)
_, srv := serve(t, vm)
for _, method := range []string{"mpc_getKey", "threshold_getPublicKey", "threshold_getAddress"} {
got := call(t, srv, method, map[string]string{"keyId": "nothing"})
require.NotNilf(t, got.Error, "%s", method)
require.Equalf(t, RPCErrorKeyNotFound, got.Error.Code, "%s", method)
}
got := call(t, srv, "mpc_getCeremony", map[string]string{"ceremonyId": "mpc/nothing"})
require.Equal(t, RPCErrorCeremonyNotFound, got.Error.Code)
got = call(t, srv, "threshold_getQuota", map[string]string{"chainId": "Nobody"})
require.Equal(t, RPCErrorUnauthorized, got.Error.Code)
got = call(t, srv, "threshold_getChainPermissions", map[string]string{"chainId": "Nobody"})
require.Equal(t, RPCErrorUnauthorized, got.Error.Code)
}
func TestAnRPCErrorSaysItsCodeAndMessage(t *testing.T) {
err := &RPCError{Code: RPCErrorKeyNotFound, Message: "no such key"}
require.Equal(t, "RPC error -32005: no such key", err.Error())
}
// -----------------------------------------------------------------------------
// The registry, over the wire
// -----------------------------------------------------------------------------
// One projection in one place: every RPC that returns a key returns the same
// shape, so a field cannot mean one thing under mpc_getKey and another
// elsewhere.
func TestACustodyKeyReadsBackIdenticallyThroughEveryDoor(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 80)
key.register(t, vm)
client, _ := serve(t, vm)
one, err := client.GetKey(ctx(), "vault")
require.NoError(t, err)
require.Equal(t, "vault", one.KeyID)
require.Equal(t, KindCGGMP21, one.Kind)
require.Equal(t, "3-of-5", one.Policy)
require.Equal(t, 2, one.Degree, "the degree is reported so it can be checked against the policy, not inferred")
require.Equal(t, "0x"+hex.EncodeToString(key.rec.GroupPublicKey), one.GroupPublicKey)
require.Equal(t, "0x"+hex.EncodeToString(key.rec.Address), one.Address)
require.Len(t, one.Participants, 5)
all, err := client.ListKeys(ctx())
require.NoError(t, err)
require.Len(t, all, 1)
require.Equal(t, *one, all[0])
pub, err := client.GetPublicKey(ctx(), "vault")
require.NoError(t, err)
require.Equal(t, key.rec.GroupPublicKey, pub)
addr, err := client.GetAddress(ctx(), "vault")
require.NoError(t, err)
require.Equal(t, key.rec.Address, addr)
}
// A produced signature is readable back out of replicated state, split into the
// parts a verifier wants.
func TestARecordedCeremonyReadsBackAsItsParts(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 81)
key.register(t, vm)
op := key.signOpOver(t, digestOf(1))
blk := blockOver(t, vm, op)
require.NoError(t, blk.Verify(ctx()))
require.NoError(t, blk.Accept(ctx()))
client, _ := serve(t, vm)
got, err := client.GetCeremony(ctx(), op.CeremonyID)
require.NoError(t, err)
require.Equal(t, OpTypeSign, got.Kind)
require.Equal(t, "vault", got.KeyID)
require.Equal(t, "0x"+hex.EncodeToString(op.Digest), got.Digest)
require.Equal(t, "0x"+hex.EncodeToString(op.Artifact), got.Signature)
require.Equal(t, "0x"+hex.EncodeToString(op.Artifact[0:32]), got.R)
require.Equal(t, "0x"+hex.EncodeToString(op.Artifact[32:64]), got.S)
require.Equal(t, int(op.Artifact[64]), got.V)
require.Equal(t, blk.BlockHeight, got.Height)
require.Len(t, got.Signers, 3)
all, err := client.ListCeremonies(ctx())
require.NoError(t, err)
require.Len(t, all, 2, "the keygen and the signature are both in the log")
root, err := client.StateRoot(ctx())
require.NoError(t, err)
require.Equal(t, "0x"+hex.EncodeToString(mustRoot(vm)), root)
}
// An artifact that is not the 65-byte encoding is reported whole rather than
// sliced into parts that would be wrong.
func TestAnArtifactThatIsNotSixtyFiveBytesIsNotSplit(t *testing.T) {
info := ceremonyInfoOf(&CeremonyRecord{ID: "mpc/x", Artifact: []byte{1, 2, 3}})
require.Equal(t, "0x010203", info.Signature)
require.Empty(t, info.R)
require.Empty(t, info.S)
require.Zero(t, info.V)
}
// -----------------------------------------------------------------------------
// Node information
// -----------------------------------------------------------------------------
// What the chain agrees on and what is true of THIS node are reported
// separately, because conflating them is how an operator concludes the chain is
// broken when in fact this one validator holds no share.
func TestNodeFactsAndChainFactsAreReportedSeparately(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 82)
key.register(t, vm)
vm.stage(key.signOpOver(t, digestOf(1)))
client, _ := serve(t, vm)
info, err := client.GetInfo(ctx())
require.NoError(t, err)
require.Equal(t, Version.String(), info.Version)
require.Equal(t, vm.rt.NodeID.String(), info.NodeID)
require.Equal(t, vm.rt.ChainID.String(), info.ChainID)
require.Equal(t, string(vm.partyID), info.PartyID)
require.Equal(t, "3-of-5", info.Policy)
require.Equal(t, 1, info.TotalKeys, "chain fact")
require.Equal(t, 0, info.SharesHeld, "node fact: this validator holds none")
require.Equal(t, 1, info.StagedCeremonies, "node fact")
require.Equal(t, "0x"+hex.EncodeToString(mustRoot(vm)), info.StateRoot)
require.NotEmpty(t, info.AuthorizedChains)
}
// Every counter is incremented where the thing happens. A counter that is only
// reported and never written is a number that reads as evidence and is not.
func TestTheCountersReportWhatThisNodeDid(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 83)
key.register(t, vm)
vm.stage(key.signOpOver(t, digestOf(1)))
client, _ := serve(t, vm)
stats, err := client.GetStats(ctx())
require.NoError(t, err)
require.Zero(t, stats.TotalSignatures, "this node ran no ceremony")
require.Zero(t, stats.TotalKeygens)
require.Equal(t, 1, stats.StagedCeremonies)
require.NotNil(t, stats.SignaturesByChain)
// The counters move when a ceremony completes, not when a block lands.
vm.stats.mu.Lock()
vm.stats.TotalSignatures = 3
vm.stats.TotalKeygens = 1
vm.stats.SignaturesByChain["B-Chain"] = 3
vm.stats.mu.Unlock()
stats, err = client.GetStats(ctx())
require.NoError(t, err)
require.Equal(t, uint64(3), stats.TotalSignatures)
require.Equal(t, uint64(1), stats.TotalKeygens)
require.Equal(t, uint64(3), stats.SignaturesByChain["B-Chain"])
}
// The committee IS the validator set; there is no separate MPC party roster to
// drift from it.
func TestThePartyListIsTheValidatorSet(t *testing.T) {
node := ids.GenerateTestNodeID()
vm := openVM(t, memdb.New(), ids.GenerateTestID(), oneValidator(node), nil)
defer vm.Shutdown(ctx())
_, srv := serve(t, vm)
got := call(t, srv, "threshold_getParties", nil)
require.Nil(t, got.Error)
var parties []PartyInfo
remarshal(t, got.Result, &parties)
require.Len(t, parties, 1)
require.Equal(t, node.String(), parties[0].PartyID)
require.Equal(t, parties[0].PartyID, parties[0].NodeID,
"a party id IS a node id; reporting both saves a caller a side table, it does not create one")
require.False(t, parties[0].IsLocal, "this node is not the one validator")
}
func TestANodeWithNoValidatorStateHasNoCommitteeToReport(t *testing.T) {
vm := newVM(t)
_, srv := serve(t, vm)
got := call(t, srv, "threshold_getParties", nil)
require.NotNil(t, got.Error)
require.Equal(t, RPCErrorInternal, got.Error.Code)
}
// -----------------------------------------------------------------------------
// Quota and permissions
// -----------------------------------------------------------------------------
func TestQuotaIsReportedAgainstWhicheverLimitApplies(t *testing.T) {
bridge := ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"3-of-5","authorizedChains":{
"B-Chain":{"chainId":%q,"canSign":true,"dailySigningLimit":10}}}`, bridge)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
vm.mu.Lock()
vm.dailySigningCount["B-Chain"] = 4
vm.mu.Unlock()
client, _ := serve(t, vm)
q, err := client.GetQuota(ctx())
require.NoError(t, err)
require.Equal(t, "B-Chain", q.ChainID)
require.Equal(t, uint64(10), q.DailyLimit)
require.Equal(t, uint64(4), q.UsedToday)
require.Equal(t, uint64(6), q.Remaining)
require.NotZero(t, q.ResetTime)
// Past the limit, remaining is zero rather than negative-wrapped.
vm.mu.Lock()
vm.dailySigningCount["B-Chain"] = 99
vm.mu.Unlock()
q, err = client.GetQuota(ctx())
require.NoError(t, err)
require.Zero(t, q.Remaining)
}
func TestPermissionsAreReadableForABoundChain(t *testing.T) {
bridge := ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"3-of-5","authorizedChains":{
"B-Chain":{"chainId":%q,"chainName":"Bridge Chain","canSign":true,"canKeygen":true,"maxSigningSize":1024}}}`, bridge)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
_, srv := serve(t, vm)
got := call(t, srv, "threshold_getChainPermissions", map[string]string{"chainId": "B-Chain"})
require.Nil(t, got.Error)
var perms ChainPermissions
remarshal(t, got.Result, &perms)
require.Equal(t, bridge.String(), perms.ChainID)
require.True(t, perms.CanSign)
require.Equal(t, 1024, perms.MaxSigningSize)
got = call(t, srv, "threshold_getAuthorizedChains", nil)
require.Nil(t, got.Error)
var names []string
remarshal(t, got.Result, &names)
require.Equal(t, []string{"B-Chain"}, names)
}
// -----------------------------------------------------------------------------
// Health
// -----------------------------------------------------------------------------
func TestHealthOverRPCIsTheHealthTheEngineIsTold(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 84)
key.register(t, vm)
client, _ := serve(t, vm)
got, err := client.Health(ctx())
require.NoError(t, err)
require.Equal(t, true, got["healthy"])
require.Equal(t, "1", got["custodyKeys"])
engine, err := vm.HealthCheck(ctx())
require.NoError(t, err)
require.Equal(t, engine.Details["stateRoot"], got["stateRoot"])
}
func TestAnUnhealthyNodeSaysSoOverRPC(t *testing.T) {
vm := newVM(t)
vm.state.db = &faultyIterDB{Database: vm.state.db}
_, srv := serve(t, vm)
got := call(t, srv, "threshold_health", nil)
require.NotNil(t, got.Error)
require.Equal(t, RPCErrorInternal, got.Error.Code)
}
// -----------------------------------------------------------------------------
// The client's own failure modes
// -----------------------------------------------------------------------------
func TestAClientReportsWhatWentWrongRatherThanAZeroValue(t *testing.T) {
unreachable := NewClient("http://127.0.0.1:1", "B-Chain")
_, err := unreachable.GetInfo(ctx())
require.Error(t, err)
require.Contains(t, err.Error(), "failed to send request")
// A server that answers with something that is not a JSON-RPC envelope.
junk := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("not json"))
}))
defer junk.Close()
_, err = NewClient(junk.URL, "B-Chain").GetInfo(ctx())
require.ErrorContains(t, err, "failed to unmarshal response")
// A well-formed envelope whose result is not the shape the caller expects.
wrong := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"a string"}`))
}))
defer wrong.Close()
_, err = NewClient(wrong.URL, "B-Chain").GetInfo(ctx())
require.ErrorContains(t, err, "failed to unmarshal result")
// A request that cannot be built at all.
bad := NewClient("://not a url", "B-Chain")
_, err = bad.GetInfo(ctx())
require.ErrorContains(t, err, "failed to create request")
}
func TestAClientSurfacesTheServersErrorCode(t *testing.T) {
vm := newVM(t)
client, _ := serve(t, vm)
_, err := client.GetKey(ctx(), "nothing")
require.ErrorContains(t, err, fmt.Sprintf("RPC error %d", RPCErrorKeyNotFound))
}
// -----------------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------------
func mustRoot(vm *VM) []byte {
root := vm.StateRoot()
return root[:]
}
// remarshal moves a decoded interface{} result into a typed value, which is
// what a caller that used the typed client would have got directly.
func remarshal(t *testing.T, from any, into any) {
t.Helper()
raw, err := json.Marshal(from)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, into))
}
// A global override outranks the chain's own limit, and the quota report says
// so — an operator throttling one chain must see the number that is in force.
func TestTheQuotaReportedIsTheOneInForce(t *testing.T) {
bridge := ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"3-of-5","dailySigningQuota":{"B-Chain":3},
"authorizedChains":{"B-Chain":{"chainId":%q,"canSign":true,"dailySigningLimit":1000}}}`, bridge)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
client, _ := serve(t, vm)
q, err := client.GetQuota(ctx())
require.NoError(t, err)
require.Equal(t, uint64(3), q.DailyLimit, "the override is the limit, not the entry's own")
require.Equal(t, uint64(3), q.Remaining)
}
+13 -11
View File
@@ -26,7 +26,6 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"github.com/luxfi/ids"
"github.com/luxfi/threshold/pkg/party"
@@ -46,18 +45,21 @@ const ceremonyDomainTag = "LUX_MPC_CEREMONY_v1"
// for a given signing task. It binds the key, the exact 32-byte digest, and the
// (order-independent) signer set: distinct keys, messages or quorums get
// distinct ceremony ids, and the same task always maps to the same id.
//
// Every field is length-prefixed, through the same writers every other digest
// in this package uses. It used to concatenate the key id and the digest raw
// and separate signers with a zero byte, which is the one hash here that could
// be forged by moving a byte from the end of a caller-chosen key id into the
// front of the digest. Nothing does today — the digest is pinned at 32 bytes on
// both the produce and the verify path, which fixes the split — but the
// property then belongs to a check two files away instead of to the hash, and
// the day someone relaxes that check the forge is live.
func ceremonyID(keyID string, digest []byte, signers []party.ID) string {
sorted := append([]party.ID(nil), signers...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
h := sha256.New()
h.Write([]byte(ceremonyDomainTag))
h.Write([]byte(keyID))
h.Write(digest)
for _, s := range sorted {
h.Write([]byte{0x00}) // length-free separator; party ids are ascii node ids
h.Write([]byte(s))
}
writeTagged(h, ceremonyDomainTag)
writeField(h, []byte(keyID))
writeField(h, digest)
writeParties(h, canonicalParties(signers))
return "mpc/" + hex.EncodeToString(h.Sum(nil))
}
+32 -35
View File
@@ -160,6 +160,14 @@ func (r *KeyRecord) Validate() error {
if len(r.Address) != 20 {
return fmt.Errorf("mpcvm: key %s address is %d bytes, want 20", r.KeyID, len(r.Address))
}
// Generation counts resharings of the same public key, and M-Chain has no
// resharing ceremony, so the only generation a key can honestly be at is
// its first. Left unchecked the field is proposer-chosen: a registration
// could enter consensus state claiming a rotation history the chain has no
// record of, and every reader of the registry would believe it.
if r.Generation != 0 {
return fmt.Errorf("mpcvm: key %s registers at generation %d; a key is registered once, at 0", r.KeyID, r.Generation)
}
return nil
}
@@ -285,8 +293,12 @@ func advance(root [32]byte, opDigest [32]byte) [32]byte {
// PutKey registers a custody key. Registration is once-only: a key id is a
// permanent binding to a group public key, and silently rebinding it would let
// a later ceremony redirect custody of live funds. Rotation is a new
// Generation of the same record via ReplaceKey, never a fresh PutKey.
// a later ceremony redirect custody of live funds.
//
// It is the ONLY writer of the key registry. There was a second — a ReplaceKey
// that overwrote a record in place for a resharing path that does not exist —
// and a second writer into a registry whose whole value is "this binding never
// moves" is a rebinding waiting for its first caller.
func (s *State) PutKey(r *KeyRecord) error {
if err := r.Validate(); err != nil {
return err
@@ -298,34 +310,7 @@ func (s *State) PutKey(r *KeyRecord) error {
case has:
return fmt.Errorf("%w: %s", ErrKeyExists, r.KeyID)
}
b, err := marshalKeyRecord(r)
if err != nil {
return err
}
return s.db.Put(k, b)
}
// ReplaceKey overwrites an existing key record — the resharing/refresh path,
// where the group public key is unchanged but the shares and generation move.
func (s *State) ReplaceKey(r *KeyRecord) error {
if err := r.Validate(); err != nil {
return err
}
prev, err := s.GetKey(r.KeyID)
if err != nil {
return err
}
if string(prev.GroupPublicKey) != string(r.GroupPublicKey) {
return fmt.Errorf("mpcvm: refusing to rebind custody key %s to a different public key", r.KeyID)
}
if r.Generation <= prev.Generation {
return fmt.Errorf("mpcvm: key %s generation must increase (%d -> %d)", r.KeyID, prev.Generation, r.Generation)
}
b, err := marshalKeyRecord(r)
if err != nil {
return err
}
return s.db.Put(append(append([]byte(nil), prefixKeyRecord...), r.KeyID...), b)
return s.db.Put(k, marshalKeyRecord(r))
}
// GetKey reads a registered custody key.
@@ -379,11 +364,7 @@ func (s *State) PutCeremony(c *CeremonyRecord) error {
case has:
return fmt.Errorf("%w: %s", ErrCeremonyExists, c.ID)
}
b, err := marshalCeremonyRecord(c)
if err != nil {
return err
}
return s.db.Put(k, b)
return s.db.Put(k, marshalCeremonyRecord(c))
}
// GetCeremony reads a recorded ceremony.
@@ -532,3 +513,19 @@ func sortedUnique(ps []party.ID) bool {
}
return true
}
// samePartySet reports whether two canonical party lists are the same set. Both
// sides are sorted and de-duplicated wherever this is called, so equality of
// the sequences is equality of the sets, and a caller cannot smuggle a
// difference past it by reordering or repeating a name.
func samePartySet(a, b []party.ID) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return sortedUnique(a)
}
+422
View File
@@ -0,0 +1,422 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// state_test.go — the line between what is replicated and what is not, and the
// root that says two validators agree.
import (
"errors"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/quorum"
)
func newState(t *testing.T) (*State, database.Database) {
t.Helper()
db := memdb.New()
s, err := NewState(db, ids.GenerateTestID())
require.NoError(t, err)
return s, db
}
// -----------------------------------------------------------------------------
// The two keyspaces
// -----------------------------------------------------------------------------
// A secret never reaches consensus state. The share prefix and the replicated
// prefixes are disjoint by construction, and Root() folds only operation
// digests — never a stored value — so the question "could a secret have got
// into the root" is answered by reading the three Put methods rather than by
// auditing the whole VM.
func TestASecretNeverEntersReplicatedState(t *testing.T) {
s, db := newState(t)
secret := []byte("this is a private key share")
require.NoError(t, s.PutShare("vault", secret))
it := db.NewIteratorWithPrefix([]byte("c/"))
defer it.Release()
for it.Next() {
require.NotContains(t, string(it.Value()), string(secret),
"a secret appeared under the replicated prefix %q", it.Key())
}
require.NoError(t, it.Error())
// And the share is where it belongs, readable only by this node.
got, err := s.GetShare("vault")
require.NoError(t, err)
require.Equal(t, secret, got)
held, err := s.HasShare("vault")
require.NoError(t, err)
require.True(t, held)
absent, err := s.HasShare("nothing-here")
require.NoError(t, err)
require.False(t, absent)
}
// A node that holds no share for a key says so with a named error, so a caller
// can tell "not a participant" (normal) from "the store is broken".
func TestNotHoldingAShareIsNamedNotGuessed(t *testing.T) {
s, _ := newState(t)
_, err := s.GetShare("vault")
require.ErrorIs(t, err, ErrShareNotHeld)
}
// An empty share is refused rather than stored. A stored empty share reads back
// as "this node participates" while being unable to contribute anything.
func TestAnEmptyShareIsRefused(t *testing.T) {
s, _ := newState(t)
require.Error(t, s.PutShare("vault", nil))
require.Error(t, s.PutShare("vault", []byte{}))
held, err := s.HasShare("vault")
require.NoError(t, err)
require.False(t, held)
}
// -----------------------------------------------------------------------------
// The registry
// -----------------------------------------------------------------------------
// A key id is a permanent binding to a group public key. Rebinding it would
// redirect custody of live funds, so registration happens once and PutKey is
// the only writer.
func TestAKeyIdBindsToOnePublicKeyForever(t *testing.T) {
s, _ := newState(t)
a := sampleKeyRecord()
require.NoError(t, s.PutKey(a))
b := sampleKeyRecord()
b.GroupPublicKey = flip(b.GroupPublicKey)
require.ErrorIs(t, s.PutKey(b), ErrKeyExists)
got, err := s.GetKey(a.KeyID)
require.NoError(t, err)
require.Equal(t, a.GroupPublicKey, got.GroupPublicKey)
}
func TestAnUnregisteredKeyIsNamedNotEmpty(t *testing.T) {
s, _ := newState(t)
_, err := s.GetKey("nothing")
require.ErrorIs(t, err, ErrUnknownKey)
has, err := s.HasKey("nothing")
require.NoError(t, err)
require.False(t, has)
}
func TestAMalformedRecordNeverReachesTheRegistry(t *testing.T) {
s, _ := newState(t)
bad := sampleKeyRecord()
bad.GroupPublicKey = bad.GroupPublicKey[:32]
require.Error(t, s.PutKey(bad))
has, err := s.HasKey(bad.KeyID)
require.NoError(t, err)
require.False(t, has, "a record that failed validation must not be on disk")
}
func TestTheRegistryListsWhatWasRegistered(t *testing.T) {
s, _ := newState(t)
keys, err := s.Keys()
require.NoError(t, err)
require.Empty(t, keys)
for _, id := range []string{"b", "a", "c"} {
rec := sampleKeyRecord()
rec.KeyID = id
require.NoError(t, s.PutKey(rec))
}
keys, err = s.Keys()
require.NoError(t, err)
require.Len(t, keys, 3)
require.Equal(t, []string{"a", "b", "c"}, []string{keys[0].KeyID, keys[1].KeyID, keys[2].KeyID},
"the registry is enumerated in key-id order, so two validators list it identically")
}
// -----------------------------------------------------------------------------
// KeyRecord validity — every field a later signature check trusts
// -----------------------------------------------------------------------------
func TestARecordThatALaterCheckWouldTrustIsValidatedFirst(t *testing.T) {
for name, mangle := range map[string]func(*KeyRecord){
"no id": func(r *KeyRecord) { r.KeyID = "" },
"no protocol": func(r *KeyRecord) { r.Kind = "" },
"undeployable policy": func(r *KeyRecord) { r.Policy = quorum.Policy{} },
"one signer": func(r *KeyRecord) { r.Policy = quorum.Policy{K: 1, N: 3} },
"two disjoint quorums": func(r *KeyRecord) { r.Policy = quorum.MustNew(2, 5); r.Participants = parties(5) },
"too few participants": func(r *KeyRecord) { r.Participants = r.Participants[:1] },
"unsorted participants": func(r *KeyRecord) { r.Participants = []party.ID{"p3", "p1", "p2"} },
"repeated participant": func(r *KeyRecord) { r.Participants = []party.ID{"p1", "p1", "p2"} },
"uncompressed group key": func(r *KeyRecord) { r.GroupPublicKey = make([]byte, 65) },
"short address": func(r *KeyRecord) { r.Address = make([]byte, 19) },
"a generation it never had": func(r *KeyRecord) { r.Generation = 1 },
} {
rec := sampleKeyRecord()
mangle(rec)
require.Errorf(t, rec.Validate(), "%s must not validate", name)
}
require.NoError(t, sampleKeyRecord().Validate())
}
// The custody floor: a policy admitting two disjoint quorums lets two halves of
// the committee authorise contradictory releases of the same funds, so
// possession of a signature stops implying the committee authorised anything.
//
// CGGMP21 is unforgeable at any of these; this is a custody policy, not a
// soundness requirement, and it is enforced where the record enters state.
func TestAPolicyWithTwoDisjointQuorumsIsNotDeployableHere(t *testing.T) {
for _, tc := range []struct {
k, n int
want bool
}{
{2, 2, true}, {2, 3, true}, {2, 4, false}, {2, 5, false},
{3, 4, true}, {3, 5, true}, {3, 6, false}, {3, 7, false},
{4, 5, true}, {4, 7, true}, {5, 7, true}, {7, 7, true},
} {
rec := sampleKeyRecord()
rec.Policy = quorum.MustNew(tc.k, tc.n)
rec.Participants = parties(tc.n)
err := rec.Validate()
if tc.want {
require.NoErrorf(t, err, "%d-of-%d has a unique quorum and must be deployable", tc.k, tc.n)
} else {
require.Errorf(t, err, "%d-of-%d admits two disjoint quorums and must be refused", tc.k, tc.n)
}
}
}
// The degree is derived at one place, never stored beside the policy. Two
// stored numbers can disagree; one cannot.
func TestTheDegreeIsAlwaysOneBelowTheSignerCount(t *testing.T) {
for n := 2; n <= 7; n++ {
for k := 2; k <= n; k++ {
rec := sampleKeyRecord()
rec.Policy = quorum.MustNew(k, n)
require.Equalf(t, k-1, rec.Degree(), "%d-of-%d", k, n)
}
}
}
// -----------------------------------------------------------------------------
// The ceremony log
// -----------------------------------------------------------------------------
func TestACeremonyIsRecordedOnce(t *testing.T) {
s, _ := newState(t)
rec := &CeremonyRecord{ID: "mpc/one", Kind: OpTypeSign, KeyID: "vault"}
require.NoError(t, s.PutCeremony(rec))
require.ErrorIs(t, s.PutCeremony(rec), ErrCeremonyExists,
"recording a ceremony twice double-counts whatever it authorised")
require.Error(t, s.PutCeremony(&CeremonyRecord{}), "a ceremony with no id has no primary key")
got, err := s.GetCeremony("mpc/one")
require.NoError(t, err)
require.Equal(t, rec.KeyID, got.KeyID)
_, err = s.GetCeremony("mpc/never")
require.Error(t, err)
all, err := s.Ceremonies()
require.NoError(t, err)
require.Len(t, all, 1)
}
// -----------------------------------------------------------------------------
// The root
// -----------------------------------------------------------------------------
// Two chains running identical operations must not reach identical roots, or a
// block built on one applies to the other.
func TestTheGenesisRootIsPerChain(t *testing.T) {
a, b := ids.GenerateTestID(), ids.GenerateTestID()
require.NotEqual(t, genesisRoot(a), genesisRoot(b))
require.Equal(t, genesisRoot(a), genesisRoot(a))
}
// The root is a hash chain over applied operations, so it depends on both the
// set and the order.
func TestTheRootDependsOnWhatWasAppliedAndInWhatOrder(t *testing.T) {
base := genesisRoot(ids.GenerateTestID())
x, y := [32]byte{1}, [32]byte{2}
require.NotEqual(t, advance(base, x), advance(base, y))
require.NotEqual(t, advance(advance(base, x), y), advance(advance(base, y), x),
"a root that ignored order would let two validators apply the same operations differently")
require.Equal(t, advance(base, x), advance(base, x))
require.NotEqual(t, base, advance(base, x))
}
// A root read back from disk is the one that was committed, and a root that is
// not 32 bytes is a corrupted store rather than a value to copy into.
func TestARootIsEitherThirtyTwoBytesOrAnError(t *testing.T) {
s, db := newState(t)
moved := advance(s.Root(), [32]byte{9})
require.NoError(t, s.WriteRoot(moved))
require.NotEqual(t, moved, s.Root(), "WriteRoot stages; it does not publish")
s.HoldRoot(moved)
require.Equal(t, moved, s.Root())
require.NoError(t, s.ReadRoot())
require.Equal(t, moved, s.Root())
require.NoError(t, db.Put(keyRoot, []byte{1, 2, 3}))
require.Error(t, s.ReadRoot())
_, err := NewState(db, ids.GenerateTestID())
require.Error(t, err, "a store whose root is not 32 bytes must not open")
}
// Opening state twice on the same database resumes; it does not re-seed.
func TestReopeningStateResumesRatherThanReseeding(t *testing.T) {
db := memdb.New()
chainID := ids.GenerateTestID()
first, err := NewState(db, chainID)
require.NoError(t, err)
moved := advance(first.Root(), [32]byte{7})
require.NoError(t, first.WriteRoot(moved))
second, err := NewState(db, chainID)
require.NoError(t, err)
require.Equal(t, moved, second.Root())
// Even under a different chain id: what is on disk wins over what the
// caller says the chain is.
third, err := NewState(db, ids.GenerateTestID())
require.NoError(t, err)
require.Equal(t, moved, third.Root())
}
// A read fault on the root is an error, not an empty chain.
func TestAnUnreadableRootIsAFaultNotAFreshChain(t *testing.T) {
db := &faultyDB{Database: memdb.New()}
db.failGet = keyRoot
_, err := NewState(db, ids.GenerateTestID())
require.ErrorIs(t, err, errFaulty)
}
func TestASeedThatCannotBeWrittenFailsLoudly(t *testing.T) {
db := &faultyDB{Database: memdb.New()}
db.failPut = keyRoot
_, err := NewState(db, ids.GenerateTestID())
require.ErrorIs(t, err, errFaulty)
}
// -----------------------------------------------------------------------------
// Canonical hashing
// -----------------------------------------------------------------------------
// Every variable-length field is length-prefixed before hashing. Without it,
// ("ab","c") and ("a","bc") hash identically, and a ceremony id could be forged
// by moving a byte between the key id and the digest.
func TestMovingAByteBetweenFieldsChangesTheHash(t *testing.T) {
require.NotEqual(t,
keygenCeremonyID("ab", quorum.MustNew(2, 3), parties(3)),
keygenCeremonyID("a", quorum.MustNew(2, 3), parties(3)))
// The same split, in the signing id, where the key id is caller-chosen.
d := digestOf(1)
require.NotEqual(t,
ceremonyID("ab", d, parties(3)),
ceremonyID("a", append([]byte("b"), d[:31]...), parties(3)))
// And in the participant list, where an id could otherwise absorb its
// neighbour.
require.NotEqual(t,
keygenCeremonyID("k", quorum.MustNew(2, 3), []party.ID{"ab", "c", "d"}),
keygenCeremonyID("k", quorum.MustNew(2, 3), []party.ID{"a", "bc", "d"}))
}
// A key commitment binds everything a signature check later trusts, so a
// proposer cannot register a key under a policy or a committee other than the
// one the ceremony ran with and still produce a verifying proof.
func TestTheKeyCommitmentBindsEveryFieldASignatureCheckTrusts(t *testing.T) {
base := KeyCommitDigest(sampleKeyRecord())
for name, mangle := range map[string]func(*KeyRecord){
"key id": func(r *KeyRecord) { r.KeyID = "elsewhere" },
"protocol": func(r *KeyRecord) { r.Kind = "frost" },
"signer count": func(r *KeyRecord) { r.Policy = quorum.MustNew(3, 3) },
"party count": func(r *KeyRecord) { r.Policy = quorum.MustNew(2, 5) },
"committee": func(r *KeyRecord) { r.Participants = []party.ID{"q1", "q2", "q3"} },
"group key": func(r *KeyRecord) { r.GroupPublicKey = flip(r.GroupPublicKey) },
"generation": func(r *KeyRecord) { r.Generation = 1 },
} {
rec := sampleKeyRecord()
mangle(rec)
require.NotEqualf(t, base, KeyCommitDigest(rec), "%s is not bound by the commitment", name)
}
// The address is NOT in the commitment — it is derived from the group key
// and recomputed by Verify, so committing to it too would be two sources
// for one fact.
rec := sampleKeyRecord()
rec.Address = make([]byte, 20)
require.Equal(t, base, KeyCommitDigest(rec))
}
func TestACanonicalPartySetIsSortedAndDeduplicated(t *testing.T) {
require.Equal(t, []party.ID{"a", "b", "c"}, canonicalParties([]party.ID{"c", "a", "b", "a", "c"}))
require.Empty(t, canonicalParties(nil))
require.True(t, sortedUnique(nil))
require.True(t, sortedUnique([]party.ID{"a"}))
require.True(t, sortedUnique([]party.ID{"a", "b"}))
require.False(t, sortedUnique([]party.ID{"b", "a"}))
require.False(t, sortedUnique([]party.ID{"a", "a"}))
}
// samePartySet compares sets, and refuses a non-canonical side rather than
// silently treating a repeated name as a distinct member.
func TestSamePartySetRefusesANonCanonicalSide(t *testing.T) {
require.True(t, samePartySet(nil, nil))
require.True(t, samePartySet([]party.ID{"a", "b"}, []party.ID{"a", "b"}))
require.False(t, samePartySet([]party.ID{"a"}, []party.ID{"a", "b"}))
require.False(t, samePartySet([]party.ID{"a", "c"}, []party.ID{"a", "b"}))
require.False(t, samePartySet([]party.ID{"b", "a"}, []party.ID{"b", "a"}),
"two equal unsorted lists are still not a canonical set")
require.False(t, samePartySet([]party.ID{"a", "a"}, []party.ID{"a", "a"}))
}
// -----------------------------------------------------------------------------
// a database that can be made to fail one key
// -----------------------------------------------------------------------------
var errFaulty = errors.New("read fault")
type faultyDB struct {
database.Database
failGet []byte
failPut []byte
}
func (d *faultyDB) Get(k []byte) ([]byte, error) {
if d.failGet != nil && string(k) == string(d.failGet) {
return nil, errFaulty
}
return d.Database.Get(k)
}
func (d *faultyDB) Put(k, v []byte) error {
if d.failPut != nil && string(k) == string(d.failPut) {
return errFaulty
}
return d.Database.Put(k, v)
}
func (d *faultyDB) Has(k []byte) (bool, error) {
if d.failGet != nil && string(k) == string(d.failGet) {
return false, errFaulty
}
return d.Database.Has(k)
}
+13 -179
View File
@@ -3,25 +3,18 @@
package mpcvm
// transport.go — the MessageRouter concrete implementations that let an MPC
// ceremony (DKG / sign / reshare) run NATIVELY across the M-Chain's validators
// instead of the old off-chain REST cluster (luxfi/mpc). The executor drives a
// per-party threshold protocol.Handler; the router carries that handler's
// messages to the other parties and delivers theirs back. Two impls:
// transport.go — the MessageRouter that lets an MPC ceremony (DKG, sign) run
// NATIVELY across the M-Chain's validators instead of an off-chain REST
// cluster. The executor drives a per-party threshold protocol.Handler; the
// router carries that handler's messages to the other parties and delivers
// theirs back.
//
// - inProcRouter: a shared in-memory mesh. Used by unit tests and by a
// single-process/local dev node. This is the SAME fan-out the threshold
// library's own test harness uses (broadcast-to-all-except-sender, or a
// directed send by To), lifted to the MessageRouter boundary so the exact
// executor code path that runs in production is exercised under test.
//
// - gossipRouter: the cross-validator transport. Each validator runs its own
// mpcvm with its own party.ID; the router serialises a ceremony message into
// a session-tagged envelope and broadcasts it over the chain's app-gossip
// (or sends it to one peer), and the VM's app-message handler calls Deliver
// to feed incoming envelopes back to the right session. Decoupled from node
// types via the small AppNetwork interface so this file stays node-free and
// unit-testable; the VM supplies an AppNetwork backed by its AppSender.
// There is ONE router. It serialises a ceremony message into a session-tagged
// envelope and broadcasts it over the chain's app-gossip (or sends it to one
// peer); the receiving validator's Gossip handler demultiplexes by session and
// calls Deliver. Decoupled from node types via the small AppNetwork interface,
// so this file imports no node packages and the send side is testable against
// an in-memory fabric.
//
// threshold stays the shared library — the router only moves its opaque
// protocol.Message bytes; it never interprets the cryptography.
@@ -40,162 +33,7 @@ import (
)
// =============================================================================
// In-process mesh (tests + single-node local)
// =============================================================================
// inProcNetwork is a shared in-memory bus for one ceremony running inside a
// single process. Per-party buffered inboxes; Send fans a message out to its
// targets. Close drains in-flight sends before shutting the inboxes so a
// "go router loop" + deferred Close is race-free (mirrors the close discipline
// of threshold/internal/test.Network).
type inProcNetwork struct {
mu sync.RWMutex
inboxes map[party.ID]chan *protocol.Message
inFlight sync.RWMutex
closed bool
}
// newInProcNetwork builds an in-memory mesh over the given parties.
func newInProcNetwork(parties []party.ID) *inProcNetwork {
n := &inProcNetwork{inboxes: make(map[party.ID]chan *protocol.Message, len(parties))}
for _, id := range parties {
n.inboxes[id] = make(chan *protocol.Message, 1024)
}
return n
}
// send delivers msg to its targets: every party but the sender when To=="",
// otherwise the single addressed party.
func (n *inProcNetwork) send(msg *protocol.Message) {
if msg == nil {
return
}
n.inFlight.RLock()
defer n.inFlight.RUnlock()
if n.closed {
return
}
n.mu.RLock()
var targets []chan *protocol.Message
if msg.To == "" {
for id, ch := range n.inboxes {
if id != msg.From {
targets = append(targets, ch)
}
}
} else if ch, ok := n.inboxes[msg.To]; ok {
targets = append(targets, ch)
}
n.mu.RUnlock()
for _, ch := range targets {
ch <- msg
}
}
// inbox returns a party's receive channel.
func (n *inProcNetwork) inbox(id party.ID) chan *protocol.Message {
n.mu.RLock()
defer n.mu.RUnlock()
return n.inboxes[id]
}
// close shuts every inbox after in-flight sends drain, unblocking the router
// receive loops (which range over the inbox channel).
func (n *inProcNetwork) close() {
n.inFlight.Lock()
defer n.inFlight.Unlock()
if n.closed {
return
}
n.closed = true
n.mu.Lock()
defer n.mu.Unlock()
for _, ch := range n.inboxes {
close(ch)
}
}
// inProcRouter is one party's MessageRouter view onto a shared inProcNetwork.
type inProcRouter struct {
self party.ID
net *inProcNetwork
}
// newInProcRouters builds a mesh plus one router per party, ready to hand to
// each party's executor. Call net.close() once every party's ceremony returns.
func newInProcRouters(parties []party.ID) (*inProcNetwork, map[party.ID]*inProcRouter) {
net := newInProcNetwork(parties)
routers := make(map[party.ID]*inProcRouter, len(parties))
for _, id := range parties {
routers[id] = &inProcRouter{self: id, net: net}
}
return net, routers
}
// Send implements MessageRouter.
func (r *inProcRouter) Send(msg *protocol.Message) error {
r.net.send(msg)
return nil
}
// Receive implements MessageRouter.
func (r *inProcRouter) Receive() <-chan *protocol.Message { return r.net.inbox(r.self) }
// =============================================================================
// Multi-ceremony in-process mesh
// =============================================================================
// localMesh is a process-wide ceremony bus shared by several VMs running in one
// process — the committee of an in-process devnet, and the harness that proves
// the ceremony path end to end.
//
// It exists so that a single-process committee runs the SAME executor and the
// SAME MessageRouter boundary as a networked one; only the bytes underneath
// differ (channels instead of app-gossip). A test that stubbed the ceremony
// would prove nothing about the code that signs real custody transactions.
//
// Routing is by (ceremony, party): concurrent ceremonies get disjoint meshes,
// which is exactly the property a single global router cannot provide.
type localMesh struct {
mu sync.Mutex
parties []party.ID
nets map[string]*inProcNetwork
}
// NewLocalMesh builds an in-process bus over a fixed committee.
func NewLocalMesh(parties []party.ID) *localMesh {
return &localMesh{
parties: append([]party.ID(nil), parties...),
nets: make(map[string]*inProcNetwork),
}
}
// routerFor returns self's router for one ceremony, creating the ceremony's
// mesh on first use. Every party in the committee gets a view onto the same
// mesh regardless of which one arrives first.
func (m *localMesh) routerFor(ceremony string, self party.ID) MessageRouter {
m.mu.Lock()
defer m.mu.Unlock()
net, ok := m.nets[ceremony]
if !ok {
net = newInProcNetwork(m.parties)
m.nets[ceremony] = net
}
return &inProcRouter{self: self, net: net}
}
// release drops a finished ceremony's mesh. It does NOT close the inboxes: the
// other parties in this process may still be draining the last round, and
// closing under them would race their receive loops. Dropping the reference is
// enough — the mesh is garbage once every party's router is gone.
func (m *localMesh) release(ceremony string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.nets, ceremony)
}
// =============================================================================
// Cross-validator gossip transport (production)
// Cross-validator gossip transport
// =============================================================================
// AppNetwork is the minimal consensus-gossip send capability the gossipRouter
@@ -328,11 +166,7 @@ func (r *gossipRouter) Close() {
close(r.inbox)
}
// Compile-time checks that both routers satisfy MessageRouter.
var (
_ MessageRouter = (*inProcRouter)(nil)
_ MessageRouter = (*gossipRouter)(nil)
)
var _ MessageRouter = (*gossipRouter)(nil)
// =============================================================================
// warp.Sender-backed AppNetwork (the production send side)
+2 -2
View File
@@ -41,7 +41,7 @@ func runKeygenViaTransport(
threshold int,
) map[party.ID]*cmpconfig.Config {
t.Helper()
net, routers := newInProcRouters(parties)
net, routers := newMeshRouters(parties)
defer net.close()
var mu sync.Mutex
@@ -76,7 +76,7 @@ func runSignViaTransport(
digest []byte,
) map[party.ID]*ECDSASignature {
t.Helper()
net, routers := newInProcRouters(signers)
net, routers := newMeshRouters(signers)
defer net.close()
var mu sync.Mutex
+236 -7
View File
@@ -4,6 +4,8 @@
package types
import (
"math/bits"
"strings"
"testing"
"github.com/luxfi/threshold/pkg/quorum"
@@ -106,6 +108,184 @@ func TestCeremony_RejectsUnsetPolicy(t *testing.T) {
}
}
// HasUniqueQuorum agrees with what it claims to mean.
//
// 2K > N is a shortcut for "no two disjoint K-subsets of the N parties exist",
// and the shortcut is the thing the custody floor is actually asserting. This
// enumerates the subsets instead of restating the arithmetic, so an edit to
// HasUniqueQuorum that keeps the comparison plausible — >= for >, N/2 rounding
// the wrong way — is caught by the definition rather than by a copy of itself.
func TestCeremony_UniqueQuorumMeansNoTwoDisjointSigningSets(t *testing.T) {
for n := 0; n <= 7; n++ {
for k := 0; k <= n; k++ {
got := HasUniqueQuorum(quorum.Policy{K: k, N: n})
if want := !disjointQuorumsExist(k, n); got != want {
t.Errorf("HasUniqueQuorum(%d-of-%d) = %v; enumerating subsets says %v", k, n, got, want)
}
}
}
}
// disjointQuorumsExist reports whether two disjoint K-subsets of an N-set exist,
// by enumerating every pair of subsets.
func disjointQuorumsExist(k, n int) bool {
for a := 0; a < 1<<n; a++ {
if bits.OnesCount(uint(a)) != k {
continue
}
for b := 0; b < 1<<n; b++ {
if bits.OnesCount(uint(b)) == k && a&b == 0 {
return true
}
}
}
return false
}
// Validate refuses a ceremony with no kind, and a nil ceremony.
//
// Kind decides which lane the artifact takes and which verifier reads each
// round. KindUnknown is the zero value, so accepting it would let a
// zero-initialised struct reach the state machine and finalize into whatever
// lane the dispatch table happened to answer with.
func TestCeremony_ValidateRefusesNilAndKindless(t *testing.T) {
if err := (*Ceremony)(nil).Validate(); err == nil {
t.Fatal("a nil ceremony must not validate")
}
err := (&Ceremony{Policy: quorum.MustNew(3, 5)}).Validate()
if err == nil || !strings.Contains(err.Error(), "kind unset") {
t.Fatalf("a ceremony with no kind must be refused, got: %v", err)
}
}
// Every state renders as a distinct label, including the zero value.
//
// The labels are what a transition error says, and an operator reading
// "ceremony: unknown -> unknown not legal" needs those two words to be
// different states, not the same word for two of them.
func TestCeremonyState_LabelsAreDistinct(t *testing.T) {
seen := map[string]CeremonyState{}
for state, want := range map[CeremonyState]string{
StateUnknown: "unknown",
StateRegistered: "registered",
StateRound1: "round1",
StateRound2: "round2",
StateFinalized: "finalized",
StateAborted: "aborted",
} {
got := state.String()
if got != want {
t.Errorf("state %d renders as %q, want %q", uint8(state), got, want)
}
if prior, dup := seen[got]; dup {
t.Errorf("states %d and %d both render as %q", uint8(prior), uint8(state), got)
}
seen[got] = state
}
if got := CeremonyState(200).String(); got != "unknown" {
t.Errorf("an unassigned state renders as %q, want %q", got, "unknown")
}
}
// The transition table is exactly four edges plus abort from anywhere.
//
// The state machine is the substrate's only mutable thing, and every other
// invariant is stated relative to a state: shares are only meaningful in Round1
// and Round2, the cert artifact only exists after Finalized. An extra edge —
// Registered straight to Finalized, Round1 back to Registered — would let a
// ceremony emit an artifact for rounds that never ran.
func TestCeremony_TransitionAdmitsOnlyTheFourEdgesAndAbort(t *testing.T) {
type post struct {
state CeremonyState
round uint8
}
legal := map[[2]CeremonyState]post{
{StateRegistered, StateRound1}: {StateRound1, 1},
{StateRound1, StateRound2}: {StateRound2, 2},
{StateRound2, StateFinalized}: {StateFinalized, 0},
}
for from := StateUnknown; from <= StateAborted; from++ {
legal[[2]CeremonyState{from, StateAborted}] = post{StateAborted, 0}
}
for from := StateUnknown; from <= StateAborted; from++ {
for to := StateUnknown; to <= StateAborted; to++ {
before := Ceremony{ID: CeremonyID{0xAA}, Kind: KindFROST, State: from, Policy: quorum.MustNew(3, 5)}
after, err := before.Transition(to)
if after.ID != before.ID || after.Kind != before.Kind || after.Policy != before.Policy {
t.Errorf("%s -> %s altered a field other than State and Round", from, to)
}
want, ok := legal[[2]CeremonyState{from, to}]
if !ok {
if err == nil {
t.Errorf("%s -> %s was accepted", from, to)
}
if after.State != before.State || after.Round != before.Round {
t.Errorf("%s -> %s: refused but returned %s/round %d instead of the ceremony unchanged",
from, to, after.State, after.Round)
}
continue
}
if err != nil {
t.Errorf("%s -> %s: %v", from, to, err)
continue
}
if after.State != want.state || after.Round != want.round {
t.Errorf("%s -> %s gave %s/round %d, want %s/round %d",
from, to, after.State, after.Round, want.state, want.round)
}
}
}
}
// Transition hands back a new ceremony and leaves the caller's alone.
//
// The host chain drives transitions on a block tick and only commits the result
// once the block verifies. If Transition mutated in place, a transition
// attempted on a block that is later rejected would already have advanced the
// live ceremony, and a refused transition would advance it halfway.
func TestCeremony_TransitionReturnsACopy(t *testing.T) {
before := newCeremony()
after, err := before.Transition(StateRound1)
if err != nil {
t.Fatalf("registered -> round1: %v", err)
}
after.State, after.Round, after.Kind = StateAborted, 9, KindCGGMP21
if before.State != StateRegistered || before.Round != 0 || before.Kind != KindFROST {
t.Fatalf("writing to the returned ceremony reached the receiver: %s/round %d/kind %d",
before.State, before.Round, before.Kind)
}
if _, err := before.Transition(StateRound1); err != nil {
t.Fatalf("the receiver is no longer in Registered: %v", err)
}
}
// The copy is shallow, and the arena is deliberately shared.
//
// PayloadArena is the host chain's buffer holding every participant's payload
// for the ceremony — for a TFHE bootstrap-key round that is hundreds of
// megabytes. The substrate never allocates, so a transition must not clone it:
// a per-tick deep copy would be the arena's size times the block rate. Copying
// the scalar fields while aliasing the arena is the intended shape, and this
// pins it so a later "make Transition a real deep copy" does not quietly turn
// one buffer into one per tick.
func TestCeremony_TransitionSharesTheArenaRatherThanCloningIt(t *testing.T) {
before := newCeremony()
before.PayloadArena = []byte("payload")
after, err := before.Transition(StateRound1)
if err != nil {
t.Fatalf("registered -> round1: %v", err)
}
after.PayloadArena[0] = 'P'
if before.PayloadArena[0] != 'P' {
t.Fatal("Transition cloned the payload arena; the substrate must not allocate it")
}
}
func TestCeremony_LegalTransitions(t *testing.T) {
c := newCeremony()
if err := c.Validate(); err != nil {
@@ -143,14 +323,63 @@ func TestCeremony_AbortAlwaysAllowed(t *testing.T) {
}
}
func TestCeremonyKind_OrthogonalityFlags(t *testing.T) {
if !KindFROST.IsMChain() || KindFROST.IsFChain() {
t.Fatal("FROST is M-Chain only")
// No kind is claimed by two of the three predicates.
//
// The predicates decide which chain runs a ceremony. A kind claimed by both
// chains would run twice, once on each, producing two artifacts for one
// ceremony id. TFHEKeygen is deliberately claimed by neither chain: it is the
// M-to-F handoff, so it originates on M-Chain and finalizes into F-Chain, and a
// predicate that assigned it to a single chain would put the whole ceremony
// there and drop the handoff. The sweep runs over all 256 encodable kinds
// because the kind is a byte on the wire.
func TestCeremonyKind_PredicatesAreMutuallyExclusive(t *testing.T) {
m := map[CeremonyKind]bool{KindCGGMP21: true, KindFROST: true, KindCoronaGen: true}
f := map[CeremonyKind]bool{KindTFHECompute: true, KindTFHEBootstrap: true}
handoff := map[CeremonyKind]bool{KindTFHEKeygen: true}
for i := 0; i <= 255; i++ {
k := CeremonyKind(i)
claims := 0
for _, claimed := range []bool{k.IsMChain(), k.IsFChain(), k.IsHandoff()} {
if claimed {
claims++
}
}
if claims > 1 {
t.Fatalf("kind %d is claimed by %d of the three predicates", k, claims)
}
if got := k.IsMChain(); got != m[k] {
t.Errorf("kind %d IsMChain() = %v, want %v", k, got, m[k])
}
if got := k.IsFChain(); got != f[k] {
t.Errorf("kind %d IsFChain() = %v, want %v", k, got, f[k])
}
if got := k.IsHandoff(); got != handoff[k] {
t.Errorf("kind %d IsHandoff() = %v, want %v", k, got, handoff[k])
}
}
if !KindTFHEBootstrap.IsFChain() || KindTFHEBootstrap.IsMChain() {
t.Fatal("TFHEBootstrap is F-Chain only")
if KindTFHEKeygen.IsMChain() || KindTFHEKeygen.IsFChain() {
t.Fatal("TFHEKeygen belongs to the handoff, not to either chain alone")
}
if !KindTFHEKeygen.IsHandoff() {
t.Fatal("TFHEKeygen is the cross-chain handoff kind")
if KindUnknown.IsMChain() || KindUnknown.IsFChain() || KindUnknown.IsHandoff() {
t.Fatal("the zero kind must belong to nothing")
}
}
// Kind numbers are the wire encoding and never move.
//
// The comment on the constant block says kinds append and never reorder. A
// renumbering is silent across a mixed-version network: a node reading kind 5
// as TFHECompute while its peer wrote FROST routes the ceremony to the wrong
// chain's protocol driver.
func TestCeremonyKind_ValuesAreAppendOnly(t *testing.T) {
for kind, want := range map[CeremonyKind]uint8{
KindUnknown: 0, KindCGGMP21: 1, KindFROST: 2, KindCoronaGen: 3,
KindTFHEKeygen: 4, KindTFHECompute: 5, KindTFHEBootstrap: 6,
} {
if uint8(kind) != want {
t.Errorf("kind encodes as %d, want %d", uint8(kind), want)
}
}
}
-10
View File
@@ -60,16 +60,6 @@ func NewParticipantSet(ceremonyID CeremonyID, members []Participant) (*Participa
return &ParticipantSet{CeremonyID: ceremonyID, Members: out}, nil
}
// Lookup returns the participant for a node, or false if absent.
func (ps *ParticipantSet) Lookup(node NodeID) (Participant, bool) {
for _, p := range ps.Members {
if p.Node == node {
return p, true
}
}
return Participant{}, false
}
// Digest returns the canonical 32-byte digest of the set. Used as the
// participant_root when a ceremony is registered, and as input to the
// stake-weighted VRF for the next ceremony.
+228
View File
@@ -0,0 +1,228 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package types
import (
"bytes"
"math/rand"
"strings"
"testing"
)
func node(b ...byte) NodeID {
var n NodeID
copy(n[:], b)
return n
}
// A set with no members is refused.
//
// An empty set makes every ParticipantID out of range, so Share.Validate would
// reject every share and the ceremony would stall rather than fail. Refusing at
// construction puts the error where the selection bug is.
func TestParticipantSet_EmptyIsRefused(t *testing.T) {
for _, in := range [][]Participant{nil, {}} {
if _, err := NewParticipantSet(CeremonyID{1}, in); err == nil {
t.Error("an empty participant set was accepted")
}
}
}
// A node appearing twice is refused, wherever the repeats sit in the input.
//
// A duplicated node holds two shares of one secret. At 3-of-5 that turns three
// distinct custodians into two, and a policy verified as 3-of-5 signs with two
// operators — precisely the disjoint-quorum failure HasUniqueQuorum exists to
// prevent, arriving through the participant set instead of the policy.
func TestParticipantSet_DuplicateNodeIsRefused(t *testing.T) {
dup := node(7)
for _, tc := range []struct {
name string
in []Participant
}{
{"adjacent in input", []Participant{{Node: dup}, {Node: dup}, {Node: node(9)}}},
{"separated in input", []Participant{{Node: dup}, {Node: node(9)}, {Node: dup}}},
{"differing weights", []Participant{{Node: dup, Weight: 1}, {Node: dup, Weight: 2}}},
{"the whole set", []Participant{{Node: dup}, {Node: dup}}},
} {
_, err := NewParticipantSet(CeremonyID{1}, tc.in)
if err == nil {
t.Errorf("%s: a duplicated node was accepted", tc.name)
continue
}
if !strings.Contains(err.Error(), "duplicate node") {
t.Errorf("%s: want a duplicate-node error, got: %v", tc.name, err)
}
}
}
// The set is sorted by NodeID and Index is the position, whatever the caller sent.
//
// Index is what a Share's ParticipantID means and what Digest commits to, so it
// has to be a property of the set rather than of the caller. A caller that
// passes stale indices — resharing code that reuses last ceremony's set,
// selection code that numbered by stake rank — must not be able to make Index
// disagree with position, or a share would authenticate against the wrong
// participant's key.
func TestParticipantSet_IndexIsPositionInNodeOrder(t *testing.T) {
set, err := NewParticipantSet(CeremonyID{1}, []Participant{
{Node: node(9), Index: 400, Weight: 90},
{Node: node(2), Index: 401, Weight: 20},
{Node: node(5), Index: 402, Weight: 50},
{Node: node(1), Index: 403, Weight: 10},
})
if err != nil {
t.Fatalf("build: %v", err)
}
want := []struct {
node NodeID
weight uint64
}{{node(1), 10}, {node(2), 20}, {node(5), 50}, {node(9), 90}}
for i, m := range set.Members {
if m.Node != want[i].node {
t.Errorf("position %d holds node %x, want %x", i, m.Node[:1], want[i].node[:1])
}
if m.Index != uint32(i) {
t.Errorf("position %d has Index %d; the caller's value survived", i, m.Index)
}
if m.Weight != want[i].weight {
t.Errorf("position %d has weight %d, want %d", i, m.Weight, want[i].weight)
}
}
for i := 1; i < len(set.Members); i++ {
if bytes.Compare(set.Members[i-1].Node[:], set.Members[i].Node[:]) >= 0 {
t.Fatalf("members are not ascending by NodeID at position %d", i)
}
}
if set.CeremonyID != (CeremonyID{1}) {
t.Fatal("the set does not carry the ceremony it was built for")
}
}
// Ordering is decided by the whole NodeID, not its first byte.
//
// NodeIDs are content-addressed, so the discriminating byte is anywhere in the
// 32. A comparator that stopped early would order two nodes by luck, and since
// Index is position, two nodes would swap indices between the node that built
// the set and the node that rebuilt it — the same share then verifies against
// two different participants.
func TestParticipantSet_OrderUsesTheWholeNodeID(t *testing.T) {
for _, at := range []int{0, 1, 15, 30, 31} {
lo, hi := node(), node()
for i := 0; i < at; i++ {
lo[i], hi[i] = 0xFF, 0xFF
}
lo[at], hi[at] = 0x01, 0x02
set, err := NewParticipantSet(CeremonyID{1}, []Participant{{Node: hi}, {Node: lo}})
if err != nil {
t.Fatalf("byte %d: %v", at, err)
}
if set.Members[0].Node != lo {
t.Fatalf("byte %d decides the order and was not consulted", at)
}
}
}
// The set is independent of the slice the caller handed in.
//
// The caller's slice is the host chain's selection buffer, reused across
// ceremonies. If the set aliased it, the next selection round would silently
// rewrite the membership of a ceremony already in flight.
func TestParticipantSet_DoesNotAliasTheCallersSlice(t *testing.T) {
in := []Participant{{Node: node(3), Weight: 30}, {Node: node(1), Weight: 10}}
set, err := NewParticipantSet(CeremonyID{1}, in)
if err != nil {
t.Fatalf("build: %v", err)
}
before := set.Digest()
in[0] = Participant{Node: node(99), Index: 7, Weight: 999}
in[1] = Participant{Node: node(98), Index: 8, Weight: 998}
if set.Digest() != before {
t.Fatal("rewriting the caller's slice changed the set that was already built")
}
if set.Members[0].Node != node(1) || set.Members[1].Node != node(3) {
t.Fatal("the set shares its backing array with the caller's slice")
}
}
// Two nodes given the same members in any order compute the same digest.
//
// The digest is the participant_root: it goes into the ceremony record and
// seeds the next ceremony's VRF. Every node derives it independently from its
// own view of the selection, which arrives over gossip in no particular order.
// An order-dependent digest is a consensus fork, not a hash mismatch.
func TestParticipantSet_DigestIsOrderIndependent(t *testing.T) {
members := []Participant{
{Node: node(4), Weight: 40}, {Node: node(1), Weight: 10},
{Node: node(3), Weight: 30}, {Node: node(2), Weight: 20},
{Node: node(5), Weight: 50},
}
canonical, err := NewParticipantSet(CeremonyID{0x77}, members)
if err != nil {
t.Fatalf("build: %v", err)
}
want := canonical.Digest()
rng := rand.New(rand.NewSource(1))
for trial := 0; trial < 50; trial++ {
shuffled := append([]Participant(nil), members...)
rng.Shuffle(len(shuffled), func(i, j int) { shuffled[i], shuffled[j] = shuffled[j], shuffled[i] })
set, err := NewParticipantSet(CeremonyID{0x77}, shuffled)
if err != nil {
t.Fatalf("trial %d: %v", trial, err)
}
if got := set.Digest(); got != want {
t.Fatalf("trial %d: a reordered input produced digest %x, want %x", trial, got, want)
}
}
}
// Every field the digest claims to commit to actually moves it.
//
// The digest is what a ceremony record binds instead of the membership itself.
// A field left out of the hash is a field an adversary may vary freely after
// the set is committed: a Weight not covered lets stake accounting be rewritten
// post hoc, an Index not covered lets two participants trade positions, and a
// CeremonyID not covered lets one committed set be replayed as another
// ceremony's.
func TestParticipantSet_DigestCommitsToEveryField(t *testing.T) {
base := ParticipantSet{
CeremonyID: CeremonyID{0x01},
Members: []Participant{
{Node: node(1), Index: 0, Weight: 100},
{Node: node(2), Index: 1, Weight: 200},
},
}
want := base.Digest()
alter := func(f func(*ParticipantSet)) [32]byte {
s := ParticipantSet{CeremonyID: base.CeremonyID, Members: append([]Participant(nil), base.Members...)}
f(&s)
return s.Digest()
}
for _, tc := range []struct {
name string
f func(*ParticipantSet)
}{
{"ceremony id", func(s *ParticipantSet) { s.CeremonyID = CeremonyID{0x02} }},
{"a member's node", func(s *ParticipantSet) { s.Members[1].Node = node(3) }},
{"a member's index", func(s *ParticipantSet) { s.Members[1].Index = 9 }},
{"a member's weight", func(s *ParticipantSet) { s.Members[1].Weight = 201 }},
{"two members' indices swapped", func(s *ParticipantSet) { s.Members[0].Index, s.Members[1].Index = 1, 0 }},
{"a member dropped", func(s *ParticipantSet) { s.Members = s.Members[:1] }},
{"a member appended", func(s *ParticipantSet) { s.Members = append(s.Members, Participant{Node: node(3), Index: 2}) }},
} {
if alter(tc.f) == want {
t.Errorf("changing %s left the digest unchanged", tc.name)
}
}
if base.Digest() != want {
t.Fatal("Digest is not a pure function of the set")
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ func (s Share) PayloadFrom(arena []byte) ([]byte, error) {
return nil, fmt.Errorf("share: payload [%d..%d] out of arena (%d)",
s.PayloadOffset, end, len(arena))
}
return arena[s.PayloadOffset : s.PayloadOffset+s.PayloadLen], nil
return arena[uint64(s.PayloadOffset):end], nil
}
// Validate checks the envelope-level invariants. Payload validation
+204
View File
@@ -0,0 +1,204 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package types
import (
"math"
"strings"
"testing"
)
// The lane space is partitioned: no lane belongs to both chains.
//
// IsMChain and IsFChain are what cert.LaneRegistry consults to decide whether a
// chain may answer for a lane. A lane both predicates claimed would be
// registrable on both chains at once, so the same protocol round would be
// verified by two different chains' code and the M/F split would exist only in
// the documentation. Lanes 0..4 are Quasar's own — a chain claiming one of
// those would be answering for consensus.
func TestCertLane_ChainsClaimDisjointHalvesOfTheLaneSpace(t *testing.T) {
m := map[CertLane]bool{LaneMChainCGGMP21: true, LaneMChainFROST: true, LaneMChainCoronaGen: true}
f := map[CertLane]bool{LaneFChainTFHE: true, LaneFChainBootstrap: true}
for i := 0; i <= 255; i++ {
lane := CertLane(i)
if lane.IsMChain() && lane.IsFChain() {
t.Fatalf("lane %d is claimed by both chains", lane)
}
if got := lane.IsMChain(); got != m[lane] {
t.Errorf("lane %d IsMChain() = %v, want %v", lane, got, m[lane])
}
if got := lane.IsFChain(); got != f[lane] {
t.Errorf("lane %d IsFChain() = %v, want %v", lane, got, f[lane])
}
}
}
// Lane numbers are the wire encoding and never move.
//
// A CertLane is a byte on the QuasarCertIngress wire, read by peers running
// older binaries. Renumbering a lane does not fail — it silently routes a
// ceremony's shares to another protocol's verifier on every node that has not
// upgraded.
func TestCertLane_ValuesAreFixedByLP134(t *testing.T) {
for lane, want := range map[CertLane]uint8{
LaneBLS: 0, LaneCorona: 1, LaneMLDSAGroth16: 2, LaneAChainAttest: 3,
LaneBChainBridge: 4, LaneMChainCGGMP21: 5, LaneMChainFROST: 6,
LaneMChainCoronaGen: 7, LaneFChainTFHE: 8, LaneFChainBootstrap: 9,
} {
if uint8(lane) != want {
t.Errorf("lane encodes as %d, want %d", uint8(lane), want)
}
}
}
// PayloadFrom returns the exact window and nothing adjacent.
//
// The verifier never sees the arena, only this slice, so the window is the
// whole of what it authenticates. A window one byte wide of the truth is
// indistinguishable downstream from a bad signature.
func TestShare_PayloadIsExactlyTheNamedWindow(t *testing.T) {
arena := []byte("0123456789")
for _, tc := range []struct {
off, n uint32
want string
}{
{0, 10, "0123456789"}, // exact fit over the whole arena
{0, 1, "0"},
{3, 4, "3456"},
{9, 1, "9"}, // last byte
{10, 0, ""}, // empty window at the very end
{0, 0, ""}, // empty window at the start
{5, 5, "56789"},
} {
got, err := Share{PayloadOffset: tc.off, PayloadLen: tc.n}.PayloadFrom(arena)
if err != nil {
t.Errorf("[%d..%d): %v", tc.off, tc.off+tc.n, err)
continue
}
if string(got) != tc.want {
t.Errorf("[%d..%d) = %q, want %q", tc.off, tc.off+tc.n, got, tc.want)
}
}
if got, err := (Share{}).PayloadFrom(nil); err != nil || len(got) != 0 {
t.Errorf("an empty window over an empty arena must succeed: %q %v", got, err)
}
}
// A window that runs past the arena is refused, never truncated.
//
// The arena is the host chain's buffer and holds every participant's payload
// for the ceremony. Clamping an over-long window to the arena end would hand
// the verifier a neighbouring participant's bytes; reading past it would hand
// it whatever the allocator left there.
func TestShare_PayloadPastTheArenaIsRefused(t *testing.T) {
arena := make([]byte, 16)
for _, tc := range []struct{ off, n uint32 }{
{0, 17}, // one byte too long
{16, 1}, // starts at the end
{17, 0}, // starts past the end
{8, 9}, // window straddles the end
{1 << 20, 1},
} {
if got, err := (Share{PayloadOffset: tc.off, PayloadLen: tc.n}).PayloadFrom(arena); err == nil {
t.Errorf("[%d..%d) over a 16-byte arena returned %d bytes, want an error",
tc.off, uint64(tc.off)+uint64(tc.n), len(got))
}
}
}
// The bounds arithmetic widens before it adds.
//
// PayloadOffset and PayloadLen are both uint32 and both attacker-chosen. Summed
// in uint32 they wrap at 4 GiB: offset 0xFFFFFFFF with length 1 sums to 0, so a
// bounds check on the wrapped sum passes for every arena, and the slice
// expression that follows panics with low greater than high — a remote node
// halt from two fields on the wire. Widening to uint64 first is what makes the
// sum unrepresentable-as-small.
func TestShare_PayloadWindowArithmeticDoesNotWrap(t *testing.T) {
arena := make([]byte, 64)
for _, tc := range []struct{ off, n uint32 }{
{math.MaxUint32, 1},
{math.MaxUint32, math.MaxUint32},
{math.MaxUint32 - 3, 8},
{1 << 31, 1 << 31},
{0xFFFFFF00, 0x100},
} {
got, err := (Share{PayloadOffset: tc.off, PayloadLen: tc.n}).PayloadFrom(arena)
if err == nil {
t.Errorf("offset %d len %d returned %d bytes over a 64-byte arena, want an error",
tc.off, tc.n, len(got))
}
}
}
// Validate rejects every envelope a verifier must never see.
//
// Each of these is a distinct way for a share to be about something other than
// what the caller thinks: a share from another ceremony (a replay), a share
// attributed to a participant that is not in the set, a round-0 share (the
// reserved value, so a zero-valued struct cannot pass as a real round), and an
// empty payload (nothing to verify, so the verifier would trivially accept).
func TestShare_ValidateRejectsEveryEnvelopeDefect(t *testing.T) {
id := CeremonyID{0xAB}
set, err := NewParticipantSet(id, []Participant{{Node: NodeID{1}}, {Node: NodeID{2}}})
if err != nil {
t.Fatalf("participant set: %v", err)
}
good := Share{CeremonyID: id, ParticipantID: 1, Round: 1, PayloadLen: 8}
if err := good.Validate(set); err != nil {
t.Fatalf("a well-formed share must validate: %v", err)
}
mangle := func(f func(*Share)) Share {
s := good
f(&s)
return s
}
for _, tc := range []struct {
name string
share Share
set *ParticipantSet
want string
}{
{"nil set", good, nil, "nil participant set"},
{"another ceremony's id", mangle(func(s *Share) { s.CeremonyID = CeremonyID{0xAC} }), set, "does not match participant set"},
{"zero ceremony id", mangle(func(s *Share) { s.CeremonyID = CeremonyID{} }), set, "does not match participant set"},
{"participant one past the set", mangle(func(s *Share) { s.ParticipantID = 2 }), set, ">= set size"},
{"participant at uint32 max", mangle(func(s *Share) { s.ParticipantID = ^uint32(0) }), set, ">= set size"},
{"round 0", mangle(func(s *Share) { s.Round = 0 }), set, "round 0 reserved"},
{"empty payload", mangle(func(s *Share) { s.PayloadLen = 0 }), set, "empty payload"},
} {
err := tc.share.Validate(tc.set)
if err == nil {
t.Errorf("%s: accepted", tc.name)
continue
}
if !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s: want an error mentioning %q, got: %v", tc.name, tc.want, err)
}
}
}
// Validate says nothing about the lane.
//
// Lane ownership is the registry's question, not the envelope's: cert's
// LaneRegistry answers it per chain, and a share validated here is still
// refused there if the chain does not own the lane. Duplicating the check in
// Validate would give two places that can disagree about who owns a lane.
func TestShare_ValidateLeavesLaneOwnershipToTheRegistry(t *testing.T) {
id := CeremonyID{0xAB}
set, err := NewParticipantSet(id, []Participant{{Node: NodeID{1}}})
if err != nil {
t.Fatalf("participant set: %v", err)
}
for i := 0; i <= 255; i++ {
s := Share{CeremonyID: id, ParticipantID: 0, Round: 1, Lane: CertLane(i), PayloadLen: 1}
if err := s.Validate(set); err != nil {
t.Fatalf("lane %d: envelope validation must not judge the lane: %v", i, err)
}
}
}
+110 -40
View File
@@ -79,21 +79,27 @@ type ThresholdConfig struct {
// place (quorum.Policy.Degree) at the keygen boundary.
Policy quorum.Policy `json:"policy"`
// Session Configuration
SessionTimeout time.Duration `json:"sessionTimeout"` // Max wall-clock for one ceremony
MaxActiveSessions int `json:"maxActiveSessions"` // Max concurrent ceremonies
MaxSessionsPerChain int `json:"maxSessionsPerChain"` // Max concurrent ceremonies per requesting chain
MaxOpsPerBlock int `json:"maxOpsPerBlock"` // Max operations in one block
// SessionTimeout is the wall clock one ceremony gets before it is abandoned.
//
// It is the only session knob. There were three more — a global concurrency
// cap, a per-chain concurrency cap and a per-block operation cap — and not
// one of them was ever read. The last was the dangerous one: a block-size
// bound is a consensus rule, and had it been read where it looks like it
// belongs, two validators configured differently would have reached
// opposite verdicts on the same block. It is a constant now
// (maxOpsPerBlock), where an operator cannot move it.
SessionTimeout time.Duration `json:"sessionTimeout"`
// Quota Configuration (daily limits)
DailySigningQuota map[string]uint64 `json:"dailySigningQuota"` // ChainID -> daily signing limit
// DailySigningQuota overrides a chain's own DailySigningLimit, by chain
// name. It is per-node rate limiting, not consensus: refusing to START a
// ceremony is local, and a ceremony that completes is verified by everyone
// on its own evidence.
DailySigningQuota map[string]uint64 `json:"dailySigningQuota"`
// Authorized Chains that can request MPC services
// AuthorizedChains is the custody permission table, keyed by the operator's
// label for a chain. Authorization binds to the entry's ChainID; see
// caller.go.
AuthorizedChains map[string]*ChainPermissions `json:"authorizedChains"`
// Key Management
KeyRotationPeriod time.Duration `json:"keyRotationPeriod"` // How often to rotate keys
MaxKeyAge time.Duration `json:"maxKeyAge"` // Maximum age of a key before forced rotation
}
// ChainPermissions defines what a chain can do with MPC services
@@ -167,10 +173,6 @@ type VM struct {
// then the pool's.
staged *chain.Pool[*Operation, string]
// localMesh is the in-process ceremony transport used when the VM has no
// p2p sender (single-process dev and tests). Nil on a networked node.
localMesh *localMesh
// Quota Tracking
dailySigningCount map[string]uint64 // ChainID -> count today
quotaResetTime time.Time // When to reset quotas
@@ -304,6 +306,21 @@ func (vm *VM) Initialize(
return fmt.Errorf("mpcvm: open chain state: %w", err)
}
if fresh {
// "Fresh" is a claim about the tip pointer, and the store reports it on
// ANY failure to read that pointer — a missing one and an unreadable one
// look the same from here. So it is checked against the thing it implies
// rather than believed: an empty chain has applied no operations, and
// the root of a chain that has applied none is exactly its genesis root.
//
// Without this, one transient read fault at boot re-installs genesis
// over a live custody registry, durably, and Initialize returns nil.
// Refusing to start is the only safe answer: the operator can look at
// the disk, and a node that will not start loses no funds.
if root != genesisRoot(vm.rt.ChainID) {
return fmt.Errorf("mpcvm: refusing to install genesis over applied state: "+
"the chain has no tip but its root is %x, not the genesis root %x",
root[:8], genesisRoot(vm.rt.ChainID))
}
// NewState seeded the genesis root through the view; commit it, so a
// node that accepts no block still comes back to the same root.
if err := vm.chain.Seed(func(database.Database) error { return nil }); err != nil {
@@ -415,15 +432,10 @@ func (vm *VM) parseConfig(configBytes []byte) error {
//
// Degree 2: two parties may be corrupt or offline. 2K > N holds, so the
// quorum is unique — two disjoint quorums cannot both sign.
Policy: quorum.MustNew(3, 5),
SessionTimeout: 5 * time.Minute,
MaxActiveSessions: 100,
MaxSessionsPerChain: 10,
MaxOpsPerBlock: 64,
KeyRotationPeriod: 30 * 24 * time.Hour,
MaxKeyAge: 90 * 24 * time.Hour,
DailySigningQuota: make(map[string]uint64),
AuthorizedChains: make(map[string]*ChainPermissions),
Policy: quorum.MustNew(3, 5),
SessionTimeout: 5 * time.Minute,
DailySigningQuota: make(map[string]uint64),
AuthorizedChains: make(map[string]*ChainPermissions),
}
// Default authorized chains (all internal Lux chains)
@@ -492,9 +504,6 @@ func (vm *VM) parseConfig(configBytes []byte) error {
return fmt.Errorf("%w: %q is not a deployable policy (want the form \"3-of-5\")",
ErrInvalidThreshold, vm.config.Policy)
}
if vm.config.MaxOpsPerBlock <= 0 {
vm.config.MaxOpsPerBlock = 64
}
return nil
}
@@ -666,11 +675,6 @@ func (vm *VM) checkQuotaReset() {
// BuildBlock implements the chain.ChainVM interface
func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
built, err := vm.chain.Propose(func(parent *Block) (*Block, error) {
staged := vm.staged.Take(vm.config.MaxOpsPerBlock)
if len(staged) == 0 {
return nil, errors.New("mpcvm: no completed ceremonies to include")
}
// Only include operations that still verify against current state. A
// ceremony can be staged and then invalidated by a block that landed first
// (the same key registered, the same ceremony recorded); proposing it
@@ -679,6 +683,25 @@ func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
defer vm.mu.RUnlock()
root := vm.state.Root()
// Build only on state this node has applied. The engine's preferred
// block may be one still in flight, and every check below — the
// registry, the ceremony log, the accumulated root — reads applied
// state. A block built on an unapplied parent therefore claims a root
// that skips the parent's operations: it VERIFIES on every peer, wins
// consensus, and then fails at Accept once the parent lands, which for
// a decided block halts the chain. Refusing to build is a wait; the
// ceremonies stay staged and the next proposal carries them.
if parent.StateRoot != root {
return nil, fmt.Errorf("%w: %s leaves root %x, this node is at %x",
ErrStaleParent, parent.ID(), parent.StateRoot[:8], root[:8])
}
staged := vm.staged.Take(maxOpsPerBlock)
if len(staged) == 0 {
return nil, errors.New("mpcvm: no completed ceremonies to include")
}
pending := make(map[string]*KeyRecord, len(staged))
operations := make([]*Operation, 0, len(staged))
var invalid []*Operation
@@ -868,6 +891,16 @@ func (vm *VM) RequestFailed(ctx context.Context, nodeID ids.NodeID, requestID ui
// the ceremony's router. Messages that arrive before our own router for that
// ceremony is registered are buffered and drained on register, so no round-one
// broadcast is lost to a start-order race.
//
// It is also where a ceremony message becomes attributable. The threshold
// library carries the sender in the message body and does not authenticate it —
// it is written for an authenticated channel and expects its transport to
// supply that. This is the transport. The p2p layer authenticated nodeID, and
// party.ID IS the NodeID string on this chain, so a message whose From names a
// different party is one peer speaking as another. Delivering it would let any
// node that can reach this method inject rounds as any committee member:
// enough to abort every custody ceremony on demand, and enough to make the
// protocol's identifiable abort name an honest validator for it.
func (vm *VM) Gossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error {
sessionID, ceremonyMsg, err := unmarshalEnvelope(msg)
if err != nil {
@@ -875,6 +908,14 @@ func (vm *VM) Gossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error {
// consensus on a peer's malformed gossip.
return nil
}
if ceremonyMsg.From != party.ID(nodeID.String()) {
vm.log.Warn("dropping ceremony message sent under another party's name",
log.String("sender", nodeID.String()),
log.String("claimed", string(ceremonyMsg.From)),
log.String("ceremony", sessionID),
)
return nil
}
vm.deliverCeremonyMessage(sessionID, ceremonyMsg)
return nil
}
@@ -1310,17 +1351,46 @@ func (vm *VM) VerifyAttestation(a *QuantumAttestation) error {
if _, ok := domainSeparators[a.Domain]; !ok {
return fmt.Errorf("mpcvm: unknown attestation domain %q", a.Domain)
}
// K signers, not K-1: a set the size of the polynomial degree cannot
// produce a signature, so a claim that it did is a wrong-degree key.
if len(a.Signers) < a.Policy.K {
return fmt.Errorf("%w: %d signers for policy %s", ErrQuorumTooSmall, len(a.Signers), a.Policy)
}
pubKey, err := vm.PublicKey(a.KeyID)
// The registry decides the quorum, not the attestation.
//
// This used to compare len(a.Signers) against a.Policy — two fields of the
// same attacker-supplied document — so it held for every attestation ever
// written, including one declaring 1-of-1 with one signer. A quorum check
// against the checked party's own claim is not a check. Reading the key's
// record is: the policy a key was generated under is consensus state, and
// an attestation that misdeclares it is refused before its signature is
// even considered.
rec, err := vm.Key(a.KeyID)
if err != nil {
return err
}
if a.Policy != rec.Policy {
return fmt.Errorf("%w: attestation declares policy %s, key %s is %s",
ErrInvalidOperation, a.Policy, a.KeyID, rec.Policy)
}
// K signers, not K-1: a set the size of the polynomial degree cannot
// produce a signature, so a claim that it did is a wrong-degree key.
if len(a.Signers) < rec.Policy.K {
return fmt.Errorf("%w: %d signers for policy %s", ErrQuorumTooSmall, len(a.Signers), rec.Policy)
}
// Canonical and real: without this, one signer repeated K times, or under K
// spellings, or K names that hold no share of this key, all count as a
// quorum. Block.verifySign holds the same three rules on the same set, so
// an attestation cannot pass here under a rule a block would have refused.
if !sortedUnique(a.Signers) {
return fmt.Errorf("%w: signer set is not canonical (sorted+unique)", ErrInvalidOperation)
}
members := make(map[party.ID]struct{}, len(rec.Participants))
for _, p := range rec.Participants {
members[p] = struct{}{}
}
for _, s := range a.Signers {
if _, ok := members[s]; !ok {
return fmt.Errorf("%w: %s is not a participant of key %s", ErrInvalidOperation, s, rec.KeyID)
}
}
payload := ComputeAttestationPayload(a.Domain, a.SubjectID, a.CommitmentRoot, a.Epoch)
if err := verifyGroupSignature(pubKey, payload[:], a.Signature); err != nil {
if err := verifyGroupSignature(rec.GroupPublicKey, payload[:], a.Signature); err != nil {
return fmt.Errorf("%w: %w", ErrBadArtifact, err)
}
return nil
+953
View File
@@ -0,0 +1,953 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// vm_test.go — boot, configuration, and the entry points that are reachable
// from outside the ceremony path.
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/constants"
"github.com/luxfi/database"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/protocol"
"github.com/luxfi/threshold/pkg/quorum"
validators "github.com/luxfi/validators"
"github.com/luxfi/validators/validatorstest"
vmcore "github.com/luxfi/vm"
vmchain "github.com/luxfi/vm/chain"
"github.com/luxfi/warp"
)
// -----------------------------------------------------------------------------
// Boot
// -----------------------------------------------------------------------------
// A restarted node comes back at the height it left, with the registry it had.
// Recomputing genesis unconditionally would discard every accepted block and
// re-run history from an empty registry — which for a custody chain means
// losing the record of who holds the funds.
func TestARestartedNodeResumesItsCustodyRegistry(t *testing.T) {
db := memdb.New()
chainID := ids.GenerateTestID()
first := openVM(t, db, chainID, nil, nil)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 60)
blk := key.register(t, first)
wantRoot := first.StateRoot()
require.NoError(t, first.Shutdown(ctx()))
second := openVM(t, db, chainID, nil, nil)
defer second.Shutdown(ctx())
tip, err := second.LastAccepted(ctx())
require.NoError(t, err)
require.Equal(t, blk.ID(), tip, "a restarted node must come back at the tip it accepted")
require.Equal(t, wantRoot, second.StateRoot())
rec, err := second.Key("vault")
require.NoError(t, err)
require.Equal(t, key.rec.GroupPublicKey, rec.GroupPublicKey)
at, err := second.GetBlockIDAtHeight(ctx(), blk.BlockHeight)
require.NoError(t, err)
require.Equal(t, blk.ID(), at, "the height index survives a restart; an in-memory one would not")
// And the block itself is readable back out of committed state, decoded by
// the same parser the wire uses.
got, err := second.GetBlock(ctx(), blk.ID())
require.NoError(t, err)
require.Equal(t, blk.ID(), got.ID())
}
// "Fresh chain" is a claim about the tip pointer, and this checks it against
// the thing it implies: a chain that has applied nothing is at its genesis
// root, and one that has applied anything is not.
//
// A missing tip over a live registry is what a partial restore looks like — the
// block namespace rolled back, or a key range wiped, while the custody records
// survive. Believing the pointer there installs genesis over the registry,
// durably, and the next accepted block writes a new tip on top. Refusing to
// start loses nothing: the operator can look at the disk, and a node that will
// not start holds no funds wrongly.
func TestAMissingTipOverALiveRegistryDoesNotReinstallGenesis(t *testing.T) {
db := memdb.New()
chainID := ids.GenerateTestID()
first := openVM(t, db, chainID, nil, nil)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 61)
key.register(t, first)
require.NoError(t, first.Shutdown(ctx()))
// The tip is on disk; the node restarts normally.
require.NoError(t, openVM(t, db, chainID, nil, nil).Shutdown(ctx()))
// The tip is gone and the registry is not.
require.NoError(t, db.Delete([]byte("chain/tip")))
vm := &VM{}
err := vm.Initialize(ctx(), vmcore.Init{
Runtime: &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), ChainID: chainID},
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
})
require.ErrorContains(t, err, "refusing to install genesis over applied state")
// And the registry is still there, because nothing was written over it.
rec, err := NewState(db, chainID)
require.NoError(t, err)
_, err = rec.GetKey("vault")
require.NoError(t, err)
}
// A genuinely empty database seeds genesis and commits the root, so a node that
// accepts no block still comes back to the same root rather than re-deriving
// one.
func TestAFreshChainSeedsItsRootAndComesBackToIt(t *testing.T) {
db := memdb.New()
chainID := ids.GenerateTestID()
first := openVM(t, db, chainID, nil, nil)
root := first.StateRoot()
require.Equal(t, genesisRoot(chainID), root)
require.NoError(t, first.Shutdown(ctx()))
second := openVM(t, db, chainID, nil, nil)
defer second.Shutdown(ctx())
require.Equal(t, root, second.StateRoot())
tip, err := second.LastAccepted(ctx())
require.NoError(t, err)
require.Equal(t, second.genesisBlock.ID(), tip)
}
// The genesis timestamp is part of the genesis block, so two nodes given the
// same genesis derive the same id for it.
func TestGenesisIsDerivedFromWhatEveryValidatorHolds(t *testing.T) {
chainID := ids.GenerateTestID()
genesis, err := json.Marshal(Genesis{Timestamp: 1_700_000_000, Policy: quorum.MustNew(4, 7)})
require.NoError(t, err)
a := openVM(t, memdb.New(), chainID, nil, genesis)
b := openVM(t, memdb.New(), chainID, nil, genesis)
defer a.Shutdown(ctx())
defer b.Shutdown(ctx())
require.Equal(t, a.genesisBlock.ID(), b.genesisBlock.ID())
require.Equal(t, int64(1_700_000_000), a.genesisBlock.BlockTimestamp)
require.Equal(t, "4-of-7", a.Policy().String(),
"a genesis policy overrides the per-node config, so the chain's quorum is one value")
}
func TestUnparseableGenesisRefusesToBoot(t *testing.T) {
vm := &VM{}
err := vm.Initialize(ctx(), vmcore.Init{
Runtime: &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), ChainID: ids.GenerateTestID()},
DB: memdb.New(),
Genesis: []byte("{not json"),
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
})
require.ErrorContains(t, err, "failed to parse genesis")
}
// A malformed genesis policy leaves the config default in place rather than
// failing: an absent policy is the normal case, and the config default is the
// documented fallback.
func TestAnAbsentGenesisPolicyLeavesTheConfigDefault(t *testing.T) {
genesis, err := json.Marshal(map[string]any{"timestamp": 5})
require.NoError(t, err)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, genesis)
defer vm.Shutdown(ctx())
require.Equal(t, "3-of-5", vm.Policy().String())
}
// The default policy is 3-of-5, and two seats are deliberately not a quorum:
// validator entry is permissionless, so a policy an adversary can buy two seats
// into is a policy it holds a quorum of every key with.
func TestTheDefaultQuorumCostsAMajorityOfTheCustodySet(t *testing.T) {
vm := newVM(t)
p := vm.Policy()
require.Equal(t, "3-of-5", p.String())
require.Equal(t, 2, p.Degree())
require.True(t, 2*p.K > p.N, "the default policy must admit only one quorum at a time")
}
// A policy that does not decode is a hard failure, not a default. Falling back
// to a built-in quorum for a chain holding bridged funds would mean the
// operator's intent and the deployed key silently differ.
func TestAnUndeployablePolicyRefusesToBoot(t *testing.T) {
for _, cfg := range []string{
`{"policy":"1-of-5"}`,
`{"policy":"9-of-5"}`,
`{"policy":"nonsense"}`,
`{"policy":""}`,
`{}`,
} {
vm := &VM{}
err := vm.Initialize(ctx(), vmcore.Init{
Runtime: &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), ChainID: ids.GenerateTestID()},
DB: memdb.New(),
Config: []byte(cfg),
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
})
require.Errorf(t, err, "config %s must not boot", cfg)
}
}
func TestUnparseableConfigRefusesToBoot(t *testing.T) {
vm := &VM{}
err := vm.Initialize(ctx(), vmcore.Init{
Runtime: &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), ChainID: ids.GenerateTestID()},
DB: memdb.New(),
Config: []byte("{"),
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
})
require.ErrorContains(t, err, "failed to parse config")
}
// An operator-supplied config replaces the defaults wholesale, including the
// permission table: a config that names one chain grants custody to one chain.
func TestAnOperatorConfigReplacesTheStockPermissionTable(t *testing.T) {
bridge := ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"2-of-3","sessionTimeout":60000000000,
"authorizedChains":{"B-Chain":{"chainId":%q,"canSign":true,"canKeygen":true,"maxSigningSize":64}}}`, bridge)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
require.Equal(t, "2-of-3", vm.Policy().String())
require.Equal(t, time.Minute, vm.sessionTimeout())
by, err := vm.caller(bridge)
require.NoError(t, err)
require.Equal(t, "B-Chain", by.Name())
perms, ok := vm.permissions("B-Chain")
require.True(t, ok)
require.True(t, perms.CanSign)
_, ok = vm.permissions("X-Chain")
require.False(t, ok, "the stock entries are gone; the operator's table is the table")
}
// The boot log distinguishes a permission entry BOUND to a chain id from one
// carrying a label. A label never equals a base58 chain id, so an entry
// carrying one authorizes nobody — and counting it as bound reported five
// authorized chains on a node where none were.
func TestOnlyAnEntryCarryingARealChainIdCountsAsBound(t *testing.T) {
real := ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"3-of-5","authorizedChains":{
"B-Chain":{"chainId":"B-Chain"},
"C-Chain":{"chainId":""},
"X-Chain":{"chainId":%q}}}`, real)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
// The one that is bound resolves; the two that are not do not, whatever
// they are called.
by, err := vm.caller(real)
require.NoError(t, err)
require.Equal(t, "X-Chain", by.Name())
_, err = vm.caller(ids.GenerateTestID())
require.ErrorIs(t, err, ErrUnauthorizedChain)
}
// The stock table binds nothing: every entry names a chain by label, so a
// default node grants custody to nobody until an operator sets chainId.
func TestTheStockPermissionTableGrantsCustodyToNobody(t *testing.T) {
vm := newVM(t)
require.NotEmpty(t, vm.config.AuthorizedChains)
for name, p := range vm.config.AuthorizedChains {
_, err := ids.FromString(p.ChainID)
require.Errorf(t, err, "stock entry %q must not carry a parseable chain id", name)
}
_, err := vm.caller(ids.GenerateTestID())
require.ErrorIs(t, err, ErrUnauthorizedChain)
}
// A node that restarts reloads its shares, and a share that does not match the
// registry it is filed under is a corrupted store: signing with it would
// produce signatures that fail against the registered group key.
func TestAShareThatDoesNotMatchItsRegistryEntryRefusesToBoot(t *testing.T) {
raw, _, _ := realShare(t)
// The registry says this key is a DIFFERENT group key from the one the
// stored share belongs to. Signing with it would produce signatures that
// fail against the registered key, so there is nothing to recover to.
stranger := newCustody(t, "vault", quorum.MustNew(3, 5), 62)
require.ErrorContains(t, bootWithStoredShare(t, stranger.rec, raw),
"belongs to a different group key")
}
func TestAShareAtTheWrongDegreeRefusesToBoot(t *testing.T) {
raw, pub, degree := realShare(t)
// Right group key, but the registry declares a quorum the share was not
// generated for — the off-by-one this whole design exists to catch.
rec := custodyFor(t, "vault", quorum.MustNew(degree+3, degree+4), pub)
require.NotEqual(t, degree, rec.Degree())
require.ErrorContains(t, bootWithStoredShare(t, rec, raw), "has degree")
}
// A share that agrees with its registry entry is loaded, and the node comes back
// able to sign. Without it a restarted validator is a registry reader counted as
// a committee member while unable to contribute a partial signature.
func TestAShareThatAgreesWithItsRegistryEntryIsReloaded(t *testing.T) {
raw, pub, degree := realShare(t)
rec := custodyFor(t, "vault", quorum.MustNew(degree+1, degree+2), pub)
require.Equal(t, degree, rec.Degree())
db, chainID := memdb.New(), ids.GenerateTestID()
seedRegistryAndShare(t, db, chainID, rec, raw)
vm := openVM(t, db, chainID, nil, nil)
defer vm.Shutdown(ctx())
require.Contains(t, vm.shares, "vault")
health, err := vm.HealthCheck(ctx())
require.NoError(t, err)
require.Equal(t, "1", health.Details["sharesHeld"])
}
func TestAnUnreadableShareRefusesToBoot(t *testing.T) {
key := newCustody(t, "vault", quorum.MustNew(3, 5), 65)
require.ErrorContains(t, bootWithStoredShare(t, key.rec, []byte("not a config")), "load key shares")
}
// A key this node holds no share for is skipped rather than failing the boot:
// a validator legitimately participates in some keys and not others.
func TestARestartedNodeReloadsOnlyTheSharesItHolds(t *testing.T) {
raw, pub, degree := realShare(t)
mine := custodyFor(t, "mine", quorum.MustNew(degree+1, degree+2), pub)
theirs := newCustody(t, "theirs", quorum.MustNew(3, 5), 67)
db, chainID := memdb.New(), ids.GenerateTestID()
seedRegistryAndShare(t, db, chainID, mine, raw)
seed := openVM(t, db, chainID, nil, nil)
require.NoError(t, seed.state.PutKey(theirs.rec))
require.NoError(t, seed.chain.Seed(func(database.Database) error { return nil }))
require.NoError(t, seed.Shutdown(ctx()))
restarted := openVM(t, db, chainID, nil, nil)
defer restarted.Shutdown(ctx())
require.Len(t, restarted.shares, 1)
require.Contains(t, restarted.shares, "mine")
}
// M-Chain's committee is the primary network's validator set. Nothing else on
// this chain decides who may hold a share.
func TestTheCommitteeIsThePrimaryNetworkValidatorSet(t *testing.T) {
vm := newVM(t)
require.Equal(t, constants.PrimaryNetworkID, vm.netID)
}
// -----------------------------------------------------------------------------
// Reads
// -----------------------------------------------------------------------------
func TestTheRegistryIsReadableThroughTheVM(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 68)
key.register(t, vm)
keys, err := vm.Keys()
require.NoError(t, err)
require.Len(t, keys, 1)
pub, err := vm.PublicKey("vault")
require.NoError(t, err)
require.Equal(t, key.rec.GroupPublicKey, pub)
addr, err := vm.Address("vault")
require.NoError(t, err)
require.Equal(t, key.rec.Address, addr)
_, err = vm.PublicKey("nothing")
require.ErrorIs(t, err, ErrUnknownKey)
_, err = vm.Address("nothing")
require.ErrorIs(t, err, ErrUnknownKey)
ceremonies, err := vm.Ceremonies()
require.NoError(t, err)
require.Len(t, ceremonies, 1)
_, err = vm.Ceremony("mpc/never")
require.Error(t, err)
}
// Health is "can this node read its own state", not "does this node hold a
// key". A validator that holds no share still serves reads and still verifies
// every block; gating health on key material takes healthy nodes out of
// rotation for doing their job. What such a node cannot do is visible in
// sharesHeld.
func TestHealthIsAboutReadingStateNotHoldingKeys(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 69)
key.register(t, vm)
health, err := vm.HealthCheck(ctx())
require.NoError(t, err)
require.True(t, health.Healthy, "a node holding no share is healthy")
require.Equal(t, "1", health.Details["custodyKeys"])
require.Equal(t, "0", health.Details["sharesHeld"])
require.Equal(t, "0", health.Details["stagedCeremonies"])
require.Len(t, health.Details["stateRoot"], 64)
// And the HTTP probe reports the same thing, from the same call — a probe
// that computed health separately could say "healthy" while the engine was
// being told otherwise.
w := httptest.NewRecorder()
vm.handleHealth(w, httptest.NewRequest(http.MethodGet, "/health", nil))
require.Equal(t, http.StatusOK, w.Code)
var body struct {
Healthy bool `json:"healthy"`
Details map[string]string `json:"details"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.True(t, body.Healthy)
require.Equal(t, health.Details, body.Details)
}
// A node that cannot read its registry is not healthy, and says so with a
// service-unavailable rather than a healthy body.
func TestANodeThatCannotReadItsRegistryIsNotHealthy(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 70)
key.register(t, vm)
vm.state.db = &faultyIterDB{Database: vm.state.db}
_, err := vm.HealthCheck(ctx())
require.Error(t, err)
w := httptest.NewRecorder()
vm.handleHealth(w, httptest.NewRequest(http.MethodGet, "/health", nil))
require.Equal(t, http.StatusServiceUnavailable, w.Code)
require.Contains(t, w.Body.String(), "error")
}
func TestTheVMServesItsRPCAndHealthHandlers(t *testing.T) {
vm := newVM(t)
handlers, err := vm.CreateHandlers(ctx())
require.NoError(t, err)
require.Contains(t, handlers, "/rpc")
require.Contains(t, handlers, "/health")
static, err := vm.CreateStaticHandlers(ctx())
require.NoError(t, err)
require.Nil(t, static, "M-Chain has no chain-independent surface")
h, err := vm.NewHTTPHandler(ctx())
require.NoError(t, err)
require.NotNil(t, h)
version, err := vm.Version(ctx())
require.NoError(t, err)
require.Equal(t, Version.String(), version)
}
// -----------------------------------------------------------------------------
// Building
// -----------------------------------------------------------------------------
func TestABuilderWithNothingStagedProposesNothing(t *testing.T) {
vm := newVM(t)
_, err := vm.BuildBlock(ctx())
require.ErrorContains(t, err, "no completed ceremonies")
}
// A ceremony staged and then invalidated by a block that landed first is
// dropped rather than proposed: proposing it would build a block every peer
// rejects, and it would keep doing so.
func TestAStagedCeremonyInvalidatedByAnEarlierBlockIsDropped(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 71)
key.register(t, vm)
op := key.signOpOver(t, digestOf(1))
landed := blockOver(t, vm, op)
require.NoError(t, landed.Verify(ctx()))
require.NoError(t, landed.Accept(ctx()))
// The same ceremony completes again on this node and is staged. It is now
// recorded, so it cannot go into another block.
vm.stage(op)
vm.stage(key.signOpOver(t, digestOf(2)))
built, err := vm.BuildBlock(ctx())
require.NoError(t, err)
require.Len(t, built.(*Block).Operations, 1, "the recorded ceremony must not be re-proposed")
require.NoError(t, built.Verify(ctx()))
}
func TestABuilderWithOnlyInvalidCeremoniesProposesNothing(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 72)
key.register(t, vm)
op := key.signOpOver(t, digestOf(1))
landed := blockOver(t, vm, op)
require.NoError(t, landed.Verify(ctx()))
require.NoError(t, landed.Accept(ctx()))
vm.stage(op)
_, err := vm.BuildBlock(ctx())
require.ErrorContains(t, err, "no completed ceremonies")
require.Zero(t, vm.staged.Len(), "an operation that can never be included is dropped, not requeued")
}
// The builder is demand-driven: it wakes when a ceremony completes, and blocks
// otherwise. Returning eagerly would spin the engine; blocking forever would
// mean a completed ceremony never reaches a block.
func TestTheBuilderWakesOnACompletedCeremonyAndNotBefore(t *testing.T) {
vm := newVM(t)
early, cancel := context.WithTimeout(ctx(), 20*time.Millisecond)
defer cancel()
_, err := vm.WaitForEvent(early)
require.Error(t, err, "with nothing staged there is nothing for the engine to do")
key := newCustody(t, "vault", quorum.MustNew(3, 5), 73)
key.register(t, vm)
vm.stage(key.signOpOver(t, digestOf(1)))
woken, cancel2 := context.WithTimeout(ctx(), 2*time.Second)
defer cancel2()
msg, err := vm.WaitForEvent(woken)
require.NoError(t, err)
require.Equal(t, vmcore.PendingTxs, msg.Type,
"a completed ceremony is the only thing that wakes the builder")
}
// -----------------------------------------------------------------------------
// Parsing
// -----------------------------------------------------------------------------
func TestABlockIsIdentifiedByItsBytesOnEveryValidator(t *testing.T) {
a, b := newVM(t), newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 74)
built := blockOver(t, a, key.keygenOp(t))
parsed, err := b.ParseBlock(ctx(), built.Bytes())
require.NoError(t, err)
require.Equal(t, built.ID(), parsed.ID())
require.Equal(t, built.Bytes(), parsed.Bytes())
require.Equal(t, built.BlockHeight, parsed.Height())
require.Equal(t, built.Timestamp().Unix(), parsed.(*Block).Timestamp().Unix())
require.Equal(t, built.ParentID_, parsed.(*Block).Parent())
require.Equal(t, built.ParentID_, parsed.(*Block).ParentID())
}
func TestBytesThatAreNotABlockAreRefused(t *testing.T) {
vm := newVM(t)
for name, raw := range map[string][]byte{
"empty": nil,
"junk": []byte("hello"),
"header": make([]byte, 16),
} {
_, err := vm.ParseBlock(ctx(), raw)
require.Errorf(t, err, "%s must not parse as a block", name)
}
}
func TestAnUnknownBlockIsNamedNotEmpty(t *testing.T) {
vm := newVM(t)
_, err := vm.GetBlock(ctx(), ids.GenerateTestID())
require.Error(t, err)
_, err = vm.GetBlockIDAtHeight(ctx(), 99)
require.Error(t, err)
}
func TestSetStatusIsWhatTheEngineSaysItIs(t *testing.T) {
blk := &Block{}
blk.SetStatus(3)
require.Equal(t, uint8(3), blk.Status())
}
// -----------------------------------------------------------------------------
// Quota
// -----------------------------------------------------------------------------
// A quota is per-node rate limiting: refusing to START a ceremony is local, and
// a ceremony that completes is verified by everyone on its own evidence. It is
// therefore checked before the ceremony runs and counted after it succeeds.
func TestAChainThatIsOverItsDailyQuotaIsRefusedBeforeTheCeremony(t *testing.T) {
bridge := ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"3-of-5","authorizedChains":{
"B-Chain":{"chainId":%q,"canSign":true,"dailySigningLimit":2}}}`, bridge)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
by, err := vm.caller(bridge)
require.NoError(t, err)
vm.mu.Lock()
vm.dailySigningCount["B-Chain"] = 2
vm.mu.Unlock()
_, err = vm.RequestSignature(ctx(), by, "vault", digestOf(1))
require.ErrorIs(t, err, ErrQuotaExceeded)
// The counters roll over on their own schedule.
vm.mu.Lock()
vm.quotaResetTime = time.Now().Add(-time.Second)
vm.checkQuotaReset()
remaining := vm.dailySigningCount["B-Chain"]
next := vm.quotaResetTime
vm.mu.Unlock()
require.Zero(t, remaining)
require.True(t, next.After(time.Now()))
}
// A global override outranks the chain's own limit, so an operator can throttle
// one chain without editing its permission entry.
func TestAGlobalQuotaOverridesTheChainsOwnLimit(t *testing.T) {
bridge := ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"3-of-5","dailySigningQuota":{"B-Chain":1},
"authorizedChains":{"B-Chain":{"chainId":%q,"canSign":true,"dailySigningLimit":1000}}}`, bridge)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
by, err := vm.caller(bridge)
require.NoError(t, err)
vm.mu.Lock()
vm.dailySigningCount["B-Chain"] = 1
vm.mu.Unlock()
_, err = vm.RequestSignature(ctx(), by, "vault", digestOf(1))
require.ErrorIs(t, err, ErrQuotaExceeded)
}
// A message larger than the chain is permitted to sign is refused before any
// ceremony starts.
func TestAMessageLargerThanTheChainMaySignIsRefused(t *testing.T) {
bridge := ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"3-of-5","authorizedChains":{
"B-Chain":{"chainId":%q,"canSign":true,"maxSigningSize":16}}}`, bridge)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
by, err := vm.caller(bridge)
require.NoError(t, err)
_, err = vm.RequestSignature(ctx(), by, "vault", digestOf(1))
require.ErrorContains(t, err, "message too large")
}
// A chain authorised to sign is not thereby authorised to generate keys, and
// vice versa. The two are separate rights on the same entry.
func TestSigningRightsAndKeygenRightsAreSeparate(t *testing.T) {
signer, keygen := ids.GenerateTestID(), ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"3-of-5","authorizedChains":{
"Signer":{"chainId":%q,"canSign":true},
"Keygen":{"chainId":%q,"canKeygen":true}}}`, signer, keygen)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
signerCaller, err := vm.caller(signer)
require.NoError(t, err)
_, err = vm.StartKeygen(ctx(), "vault", signerCaller)
require.ErrorIs(t, err, ErrUnauthorizedChain)
keygenCaller, err := vm.caller(keygen)
require.NoError(t, err)
_, err = vm.RequestSignature(ctx(), keygenCaller, "vault", digestOf(1))
require.ErrorIs(t, err, ErrUnauthorizedChain)
}
// -----------------------------------------------------------------------------
// Cross-chain
// -----------------------------------------------------------------------------
// Authorization comes from the chain id the TRANSPORT authenticated, never from
// the requesting-chain field the sender writes about itself. That field
// survives only as attribution, set from the authenticated name.
func TestACrossChainRequestIsAuthorizedByTheTransportNotTheBody(t *testing.T) {
bridge := ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"3-of-5","authorizedChains":{
"B-Chain":{"chainId":%q,"canSign":true,"canKeygen":true}}}`, bridge)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
// A stranger whose body says it is B-Chain gets nothing.
req := &CrossChainMPCRequest{Type: "sign", RequestingChain: "B-Chain", KeyID: "vault", MessageHash: digestOf(1)}
raw := req.Marshal()
err := vm.CrossChainRequest(ctx(), ids.GenerateTestID(), 1, time.Now(), raw)
require.ErrorIs(t, err, ErrUnauthorizedChain)
// The authenticated chain gets as far as the ceremony, which needs a key.
err = vm.CrossChainRequest(ctx(), bridge, 1, time.Now(), raw)
require.ErrorIs(t, err, ErrUnknownKey)
// And keygen reaches the ceremony, which needs a committee this node has no
// validator state for.
kg := &CrossChainMPCRequest{Type: "keygen", KeyID: "vault"}
require.ErrorIs(t, vm.CrossChainRequest(ctx(), bridge, 1, time.Now(), kg.Marshal()), ErrNoCommittee)
}
func TestAnUnknownCrossChainRequestTypeIsRefused(t *testing.T) {
bridge := ids.GenerateTestID()
cfg := fmt.Sprintf(`{"policy":"3-of-5","authorizedChains":{"B-Chain":{"chainId":%q,"canSign":true}}}`, bridge)
vm := openVM(t, memdb.New(), ids.GenerateTestID(), nil, nil, cfg)
defer vm.Shutdown(ctx())
raw := (&CrossChainMPCRequest{Type: "reshare"}).Marshal()
require.ErrorContains(t, vm.CrossChainRequest(ctx(), bridge, 1, time.Now(), raw), "unknown cross-chain request type")
require.Error(t, vm.CrossChainRequest(ctx(), bridge, 1, time.Now(), []byte("junk")))
}
// -----------------------------------------------------------------------------
// Gossip
// -----------------------------------------------------------------------------
// A ceremony message is attributable to the peer that sent it.
//
// The threshold library carries the sender in the message body and does not
// authenticate it — it is written for an authenticated channel and expects its
// transport to supply that. This is the transport. party.ID IS the NodeID
// string here, so a message whose From names a different party is one peer
// speaking as another: enough to abort every custody ceremony on demand, and
// enough to make the protocol's identifiable abort name an honest validator
// for it.
func TestAPeerCannotSpeakUnderAnotherPartysName(t *testing.T) {
vm := newVM(t)
self, impostor := ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
router := newGossipRouter(ctx(), "mpc/session", party.ID(self.String()), nil, nil)
vm.registerSessionRouter("mpc/session", router)
defer vm.unregisterSessionRouter("mpc/session")
honest := &protocol.Message{From: party.ID(impostor.String()), Protocol: "cmp", RoundNumber: 1}
env, err := marshalEnvelope("mpc/session", honest)
require.NoError(t, err)
// Sent by the party it names: delivered.
require.NoError(t, vm.Gossip(ctx(), impostor, env))
require.Len(t, router.Receive(), 1)
<-router.Receive()
// Sent by anyone else, claiming to be that party: dropped.
require.NoError(t, vm.Gossip(ctx(), ids.GenerateTestNodeID(), env))
require.Empty(t, router.Receive(),
"a peer must not be able to inject rounds under another committee member's name")
}
// Malformed gossip is ignored, never faulted on: a peer's junk must not be able
// to fail this node's consensus.
func TestMalformedGossipIsIgnored(t *testing.T) {
vm := newVM(t)
require.NoError(t, vm.Gossip(ctx(), ids.GenerateTestNodeID(), []byte("not an envelope")))
require.NoError(t, vm.Gossip(ctx(), ids.GenerateTestNodeID(), nil))
bad, err := json.Marshal(sessionEnvelope{SessionID: "s", Message: []byte("not a protocol message")})
require.NoError(t, err)
require.NoError(t, vm.Gossip(ctx(), ids.GenerateTestNodeID(), bad))
}
// A message that arrives before this node's own router for that ceremony is
// registered is buffered and drained on register, so no round-one broadcast is
// lost to a start-order race — and the buffer is bounded, so junk-session
// gossip cannot exhaust memory.
func TestAnEarlyRoundIsBufferedAndTheBufferIsBounded(t *testing.T) {
vm := newVM(t)
sender := ids.GenerateTestNodeID()
msg := &protocol.Message{From: party.ID(sender.String()), Protocol: "cmp", RoundNumber: 1}
env, err := marshalEnvelope("mpc/early", msg)
require.NoError(t, err)
for i := 0; i < maxPendingPerSession+10; i++ {
require.NoError(t, vm.Gossip(ctx(), sender, env))
}
vm.routerMu.Lock()
buffered := len(vm.pendingBySession["mpc/early"])
vm.routerMu.Unlock()
require.Equal(t, maxPendingPerSession, buffered)
router := newGossipRouter(ctx(), "mpc/early", "self", nil, nil)
vm.registerSessionRouter("mpc/early", router)
require.Len(t, router.Receive(), maxPendingPerSession)
// Tearing the ceremony down drops both the router and anything still
// buffered for it.
vm.unregisterSessionRouter("mpc/early")
vm.routerMu.Lock()
_, stillBuffered := vm.pendingBySession["mpc/early"]
_, stillRouted := vm.sessionRouters["mpc/early"]
vm.routerMu.Unlock()
require.False(t, stillBuffered)
require.False(t, stillRouted)
vm.unregisterSessionRouter("mpc/early") // idempotent
}
// -----------------------------------------------------------------------------
// The engine surface that M-Chain does not use
// -----------------------------------------------------------------------------
// M-Chain answers no peer requests: every ceremony message rides app-gossip, so
// there is ONE receive path and one demux point. These exist to satisfy the
// engine and are deliberately empty rather than deliberately absent — a VM that
// did not implement them would not start.
func TestMChainAnswersNoPeerRequests(t *testing.T) {
vm := newVM(t)
node := ids.GenerateTestNodeID()
require.NoError(t, vm.Connected(ctx(), node, &vmchain.VersionInfo{}))
require.NoError(t, vm.Disconnected(ctx(), node))
require.NoError(t, vm.Request(ctx(), node, 1, time.Now(), nil))
require.NoError(t, vm.Response(ctx(), node, 1, nil))
require.NoError(t, vm.RequestFailed(ctx(), node, 1, &warp.Error{}))
require.NoError(t, vm.CrossChainResponse(ctx(), ids.GenerateTestID(), 1, nil))
require.NoError(t, vm.CrossChainRequestFailed(ctx(), ids.GenerateTestID(), 1, &warp.Error{}))
require.NoError(t, vm.SetState(ctx(), 0))
}
// Nothing is flushed at shutdown. Every durable fact is written at the moment
// it becomes true, so a crash, a kill or a power cut loses nothing — which for
// a custody chain means not losing the record of who holds the funds.
func TestShutdownFlushesNothingBecauseNothingIsPending(t *testing.T) {
db := memdb.New()
chainID := ids.GenerateTestID()
vm := openVM(t, db, chainID, nil, nil)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 75)
key.register(t, vm)
root := vm.StateRoot()
// No Shutdown at all — the process simply ends.
reopened := openVM(t, db, chainID, nil, nil)
defer reopened.Shutdown(ctx())
require.Equal(t, root, reopened.StateRoot())
_, err := reopened.Key("vault")
require.NoError(t, err)
require.NoError(t, vm.Shutdown(ctx()))
require.NoError(t, vm.Shutdown(ctx()), "shutdown is idempotent")
}
// The factory holds no state: everything that needs configuration or a database
// arrives at Initialize.
func TestTheFactoryProducesAChainVMWithNoState(t *testing.T) {
raw, err := (&Factory{}).New(log.NewNoOpLogger())
require.NoError(t, err)
vm, ok := raw.(vmchain.ChainVM)
require.True(t, ok, "the plugin serves what the node's registry expects")
require.Equal(t, &VM{}, vm)
}
// -----------------------------------------------------------------------------
// helpers
// -----------------------------------------------------------------------------
// seedRegistryAndShare puts a key record and this node's share for it on disk,
// committed, so the next VM opened over that database finds them at boot.
func seedRegistryAndShare(t *testing.T, db database.Database, chainID ids.ID, rec *KeyRecord, share []byte) {
t.Helper()
vm := openVM(t, db, chainID, nil, nil)
require.NoError(t, vm.state.PutKey(rec))
require.NoError(t, vm.state.PutShare(rec.KeyID, share))
require.NoError(t, vm.chain.Seed(func(database.Database) error { return nil }))
require.NoError(t, vm.Shutdown(ctx()))
}
// bootWithStoredShare seeds a registry entry plus a share and returns whatever
// the next boot says about the pair.
func bootWithStoredShare(t *testing.T, rec *KeyRecord, share []byte) error {
t.Helper()
db, chainID := memdb.New(), ids.GenerateTestID()
seedRegistryAndShare(t, db, chainID, rec, share)
vm := &VM{}
return vm.Initialize(ctx(), vmcore.Init{
Runtime: &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), ChainID: chainID},
DB: db,
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
})
}
// openVM initializes a VM the way the node does, with whatever validator state,
// genesis and config the test supplies.
func openVM(t *testing.T, db database.Database, chainID ids.ID, vs *validatorstest.TestState, genesis []byte, config ...string) *VM {
t.Helper()
var cfg []byte
if len(config) > 0 {
cfg = []byte(config[0])
}
rt := &runtime.Runtime{NodeID: ids.GenerateTestNodeID(), ChainID: chainID}
if vs != nil {
rt.ValidatorState = vs
}
vm := &VM{}
require.NoError(t, vm.Initialize(ctx(), vmcore.Init{
Runtime: rt,
DB: db,
Genesis: genesis,
Config: cfg,
Log: log.NewNoOpLogger(),
ToEngine: make(chan vmcore.Message, 1),
}))
return vm
}
// oneValidator is the validator state of a chain with a single validator, which
// is enough for the committee paths and not enough for a 3-of-5 custody set.
func oneValidator(node ids.NodeID) *validatorstest.TestState {
return &validatorstest.TestState{
GetValidatorSetF: func(context.Context, uint64, ids.ID) (map[ids.NodeID]*validators.GetValidatorOutput, error) {
return map[ids.NodeID]*validators.GetValidatorOutput{
node: {NodeID: node, Weight: 1},
}, nil
},
}
}
// faultyIterDB fails only iteration, which is how a registry read fails.
type faultyIterDB struct {
database.Database
}
func (d *faultyIterDB) NewIteratorWithPrefix([]byte) database.Iterator {
return &faultyIter{}
}
type faultyIter struct{ database.Iterator }
func (*faultyIter) Next() bool { return false }
func (*faultyIter) Error() error { return errors.New("iterator fault") }
func (*faultyIter) Key() []byte { return nil }
func (*faultyIter) Value() []byte { return nil }
func (*faultyIter) Release() {}
// A node that is given no logger of its own falls back to the runtime's, so a
// VM always has somewhere to report. A VM with neither logs nowhere rather than
// faulting on the first Info call.
func TestANodeAlwaysHasSomewhereToReport(t *testing.T) {
rt := &runtime.Runtime{
NodeID: ids.GenerateTestNodeID(),
ChainID: ids.GenerateTestID(),
Log: log.NewNoOpLogger(),
}
vm := &VM{}
require.NoError(t, vm.Initialize(ctx(), vmcore.Init{
Runtime: rt,
DB: memdb.New(),
ToEngine: make(chan vmcore.Message, 1),
}))
defer vm.Shutdown(ctx())
require.NotNil(t, vm.log)
}
+86 -41
View File
@@ -33,11 +33,16 @@ import (
// ReqChain bytes @ 24
// Digest bytes @ 32
// Artifact bytes @ 40
// Timestamp i64 @ 48
// SignerLens list @ 56
// SignerBlob bytes @ 64
// KeyPresent u8 @ 72 (1 iff a KeyRecord follows — keygen operations)
// KeyBlob bytes @ 80 (marshalled KeyRecord)
// SignerLens list @ 48
// SignerBlob bytes @ 56
// KeyPresent u8 @ 64 (1 iff a KeyRecord follows — keygen operations)
// KeyBlob bytes @ 72 (marshalled KeyRecord)
//
// There is no timestamp field. An operation used to carry one; nothing read it,
// nothing checked it, and it was not in the operation's state-root digest — so
// it was a byte-range any relay could rewrite to produce a DIFFERENT, equally
// valid block from the same transition. A field that changes the block id and
// changes nothing else is malleability with no upside.
const (
opType = 0
opCeremony = 8
@@ -45,22 +50,18 @@ const (
opReqChn = 24
opDigest = 32
opArtifact = 40
opTS = 48
opSignerLens = 56
opSignerBlob = 64
opKeyPresent = 72
opKeyBlob = 80
opSize = 88
opSignerLens = 48
opSignerBlob = 56
opKeyPresent = 64
opKeyBlob = 72
opSize = 80
)
func marshalOperation(op *Operation) ([]byte, error) {
func marshalOperation(op *Operation) []byte {
signerLens, signerBlob := packStrings(partyIDsToStrings(op.Signers))
var keyBlob []byte
if op.Key != nil {
var err error
if keyBlob, err = marshalKeyRecord(op.Key); err != nil {
return nil, err
}
keyBlob = marshalKeyRecord(op.Key)
}
b := zap.NewBuilder(zap.HeaderSize + opSize + len(op.Type) + len(op.CeremonyID) +
len(op.KeyID) + len(op.RequestingChain) + len(op.Digest) + len(op.Artifact) +
@@ -74,16 +75,19 @@ func marshalOperation(op *Operation) ([]byte, error) {
ob.SetBytes(opReqChn, []byte(op.RequestingChain))
ob.SetBytes(opDigest, op.Digest)
ob.SetBytes(opArtifact, op.Artifact)
ob.SetInt64(opTS, op.Timestamp)
ob.SetList(opSignerLens, signerLensOff, len(signerLens))
ob.SetBytes(opSignerBlob, signerBlob)
ob.SetUint8(opKeyPresent, boolByte(op.Key != nil))
ob.SetBytes(opKeyBlob, keyBlob)
ob.FinishAsRoot()
return b.Finish(), nil
return b.Finish()
}
func readOperation(o zap.Object) (*Operation, error) {
signers, err := unpackStrings(readU32List(o, opSignerLens), o.Bytes(opSignerBlob))
if err != nil {
return nil, fmt.Errorf("mpcvm operation: signers: %w", err)
}
op := &Operation{
Type: string(o.Bytes(opType)),
CeremonyID: string(o.Bytes(opCeremony)),
@@ -91,8 +95,7 @@ func readOperation(o zap.Object) (*Operation, error) {
RequestingChain: string(o.Bytes(opReqChn)),
Digest: appendBytes(o.Bytes(opDigest)),
Artifact: appendBytes(o.Bytes(opArtifact)),
Timestamp: o.Int64(opTS),
Signers: stringsToPartyIDs(unpackStrings(readU32List(o, opSignerLens), o.Bytes(opSignerBlob))),
Signers: stringsToPartyIDs(signers),
}
if o.Uint8(opKeyPresent) != 0 {
rec, err := parseKeyRecord(o.Bytes(opKeyBlob))
@@ -123,14 +126,16 @@ const (
)
// Marshal encodes the block (excluding the derived ID_) to canonical wire.
func (b *Block) Marshal() ([]byte, error) {
//
// Encoding cannot fail. A builder writes into a buffer it sized itself, so
// there is no input for which this returns half a block — which is why it
// returns bytes rather than bytes and an error nobody could produce and every
// caller discarded.
func (b *Block) Marshal() []byte {
var opBlob []byte
opLens := make([]uint32, 0, len(b.Operations))
for _, op := range b.Operations {
ob, err := marshalOperation(op)
if err != nil {
return nil, err
}
ob := marshalOperation(op)
opLens = append(opLens, uint32(len(ob)))
opBlob = append(opBlob, ob...)
}
@@ -145,10 +150,15 @@ func (b *Block) Marshal() ([]byte, error) {
ob.SetList(blkOpLens, opLensOff, len(opLens))
ob.SetBytes(blkOpBlob, opBlob)
ob.FinishAsRoot()
return bld.Finish(), nil
return bld.Finish()
}
func parseBlockBytes(data []byte, blk *Block) error {
// Refuse the size before decoding it. Everything below allocates in
// proportion to what the bytes claim, and a peer chooses the bytes.
if len(data) > maxBlockBytes {
return fmt.Errorf("mpcvm block: %d bytes, at most %d", len(data), maxBlockBytes)
}
msg, err := zap.Parse(data)
if err != nil {
return err
@@ -157,12 +167,23 @@ func parseBlockBytes(data []byte, blk *Block) error {
return fmt.Errorf("mpcvm block: trailing bytes")
}
o := msg.Root()
copy(blk.ParentID_[:], o.BytesFixedSlice(blkParent, 32))
parent := o.BytesFixedSlice(blkParent, 32)
root := o.BytesFixedSlice(blkRoot, 32)
// copy() into a fixed array zero-fills whatever the source did not cover,
// so a truncated object would decode to a block naming a DIFFERENT parent
// and a different post-state instead of failing.
if len(parent) != 32 || len(root) != 32 {
return fmt.Errorf("mpcvm block: header is truncated")
}
copy(blk.ParentID_[:], parent)
blk.BlockHeight = o.Uint64(blkHeight)
blk.BlockTimestamp = o.Int64(blkTime)
copy(blk.StateRoot[:], o.BytesFixedSlice(blkRoot, 32))
copy(blk.StateRoot[:], root)
opLens := readU32List(o, blkOpLens)
if len(opLens) > maxOpsPerBlock {
return fmt.Errorf("mpcvm block: %d operations, at most %d", len(opLens), maxOpsPerBlock)
}
if len(opLens) == 0 {
blk.Operations = nil
return nil
@@ -185,6 +206,13 @@ func parseBlockBytes(data []byte, blk *Block) error {
blk.Operations = append(blk.Operations, op)
pos += int(l)
}
// Past the CONTENT, not past the declared size: bytes after the last
// operation sit inside the object and would otherwise be accepted and
// silently dropped, so one transition would have unboundedly many
// encodings on the wire.
if pos != len(opBlob) {
return fmt.Errorf("mpcvm block: %d bytes after the last operation", len(opBlob)-pos)
}
return nil
}
@@ -206,7 +234,7 @@ const (
ccSize = 48
)
func (r *CrossChainMPCRequest) Marshal() ([]byte, error) {
func (r *CrossChainMPCRequest) Marshal() []byte {
b := zap.NewBuilder(zap.HeaderSize + ccSize + len(r.Type) + len(r.RequestingChain) +
len(r.KeyID) + len(r.KeyType) + len(r.MessageHash) + len(r.MessageType) + 64)
ob := b.StartObject(ccSize)
@@ -217,7 +245,7 @@ func (r *CrossChainMPCRequest) Marshal() ([]byte, error) {
ob.SetBytes(ccMsgHash, r.MessageHash)
ob.SetBytes(ccMsgType, []byte(r.MessageType))
ob.FinishAsRoot()
return b.Finish(), nil
return b.Finish()
}
func parseCrossChainMPCRequest(data []byte, r *CrossChainMPCRequest) error {
@@ -268,7 +296,7 @@ const (
krSize = 80
)
func marshalKeyRecord(r *KeyRecord) ([]byte, error) {
func marshalKeyRecord(r *KeyRecord) []byte {
partyLens, partyBlob := packStrings(partyIDsToStrings(r.Participants))
bld := zap.NewBuilder(zap.HeaderSize + krSize + len(r.KeyID) + len(r.Kind) +
len(r.GroupPublicKey) + len(r.Address) + len(partyBlob) + 4*len(partyLens) + 128)
@@ -286,7 +314,7 @@ func marshalKeyRecord(r *KeyRecord) ([]byte, error) {
ob.SetList(krPartyLens, partyLensOff, len(partyLens))
ob.SetBytes(krPartyBlob, partyBlob)
ob.FinishAsRoot()
return bld.Finish(), nil
return bld.Finish()
}
func parseKeyRecord(data []byte) (*KeyRecord, error) {
@@ -298,6 +326,10 @@ func parseKeyRecord(data []byte) (*KeyRecord, error) {
return nil, fmt.Errorf("mpcvm key record: trailing bytes")
}
o := msg.Root()
parties, err := unpackStrings(readU32List(o, krPartyLens), o.Bytes(krPartyBlob))
if err != nil {
return nil, fmt.Errorf("mpcvm key record: participants: %w", err)
}
return &KeyRecord{
KeyID: string(o.Bytes(krKeyID)),
Kind: string(o.Bytes(krKind)),
@@ -306,7 +338,7 @@ func parseKeyRecord(data []byte) (*KeyRecord, error) {
Address: appendBytes(o.Bytes(krAddr)),
Generation: o.Uint64(krGen),
CreatedHeight: o.Uint64(krCreatedHt),
Participants: stringsToPartyIDs(unpackStrings(readU32List(o, krPartyLens), o.Bytes(krPartyBlob))),
Participants: stringsToPartyIDs(parties),
}, nil
}
@@ -334,7 +366,7 @@ const (
crSize = 72
)
func marshalCeremonyRecord(c *CeremonyRecord) ([]byte, error) {
func marshalCeremonyRecord(c *CeremonyRecord) []byte {
signerLens, signerBlob := packStrings(partyIDsToStrings(c.Signers))
bld := zap.NewBuilder(zap.HeaderSize + crSize + len(c.ID) + len(c.Kind) + len(c.KeyID) +
len(c.Digest) + len(c.Artifact) + len(c.RequestingChain) + len(signerBlob) + 4*len(signerLens) + 128)
@@ -351,7 +383,7 @@ func marshalCeremonyRecord(c *CeremonyRecord) ([]byte, error) {
ob.SetList(crSignerLens, signerLensOff, len(signerLens))
ob.SetBytes(crSignerBlob, signerBlob)
ob.FinishAsRoot()
return bld.Finish(), nil
return bld.Finish()
}
func parseCeremonyRecord(data []byte) (*CeremonyRecord, error) {
@@ -363,6 +395,10 @@ func parseCeremonyRecord(data []byte) (*CeremonyRecord, error) {
return nil, fmt.Errorf("mpcvm ceremony record: trailing bytes")
}
o := msg.Root()
signers, err := unpackStrings(readU32List(o, crSignerLens), o.Bytes(crSignerBlob))
if err != nil {
return nil, fmt.Errorf("mpcvm ceremony record: signers: %w", err)
}
return &CeremonyRecord{
ID: string(o.Bytes(crID)),
Kind: string(o.Bytes(crKind)),
@@ -371,7 +407,7 @@ func parseCeremonyRecord(data []byte) (*CeremonyRecord, error) {
Artifact: appendBytes(o.Bytes(crArtifact)),
RequestingChain: string(o.Bytes(crReqChain)),
Height: o.Uint64(crHeight),
Signers: stringsToPartyIDs(unpackStrings(readU32List(o, crSignerLens), o.Bytes(crSignerBlob))),
Signers: stringsToPartyIDs(signers),
}, nil
}
@@ -415,20 +451,29 @@ func packStrings(ss []string) ([]uint32, []byte) {
}
// unpackStrings re-splits blob by lens. Returns nil (not empty) for no entries.
func unpackStrings(lens []uint32, blob []byte) []string {
//
// A blob that does not cover exactly what the lengths declare is an error, not
// a short answer. It used to stop at the first overrun and return what it had,
// so a crafted encoding declaring five signers with room for three decoded to
// three signers with no error anywhere — a party set quietly smaller than the
// one on the wire, which is the shape every threshold check downstream trusts.
func unpackStrings(lens []uint32, blob []byte) ([]string, error) {
if len(lens) == 0 {
return nil
return nil, nil
}
out := make([]string, 0, len(lens))
pos := 0
for _, l := range lens {
for i, l := range lens {
if pos+int(l) > len(blob) {
break
return nil, fmt.Errorf("entry %d wants %d bytes, %d remain", i, l, len(blob)-pos)
}
out = append(out, string(blob[pos:pos+int(l)]))
pos += int(l)
}
return out
if pos != len(blob) {
return nil, fmt.Errorf("%d bytes after the last entry", len(blob)-pos)
}
return out, nil
}
func partyIDsToStrings(ids []party.ID) []string {
+348
View File
@@ -0,0 +1,348 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// wire_canonical_test.go — what the receive path refuses.
//
// The wire is where a peer chooses the bytes. Everything below asks the same
// question in a different shape: can two different encodings mean one thing, or
// one encoding mean nothing, without the decoder saying so?
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/quorum"
"github.com/luxfi/zap"
)
// A block arrives as bytes and is refused by SIZE before anything decodes it.
// Everything below the size check allocates in proportion to what the bytes
// claim, and a peer chooses the bytes.
func TestABlockLargerThanABlockMayBeIsRefusedBeforeDecoding(t *testing.T) {
vm := newVM(t)
_, err := vm.ParseBlock(ctx(), make([]byte, maxBlockBytes+1))
require.ErrorContains(t, err, "at most")
}
// An operation count past the bound is refused at the decoder, so a block that
// would make every validator run an unbounded number of signature checks never
// reaches Verify.
func TestAnOperationCountPastTheBoundIsRefusedAtTheDecoder(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 120)
ops := make([]*Operation, 0, maxOpsPerBlock+1)
for i := 0; i <= maxOpsPerBlock; i++ {
ops = append(ops, key.signOpOver(t, digestOf(byte(i))))
}
blk := &Block{BlockHeight: 1, Operations: ops, vm: vm}
_, err := vm.ParseBlock(ctx(), blk.Marshal())
require.ErrorContains(t, err, "at most")
}
// Trailing bytes are refused past the CONTENT, not past the declared size.
// Bytes after the last operation sit inside the object and would otherwise be
// accepted and dropped, so one transition would have unboundedly many
// encodings on the wire.
func TestBytesAfterTheLastOperationAreRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 121)
blk := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
padded := blockWithPaddedOperationBlob(t, blk, 16)
_, err := vm.ParseBlock(ctx(), padded)
require.ErrorContains(t, err, "after the last operation")
// And past the whole message, which the header length already covers.
raw := append(append([]byte(nil), blk.Bytes()...), 0, 0, 0, 0)
_, err = vm.ParseBlock(ctx(), raw)
require.ErrorContains(t, err, "trailing bytes")
}
// A declared string list that the blob does not exactly cover is an error, not
// a short answer.
//
// It used to stop at the first overrun and return what it had, so an encoding
// declaring five signers with room for three decoded to three signers with no
// error anywhere — a party set quietly smaller than the one on the wire, and
// the shape every threshold check downstream trusts.
func TestADeclaredPartySetTheBlobDoesNotCoverIsRefused(t *testing.T) {
got, err := unpackStrings(nil, nil)
require.NoError(t, err)
require.Nil(t, got)
got, err = unpackStrings([]uint32{2, 3}, []byte("aabbb"))
require.NoError(t, err)
require.Equal(t, []string{"aa", "bbb"}, got)
_, err = unpackStrings([]uint32{2, 3}, []byte("aabb"))
require.ErrorContains(t, err, "entry 1 wants 3 bytes, 2 remain")
_, err = unpackStrings([]uint32{2}, []byte("aabbb"))
require.ErrorContains(t, err, "after the last entry")
}
// The three records that carry a party list all refuse a truncated one, so the
// property holds wherever a set is decoded rather than at one call site.
func TestEveryRecordThatCarriesAPartySetRefusesATruncatedOne(t *testing.T) {
rec := sampleKeyRecord()
raw := marshalKeyRecord(rec)
_, err := parseKeyRecord(truncateLastList(t, raw))
require.ErrorContains(t, err, "participants")
cer := &CeremonyRecord{ID: "mpc/x", Kind: OpTypeSign, KeyID: "vault", Signers: parties(3)}
raw = marshalCeremonyRecord(cer)
_, err = parseCeremonyRecord(truncateLastList(t, raw))
require.ErrorContains(t, err, "signers")
}
// Every record refuses bytes past its declared size.
func TestEveryRecordRefusesTrailingBytes(t *testing.T) {
rec := sampleKeyRecord()
raw := marshalKeyRecord(rec)
_, err := parseKeyRecord(append(raw, 0))
require.ErrorContains(t, err, "trailing bytes")
cer := &CeremonyRecord{ID: "mpc/x"}
raw = marshalCeremonyRecord(cer)
_, err = parseCeremonyRecord(append(raw, 0))
require.ErrorContains(t, err, "trailing bytes")
req := &CrossChainMPCRequest{Type: "sign", KeyID: "vault"}
raw = req.Marshal()
require.Error(t, parseCrossChainMPCRequest(append(raw, 0), &CrossChainMPCRequest{}))
}
// Bytes that are not a message at all are refused everywhere, rather than
// decoding to a zero-valued record.
func TestBytesThatAreNotARecordAreRefused(t *testing.T) {
for name, raw := range map[string][]byte{
"empty": nil,
"junk": []byte("hello there"),
"short": make([]byte, 8),
} {
_, err := parseKeyRecord(raw)
require.Errorf(t, err, "key record: %s", name)
_, err = parseCeremonyRecord(raw)
require.Errorf(t, err, "ceremony record: %s", name)
require.Errorf(t, parseCrossChainMPCRequest(raw, &CrossChainMPCRequest{}), "request: %s", name)
}
}
// A block header that is truncated decodes to nothing, rather than to a block
// naming a DIFFERENT parent and a different post-state: copy() into a fixed
// array zero-fills whatever the source did not cover.
func TestATruncatedHeaderDoesNotDecodeToADifferentBlock(t *testing.T) {
vm := newVM(t)
b := zap.NewBuilder(zap.HeaderSize + 8 + 64)
ob := b.StartObject(8)
ob.SetUint64(0, 1)
ob.FinishAsRoot()
_, err := vm.ParseBlock(ctx(), b.Finish())
require.ErrorContains(t, err, "truncated")
}
// An operation that does not decode fails the whole block, rather than yielding
// a block with one fewer operation than its encoding declared.
func TestABlockWhoseNestedOperationIsBrokenIsRefused(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 122)
op := key.keygenOp(t)
// A key record whose own encoding is junk: the operation frames it, the
// operation decodes, and the record inside it does not.
_, err := vm.ParseBlock(ctx(), blockCarrying(t, vm, swapNestedKeyRecord(t, op)))
require.ErrorContains(t, err, "key record")
}
// And an operation whose own framing is junk fails the same way.
func TestABlockWhoseOperationFramingIsJunkIsRefused(t *testing.T) {
vm := newVM(t)
_, err := vm.ParseBlock(ctx(), blockCarrying(t, vm, []byte("not an operation")))
require.Error(t, err)
}
// A block with no operations decodes to no operations rather than to an empty
// non-nil slice that a later check would read as "something happened".
func TestABlockWithNoOperationsDecodesToNone(t *testing.T) {
vm := newVM(t)
blk := &Block{BlockHeight: 1, vm: vm}
got, err := vm.ParseBlock(ctx(), blk.Marshal())
require.NoError(t, err)
require.Nil(t, got.(*Block).Operations)
}
// Absent variable-length fields decode to nil rather than to an empty slice, so
// a round trip is byte-identical and a hash over the record is stable.
func TestAnAbsentFieldDecodesToNothing(t *testing.T) {
require.Nil(t, appendBytes(nil))
require.Nil(t, appendBytes([]byte{}))
require.Equal(t, []byte{1, 2}, appendBytes([]byte{1, 2}))
require.Nil(t, stringsToPartyIDs(nil))
require.Equal(t, []party.ID{"a"}, stringsToPartyIDs([]string{"a"}))
require.Nil(t, partyIDsToStrings(nil))
require.Equal(t, []string{"a"}, partyIDsToStrings([]party.ID{"a"}))
}
// A record round-trips to the same bytes, so two validators that hash what they
// stored hash the same thing.
func TestARecordRoundTripsToTheSameBytes(t *testing.T) {
rec := sampleKeyRecord()
raw := marshalKeyRecord(rec)
back, err := parseKeyRecord(raw)
require.NoError(t, err)
again := marshalKeyRecord(back)
require.Equal(t, raw, again)
require.Equal(t, KeyCommitDigest(rec), KeyCommitDigest(back))
}
// A block's id is the hash of its canonical bytes, so a peer that pads the
// encoding cannot produce a second id for one transition — the parse refuses
// the padding, and the id is recomputed from a re-marshal rather than from the
// bytes as received.
func TestOneTransitionHasOneId(t *testing.T) {
vm := newVM(t)
key := newCustody(t, "vault", quorum.MustNew(3, 5), 123)
blk := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
parsed, err := vm.ParseBlock(ctx(), blk.Bytes())
require.NoError(t, err)
require.Equal(t, blk.ID(), parsed.ID())
require.Equal(t, blk.Bytes(), parsed.Bytes())
// Every field that is in the encoding moves the id.
for name, mangle := range map[string]func(*Block){
"parent": func(b *Block) { b.ParentID_[0] ^= 1 },
"height": func(b *Block) { b.BlockHeight++ },
"timestamp": func(b *Block) { b.BlockTimestamp++ },
"root": func(b *Block) { b.StateRoot[0] ^= 1 },
"operation": func(b *Block) { b.Operations[0].Digest = digestOf(9) },
} {
other := blockOver(t, vm, key.signOpOver(t, digestOf(1)))
mangle(other)
require.NotEqualf(t, blk.ID(), other.computeID(), "%s must move the block id", name)
}
}
// -----------------------------------------------------------------------------
// helpers that produce bytes a builder would never emit
// -----------------------------------------------------------------------------
// blockWithPaddedOperationBlob re-encodes blk with n unreferenced bytes after
// the last operation — an encoding no builder produces and a peer can.
func blockWithPaddedOperationBlob(t *testing.T, blk *Block, n int) []byte {
t.Helper()
var opBlob []byte
opLens := make([]uint32, 0, len(blk.Operations))
for _, op := range blk.Operations {
ob := marshalOperation(op)
opLens = append(opLens, uint32(len(ob)))
opBlob = append(opBlob, ob...)
}
opBlob = append(opBlob, make([]byte, n)...)
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(opBlob) + 4*len(opLens) + 128)
off := writeU32List(bld, opLens)
ob := bld.StartObject(blkSize)
ob.SetBytesFixed(blkParent, blk.ParentID_[:])
ob.SetUint64(blkHeight, blk.BlockHeight)
ob.SetInt64(blkTime, blk.BlockTimestamp)
ob.SetBytesFixed(blkRoot, blk.StateRoot[:])
ob.SetList(blkOpLens, off, len(opLens))
ob.SetBytes(blkOpBlob, opBlob)
ob.FinishAsRoot()
return bld.Finish()
}
// truncateLastList shortens the message so its final variable-length blob no
// longer covers what its length list declares.
func truncateLastList(t *testing.T, raw []byte) []byte {
t.Helper()
require.Greater(t, len(raw), 4)
out := append([]byte(nil), raw[:len(raw)-1]...)
// The declared size lives in the header; shrink it with the buffer so the
// framing stays consistent and the failure is the list, not the frame.
out[12]--
return out
}
// swapNestedKeyRecord re-encodes an operation with junk where its key record
// belongs: the operation frames correctly and the record inside it does not.
func swapNestedKeyRecord(t *testing.T, op *Operation) []byte {
t.Helper()
signerLens, signerBlob := packStrings(partyIDsToStrings(op.Signers))
b := zap.NewBuilder(zap.HeaderSize + opSize + 512)
off := writeU32List(b, signerLens)
ob := b.StartObject(opSize)
ob.SetBytes(opType, []byte(op.Type))
ob.SetBytes(opCeremony, []byte(op.CeremonyID))
ob.SetBytes(opKeyID, []byte(op.KeyID))
ob.SetBytes(opDigest, op.Digest)
ob.SetBytes(opArtifact, op.Artifact)
ob.SetList(opSignerLens, off, len(signerLens))
ob.SetBytes(opSignerBlob, signerBlob)
ob.SetUint8(opKeyPresent, 1)
ob.SetBytes(opKeyBlob, []byte("not a key record"))
ob.FinishAsRoot()
return b.Finish()
}
// blockCarrying frames one already-encoded operation blob into a block, so a
// test can hand the decoder bytes a builder would never emit.
func blockCarrying(t *testing.T, vm *VM, opBlob []byte) []byte {
t.Helper()
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(opBlob) + 128)
off := writeU32List(bld, []uint32{uint32(len(opBlob))})
ob := bld.StartObject(blkSize)
ob.SetBytesFixed(blkParent, make([]byte, 32))
ob.SetUint64(blkHeight, 1)
ob.SetInt64(blkTime, 1)
ob.SetBytesFixed(blkRoot, make([]byte, 32))
ob.SetList(blkOpLens, off, 1)
ob.SetBytes(blkOpBlob, opBlob)
ob.FinishAsRoot()
return bld.Finish()
}
// An operation whose declared length runs past the blob is refused, rather
// than read from whatever follows it.
func TestAnOperationLengthPastTheBlobIsRefused(t *testing.T) {
vm := newVM(t)
bld := zap.NewBuilder(zap.HeaderSize + blkSize + 128)
off := writeU32List(bld, []uint32{4096})
ob := bld.StartObject(blkSize)
ob.SetBytesFixed(blkParent, make([]byte, 32))
ob.SetUint64(blkHeight, 1)
ob.SetBytesFixed(blkRoot, make([]byte, 32))
ob.SetList(blkOpLens, off, 1)
ob.SetBytes(blkOpBlob, []byte("short"))
ob.FinishAsRoot()
_, err := vm.ParseBlock(ctx(), bld.Finish())
require.ErrorContains(t, err, "out of bounds")
}
// An operation whose signer list declares more than its blob holds is refused
// at the operation, so a block cannot carry a party set smaller than the one
// its encoding names.
func TestAnOperationWithATruncatedSignerListIsRefused(t *testing.T) {
vm := newVM(t)
b := zap.NewBuilder(zap.HeaderSize + opSize + 128)
off := writeU32List(b, []uint32{2, 99})
ob := b.StartObject(opSize)
ob.SetBytes(opType, []byte(OpTypeSign))
ob.SetBytes(opCeremony, []byte("mpc/x"))
ob.SetList(opSignerLens, off, 2)
ob.SetBytes(opSignerBlob, []byte("pa"))
ob.FinishAsRoot()
_, err := vm.ParseBlock(ctx(), blockCarrying(t, vm, b.Finish()))
require.ErrorContains(t, err, "signers")
}
+9 -19
View File
@@ -40,7 +40,6 @@ func sampleKeyRecord() *KeyRecord {
Participants: []party.ID{"p1", "p2", "p3"},
GroupPublicKey: pub,
Address: addr,
Generation: 5,
CreatedHeight: 42,
}
}
@@ -63,10 +62,8 @@ func TestWireRoundTrip_Operation_Sign(t *testing.T) {
Digest: make([]byte, 32),
Artifact: sampleSignature(),
Signers: []party.ID{"p1", "p2"},
Timestamp: 1_700_000_000,
}
b, err := marshalOperation(op)
require.NoError(err)
b := marshalOperation(op)
msg, err := zap.Parse(b)
require.NoError(err)
got, err := readOperation(msg.Root())
@@ -90,10 +87,8 @@ func TestWireRoundTrip_Operation_Keygen(t *testing.T) {
Artifact: sampleSignature(),
Signers: rec.Participants,
Key: rec,
Timestamp: 1_700_000_001,
}
b, err := marshalOperation(op)
require.NoError(err)
b := marshalOperation(op)
msg, err := zap.Parse(b)
require.NoError(err)
got, err := readOperation(msg.Root())
@@ -120,17 +115,16 @@ func TestWireRoundTrip_Block(t *testing.T) {
{
Type: OpTypeKeygen, CeremonyID: "c1", KeyID: rec.KeyID,
Digest: commit[:], Artifact: sampleSignature(),
Signers: rec.Participants, Key: rec, Timestamp: 1,
Signers: rec.Participants, Key: rec,
},
{
Type: OpTypeSign, CeremonyID: "c2", KeyID: rec.KeyID,
Digest: make([]byte, 32), Artifact: sampleSignature(),
Signers: []party.ID{"p1", "p2"}, Timestamp: 2,
Signers: []party.ID{"p1", "p2"},
},
},
}
b, err := blk.Marshal()
require.NoError(err)
b := blk.Marshal()
var got Block
require.NoError(parseBlockBytes(b, &got))
require.Equal(blk.ParentID_, got.ParentID_)
@@ -144,8 +138,7 @@ func TestWireRoundTrip_Block(t *testing.T) {
// empty-operations block: Operations stays nil
empty := &Block{ParentID_: ids.ID{5}, BlockHeight: 1}
eb, err := empty.Marshal()
require.NoError(err)
eb := empty.Marshal()
var egot Block
require.NoError(parseBlockBytes(eb, &egot))
require.Nil(egot.Operations)
@@ -160,8 +153,7 @@ func TestWireRoundTrip_KeyRecord(t *testing.T) {
rec := sampleKeyRecord()
require.NoError(rec.Validate())
b, err := marshalKeyRecord(rec)
require.NoError(err)
b := marshalKeyRecord(rec)
got, err := parseKeyRecord(b)
require.NoError(err)
require.Equal(rec, got)
@@ -184,8 +176,7 @@ func TestWireRoundTrip_CeremonyRecord(t *testing.T) {
RequestingChain: "B-Chain",
Height: 99,
}
b, err := marshalCeremonyRecord(c)
require.NoError(err)
b := marshalCeremonyRecord(c)
got, err := parseCeremonyRecord(b)
require.NoError(err)
require.Equal(c, got)
@@ -200,8 +191,7 @@ func TestWireRoundTrip_CrossChainMPCRequest(t *testing.T) {
Type: "sign", RequestingChain: "B-Chain", KeyID: "key-1",
KeyType: "secp256k1", MessageHash: []byte("h"), MessageType: "eth_sign",
}
b, err := r.Marshal()
require.NoError(err)
b := r.Marshal()
var got CrossChainMPCRequest
require.NoError(parseCrossChainMPCRequest(b, &got))
require.Equal(r, &got)