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]>
462 lines
13 KiB
Go
462 lines
13 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package mpcvm
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/luxfi/threshold/pkg/party"
|
|
"github.com/luxfi/threshold/pkg/quorum"
|
|
"github.com/luxfi/zap"
|
|
)
|
|
|
|
// Native-ZAP struct-is-wire for M-Chain (MPC custody VM). No pcodecs, no
|
|
// reflection, no codec registry — each persisted type owns its Marshal/parse
|
|
// over a zap object. Re-genesis authorized, so the on-disk/wire format is
|
|
// these offsets (canonical: parse rejects trailing bytes).
|
|
//
|
|
// The legacy pcodecs LinearCodec keyed on struct tags;
|
|
// every type here carries only `json:` tags, so the old codec marshaled ZERO
|
|
// fields (a Block round-tripped to all-zero — a latent corruption bug). This
|
|
// wire is the correct, field-preserving replacement.
|
|
//
|
|
// Boundaries kept as JSON (NOT encoded here): ThresholdConfig (init.Config)
|
|
// and Genesis (genesis bytes) — see vm.go. Sessions travel as JSON envelopes
|
|
// over transport (see transport.go), not through this wire.
|
|
|
|
// ---- Operation (nested in Block) ----
|
|
//
|
|
// Type bytes @ 0
|
|
// CeremonyID bytes @ 8
|
|
// KeyID bytes @ 16
|
|
// 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)
|
|
const (
|
|
opType = 0
|
|
opCeremony = 8
|
|
opKeyID = 16
|
|
opReqChn = 24
|
|
opDigest = 32
|
|
opArtifact = 40
|
|
opTS = 48
|
|
opSignerLens = 56
|
|
opSignerBlob = 64
|
|
opKeyPresent = 72
|
|
opKeyBlob = 80
|
|
opSize = 88
|
|
)
|
|
|
|
func marshalOperation(op *Operation) ([]byte, error) {
|
|
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
|
|
}
|
|
}
|
|
b := zap.NewBuilder(zap.HeaderSize + opSize + len(op.Type) + len(op.CeremonyID) +
|
|
len(op.KeyID) + len(op.RequestingChain) + len(op.Digest) + len(op.Artifact) +
|
|
len(signerBlob) + 4*len(signerLens) + len(keyBlob) + 128)
|
|
signerLensOff := 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(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
|
|
}
|
|
|
|
func readOperation(o zap.Object) (*Operation, error) {
|
|
op := &Operation{
|
|
Type: string(o.Bytes(opType)),
|
|
CeremonyID: string(o.Bytes(opCeremony)),
|
|
KeyID: string(o.Bytes(opKeyID)),
|
|
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))),
|
|
}
|
|
if o.Uint8(opKeyPresent) != 0 {
|
|
rec, err := parseKeyRecord(o.Bytes(opKeyBlob))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mpcvm operation: key record: %w", err)
|
|
}
|
|
op.Key = rec
|
|
}
|
|
return op, nil
|
|
}
|
|
|
|
// ---- Block ----
|
|
//
|
|
// ParentID 32B @ 0 (ID_ is derived: computeID = sha256(Marshal); excluded)
|
|
// Height u64 @ 32
|
|
// Timestamp i64 @ 40
|
|
// StateRoot 32B @ 48 (post-application root; commits the transition)
|
|
// OpLens list @ 80 (u32 per Operation wire length)
|
|
// OpBlob bytes @ 88 (concatenated Operation wire objects)
|
|
const (
|
|
blkParent = 0
|
|
blkHeight = 32
|
|
blkTime = 40
|
|
blkRoot = 48
|
|
blkOpLens = 80
|
|
blkOpBlob = 88
|
|
blkSize = 96
|
|
)
|
|
|
|
// Marshal encodes the block (excluding the derived ID_) to canonical wire.
|
|
func (b *Block) Marshal() ([]byte, error) {
|
|
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
|
|
}
|
|
opLens = append(opLens, uint32(len(ob)))
|
|
opBlob = append(opBlob, ob...)
|
|
}
|
|
bld := zap.NewBuilder(zap.HeaderSize + blkSize + len(opBlob) + 4*len(opLens) + 128)
|
|
opLensOff := writeU32List(bld, opLens)
|
|
|
|
ob := bld.StartObject(blkSize)
|
|
ob.SetBytesFixed(blkParent, b.ParentID_[:])
|
|
ob.SetUint64(blkHeight, b.BlockHeight)
|
|
ob.SetInt64(blkTime, b.BlockTimestamp)
|
|
ob.SetBytesFixed(blkRoot, b.StateRoot[:])
|
|
ob.SetList(blkOpLens, opLensOff, len(opLens))
|
|
ob.SetBytes(blkOpBlob, opBlob)
|
|
ob.FinishAsRoot()
|
|
return bld.Finish(), nil
|
|
}
|
|
|
|
func parseBlockBytes(data []byte, blk *Block) error {
|
|
msg, err := zap.Parse(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if msg.Size() != len(data) {
|
|
return fmt.Errorf("mpcvm block: trailing bytes")
|
|
}
|
|
o := msg.Root()
|
|
copy(blk.ParentID_[:], o.BytesFixedSlice(blkParent, 32))
|
|
blk.BlockHeight = o.Uint64(blkHeight)
|
|
blk.BlockTimestamp = o.Int64(blkTime)
|
|
copy(blk.StateRoot[:], o.BytesFixedSlice(blkRoot, 32))
|
|
|
|
opLens := readU32List(o, blkOpLens)
|
|
if len(opLens) == 0 {
|
|
blk.Operations = nil
|
|
return nil
|
|
}
|
|
opBlob := o.Bytes(blkOpBlob)
|
|
blk.Operations = make([]*Operation, 0, len(opLens))
|
|
pos := 0
|
|
for _, l := range opLens {
|
|
if pos+int(l) > len(opBlob) {
|
|
return fmt.Errorf("mpcvm block: operation blob out of bounds")
|
|
}
|
|
omsg, err := zap.Parse(opBlob[pos : pos+int(l)])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
op, err := readOperation(omsg.Root())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
blk.Operations = append(blk.Operations, op)
|
|
pos += int(l)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- CrossChainMPCRequest ----
|
|
//
|
|
// Type bytes @ 0
|
|
// ReqChain bytes @ 8
|
|
// KeyID bytes @ 16
|
|
// KeyType bytes @ 24
|
|
// MessageHash bytes @ 32
|
|
// MessageType bytes @ 40
|
|
const (
|
|
ccType = 0
|
|
ccReqChn = 8
|
|
ccKeyID = 16
|
|
ccKeyType = 24
|
|
ccMsgHash = 32
|
|
ccMsgType = 40
|
|
ccSize = 48
|
|
)
|
|
|
|
func (r *CrossChainMPCRequest) Marshal() ([]byte, error) {
|
|
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)
|
|
ob.SetBytes(ccType, []byte(r.Type))
|
|
ob.SetBytes(ccReqChn, []byte(r.RequestingChain))
|
|
ob.SetBytes(ccKeyID, []byte(r.KeyID))
|
|
ob.SetBytes(ccKeyType, []byte(r.KeyType))
|
|
ob.SetBytes(ccMsgHash, r.MessageHash)
|
|
ob.SetBytes(ccMsgType, []byte(r.MessageType))
|
|
ob.FinishAsRoot()
|
|
return b.Finish(), nil
|
|
}
|
|
|
|
func parseCrossChainMPCRequest(data []byte, r *CrossChainMPCRequest) error {
|
|
msg, err := zap.Parse(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if msg.Size() != len(data) {
|
|
return fmt.Errorf("mpcvm cross-chain request: trailing bytes")
|
|
}
|
|
o := msg.Root()
|
|
r.Type = string(o.Bytes(ccType))
|
|
r.RequestingChain = string(o.Bytes(ccReqChn))
|
|
r.KeyID = string(o.Bytes(ccKeyID))
|
|
r.KeyType = string(o.Bytes(ccKeyType))
|
|
r.MessageHash = appendBytes(o.Bytes(ccMsgHash))
|
|
r.MessageType = string(o.Bytes(ccMsgType))
|
|
return nil
|
|
}
|
|
|
|
// ---- KeyRecord (replicated custody-key registry entry) ----
|
|
//
|
|
// KeyID bytes @ 0
|
|
// Kind bytes @ 8
|
|
// PolicyK i64 @ 16
|
|
// PolicyN i64 @ 24
|
|
// GroupPub bytes @ 32
|
|
// Address bytes @ 40
|
|
// Generation u64 @ 48
|
|
// CreatedHt u64 @ 56
|
|
// PartyLens list @ 64
|
|
// PartyBlob bytes @ 72
|
|
//
|
|
// The polynomial degree is NOT a field: it is Policy.Degree(). Storing both a
|
|
// policy and a degree invites them to disagree, and a stored disagreement in a
|
|
// custody record is a wrong-degree key.
|
|
const (
|
|
krKeyID = 0
|
|
krKind = 8
|
|
krPolicyK = 16
|
|
krPolicyN = 24
|
|
krGroupPub = 32
|
|
krAddr = 40
|
|
krGen = 48
|
|
krCreatedHt = 56
|
|
krPartyLens = 64
|
|
krPartyBlob = 72
|
|
krSize = 80
|
|
)
|
|
|
|
func marshalKeyRecord(r *KeyRecord) ([]byte, error) {
|
|
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)
|
|
partyLensOff := writeU32List(bld, partyLens)
|
|
|
|
ob := bld.StartObject(krSize)
|
|
ob.SetBytes(krKeyID, []byte(r.KeyID))
|
|
ob.SetBytes(krKind, []byte(r.Kind))
|
|
ob.SetInt64(krPolicyK, int64(r.Policy.K))
|
|
ob.SetInt64(krPolicyN, int64(r.Policy.N))
|
|
ob.SetBytes(krGroupPub, r.GroupPublicKey)
|
|
ob.SetBytes(krAddr, r.Address)
|
|
ob.SetUint64(krGen, r.Generation)
|
|
ob.SetUint64(krCreatedHt, r.CreatedHeight)
|
|
ob.SetList(krPartyLens, partyLensOff, len(partyLens))
|
|
ob.SetBytes(krPartyBlob, partyBlob)
|
|
ob.FinishAsRoot()
|
|
return bld.Finish(), nil
|
|
}
|
|
|
|
func parseKeyRecord(data []byte) (*KeyRecord, error) {
|
|
msg, err := zap.Parse(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if msg.Size() != len(data) {
|
|
return nil, fmt.Errorf("mpcvm key record: trailing bytes")
|
|
}
|
|
o := msg.Root()
|
|
return &KeyRecord{
|
|
KeyID: string(o.Bytes(krKeyID)),
|
|
Kind: string(o.Bytes(krKind)),
|
|
Policy: quorum.Policy{K: int(o.Int64(krPolicyK)), N: int(o.Int64(krPolicyN))},
|
|
GroupPublicKey: appendBytes(o.Bytes(krGroupPub)),
|
|
Address: appendBytes(o.Bytes(krAddr)),
|
|
Generation: o.Uint64(krGen),
|
|
CreatedHeight: o.Uint64(krCreatedHt),
|
|
Participants: stringsToPartyIDs(unpackStrings(readU32List(o, krPartyLens), o.Bytes(krPartyBlob))),
|
|
}, nil
|
|
}
|
|
|
|
// ---- CeremonyRecord (replicated ceremony log entry) ----
|
|
//
|
|
// ID bytes @ 0
|
|
// Kind bytes @ 8
|
|
// KeyID bytes @ 16
|
|
// Digest bytes @ 24
|
|
// Artifact bytes @ 32
|
|
// ReqChain bytes @ 40
|
|
// Height u64 @ 48
|
|
// SignerLens list @ 56
|
|
// SignerBlob bytes @ 64
|
|
const (
|
|
crID = 0
|
|
crKind = 8
|
|
crKeyID = 16
|
|
crDigest = 24
|
|
crArtifact = 32
|
|
crReqChain = 40
|
|
crHeight = 48
|
|
crSignerLens = 56
|
|
crSignerBlob = 64
|
|
crSize = 72
|
|
)
|
|
|
|
func marshalCeremonyRecord(c *CeremonyRecord) ([]byte, error) {
|
|
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)
|
|
signerLensOff := writeU32List(bld, signerLens)
|
|
|
|
ob := bld.StartObject(crSize)
|
|
ob.SetBytes(crID, []byte(c.ID))
|
|
ob.SetBytes(crKind, []byte(c.Kind))
|
|
ob.SetBytes(crKeyID, []byte(c.KeyID))
|
|
ob.SetBytes(crDigest, c.Digest)
|
|
ob.SetBytes(crArtifact, c.Artifact)
|
|
ob.SetBytes(crReqChain, []byte(c.RequestingChain))
|
|
ob.SetUint64(crHeight, c.Height)
|
|
ob.SetList(crSignerLens, signerLensOff, len(signerLens))
|
|
ob.SetBytes(crSignerBlob, signerBlob)
|
|
ob.FinishAsRoot()
|
|
return bld.Finish(), nil
|
|
}
|
|
|
|
func parseCeremonyRecord(data []byte) (*CeremonyRecord, error) {
|
|
msg, err := zap.Parse(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if msg.Size() != len(data) {
|
|
return nil, fmt.Errorf("mpcvm ceremony record: trailing bytes")
|
|
}
|
|
o := msg.Root()
|
|
return &CeremonyRecord{
|
|
ID: string(o.Bytes(crID)),
|
|
Kind: string(o.Bytes(crKind)),
|
|
KeyID: string(o.Bytes(crKeyID)),
|
|
Digest: appendBytes(o.Bytes(crDigest)),
|
|
Artifact: appendBytes(o.Bytes(crArtifact)),
|
|
RequestingChain: string(o.Bytes(crReqChain)),
|
|
Height: o.Uint64(crHeight),
|
|
Signers: stringsToPartyIDs(unpackStrings(readU32List(o, crSignerLens), o.Bytes(crSignerBlob))),
|
|
}, nil
|
|
}
|
|
|
|
// ---- shared helpers ----
|
|
|
|
func boolByte(b bool) uint8 {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func writeU32List(b *zap.Builder, xs []uint32) int {
|
|
lb := b.StartList(4)
|
|
for _, x := range xs {
|
|
lb.AddUint32(x)
|
|
}
|
|
off, _ := lb.Finish()
|
|
return off
|
|
}
|
|
|
|
func readU32List(o zap.Object, ptrOff int) []uint32 {
|
|
l := o.ListStride(ptrOff, 4)
|
|
n := l.Len()
|
|
out := make([]uint32, n)
|
|
for i := 0; i < n; i++ {
|
|
out[i] = l.Uint32(i)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// packStrings returns per-string byte lengths and the concatenated blob.
|
|
func packStrings(ss []string) ([]uint32, []byte) {
|
|
lens := make([]uint32, len(ss))
|
|
var blob []byte
|
|
for i, s := range ss {
|
|
lens[i] = uint32(len(s))
|
|
blob = append(blob, s...)
|
|
}
|
|
return lens, blob
|
|
}
|
|
|
|
// unpackStrings re-splits blob by lens. Returns nil (not empty) for no entries.
|
|
func unpackStrings(lens []uint32, blob []byte) []string {
|
|
if len(lens) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(lens))
|
|
pos := 0
|
|
for _, l := range lens {
|
|
if pos+int(l) > len(blob) {
|
|
break
|
|
}
|
|
out = append(out, string(blob[pos:pos+int(l)]))
|
|
pos += int(l)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func partyIDsToStrings(ids []party.ID) []string {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, len(ids))
|
|
for i, id := range ids {
|
|
out[i] = string(id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func stringsToPartyIDs(ss []string) []party.ID {
|
|
if len(ss) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]party.ID, len(ss))
|
|
for i, s := range ss {
|
|
out[i] = party.ID(s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func appendBytes(b []byte) []byte {
|
|
if len(b) == 0 {
|
|
return nil
|
|
}
|
|
return append([]byte(nil), b...)
|
|
}
|