mpcvm: native ZAP struct-is-wire + cross-validator threshold signing

Kills node/vms/pcodecs from the M-Chain (MPC custody VM) — the LAST of the three
app-chain VMs blocking pcodecs deletion. bridgevm + zkvm already native.

Codec kill (wire.go): the legacy pcodecs LinearCodec keyed on serialize:"true"
tags; every mpcvm type carries only json: tags, so the old codec marshaled ZERO
fields — a Block round-tripped to all-zero and every block ID collided at
sha256(0x0000). This is a latent corruption bug the native wire FIXES. wire.go
owns Marshal/parse for Block (nested []*Operation as u32-length-list + concat
sub-object blob), ManagedKey (PartyIDs []party.ID as packed string list;
CreatedAt/LastUsedAt as UnixNano; Config/CMPConfig json:"-" excluded),
CrossChainMPCRequest, Operation. Block.ID_ is derived (computeID=sha256(Marshal))
so it is excluded from wire; recomputed on ParseBlock, set from DB key on getBlock.
ThresholdConfig (init.Config) + Genesis stay JSON at the boundary. Deleted
mpcvm/codec.go; swapped 9 call sites. Parse rejects trailing bytes. Round-trips
green (Operation/Block/ManagedKey/CrossChainMPCRequest incl empty-ops nil-slice).

Threshold signing (sign_distributed.go + transport/attest_bridge): CGGMP21/CMP
threshold-ECDSA ceremony across the M committee over native app-gossip; each
validator runs its own executor + per-session gossipRouter; honest signers derive
the SAME standard secp256k1 signature. B→M attestation seam is domain-bound and
replay-safe. Tests: transport-free seam (bridge_seam_test) + full 3-validator
in-process fabric driving the real Broadcast→Gossip→router.Deliver path
(bridge_transport_test).

