mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
M-Chain is the MPC/threshold-custody VM that replaces off-chain signer clusters for bridge custody. This makes it one. vmID — the one-way door. factory.go declared a private `thresholdvm` literal that matched nothing else in the stack, and node/Dockerfile installed the plugin under that name's CB58 while genesis declares constants.MPCVMID. The two never met, so M-Chain could not have started. VMID is now the shared constant, and TestVMID_IsCanonicalAndStable pins the CB58 (qCURact1n41Fco...) with the list of every declaration that must move together if it ever changes before launch. k vs t. types.Ceremony carried Threshold/Total as bare uint16s: Validate read Threshold as the signer count (enforcing threshold*2 > total) while the VM read it as the polynomial degree and passed it straight to cmp.Keygen. The same field meant K in one file and t in another, and the two differ by one — the same silent off-by-one that produces a 4-of-5 wallet from a config saying 3-of-5. Both now carry a quorum.Policy, and t is only ever obtained by calling Degree(). The majority floor survives as types.HasUniqueQuorum, defined once and enforced on every key record. State. The VM kept its custody keys in a map persisted only on a clean Shutdown, with the key share excluded from the wire entirely, and reset lastAccepted to a recomputed genesis block on every Initialize — so a restart discarded the chain and came back unable to sign. State is now persisted at the moment each fact becomes true, split into a replicated c/ keyspace (key registry, ceremony log, blocks, height index, root) and a node-private n/ keyspace (this validator's shares), and Initialize resumes the accepted tip. Blocks verify. A block carries its post-state root and every operation's artifact. Verify re-checks a signature against the group key already in the registry — a proposer cannot fabricate one without forging ECDSA — and recomputes the root, so two validators that would diverge cannot both accept. Keygen operations carry a proof of possession by the new group key over its own registration; a participant additionally cross-checks the declared degree against its own share, so one honest participant rejects a mis-declared key. Ceremonies are leaderless. The id is derived from the task (key, digest, sorted signers), so every validator converges on the same ceremony with no announce round and no coordinator, and the committee is the chain's own validator set rather than an operator roster. Address derivation was SHA-256 where every EVM chain uses Keccak-256, so the published custody address did not belong to the group key. Funds sent there would have been unspendable. Deletes the duplicate signing paths and the in-memory session machinery: one way to generate a key, one way to sign. Co-authored-by: Hanzo Dev <[email protected]>
213 lines
7.9 KiB
Go
213 lines
7.9 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package types defines the data types of the ThresholdVM substrate.
|
|
//
|
|
// These types are imported by both M-Chain (chains/mchain) and F-Chain
|
|
// (chains/fchain). The substrate has no runtime; types here are pure
|
|
// values, the state machine is an explicit-transition function, and
|
|
// the registry types in cert/ are constructed (not singletons) by the
|
|
// host chain.
|
|
package types
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/luxfi/threshold/pkg/quorum"
|
|
)
|
|
|
|
// CeremonyState is the position of a ceremony in the shared state
|
|
// machine. The same machine is used by every protocol on M-Chain and
|
|
// F-Chain; only the per-round payload differs.
|
|
type CeremonyState uint8
|
|
|
|
const (
|
|
StateUnknown CeremonyState = 0
|
|
StateRegistered CeremonyState = 1 // participant set committed, no shares yet
|
|
StateRound1 CeremonyState = 2 // round-1 commitments published
|
|
StateRound2 CeremonyState = 3 // round-2 shares published
|
|
StateFinalized CeremonyState = 4 // cert artifact emitted, root advanced
|
|
StateAborted CeremonyState = 5 // identifiable abort (CGGMP21 etc.) or timeout
|
|
)
|
|
|
|
// String returns a stable label for logs.
|
|
func (s CeremonyState) String() string {
|
|
switch s {
|
|
case StateRegistered:
|
|
return "registered"
|
|
case StateRound1:
|
|
return "round1"
|
|
case StateRound2:
|
|
return "round2"
|
|
case StateFinalized:
|
|
return "finalized"
|
|
case StateAborted:
|
|
return "aborted"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// CeremonyKind names the protocol the ceremony executes. The kind
|
|
// determines which CertLane the finalized artifact uses and which
|
|
// verifier validates each round's payload. Adding a new kind appends
|
|
// here and adds a CertLane in cert/lane.go — never reorders.
|
|
type CeremonyKind uint8
|
|
|
|
const (
|
|
KindUnknown CeremonyKind = 0
|
|
KindCGGMP21 CeremonyKind = 1 // M-Chain: ECDSA threshold
|
|
KindFROST CeremonyKind = 2 // M-Chain: Schnorr/EdDSA threshold
|
|
KindCoronaGen CeremonyKind = 3 // M-Chain: PQ general-purpose threshold
|
|
KindTFHEKeygen CeremonyKind = 4 // M-Chain → F-Chain: TFHE bootstrap-key gen
|
|
KindTFHECompute CeremonyKind = 5 // F-Chain: encrypted compute attestation
|
|
KindTFHEBootstrap CeremonyKind = 6 // F-Chain: blind-rotate / bootstrap proof
|
|
)
|
|
|
|
// IsMChain reports whether the kind belongs to the M-Chain operational chain.
|
|
func (k CeremonyKind) IsMChain() bool {
|
|
return k == KindCGGMP21 || k == KindFROST || k == KindCoronaGen
|
|
}
|
|
|
|
// IsFChain reports whether the kind belongs to the F-Chain operational chain.
|
|
func (k CeremonyKind) IsFChain() bool {
|
|
return k == KindTFHECompute || k == KindTFHEBootstrap
|
|
}
|
|
|
|
// IsHandoff reports whether the kind is a cross-chain (M → F) ceremony.
|
|
// Handoff ceremonies originate on M-Chain and finalize into F-Chain via
|
|
// the bootstrap-handoff envelope.
|
|
func (k CeremonyKind) IsHandoff() bool {
|
|
return k == KindTFHEKeygen
|
|
}
|
|
|
|
// CeremonyID is the canonical identifier for a ceremony, derived from
|
|
// the Quasar 3.0 round descriptor and the ceremony kind. It is the
|
|
// 32-byte hash that ties shares, proofs, and cert subjects together.
|
|
type CeremonyID [32]byte
|
|
|
|
// Ceremony describes a single threshold ceremony on either chain.
|
|
//
|
|
// Ceremony is a pure data type. State transitions are produced by
|
|
// Transition() in this package; the host chain (M-Chain or F-Chain)
|
|
// drives transitions on its own block ticks.
|
|
type Ceremony struct {
|
|
ID CeremonyID
|
|
Kind CeremonyKind
|
|
State CeremonyState
|
|
Round uint8 // 1..N, valid only when State is Round1 or Round2
|
|
// Policy is the quorum: K signers of N parties. It replaces the former
|
|
// pair of bare uint16s named Threshold/Total.
|
|
//
|
|
// Those two fields were the bug. "Threshold" was read as the signer count
|
|
// here (Validate enforced threshold*2 > total, an honest-majority floor on
|
|
// K) and as the polynomial degree in the VM (which computed threshold+1
|
|
// parties and passed the value straight to cmp.Keygen, which reads it as
|
|
// t). The same field therefore meant K in one file and t in another, and
|
|
// the two differ by one. A quorum.Policy cannot be read either way: K and N
|
|
// are named, and t is only ever obtained by calling Degree().
|
|
Policy quorum.Policy
|
|
StartEpoch uint64
|
|
Subject [32]byte // certificate_subject the ceremony binds into
|
|
// PayloadArena is the per-ceremony buffer that all share payloads
|
|
// index into via (offset, len). Owned by the host chain; the
|
|
// substrate never allocates.
|
|
PayloadArena []byte
|
|
}
|
|
|
|
// Degree returns t, the polynomial degree the underlying protocol is
|
|
// parameterised with, for this ceremony's policy. It is the ONLY conversion
|
|
// from the operator's k-of-n to the protocol's t, and it lives in
|
|
// luxfi/threshold/pkg/quorum so the whole stack shares one answer.
|
|
//
|
|
// For 3-of-5 this is 2: any 3 shares reconstruct, any 2 do not. Passing 3 to
|
|
// cmp.Keygen instead would build a degree-3 key that needs 4 signers — a
|
|
// 4-of-5 wallet provisioned by someone who asked for 3-of-5, discovered only
|
|
// when three custodians cannot produce a signature.
|
|
func (c *Ceremony) Degree() int { return c.Policy.Degree() }
|
|
|
|
// Validate checks structural invariants. Use at boundaries only; the
|
|
// substrate trusts its own callers (the host chain) for routine
|
|
// transitions per PHILOSOPHY.md "defensive programming against
|
|
// yourself".
|
|
func (c *Ceremony) Validate() error {
|
|
if c == nil {
|
|
return errors.New("ceremony: nil")
|
|
}
|
|
if c.Kind == KindUnknown {
|
|
return errors.New("ceremony: kind unset")
|
|
}
|
|
if !c.Policy.Valid() {
|
|
return fmt.Errorf("ceremony: %s is not a deployable policy", c.Policy)
|
|
}
|
|
if !HasUniqueQuorum(c.Policy) {
|
|
return fmt.Errorf("ceremony: policy %s admits two disjoint quorums", c.Policy)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HasUniqueQuorum reports whether a policy admits only one quorum at a time:
|
|
// 2K > N, so no two disjoint sets of K parties both exist.
|
|
//
|
|
// This is a custody policy, not a soundness requirement of the protocol.
|
|
// CGGMP21 is unforgeable against up to N-1 corruptions, so a 2-of-5 key is
|
|
// perfectly sound cryptographically. But with 2K <= N two disjoint coalitions
|
|
// can each independently produce a valid signature, so possession of a
|
|
// signature no longer implies the committee as a whole authorised anything —
|
|
// two halves could authorise contradictory releases of the same funds. For a
|
|
// chain whose keys hold bridged assets that is the wrong trade, so M-Chain
|
|
// requires the majority floor that the archived LP-076 §Security called for,
|
|
// now expressed unambiguously in terms of K rather than a field that might
|
|
// have meant t.
|
|
func HasUniqueQuorum(p quorum.Policy) bool { return 2*p.K > p.N }
|
|
|
|
// Transition advances the ceremony state. It is the only function
|
|
// that mutates State. Returns the post-transition copy or an error
|
|
// if the transition is illegal.
|
|
//
|
|
// Legal transitions:
|
|
//
|
|
// Registered -> Round1
|
|
// Round1 -> Round2
|
|
// Round2 -> Finalized
|
|
// * -> Aborted (always permitted)
|
|
func (c Ceremony) Transition(next CeremonyState) (Ceremony, error) {
|
|
if next == StateAborted {
|
|
c.State = StateAborted
|
|
return c, nil
|
|
}
|
|
switch c.State {
|
|
case StateRegistered:
|
|
if next != StateRound1 {
|
|
return c, fmt.Errorf("ceremony: registered -> %s not legal", next)
|
|
}
|
|
c.State = StateRound1
|
|
c.Round = 1
|
|
case StateRound1:
|
|
if next != StateRound2 {
|
|
return c, fmt.Errorf("ceremony: round1 -> %s not legal", next)
|
|
}
|
|
c.State = StateRound2
|
|
c.Round = 2
|
|
case StateRound2:
|
|
if next != StateFinalized {
|
|
return c, fmt.Errorf("ceremony: round2 -> %s not legal", next)
|
|
}
|
|
c.State = StateFinalized
|
|
default:
|
|
return c, fmt.Errorf("ceremony: %s -> %s not legal", c.State, next)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// CeremonyRound is the per-round bag of share commitments for a single
|
|
// ceremony. The host chain accumulates these in memory while the
|
|
// ceremony is in Round1 / Round2 and finalizes them into a CertLane
|
|
// artifact at Finalize.
|
|
type CeremonyRound struct {
|
|
CeremonyID CeremonyID
|
|
Round uint8
|
|
Shares []Share // populated as each participant publishes
|
|
}
|