Files
chains/mpcvm/wire.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

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...)
}