Files
chains/mpcvm/sign_distributed.go
T
zeekayandHanzo Dev 1c7f329fbd mpcvm: make M-Chain a real custody chain
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]>
2026-07-25 14:36:22 -07:00

132 lines
5.0 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// sign_distributed.go — the cross-validator threshold-signing core.
//
// A signing session runs a threshold-ECDSA (CGGMP21/CMP) ceremony across the
// M-Chain committee over the native app-gossip transport: each validator runs
// its own executor + a per-session gossipRouter, the routers carry the round
// messages between validators, and every honest signer derives the SAME
// standard secp256k1 signature. B (or any chain holding the group key) verifies
// that signature with zero interaction.
//
// The ceremony id is DERIVED, not announced: every validator that computes the
// same (keyID, digest, signer-set) lands on the same session id, so the round
// messages demultiplex correctly without a separate coordination round. A fresh
// nonce inside a bridge digest gives a fresh ceremony id, so distinct transfers
// never share a session.
//
// SEPARATION OF CONCERNS: this file owns "run the ceremony over a router". The
// router owns "move opaque bytes" (transport.go). The executor owns "drive the
// threshold protocol" (executor.go). None interprets another's layer.
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"github.com/luxfi/ids"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/protocol"
)
// maxPendingPerSession bounds the pre-registration buffer for one ceremony so a
// peer that opens a round a hair before us never loses its first broadcast,
// while a flood of junk-session gossip can never exhaust memory.
const maxPendingPerSession = 512
// ceremonyDomainTag domain-separates the ceremony-id hash from every other
// digest M computes, so a ceremony id can never alias a signing preimage.
const ceremonyDomainTag = "LUX_MPC_CEREMONY_v1"
// ceremonyID is the deterministic session id all honest validators converge on
// 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.
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))
}
return "mpc/" + hex.EncodeToString(h.Sum(nil))
}
// resolveCommittee maps each signer party to the validator NodeID that runs it.
// The M-Chain sets party.ID == NodeID.String() at Initialize, so the mapping is
// the exact inverse — no side table, no place-vs-value drift.
func resolveCommittee(signers []party.ID) ([]ids.NodeID, map[party.ID]ids.NodeID, error) {
nodes := make([]ids.NodeID, 0, len(signers))
peers := make(map[party.ID]ids.NodeID, len(signers))
for _, p := range signers {
nid, err := ids.NodeIDFromString(string(p))
if err != nil {
return nil, nil, fmt.Errorf("mpcvm: signer %q is not a node id: %w", p, err)
}
nodes = append(nodes, nid)
peers[p] = nid
}
return nodes, peers, nil
}
// registerSessionRouter installs a ceremony's router and drains any envelopes
// that arrived before we were ready, so no round-one broadcast is dropped.
func (vm *VM) registerSessionRouter(sessionID string, router *gossipRouter) {
vm.routerMu.Lock()
pending := vm.pendingBySession[sessionID]
delete(vm.pendingBySession, sessionID)
vm.sessionRouters[sessionID] = router
vm.routerMu.Unlock()
for _, m := range pending {
router.Deliver(m)
}
}
// unregisterSessionRouter tears a ceremony's router down and closes it so the
// executor's receive loop unwinds. Idempotent.
func (vm *VM) unregisterSessionRouter(sessionID string) {
vm.routerMu.Lock()
router := vm.sessionRouters[sessionID]
delete(vm.sessionRouters, sessionID)
delete(vm.pendingBySession, sessionID)
vm.routerMu.Unlock()
if router != nil {
router.Close()
}
}
// deliverCeremonyMessage routes one decoded envelope to its ceremony's router,
// buffering (bounded) if our own router for that ceremony has not registered
// yet. Called by the Gossip handler.
func (vm *VM) deliverCeremonyMessage(sessionID string, msg *protocol.Message) {
vm.routerMu.Lock()
router, ok := vm.sessionRouters[sessionID]
if !ok {
q := vm.pendingBySession[sessionID]
if len(q) < maxPendingPerSession {
vm.pendingBySession[sessionID] = append(q, msg)
}
vm.routerMu.Unlock()
return
}
vm.routerMu.Unlock()
router.Deliver(msg)
}
// There is exactly one signing path: custody.go's thresholdSign, driven by
// RunSign. The VM previously carried two more — a CGGMP21 path keyed off an
// in-memory ManagedKey and a registry-handler fallback — and a key could be
// signed under whichever one the caller happened to reach, with different
// quorum rules on each. Fan-in at the router, not at the caller.