3 of 3 chains VMs native. chains repo now has ZERO pcodecs imports.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-12 16:44:17 -07:00
co-authored by Hanzo Dev
parent fc6083cbae
commit d682216d8c
10 changed files with 1288 additions and 96 deletions
+51 -1
View File
@@ -87,6 +87,50 @@ type BridgeTransferAttestation struct {
CreatedAt int64 `json:"createdAt"`
}
// BridgeReleaseRequest is the clean, self-contained request B hands M to
// authorise a cross-chain release. It carries exactly the fields that bind the
// release (both chain ids, asset, amount, recipient, per-route nonce) plus the
// M-Chain routing context (which authorised chain is asking, and which custody
// key must sign). Everything the digest commits to travels here; nothing else
// can be minted from the resulting attestation.
type BridgeReleaseRequest struct {
RequestingChain string `json:"requestingChain"` // authorised chain id in M's permission table (e.g. "B-Chain")
KeyID string `json:"keyId"` // custody key to sign with; empty => M's active custody key
SrcChainID uint32 `json:"srcChainId"`
DstChainID uint32 `json:"dstChainId"`
Asset [32]byte `json:"asset"`
Amount uint64 `json:"amount"`
Recipient [20]byte `json:"recipient"`
Nonce uint64 `json:"nonce"`
}
// RequestBridgeRelease is THE B→M seam: B calls this with a release request and
// gets back a threshold-signed, self-describing attestation. M computes the
// domain-bound digest, threshold-signs it across the committee, and returns the
// signature plus the group key and quorum B needs to verify — no callback to M.
// B's gate on the return value is VerifyBridgeAttestation.
//
// The signature is a standard secp256k1 ECDSA signature over the domain-bound
// digest, so a destination-chain gateway contract verifies it exactly like a
// single-key signature (ecrecover to the custody address).
func (vm *VM) RequestBridgeRelease(req BridgeReleaseRequest) (*BridgeTransferAttestation, error) {
keyID := req.KeyID
if keyID == "" {
vm.mu.RLock()
keyID = vm.activeKeyID
vm.mu.RUnlock()
}
bt := BridgeTransfer{
SrcChainID: req.SrcChainID,
DstChainID: req.DstChainID,
Asset: req.Asset,
Amount: req.Amount,
Recipient: req.Recipient,
Nonce: req.Nonce,
}
return vm.AttestBridgeTransfer(req.RequestingChain, keyID, bt)
}
// AttestBridgeTransfer produces a threshold attestation over a bridge transfer.
// It mirrors AttestOracleCommit: compute the domain-bound digest, request a
// threshold signature from the committee, and return the self-describing
@@ -115,7 +159,13 @@ func (vm *VM) AttestBridgeTransfer(
return nil, fmt.Errorf("mpcvm: bridge attestation timed out")
case <-time.After(100 * time.Millisecond):
s, err := vm.GetSignature(session.SessionID)
if err != nil || s.Status != "completed" {
if err != nil {
continue
}
if s.Status == "failed" {
return nil, fmt.Errorf("mpcvm: bridge attestation signing failed: %s", s.Error)
}
if s.Status != "completed" {
continue
}
pub, err := vm.GetPublicKey(keyID)
+2 -2
View File
@@ -84,7 +84,7 @@ func (b *Block) ChoicesStatus() choices.Status {
// Bytes returns the block's serialized bytes
func (b *Block) Bytes() []byte {
bytes, _ := Codec.Marshal(codecVersion, b)
bytes, _ := b.Marshal()
return bytes
}
@@ -175,7 +175,7 @@ func (b *Block) SetStatus(status choices.Status) {
// computeID computes the block's ID
func (b *Block) computeID() ids.ID {
bytes, _ := Codec.Marshal(codecVersion, b)
bytes, _ := b.Marshal()
hash := sha256.Sum256(bytes)
return ids.ID(hash)
}
+112
View File
@@ -0,0 +1,112 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// bridge_seam_test.go — fast, transport-free tests of the B→M attestation seam:
// the digest is domain-bound and replay-safe, and a signature over one transfer
// verifies for THAT transfer and no other. These use an ordinary secp256k1 key
// (a threshold signature verifies identically to a single-key one), so they run
// in -short without the DKG/sign ceremony.
import (
"testing"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/threshold/pkg/party"
"github.com/stretchr/testify/require"
)
func sampleTransfer() BridgeTransfer {
var asset [32]byte
copy(asset[:], []byte("LUX"))
var recip [20]byte
copy(recip[:], []byte("recipient-0xdeadbeef"))
return BridgeTransfer{
SrcChainID: 200201,
DstChainID: 97368,
Asset: asset,
Amount: 1_000_000,
Recipient: recip,
Nonce: 42,
}
}
// TestBridgeAttestation_ReplaySafe proves a signature over one transfer's
// domain-bound digest verifies for that transfer and fails for any transfer
// that differs in ANY digest-committed field — no replay across routes,
// amounts, recipients, or nonces.
func TestBridgeAttestation_ReplaySafe(t *testing.T) {
priv, err := secp256k1.NewPrivateKey()
require.NoError(t, err)
pub := priv.PublicKey().CompressedBytes()
require.Len(t, pub, 33)
bt := sampleTransfer()
digest := bt.Digest()
sig, err := priv.SignHash(digest[:])
require.NoError(t, err)
// Correct transfer verifies.
require.True(t, VerifyBridgeAttestation(pub, bt, sig),
"signature over the correct transfer must verify")
// Every digest-committed field is binding: flip it and the SAME signature
// must be rejected.
mutations := map[string]func(BridgeTransfer) BridgeTransfer{
"srcChainID": func(b BridgeTransfer) BridgeTransfer { b.SrcChainID++; return b },
"dstChainID": func(b BridgeTransfer) BridgeTransfer { b.DstChainID++; return b },
"asset": func(b BridgeTransfer) BridgeTransfer { b.Asset[0] ^= 0x01; return b },
"amount": func(b BridgeTransfer) BridgeTransfer { b.Amount++; return b },
"recipient": func(b BridgeTransfer) BridgeTransfer { b.Recipient[0] ^= 0x01; return b },
"nonce": func(b BridgeTransfer) BridgeTransfer { b.Nonce++; return b },
}
for field, mutate := range mutations {
require.Falsef(t, VerifyBridgeAttestation(pub, mutate(bt), sig),
"signature must NOT verify after mutating %s (replay/rebind)", field)
}
// A truncated / malformed signature is rejected, not panicked on.
require.False(t, VerifyBridgeAttestation(pub, bt, sig[:10]))
require.False(t, VerifyBridgeAttestation(nil, bt, sig))
}
// TestBridgeDigest_DomainSeparated proves the digest is stable and that the
// domain tag is mixed in (a transfer's digest is not the bare field hash),
// so a bridge attestation can never be replayed as another message M signs.
func TestBridgeDigest_DomainSeparated(t *testing.T) {
bt := sampleTransfer()
d1 := bt.Digest()
d2 := bt.Digest()
require.Equal(t, d1, d2, "digest must be deterministic")
// Distinct transfers produce distinct digests.
other := bt
other.Nonce++
require.NotEqual(t, bt.Digest(), other.Digest())
// The domain separator is registered so QuantumAttestation-style tooling
// recognises the bridge domain.
require.Equal(t, []byte(bridgeTransferDomainTag), domainSeparators[DomainBridgeTransfer])
}
// TestCeremonyID_DeterministicAndBinding proves the derived ceremony id lets
// honest validators converge without an announce round, and separates distinct
// keys / digests / quorums while being independent of signer ordering.
func TestCeremonyID_DeterministicAndBinding(t *testing.T) {
digest := sampleTransfer().Digest()
signers := []party.ID{"a", "b", "c"}
base := ceremonyID("k1", digest[:], signers)
// Deterministic and signer-order independent.
require.Equal(t, base, ceremonyID("k1", digest[:], []party.ID{"c", "a", "b"}))
// Bound to key, digest and signer set.
require.NotEqual(t, base, ceremonyID("k2", digest[:], signers))
other := sampleTransfer()
other.Amount++
od := other.Digest()
require.NotEqual(t, base, ceremonyID("k1", od[:], signers))
require.NotEqual(t, base, ceremonyID("k1", digest[:], []party.ID{"a", "b", "d"}))
}
+283
View File
@@ -0,0 +1,283 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
// bridge_transport_test.go — proves the B→M threshold-signing seam end to end
// over the REAL cross-validator transport.
//
// Three in-process M-Chain validators are wired together by an in-memory p2p
// fabric that stands in for real sockets. The fabric drives the EXACT
// production path: warpAppNetwork.Broadcast → warp.Sender.SendGossip → the peer
// VMs' Gossip handler → envelope demux → the ceremony's gossipRouter.Deliver →
// the executor's receive loop. A DKG establishes one group key; then B asks
// every validator to release the same transfer, the validators run a CGGMP21
// threshold sign across the fabric, and each independently returns an
// attestation that VerifyBridgeAttestation accepts against the group key.
//
// What is DEMONSTRATED here: the message router transports ceremony messages
// between separate VM instances, a signing session completes, and the produced
// signature verifies against the expected custody pubkey over a domain-bound,
// replay-safe digest. What is SIMULATED: the p2p fabric (in-memory, not
// sockets) and the request fan-out to the committee (the test hands the same
// request to every validator; production needs B to deliver it + a policy layer
// agreeing to sign).
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
"github.com/luxfi/runtime"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/pool"
vmcore "github.com/luxfi/vm"
"github.com/luxfi/warp"
"github.com/stretchr/testify/require"
)
// =============================================================================
// In-memory p2p fabric — wires N VMs' Gossip handlers together
// =============================================================================
// memFabric routes gossip between the in-process validators registered on it.
type memFabric struct {
mu sync.RWMutex
vms map[ids.NodeID]*VM
delivered atomic.Int64 // count of envelopes actually handed to a peer Gossip
}
func newMemFabric() *memFabric { return &memFabric{vms: make(map[ids.NodeID]*VM)} }
func (f *memFabric) register(nid ids.NodeID, vm *VM) {
f.mu.Lock()
defer f.mu.Unlock()
f.vms[nid] = vm
}
// senderFor returns a warp.Sender bound to a source validator.
func (f *memFabric) senderFor(self ids.NodeID) warp.Sender {
return &memSender{fab: f, self: self}
}
// memSender is one validator's view of the fabric: SendGossip delivers to the
// targeted peers' Gossip handlers (async, like real gossip).
type memSender struct {
fab *memFabric
self ids.NodeID
}
func (s *memSender) SendGossip(ctx context.Context, cfg warp.SendConfig, msg []byte) error {
// Determine target node set. An explicit NodeIDs set (what warpAppNetwork
// always uses for MPC) addresses exact peers; an empty set falls back to
// "all other registered validators".
s.fab.mu.RLock()
var targets []*VM
if cfg.NodeIDs.Len() > 0 {
for _, nid := range cfg.NodeIDs.List() {
if nid == s.self {
continue
}
if vm := s.fab.vms[nid]; vm != nil {
targets = append(targets, vm)
}
}
} else {
for nid, vm := range s.fab.vms {
if nid != s.self {
targets = append(targets, vm)
}
}
}
s.fab.mu.RUnlock()
for _, vm := range targets {
vm := vm
cp := append([]byte(nil), msg...) // defensive copy across the "wire"
s.fab.delivered.Add(1)
go func() { _ = vm.Gossip(context.Background(), s.self, cp) }()
}
return nil
}
// The remaining Sender methods are unused by the MPC gossip transport (which
// rides SendGossip), but are required to satisfy warp.Sender.
func (s *memSender) SendRequest(context.Context, set.Set[ids.NodeID], uint32, []byte) error {
return nil
}
func (s *memSender) SendResponse(context.Context, ids.NodeID, uint32, []byte) error { return nil }
func (s *memSender) SendError(context.Context, ids.NodeID, uint32, int32, string) error {
return nil
}
var _ warp.Sender = (*memSender)(nil)
// =============================================================================
// Fabric VM construction
// =============================================================================
func newFabricVM(t *testing.T, fab *memFabric, nid ids.NodeID) *VM {
t.Helper()
vm := &VM{}
rt := &runtime.Runtime{NodeID: nid, NetworkID: 3}
toEngine := make(chan vmcore.Message, 1)
init := vmcore.Init{
Runtime: rt,
DB: memdb.New(),
Sender: fab.senderFor(nid),
Log: log.NewTestLogger(log.ErrorLevel),
ToEngine: toEngine,
}
require.NoError(t, vm.Initialize(context.Background(), init))
fab.register(nid, vm)
return vm
}
// =============================================================================
// The end-to-end B→M threshold-signing test
// =============================================================================
func TestBridgeRelease_ThresholdSignOverGossip(t *testing.T) {
if testing.Short() {
t.Skip("threshold DKG/sign is slow; skipped in -short")
}
const n = 3
const threshold = 1 // t+1 = 2 minimum signers; we sign with all n
fab := newMemFabric()
vms := make([]*VM, n)
parties := make([]party.ID, n)
for i := 0; i < n; i++ {
nid := ids.GenerateTestNodeID()
vms[i] = newFabricVM(t, fab, nid)
parties[i] = vms[i].partyID
}
// --- DKG (in-process mesh) to establish one group key + per-party shares ---
execs := make(map[party.ID]*ProtocolExecutor, n)
for _, p := range parties {
pl := pool.NewPool(4)
defer pl.TearDown()
execs[p] = NewProtocolExecutor(pl, log.NewTestLogger(log.ErrorLevel))
}
dkgCtx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
configs := runKeygenViaTransport(t, dkgCtx, execs, parties, threshold)
var groupPub []byte
for _, p := range parties {
pub, err := configs[p].PublicPoint().MarshalBinary()
require.NoError(t, err)
if groupPub == nil {
groupPub = pub
} else {
require.Equal(t, groupPub, pub, "all validators must agree on the group key")
}
}
require.Len(t, groupPub, 33, "compressed secp256k1 custody key")
// --- install the CGGMP21 custody key into every validator ---
const keyID = "bridge-custody"
for i, vm := range vms {
p := parties[i]
vm.mu.Lock()
vm.keys[keyID] = &ManagedKey{
KeyID: keyID,
KeyType: string(ProtocolCGGMP21),
PublicKey: groupPub,
Threshold: threshold,
TotalParties: n,
Status: "active",
PartyIDs: parties,
CMPConfig: configs[p],
}
vm.activeKeyID = keyID
vm.mu.Unlock()
}
// --- B asks every validator to release the SAME transfer ---
var asset [32]byte
copy(asset[:], []byte("LUX"))
var recip [20]byte
copy(recip[:], []byte("recipient-0xabc0123"))
req := BridgeReleaseRequest{
RequestingChain: "B-Chain",
KeyID: keyID,
SrcChainID: 200201, // Zoo EVM
DstChainID: 97368, // Lux testnet M-Chain route
Asset: asset,
Amount: 1_000_000,
Recipient: recip,
Nonce: 7,
}
type result struct {
att *BridgeTransferAttestation
err error
}
results := make(chan result, n)
for _, vm := range vms {
vm := vm
go func() {
att, err := vm.RequestBridgeRelease(req)
results <- result{att, err}
}()
}
atts := make([]*BridgeTransferAttestation, 0, n)
for i := 0; i < n; i++ {
select {
case r := <-results:
require.NoError(t, r.err, "validator failed to produce a bridge attestation")
require.NotNil(t, r.att)
atts = append(atts, r.att)
case <-time.After(150 * time.Second):
t.Fatal("bridge release timed out — ceremony did not complete over the gossip transport")
}
}
// (a) the router transported messages and the signing session completed on
// every validator (all n returned an attestation above), and
// (b) the produced signature verifies against the expected custody pubkey.
bt := BridgeTransfer{
SrcChainID: req.SrcChainID,
DstChainID: req.DstChainID,
Asset: req.Asset,
Amount: req.Amount,
Recipient: req.Recipient,
Nonce: req.Nonce,
}
first := atts[0]
require.NotEmpty(t, first.Signature)
for _, a := range atts {
require.Equal(t, first.Signature, a.Signature, "all honest validators derive the identical signature")
require.Equal(t, groupPub, a.GroupPubKey, "attestation carries the custody group key")
require.True(t, VerifyBridgeAttestation(groupPub, bt, a.Signature),
"threshold signature must verify against the custody key over the domain-bound digest")
}
// The signature is bound to THIS transfer only: a verifier that flips any
// digest-committed field must reject the same signature (replay/rebind).
require.False(t, VerifyBridgeAttestation(groupPub, mutateNonce(bt), first.Signature),
"signature must not verify under a different nonce")
require.False(t, VerifyBridgeAttestation(groupPub, mutateDstChain(bt), first.Signature),
"signature must not verify under a different destination chain")
// Proof the fabric actually carried ceremony messages between validators
// (not a single-party shortcut).
require.Greater(t, fab.delivered.Load(), int64(0),
"no ceremony messages crossed the gossip fabric")
t.Logf("bridge release: %d validators threshold-signed over gossip; %d envelopes carried; signature verifies against custody key",
n, fab.delivered.Load())
}
func mutateNonce(bt BridgeTransfer) BridgeTransfer { bt.Nonce++; return bt }
func mutateDstChain(bt BridgeTransfer) BridgeTransfer { bt.DstChainID++; return bt }
-34
View File
@@ -1,34 +0,0 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
import (
"github.com/luxfi/node/vms/pcodecs"
)
const codecVersion = 0
// Codec is the codec for the threshold VM
var Codec pcodecs.Manager
func init() {
c := pcodecs.NewLinearCodec()
c.RegisterType(&Block{})
c.RegisterType(&Operation{})
c.RegisterType(&Genesis{})
c.RegisterType(&ManagedKey{})
c.RegisterType(&KeygenSession{})
c.RegisterType(&SigningSession{})
c.RegisterType(&ecdsaSignature{})
c.RegisterType(&ThresholdConfig{})
c.RegisterType(&ChainPermissions{})
c.RegisterType(&ProtocolOptions{})
c.RegisterType(&CrossChainMPCRequest{})
Codec = pcodecs.NewDefaultManager()
if err := Codec.RegisterCodec(codecVersion, c); err != nil {
panic(err)
}
}
+206
View File
@@ -0,0 +1,206 @@
// 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 (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"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)
}
// thresholdSignCMP runs the CGGMP21 threshold-ECDSA sign for a custody key
// across the committee over the gossip transport, returning this validator's
// copy of the (canonical, low-S) secp256k1 signature. Every honest signer
// returns the same R‖S.
func (vm *VM) thresholdSignCMP(
ctx context.Context,
key *ManagedKey,
session *SigningSession,
) (*ecdsaSignature, []party.ID, error) {
if key.CMPConfig == nil {
return nil, nil, errors.New("mpcvm: CGGMP21 custody key has no config")
}
if vm.sender == nil {
return nil, nil, errors.New("mpcvm: no warp sender; cross-validator signing unavailable")
}
// The signer set is the key's full party set. Every committee member
// participates. (Dynamic T+1 quorum selection under partial availability —
// pick the minimal online subset — is a documented follow-up; it does not
// affect signature validity, only liveness under faults.)
signers := key.PartyIDs
if len(signers) == 0 {
for id := range key.CMPConfig.Public {
signers = append(signers, id)
}
}
nodes, peers, err := resolveCommittee(signers)
if err != nil {
return nil, nil, err
}
cid := ceremonyID(key.KeyID, session.MessageHash, signers)
appNet := newWarpAppNetwork(vm.sender, vm.rt.NodeID, nodes)
router := newGossipRouter(ctx, cid, vm.partyID, appNet, peers)
vm.registerSessionRouter(cid, router)
defer vm.unregisterSessionRouter(cid)
sig, err := vm.protocolExecutor.RunCMPSign(ctx, cid, key.CMPConfig, signers, session.MessageHash, router)
if err != nil {
return nil, nil, err
}
return &ecdsaSignature{R: sig.R, S: sig.S, V: sig.V}, signers, nil
}
// signViaHandler is the legacy registry-handler signing path (LSS and any other
// protocol whose share is carried in ManagedKey.Config). Kept intact and in its
// own lane; the CGGMP21 bridge-custody path does not touch it.
func (vm *VM) signViaHandler(
ctx context.Context,
key *ManagedKey,
session *SigningSession,
) (*ecdsaSignature, []party.ID, error) {
handler, err := vm.protocolRegistry.Get(Protocol(key.KeyType))
if err != nil {
handler, err = vm.protocolRegistry.Get(ProtocolLSS)
if err != nil {
return nil, nil, err
}
}
if key.Config == nil {
return nil, nil, errors.New("mpcvm: no key share available for signing")
}
share := &lssKeyShare{
config: key.Config,
pubKey: key.PublicKey,
partyID: vm.partyID,
thresh: key.Threshold,
total: key.TotalParties,
gen: key.Generation,
}
sig, err := handler.Sign(ctx, share, session.MessageHash, key.PartyIDs)
if err != nil {
return nil, nil, err
}
return &ecdsaSignature{R: sig.R().Bytes(), S: sig.S().Bytes(), V: sig.V()}, key.PartyIDs, nil
}
+85 -3
View File
@@ -33,8 +33,10 @@ import (
"sync"
"github.com/luxfi/ids"
"github.com/luxfi/math/set"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/protocol"
"github.com/luxfi/warp"
)
// =============================================================================
@@ -195,6 +197,11 @@ type gossipRouter struct {
// a directed (To != "") ceremony message becomes an app-request to one peer.
peers map[party.ID]ids.NodeID
inbox chan *protocol.Message
// mu guards closed so a late Deliver (from a peer still gossiping as the
// session tears down) can never send on a closed inbox.
mu sync.Mutex
closed bool
}
// newGossipRouter builds the cross-validator router for one session.
@@ -240,22 +247,97 @@ func (r *gossipRouter) Receive() <-chan *protocol.Message { return r.inbox }
// Deliver hands an incoming ceremony message (already demultiplexed to this
// session) to the local handler. Called by the VM's app-message handler; drops
// on a full inbox rather than blocking consensus.
// on a full inbox rather than blocking consensus, and is a no-op once the
// session is closed (never sends on a closed channel).
func (r *gossipRouter) Deliver(msg *protocol.Message) {
if msg == nil || msg.From == r.self {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return
}
select {
case r.inbox <- msg:
default:
}
}
// Close releases the receive channel when the session ends.
func (r *gossipRouter) Close() { close(r.inbox) }
// Close releases the receive channel when the session ends. Idempotent.
func (r *gossipRouter) Close() {
r.mu.Lock()
defer r.mu.Unlock()
if r.closed {
return
}
r.closed = true
close(r.inbox)
}
// Compile-time checks that both routers satisfy MessageRouter.
var (
_ MessageRouter = (*inProcRouter)(nil)
_ MessageRouter = (*gossipRouter)(nil)
)
// =============================================================================
// warp.Sender-backed AppNetwork (the production send side)
// =============================================================================
// warpAppNetwork adapts the node's canonical warp.Sender (handed to the VM at
// Initialize as block.Init.Sender) to the AppNetwork the gossipRouter sends
// over. It is constructed PER CEREMONY with the exact set of committee node IDs
// that must receive the ceremony's messages: an MPC round is only sound if
// EVERY listed signer receives EVERY broadcast, so "Broadcast" here is an
// explicit multicast to the committee — never a validator SAMPLE (sampling a
// subset would silently starve a signer and stall the round). Both Broadcast
// and SendTo travel as app-gossip so they arrive at the receiver's single
// Gossip handler, which demultiplexes by session and calls the router's
// Deliver — one receive path, one demux point.
//
// Delivery is best-effort at the p2p layer: a dropped gossip datagram stalls a
// round until the threshold handler's RoundTimeout fires. Production hardening
// (reliable delivery via acks/retransmit, or SendAppRequest with retry) is
// tracked separately; the in-memory fabric used by the transport tests delivers
// synchronously so the ceremony is deterministic under test.
type warpAppNetwork struct {
sender warp.Sender
self ids.NodeID
committee set.Set[ids.NodeID] // every committee node (self included; excluded on send)
}
// newWarpAppNetwork binds a ceremony's committee to the node's warp sender.
func newWarpAppNetwork(sender warp.Sender, self ids.NodeID, committee []ids.NodeID) *warpAppNetwork {
s := set.NewSet[ids.NodeID](len(committee))
s.Add(committee...)
return &warpAppNetwork{sender: sender, self: self, committee: s}
}
// Broadcast multicasts msg to every committee node except self.
func (n *warpAppNetwork) Broadcast(ctx context.Context, msg []byte) error {
if n.sender == nil {
return fmt.Errorf("mpcvm: no warp sender configured")
}
targets := set.NewSet[ids.NodeID](n.committee.Len())
targets.Add(n.committee.List()...)
targets.Remove(n.self)
if targets.Len() == 0 {
return nil
}
return n.sender.SendGossip(ctx, warp.SendConfig{NodeIDs: targets}, msg)
}
// SendTo delivers msg to a single committee node.
func (n *warpAppNetwork) SendTo(ctx context.Context, nodeID ids.NodeID, msg []byte) error {
if n.sender == nil {
return fmt.Errorf("mpcvm: no warp sender configured")
}
if nodeID == n.self {
return nil
}
return n.sender.SendGossip(ctx, warp.SendConfig{NodeIDs: set.Of(nodeID)}, msg)
}
// Compile-time check that warpAppNetwork satisfies AppNetwork.
var _ AppNetwork = (*warpAppNetwork)(nil)
+81 -56
View File
@@ -31,6 +31,8 @@ import (
"github.com/luxfi/runtime"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/threshold/pkg/pool"
"github.com/luxfi/threshold/pkg/protocol"
cmpconfig "github.com/luxfi/threshold/protocols/cmp/config"
lssconfig "github.com/luxfi/threshold/protocols/lss/config"
vmcore "github.com/luxfi/vm"
"github.com/luxfi/warp"
@@ -111,6 +113,21 @@ type VM struct {
// Message Router for multi-party communication
messageRouter MessageRouter
// Cross-validator MPC transport.
// sender — the node's canonical warp sender (block.Init.Sender);
// nil on a node with no p2p (single-process dev/test).
// sessionRouters — per-ceremony gossip routers, keyed by ceremony id, so
// the Gossip handler can demultiplex an incoming envelope
// to the ceremony it belongs to.
// pendingBySession — envelopes that arrived before our own router for that
// ceremony was registered (the peer started the round a
// hair before us); drained into the router on register so
// no round-one broadcast is ever dropped. Bounded.
sender warp.Sender
routerMu sync.Mutex
sessionRouters map[string]*gossipRouter
pendingBySession map[string][]*protocol.Message
// LSS MPC Protocol Components (default protocol)
lssConfig *lssconfig.Config // LSS config for this party (after keygen)
partyID party.ID // This party's ID
@@ -161,6 +178,7 @@ type ManagedKey struct {
SignCount uint64 `json:"signCount"` // Total signatures made
Status string `json:"status"` // active, rotating, expired
Config *lssconfig.Config `json:"-"` // LSS configuration (not serialized)
CMPConfig *cmpconfig.Config `json:"-"` // CGGMP21 (threshold-ECDSA) configuration (not serialized)
PartyIDs []party.ID `json:"partyIds"`
}
@@ -223,6 +241,7 @@ func (vm *VM) Initialize(
vm.rt = init.Runtime
vm.db = init.DB
vm.toEngine = init.ToEngine
vm.sender = init.Sender
// Use init.Log if provided, otherwise fallback to Runtime.Log
if init.Log != nil {
vm.log = init.Log
@@ -239,6 +258,8 @@ func (vm *VM) Initialize(
vm.keygenSessions = make(map[string]*KeygenSession)
vm.signingSessions = make(map[string]*SigningSession)
vm.sessionsByChain = make(map[string][]string)
vm.sessionRouters = make(map[string]*gossipRouter)
vm.pendingBySession = make(map[string][]*protocol.Message)
vm.dailySigningCount = make(map[string]uint64)
vm.quotaResetTime = time.Now().Add(24 * time.Hour)
vm.stats = &vmStats{
@@ -387,7 +408,7 @@ func (vm *VM) parseConfig(configBytes []byte) error {
return nil
}
if _, err := Codec.Unmarshal(configBytes, &vm.config); err != nil {
if err := json.Unmarshal(configBytes, &vm.config); err != nil {
return err
}
@@ -549,10 +570,23 @@ func (vm *VM) runKeygenWithProtocol(session *KeygenSession, handler ProtocolHand
PartyIDs: session.PartyIDs,
}
// Store protocol-specific config if LSS
if lssShare, ok := share.(*lssKeyShare); ok {
key.Config = lssShare.config
vm.lssConfig = lssShare.config
// Store protocol-specific config so the signing path can reconstruct the
// party's share. Each protocol keeps its own typed config slot (orthogonal;
// no shared union) — LSS and CGGMP21 are independently complete.
switch s := share.(type) {
case *lssKeyShare:
key.Config = s.config
vm.lssConfig = s.config
case *cmpKeyShare:
key.CMPConfig = s.config
if s.config != nil {
// The CMP handler leaves pubKey nil at keygen; derive the canonical
// compressed group key from the config so B can verify releases.
if pub, err := s.config.PublicPoint().MarshalBinary(); err == nil {
key.PublicKey = pub
key.Address = publicKeyToAddress(pub)
}
}
}
vm.keys[session.KeyID] = key
@@ -901,7 +935,9 @@ func (vm *VM) RequestSignature(
}
}
if key.Config == nil {
// The key must carry a usable share for SOME protocol: LSS (Config) or
// CGGMP21 (CMPConfig). Each protocol keeps its own typed slot.
if key.Config == nil && key.CMPConfig == nil {
return nil, ErrNotInitialized
}
@@ -941,45 +977,27 @@ func (vm *VM) runSigning(session *SigningSession, key *ManagedKey) {
startTime := time.Now()
// Get the protocol handler for this key type
handler, err := vm.protocolRegistry.Get(Protocol(key.KeyType))
if err != nil {
// Fall back to LSS
handler, err = vm.protocolRegistry.Get(ProtocolLSS)
if err != nil {
vm.mu.Lock()
session.Status = "failed"
session.Error = err.Error()
vm.mu.Unlock()
return
}
}
// Create a KeyShare from the managed key for signing
var share KeyShare
if key.Config != nil {
share = &lssKeyShare{
config: key.Config,
pubKey: key.PublicKey,
partyID: vm.partyID,
thresh: key.Threshold,
total: key.TotalParties,
gen: key.Generation,
}
} else {
vm.mu.Lock()
session.Status = "failed"
session.Error = "no key share available for signing"
vm.mu.Unlock()
return
}
// Create context with timeout based on session expiration
// Use SessionTimeout from config for signing operations
// Bound the ceremony by the session timeout.
ctx, cancel := context.WithTimeout(context.Background(), vm.config.SessionTimeout)
defer cancel()
sig, err := handler.Sign(ctx, share, session.MessageHash, key.PartyIDs)
// Compute the threshold signature. Threshold ECDSA (CGGMP21) is the
// bridge-custody path: the committee runs the sign across the native
// MessageRouter transport and each honest validator derives the SAME
// standard secp256k1 signature, which B (or any chain holding the group
// key) verifies non-interactively. Other protocols (LSS, ...) still route
// through the registry handler — each protocol stays in its own lane.
var (
sig *ecdsaSignature
signers []party.ID
err error
)
switch {
case key.CMPConfig != nil:
sig, signers, err = vm.thresholdSignCMP(ctx, key, session)
default:
sig, signers, err = vm.signViaHandler(ctx, key, session)
}
vm.mu.Lock()
defer vm.mu.Unlock()
@@ -996,13 +1014,8 @@ func (vm *VM) runSigning(session *SigningSession, key *ManagedKey) {
return
}
// Convert signature to components
session.Signature = &ecdsaSignature{
R: sig.R().Bytes(),
S: sig.S().Bytes(),
V: sig.V(),
}
session.SignerParties = key.PartyIDs
session.Signature = sig
session.SignerParties = signers
session.Status = "completed"
session.CompletedAt = time.Now()
@@ -1331,7 +1344,7 @@ func (vm *VM) GetBlock(ctx context.Context, id ids.ID) (chain.Block, error) {
// ParseBlock implements the chain.ChainVM interface
func (vm *VM) ParseBlock(ctx context.Context, bytes []byte) (chain.Block, error) {
blk := &Block{vm: vm}
if _, err := Codec.Unmarshal(bytes, blk); err != nil {
if err := parseBlockBytes(bytes, blk); err != nil {
return nil, err
}
blk.ID_ = blk.computeID()
@@ -1428,8 +1441,20 @@ func (vm *VM) RequestFailed(ctx context.Context, nodeID ids.NodeID, requestID ui
return nil
}
// Gossip implements the common.VM interface
// Gossip implements the common.VM interface. It is the single receive path for
// cross-validator MPC: every ceremony message (broadcast or directed) arrives
// here as app-gossip, is decoded to (sessionID, protocol.Message) and handed to
// the ceremony's router. Messages that arrive before our own router for that
// ceremony is registered are buffered and drained on register, so no round-one
// broadcast is lost to a start-order race.
func (vm *VM) Gossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error {
sessionID, ceremonyMsg, err := unmarshalEnvelope(msg)
if err != nil {
// Not an MPC ceremony envelope (or corrupt) — ignore, never fault
// consensus on a peer's malformed gossip.
return nil
}
vm.deliverCeremonyMessage(sessionID, ceremonyMsg)
return nil
}
@@ -1443,7 +1468,7 @@ func (vm *VM) Version(ctx context.Context) (string, error) {
func (vm *VM) CrossChainRequest(ctx context.Context, chainID ids.ID, requestID uint32, deadline time.Time, request []byte) error {
// Parse cross-chain MPC request
var req CrossChainMPCRequest
if _, err := Codec.Unmarshal(request, &req); err != nil {
if err := parseCrossChainMPCRequest(request, &req); err != nil {
return err
}
@@ -1524,7 +1549,7 @@ func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
// Helper methods
func (vm *VM) putBlock(blk *Block) error {
bytes, err := Codec.Marshal(codecVersion, blk)
bytes, err := blk.Marshal()
if err != nil {
return err
}
@@ -1539,7 +1564,7 @@ func (vm *VM) getBlock(id ids.ID) (*Block, error) {
}
blk := &Block{vm: vm}
if _, err := Codec.Unmarshal(bytes, blk); err != nil {
if err := parseBlockBytes(bytes, blk); err != nil {
return nil, err
}
@@ -1548,7 +1573,7 @@ func (vm *VM) getBlock(id ids.ID) (*Block, error) {
}
func (vm *VM) persistKey(key *ManagedKey) error {
bytes, err := Codec.Marshal(codecVersion, key)
bytes, err := key.Marshal()
if err != nil {
return err
}
@@ -1564,7 +1589,7 @@ func (vm *VM) loadKeys() error {
for iter.Next() {
key := &ManagedKey{}
if _, err := Codec.Unmarshal(iter.Value(), key); err != nil {
if err := parseManagedKey(iter.Value(), key); err != nil {
continue
}
vm.keys[key.KeyID] = key
+376
View File
@@ -0,0 +1,376 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
import (
"fmt"
"time"
"github.com/luxfi/threshold/pkg/party"
"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 `serialize:"true"` 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
// SessionID bytes @ 8
// KeyID bytes @ 16
// Protocol bytes @ 24
// ReqChain bytes @ 32
// MessageHash bytes @ 40
// Signature bytes @ 48
// Error bytes @ 56
// Timestamp i64 @ 64
// Success u8 @ 72
const (
opType = 0
opSession = 8
opKeyID = 16
opProto = 24
opReqChn = 32
opMsgHash = 40
opSig = 48
opErr = 56
opTS = 64
opSuccess = 72
opSize = 73
)
func marshalOperation(op *Operation) []byte {
b := zap.NewBuilder(zap.HeaderSize + opSize + len(op.Type) + len(op.SessionID) +
len(op.KeyID) + len(op.Protocol) + len(op.RequestingChain) + len(op.MessageHash) +
len(op.Signature) + len(op.Error) + 64)
ob := b.StartObject(opSize)
ob.SetBytes(opType, []byte(op.Type))
ob.SetBytes(opSession, []byte(op.SessionID))
ob.SetBytes(opKeyID, []byte(op.KeyID))
ob.SetBytes(opProto, []byte(op.Protocol))
ob.SetBytes(opReqChn, []byte(op.RequestingChain))
ob.SetBytes(opMsgHash, op.MessageHash)
ob.SetBytes(opSig, op.Signature)
ob.SetBytes(opErr, []byte(op.Error))
ob.SetInt64(opTS, op.Timestamp)
ob.SetUint8(opSuccess, boolByte(op.Success))
ob.FinishAsRoot()
return b.Finish()
}
func readOperation(o zap.Object) *Operation {
return &Operation{
Type: string(o.Bytes(opType)),
SessionID: string(o.Bytes(opSession)),
KeyID: string(o.Bytes(opKeyID)),
Protocol: string(o.Bytes(opProto)),
RequestingChain: string(o.Bytes(opReqChn)),
MessageHash: appendBytes(o.Bytes(opMsgHash)),
Signature: appendBytes(o.Bytes(opSig)),
Error: string(o.Bytes(opErr)),
Timestamp: o.Int64(opTS),
Success: o.Uint8(opSuccess) != 0,
}
}
// ---- Block ----
//
// ParentID 32B @ 0 (ID_ is derived: computeID = sha256(Marshal); excluded)
// Height u64 @ 32
// Timestamp i64 @ 40
// OpLens list @ 48 (u32 per Operation wire length)
// OpBlob bytes @ 56 (concatenated Operation wire objects)
const (
blkParent = 0
blkHeight = 32
blkTime = 40
blkOpLens = 48
blkOpBlob = 56
blkSize = 64
)
// 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 := marshalOperation(op)
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.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)
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
}
blk.Operations = append(blk.Operations, readOperation(omsg.Root()))
pos += int(l)
}
return nil
}
// ---- ManagedKey ----
//
// KeyID bytes @ 0
// KeyType bytes @ 8
// PublicKey bytes @ 16
// Address bytes @ 24
// Status bytes @ 32
// Threshold i64 @ 40
// Total i64 @ 48
// Generation u64 @ 56
// CreatedAt i64 @ 64 (UnixNano)
// LastUsedAt i64 @ 72 (UnixNano)
// SignCount u64 @ 80
// PartyLens list @ 88 (u32 per partyID string length)
// PartyBlob bytes @ 96 (concatenated partyID strings)
//
// Config/CMPConfig (json:"-") are reconstructed off-wire and excluded.
const (
mkKeyID = 0
mkKeyType = 8
mkPubKey = 16
mkAddr = 24
mkStatus = 32
mkThreshold = 40
mkTotal = 48
mkGen = 56
mkCreated = 64
mkLastUsed = 72
mkSignCnt = 80
mkPartyLens = 88
mkPartyBlob = 96
mkSize = 104
)
func (k *ManagedKey) Marshal() ([]byte, error) {
partyLens, partyBlob := packStrings(partyIDsToStrings(k.PartyIDs))
bld := zap.NewBuilder(zap.HeaderSize + mkSize + len(k.KeyID) + len(k.KeyType) +
len(k.PublicKey) + len(k.Address) + len(k.Status) + len(partyBlob) + 4*len(partyLens) + 128)
partyLensOff := writeU32List(bld, partyLens)
ob := bld.StartObject(mkSize)
ob.SetBytes(mkKeyID, []byte(k.KeyID))
ob.SetBytes(mkKeyType, []byte(k.KeyType))
ob.SetBytes(mkPubKey, k.PublicKey)
ob.SetBytes(mkAddr, k.Address)
ob.SetBytes(mkStatus, []byte(k.Status))
ob.SetInt64(mkThreshold, int64(k.Threshold))
ob.SetInt64(mkTotal, int64(k.TotalParties))
ob.SetUint64(mkGen, k.Generation)
ob.SetInt64(mkCreated, k.CreatedAt.UnixNano())
ob.SetInt64(mkLastUsed, k.LastUsedAt.UnixNano())
ob.SetUint64(mkSignCnt, k.SignCount)
ob.SetList(mkPartyLens, partyLensOff, len(partyLens))
ob.SetBytes(mkPartyBlob, partyBlob)
ob.FinishAsRoot()
return bld.Finish(), nil
}
func parseManagedKey(data []byte, k *ManagedKey) error {
msg, err := zap.Parse(data)
if err != nil {
return err
}
if msg.Size() != len(data) {
return fmt.Errorf("mpcvm managed key: trailing bytes")
}
o := msg.Root()
k.KeyID = string(o.Bytes(mkKeyID))
k.KeyType = string(o.Bytes(mkKeyType))
k.PublicKey = appendBytes(o.Bytes(mkPubKey))
k.Address = appendBytes(o.Bytes(mkAddr))
k.Status = string(o.Bytes(mkStatus))
k.Threshold = int(o.Int64(mkThreshold))
k.TotalParties = int(o.Int64(mkTotal))
k.Generation = o.Uint64(mkGen)
k.CreatedAt = time.Unix(0, o.Int64(mkCreated)).UTC()
k.LastUsedAt = time.Unix(0, o.Int64(mkLastUsed)).UTC()
k.SignCount = o.Uint64(mkSignCnt)
k.PartyIDs = stringsToPartyIDs(unpackStrings(readU32List(o, mkPartyLens), o.Bytes(mkPartyBlob)))
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
}
// ---- 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...)
}
+92
View File
@@ -0,0 +1,92 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package mpcvm
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
"github.com/luxfi/threshold/pkg/party"
"github.com/luxfi/zap"
)
func TestWireRoundTrip_Operation(t *testing.T) {
require := require.New(t)
op := &Operation{
Type: OpTypeSign, SessionID: "sess-1", KeyID: "key-1",
Protocol: "cggmp21", RequestingChain: "C",
MessageHash: []byte("hash"), Signature: []byte("sig"),
Timestamp: 1_700_000_000, Success: true, Error: "",
}
b := marshalOperation(op)
msg, err := zap.Parse(b)
require.NoError(err)
require.Equal(op, readOperation(msg.Root()))
}
func TestWireRoundTrip_Block(t *testing.T) {
require := require.New(t)
blk := &Block{
ID_: ids.ID{9}, // derived; must NOT affect wire
ParentID_: ids.ID{1, 2, 3},
BlockHeight: 42,
BlockTimestamp: 1_700_000_000,
Operations: []*Operation{
{Type: OpTypeKeygen, SessionID: "s1", KeyID: "k1", Timestamp: 1},
{Type: OpTypeSign, SessionID: "s2", KeyID: "k2", MessageHash: []byte("m"), Success: true, 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.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)
}
func TestWireRoundTrip_ManagedKey(t *testing.T) {
require := require.New(t)
k := &ManagedKey{
KeyID: "key-1", KeyType: "secp256k1",
PublicKey: []byte("pubkey"), Address: []byte("0xADDR"),
Threshold: 2, TotalParties: 3, Generation: 5,
CreatedAt: time.Unix(1_700_000_000, 0).UTC(),
LastUsedAt: time.Unix(1_700_000_500, 0).UTC(),
SignCount: 17, Status: "active",
PartyIDs: []party.ID{"p1", "p2", "p3"},
}
b, err := k.Marshal()
require.NoError(err)
var got ManagedKey
require.NoError(parseManagedKey(b, &got))
require.Equal(k, &got)
require.Error(parseManagedKey(append(b, 0), &got))
}
func TestWireRoundTrip_CrossChainMPCRequest(t *testing.T) {
require := require.New(t)
r := &CrossChainMPCRequest{
Type: "sign", RequestingChain: "C", 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)
}