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

209 lines
6.2 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// wire_test.go — the persisted/replicated encodings must round-trip exactly.
//
// These are not cosmetic checks. Every type here is either hashed into the
// state root or read back as consensus state, so a field that silently fails
// to survive the wire is a field on which two validators can disagree while
// both believing they applied the same block.
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/quorum"
"github.com/luxfi/zap"
)
// sampleKeyRecord is a structurally valid registry entry: 33-byte compressed
// group key, 20-byte address, participants sorted and sized to the policy.
func sampleKeyRecord() *KeyRecord {
pub := make([]byte, 33)
pub[0] = 0x02
for i := 1; i < 33; i++ {
pub[i] = byte(i)
}
addr := make([]byte, 20)
for i := range addr {
addr[i] = byte(0xA0 + i)
}
return &KeyRecord{
KeyID: "bridge-custody",
Kind: KindCGGMP21,
Policy: quorum.MustNew(2, 3),
Participants: []party.ID{"p1", "p2", "p3"},
GroupPublicKey: pub,
Address: addr,
Generation: 5,
CreatedHeight: 42,
}
}
func sampleSignature() []byte {
sig := make([]byte, 65)
for i := range sig {
sig[i] = byte(i)
}
return sig
}
func TestWireRoundTrip_Operation_Sign(t *testing.T) {
require := require.New(t)
op := &Operation{
Type: OpTypeSign,
CeremonyID: "mpc/deadbeef",
KeyID: "key-1",
RequestingChain: "B-Chain",
Digest: make([]byte, 32),
Artifact: sampleSignature(),
Signers: []party.ID{"p1", "p2"},
Timestamp: 1_700_000_000,
}
b, err := marshalOperation(op)
require.NoError(err)
msg, err := zap.Parse(b)
require.NoError(err)
got, err := readOperation(msg.Root())
require.NoError(err)
require.Equal(op, got)
}
// A keygen operation carries the key record it asks consensus to register.
// That record is the whole payload of the transition, so it must survive the
// wire byte-for-byte — including the policy, from which the degree is derived.
func TestWireRoundTrip_Operation_Keygen(t *testing.T) {
require := require.New(t)
rec := sampleKeyRecord()
commit := KeyCommitDigest(rec)
op := &Operation{
Type: OpTypeKeygen,
CeremonyID: "mpc/keygen/cafe",
KeyID: rec.KeyID,
RequestingChain: "B-Chain",
Digest: commit[:],
Artifact: sampleSignature(),
Signers: rec.Participants,
Key: rec,
Timestamp: 1_700_000_001,
}
b, err := marshalOperation(op)
require.NoError(err)
msg, err := zap.Parse(b)
require.NoError(err)
got, err := readOperation(msg.Root())
require.NoError(err)
require.Equal(op, got)
// The operation digest is what the state root is folded over: identical
// operations must give an identical digest after a wire round trip, or two
// validators applying the same block compute different roots.
require.Equal(op.digest(), got.digest())
}
func TestWireRoundTrip_Block(t *testing.T) {
require := require.New(t)
rec := sampleKeyRecord()
commit := KeyCommitDigest(rec)
blk := &Block{
ID_: ids.ID{9}, // derived; must NOT affect wire
ParentID_: ids.ID{1, 2, 3},
BlockHeight: 42,
BlockTimestamp: 1_700_000_000,
StateRoot: [32]byte{7, 7, 7},
Operations: []*Operation{
{
Type: OpTypeKeygen, CeremonyID: "c1", KeyID: rec.KeyID,
Digest: commit[:], Artifact: sampleSignature(),
Signers: rec.Participants, Key: rec, Timestamp: 1,
},
{
Type: OpTypeSign, CeremonyID: "c2", KeyID: rec.KeyID,
Digest: make([]byte, 32), Artifact: sampleSignature(),
Signers: []party.ID{"p1", "p2"}, Timestamp: 2,
},
},
}
b, err := blk.Marshal()
require.NoError(err)
var got Block
require.NoError(parseBlockBytes(b, &got))
require.Equal(blk.ParentID_, got.ParentID_)
require.Equal(blk.BlockHeight, got.BlockHeight)
require.Equal(blk.BlockTimestamp, got.BlockTimestamp)
require.Equal(blk.StateRoot, got.StateRoot)
require.Equal(blk.Operations, got.Operations)
// canonical: trailing byte rejected
require.Error(parseBlockBytes(append(b, 0), &got))
// empty-operations block: Operations stays nil
empty := &Block{ParentID_: ids.ID{5}, BlockHeight: 1}
eb, err := empty.Marshal()
require.NoError(err)
var egot Block
require.NoError(parseBlockBytes(eb, &egot))
require.Nil(egot.Operations)
}
// The key record is the custody registry entry. Policy is stored as K and N and
// the degree is derived from them, so the round trip must preserve the policy
// exactly — a policy that decoded to something else is a key that needs a
// different quorum than the registry claims.
func TestWireRoundTrip_KeyRecord(t *testing.T) {
require := require.New(t)
rec := sampleKeyRecord()
require.NoError(rec.Validate())
b, err := marshalKeyRecord(rec)
require.NoError(err)
got, err := parseKeyRecord(b)
require.NoError(err)
require.Equal(rec, got)
require.Equal(rec.Policy.String(), got.Policy.String())
require.Equal(rec.Degree(), got.Degree())
_, err = parseKeyRecord(append(b, 0))
require.Error(err, "trailing bytes must be rejected")
}
func TestWireRoundTrip_CeremonyRecord(t *testing.T) {
require := require.New(t)
c := &CeremonyRecord{
ID: "mpc/abc123",
Kind: OpTypeSign,
KeyID: "bridge-custody",
Digest: make([]byte, 32),
Signers: []party.ID{"p1", "p2", "p3"},
Artifact: sampleSignature(),
RequestingChain: "B-Chain",
Height: 99,
}
b, err := marshalCeremonyRecord(c)
require.NoError(err)
got, err := parseCeremonyRecord(b)
require.NoError(err)
require.Equal(c, got)
_, err = parseCeremonyRecord(append(b, 0))
require.Error(err, "trailing bytes must be rejected")
}
func TestWireRoundTrip_CrossChainMPCRequest(t *testing.T) {
require := require.New(t)
r := &CrossChainMPCRequest{
Type: "sign", RequestingChain: "B-Chain", KeyID: "key-1",
KeyType: "secp256k1", MessageHash: []byte("h"), MessageType: "eth_sign",
}
b, err := r.Marshal()
require.NoError(err)
var got CrossChainMPCRequest
require.NoError(parseCrossChainMPCRequest(b, &got))
require.Equal(r, &got)
}