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]>
157 lines
6.1 KiB
Go
157 lines
6.1 KiB
Go
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package mpcvm
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/luxfi/constants"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/threshold/pkg/party"
|
|
"github.com/luxfi/threshold/pkg/quorum"
|
|
)
|
|
|
|
// canonicalVMIDCB58 is M-Chain's vmID in the encoding the node's plugin
|
|
// registry uses. The plugin binary MUST be installed under exactly this
|
|
// filename; the registry resolves a CreateChainTx's vmID to an implementation
|
|
// by looking for a file with this name in the plugin directory.
|
|
//
|
|
// It is written out literally rather than computed so that a change to the
|
|
// vmID — for any reason, including an "obviously equivalent" refactor of the
|
|
// constant — fails this test instead of silently producing a chain that no
|
|
// node can start. A vmID is an immutable one-way door: it is baked into the
|
|
// genesis CreateChainTx and stored by the P-Chain forever.
|
|
const canonicalVMIDCB58 = "qCURact1n41FcoNBch8iMVBwc9AWie48D118ZNJ5tBdWrvryS"
|
|
|
|
// staleThresholdVMCB58 is what this VM used to declare. The package was split
|
|
// into mpcvm (M-Chain) and fhevm (F-Chain) per LP-7050, but the private
|
|
// `thresholdvm` literal survived in factory.go and the node's Dockerfile
|
|
// installed the plugin under this name while genesis declared MPCVMID. The two
|
|
// never met, so M-Chain could not have started. Pinned here so the dead name
|
|
// can never come back by accident.
|
|
const staleThresholdVMCB58 = "tGVBwRxpmD2aFdg3iYjgRvrCe8Jcmq9UNKxyHMus2NZ8WcD8t"
|
|
|
|
func TestVMID_IsCanonicalAndStable(t *testing.T) {
|
|
if VMID != constants.MPCVMID {
|
|
t.Fatalf("VMID = %s, want constants.MPCVMID = %s", VMID, constants.MPCVMID)
|
|
}
|
|
if got := VMID.String(); got != canonicalVMIDCB58 {
|
|
t.Fatalf("VMID CB58 = %q, want %q\n"+
|
|
"A vmID cannot change after a chain is created with it. If this is an\n"+
|
|
"intentional pre-launch change, every declaration must move together:\n"+
|
|
" luxfi/constants vm_ids.go MPCVMID\n"+
|
|
" node/genesis/builder registry.go M-Chain row\n"+
|
|
" node/node vms.go OptionalVMs\n"+
|
|
" node/Dockerfile plugin build -o, verify list, runtime COPY\n"+
|
|
" node/scripts publish_plugin_set.sh\n"+
|
|
" chains/mpcvm factory.go, this test",
|
|
got, canonicalVMIDCB58)
|
|
}
|
|
if VMID.String() == staleThresholdVMCB58 {
|
|
t.Fatal("VMID regressed to the retired thresholdvm identifier")
|
|
}
|
|
if VMID == ids.Empty {
|
|
t.Fatal("VMID is the empty id")
|
|
}
|
|
}
|
|
|
|
// The bytes themselves, spelled out: an ASCII name left-padded into 32 bytes.
|
|
func TestVMID_Bytes(t *testing.T) {
|
|
want := ids.ID{'m', 'p', 'c', 'v', 'm'}
|
|
if VMID != want {
|
|
t.Fatalf("VMID bytes = %v, want %v", VMID[:8], want[:8])
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// The k-vs-t fix, at the M-Chain level
|
|
// -----------------------------------------------------------------------------
|
|
|
|
// A custody key registered as 3-of-5 must be a degree-2 key. This is the same
|
|
// property the types package asserts for ceremonies, checked here on the record
|
|
// that actually governs signature verification.
|
|
func TestKeyRecord_ThreeOfFiveIsDegreeTwo(t *testing.T) {
|
|
rec := &KeyRecord{
|
|
KeyID: "custody",
|
|
Kind: KindCGGMP21,
|
|
Policy: quorum.MustNew(3, 5),
|
|
Participants: sortedParties("a", "b", "c", "d", "e"),
|
|
GroupPublicKey: make([]byte, 33),
|
|
Address: make([]byte, 20),
|
|
}
|
|
if got := rec.Degree(); got != 2 {
|
|
t.Fatalf("3-of-5 custody key Degree() = %d, want 2", got)
|
|
}
|
|
if got := rec.Policy.K; got != 3 {
|
|
t.Fatalf("3-of-5 needs %d signers, want 3", got)
|
|
}
|
|
if err := rec.Validate(); err != nil {
|
|
t.Fatalf("a well-formed 3-of-5 record must validate, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Validate is the gate every key crosses before entering consensus state, so
|
|
// each structural invariant it enforces is load-bearing for a later signature
|
|
// check. Prove each one actually rejects.
|
|
func TestKeyRecord_ValidateRejectsMalformed(t *testing.T) {
|
|
good := func() *KeyRecord {
|
|
return &KeyRecord{
|
|
KeyID: "custody",
|
|
Kind: KindCGGMP21,
|
|
Policy: quorum.MustNew(3, 5),
|
|
Participants: sortedParties("a", "b", "c", "d", "e"),
|
|
GroupPublicKey: make([]byte, 33),
|
|
Address: make([]byte, 20),
|
|
}
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
mutup func(*KeyRecord)
|
|
}{
|
|
{"no key id", func(r *KeyRecord) { r.KeyID = "" }},
|
|
{"no protocol kind", func(r *KeyRecord) { r.Kind = "" }},
|
|
{"unset policy", func(r *KeyRecord) { r.Policy = quorum.Policy{} }},
|
|
{"1-of-n is not a threshold policy", func(r *KeyRecord) { r.Policy = quorum.Policy{K: 1, N: 5} }},
|
|
{"two disjoint quorums", func(r *KeyRecord) { r.Policy = quorum.Policy{K: 2, N: 5} }},
|
|
{"participant count disagrees with N", func(r *KeyRecord) { r.Participants = sortedParties("a", "b", "c") }},
|
|
{"participants not canonical", func(r *KeyRecord) { r.Participants = sortedParties("e", "d", "c", "b", "a") }},
|
|
{"duplicate participant", func(r *KeyRecord) { r.Participants = sortedParties("a", "a", "c", "d", "e") }},
|
|
{"uncompressed group key", func(r *KeyRecord) { r.GroupPublicKey = make([]byte, 65) }},
|
|
{"short address", func(r *KeyRecord) { r.Address = make([]byte, 19) }},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
r := good()
|
|
tc.mutup(r)
|
|
if err := r.Validate(); err == nil {
|
|
t.Fatalf("Validate accepted a record with %s", tc.name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The degree a key was generated with is never stored as its own field, so it
|
|
// cannot disagree with the policy. Assert the property holds across the range
|
|
// M-Chain will realistically deploy.
|
|
func TestKeyRecord_DegreeIsAlwaysKMinusOne(t *testing.T) {
|
|
for n := 2; n <= 12; n++ {
|
|
for k := 2; k <= n; k++ {
|
|
p := quorum.MustNew(k, n)
|
|
rec := &KeyRecord{Policy: p}
|
|
if got, want := rec.Degree(), k-1; got != want {
|
|
t.Fatalf("%s: Degree() = %d, want %d", p, got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// sortedParties builds a canonical participant list. Participants must be
|
|
// sorted and unique so every validator hashes the same bytes for a record.
|
|
func sortedParties(names ...string) []party.ID {
|
|
out := make([]party.ID, len(names))
|
|
for i, n := range names {
|
|
out[i] = party.ID(n)
|
|
}
|
|
return out
|
|
}
|