mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
+51
-1
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}))
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user