mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
A plugin is its own binary; the daemon that loads it has no business being linked into it. Twelve VMs reached into github.com/luxfi/node for four things, and each one now programs against a home that is actually its own: - fee admission (Policy, FlatPolicy, NoUserTxPolicy, Validate) joins the settlement half already in chains/fee. One fee package, and the nodefee alias that marked the split is gone. - the cross-chain artifact types move to chains/artifacts. - the factory the daemon registers is luxfi/vm/manager.Factory, and the version types are luxfi/version. Both are ordinary modules the daemon reads too, so a VM imports them directly rather than through anything of ours. The GPU switch is deleted, not moved. It was a global set during daemon startup that a separate process could never observe, so every plugin read its default; FHEOptions.Backend is the switch that was really there. go list -deps names no luxfi/node for aivm, bridgevm, dexvm, evm, fhevm, graphvm, identityvm, keyvm, mpcvm, quantumvm, schain or zkvm, and the built plugin binaries carry no such string. oraclevm and relayvm still do: they are re-export shims over luxfi/oracle and luxfi/relay, which build their fee policies upstream. Their tests keep naming the daemon's fee package because that is the type their value has.
1425 lines
51 KiB
Go
1425 lines
51 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
// Package mpcvm implements the shared threshold VM substrate — a LIBRARY,
|
|
// not a chain — consumed by M-Chain (MPC: CGGMP21/FROST/Pulsar-general threshold
|
|
// signing for bridge custody of external wallets, LP-7100) and F-Chain (FHE:
|
|
// TFHE compute / threshold decrypt, LP-8200). Per LP-1340 / LP-7050 there is NO
|
|
// T-Chain and NO teleportvm; teleport IS bridgevm (B-Chain, LP-6000). Any live
|
|
// identifier still naming "T-Chain" or "ThresholdVM-as-a-chain" is stale.
|
|
// See ../README.md.
|
|
package mpcvm
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/chains/chain"
|
|
"github.com/luxfi/chains/fee"
|
|
"github.com/luxfi/constants"
|
|
luxcrypto "github.com/luxfi/crypto"
|
|
"github.com/luxfi/crypto/secp256k1"
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/runtime"
|
|
"github.com/luxfi/threshold/pkg/party"
|
|
"github.com/luxfi/threshold/pkg/pool"
|
|
"github.com/luxfi/threshold/pkg/protocol"
|
|
"github.com/luxfi/threshold/pkg/quorum"
|
|
"github.com/luxfi/version"
|
|
vmcore "github.com/luxfi/vm"
|
|
vmchain "github.com/luxfi/vm/chain"
|
|
"github.com/luxfi/warp"
|
|
)
|
|
|
|
// maxStagedCeremonies bounds the queue of completed ceremonies waiting for a
|
|
// block. Staging is driven by ceremonies this node took part in, so the bound
|
|
// is generous, but a queue with no bound at all is whatever a long outage makes
|
|
// it.
|
|
const maxStagedCeremonies = 4096
|
|
|
|
var (
|
|
_ vmchain.ChainVM = (*VM)(nil)
|
|
|
|
Version = &version.Semantic{
|
|
Major: 1,
|
|
Minor: 0,
|
|
Patch: 0,
|
|
}
|
|
|
|
// Errors this VM owns. Everything about a KEY (unknown, already registered,
|
|
// share not held) is state.go's vocabulary and is not restated here: two
|
|
// spellings of "no such key" is one spelling too many.
|
|
ErrInvalidThreshold = errors.New("mpcvm: invalid threshold configuration")
|
|
ErrUnauthorizedChain = errors.New("mpcvm: unauthorized chain")
|
|
ErrQuotaExceeded = errors.New("mpcvm: signing quota exceeded")
|
|
)
|
|
|
|
// ThresholdConfig contains VM configuration.
|
|
type ThresholdConfig struct {
|
|
// Policy is the default signing policy for keys created on this chain,
|
|
// written the way operators say it: "7-of-10" — seven of ten parties must
|
|
// cooperate to produce one signature.
|
|
//
|
|
// It is deliberately NOT a bare number. A field called `threshold: 7` is
|
|
// read as the signer count by operators and as the polynomial degree by
|
|
// every threshold library, and those differ by one; a config that meant
|
|
// 7-of-10 and was read as a degree produces an 8-of-10 key, silently. The
|
|
// operator form cannot be misread, and the degree is derived from it at one
|
|
// place (quorum.Policy.Degree) at the keygen boundary.
|
|
Policy quorum.Policy `json:"policy"`
|
|
|
|
// SessionTimeout is the wall clock one ceremony gets before it is abandoned.
|
|
//
|
|
// It is the only session knob. There were three more — a global concurrency
|
|
// cap, a per-chain concurrency cap and a per-block operation cap — and not
|
|
// one of them was ever read. The last was the dangerous one: a block-size
|
|
// bound is a consensus rule, and had it been read where it looks like it
|
|
// belongs, two validators configured differently would have reached
|
|
// opposite verdicts on the same block. It is a constant now
|
|
// (maxOpsPerBlock), where an operator cannot move it.
|
|
SessionTimeout time.Duration `json:"sessionTimeout"`
|
|
|
|
// DailySigningQuota overrides a chain's own DailySigningLimit, by chain
|
|
// name. It is per-node rate limiting, not consensus: refusing to START a
|
|
// ceremony is local, and a ceremony that completes is verified by everyone
|
|
// on its own evidence.
|
|
DailySigningQuota map[string]uint64 `json:"dailySigningQuota"`
|
|
|
|
// AuthorizedChains is the custody permission table, keyed by the operator's
|
|
// label for a chain. Authorization binds to the entry's ChainID; see
|
|
// caller.go.
|
|
AuthorizedChains map[string]*ChainPermissions `json:"authorizedChains"`
|
|
}
|
|
|
|
// ChainPermissions defines what a chain can do with MPC services
|
|
type ChainPermissions struct {
|
|
ChainID string `json:"chainId"`
|
|
ChainName string `json:"chainName"`
|
|
CanSign bool `json:"canSign"` // Can request signatures
|
|
CanKeygen bool `json:"canKeygen"` // Can request new key generation
|
|
CanReshare bool `json:"canReshare"` // Can request key resharing
|
|
AllowedKeyTypes []string `json:"allowedKeyTypes"` // secp256k1, ed25519, etc.
|
|
MaxSigningSize int `json:"maxSigningSize"` // Max message size to sign
|
|
RequirePreHash bool `json:"requirePreHash"` // Require pre-hashed messages
|
|
DailySigningLimit uint64 `json:"dailySigningLimit"` // Override global quota
|
|
}
|
|
|
|
// VM implements the Threshold VM for MPC-as-a-service
|
|
type VM struct {
|
|
rt *runtime.Runtime
|
|
db database.Database
|
|
config ThresholdConfig
|
|
toEngine chan<- vmcore.Message
|
|
log log.Logger
|
|
|
|
// protocolExecutor drives one threshold protocol to completion over a
|
|
// router. It is the only part of this VM that knows the threshold library
|
|
// exists; everything above it speaks in ceremonies.
|
|
protocolExecutor *ProtocolExecutor
|
|
|
|
// 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
|
|
|
|
// Identity within the MPC committee. party.ID is the NodeID string, so the
|
|
// committee and the peer set are the same value in two spellings, with no
|
|
// side table to drift.
|
|
partyID party.ID
|
|
netID ids.ID // network whose validator set forms the committee
|
|
pool *pool.Pool // worker pool for MPC operations
|
|
|
|
// chain is the durable state, the blocks in flight and the tip, under the
|
|
// one lock the rest of the VM shares. State writes through its view, so a
|
|
// key registration and the block that made it land together or not at all.
|
|
chain *chain.Store[*Block]
|
|
genesisBlock *Block
|
|
|
|
// state is the persisted, replicated chain state: the custody-key registry,
|
|
// the ceremony log and the state root. It is the authority on what M-Chain
|
|
// knows — there is no second in-memory copy that could disagree with it or
|
|
// vanish on restart.
|
|
state *State
|
|
|
|
// shares is this node's secret key material, keyed by key id. Loaded from
|
|
// node-private state at Initialize. NOT replicated and NOT in the state
|
|
// root; another validator legitimately holds different bytes.
|
|
shares map[string]*heldShare
|
|
|
|
// Completed ceremonies awaiting inclusion in a block, in staging order.
|
|
// Every participant stages the same operations, so whichever node proposes
|
|
// produces a block the others can verify. LOCK ORDER: the chain's lock,
|
|
// then the pool's.
|
|
staged *chain.Pool[*Operation, string]
|
|
|
|
// Quota Tracking
|
|
dailySigningCount map[string]uint64 // ChainID -> count today
|
|
quotaResetTime time.Time // When to reset quotas
|
|
|
|
// Network Stats
|
|
stats *vmStats
|
|
|
|
// Fee policy. M-Chain is service-only (committee-driven, no
|
|
// user mempool) so this is the NoUserTxPolicy sentinel.
|
|
// fee is what this chain charges to admit a user transaction, which is
|
|
// nothing: M-Chain takes none.
|
|
fee chain.Fee
|
|
|
|
// mu guards State and the VM's own maps. State is deliberately not safe for
|
|
// concurrent use — a state machine with its own locking invites callers to
|
|
// interleave reads and writes across a transition and observe a half-applied
|
|
// block — so this is the lock that stands in for it.
|
|
//
|
|
// LOCK ORDER, without exception: the chain's lock, then this one. Accept
|
|
// holds the chain's and reaches State inside it, so any path holding this
|
|
// one while resolving a block would deadlock. Verify resolves its parent
|
|
// before taking this lock for that reason.
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// vmStats counts what this node actually did. Every field here is incremented
|
|
// at the moment the thing happens; a counter that is only ever reported and
|
|
// never written is a number that reads as evidence and is not.
|
|
type vmStats struct {
|
|
TotalSignatures uint64
|
|
TotalKeygens uint64
|
|
SignaturesByChain map[string]uint64
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
// Initialize implements the chain.ChainVM interface
|
|
func (vm *VM) Initialize(
|
|
ctx context.Context,
|
|
init vmcore.Init,
|
|
) error {
|
|
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
|
|
} else if init.Runtime != nil {
|
|
if logger, ok := init.Runtime.Log.(log.Logger); ok {
|
|
vm.log = logger
|
|
}
|
|
}
|
|
|
|
// Initialize maps
|
|
vm.shares = make(map[string]*heldShare)
|
|
vm.staged = chain.NewPool(maxStagedCeremonies,
|
|
func(op *Operation) string { return op.CeremonyID })
|
|
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.netID = constants.PrimaryNetworkID
|
|
vm.stats = &vmStats{
|
|
SignaturesByChain: make(map[string]uint64),
|
|
}
|
|
|
|
// Pin fee policy. M-Chain is service-only (committee-driven, no
|
|
// user mempool) so attach the NoUserTxPolicy sentinel.
|
|
// fee.Validate passes for the sentinel even with zero MinTxFee.
|
|
vm.fee = chain.Closed()
|
|
if err := fee.Validate(vm.fee.Policy()); err != nil {
|
|
return fmt.Errorf("mpcvm: fee policy: %w", err)
|
|
}
|
|
|
|
// Parse configuration
|
|
if err := vm.parseConfig(init.Config); err != nil {
|
|
return fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
// Initialize party ID from node ID
|
|
vm.partyID = party.ID(vm.rt.NodeID.String())
|
|
|
|
// Create worker pool for MPC operations
|
|
vm.pool = pool.NewPool(16) // 16 workers for parallel MPC
|
|
|
|
// Initialize protocol executor for handling protocol execution with proper timeouts
|
|
vm.protocolExecutor = NewProtocolExecutor(vm.pool, vm.log)
|
|
|
|
// Parse genesis - use JSON for simple genesis configuration
|
|
genesis := &Genesis{}
|
|
if len(init.Genesis) > 0 {
|
|
if err := json.Unmarshal(init.Genesis, genesis); err != nil {
|
|
return fmt.Errorf("failed to parse genesis: %w", err)
|
|
}
|
|
}
|
|
// A genesis policy overrides the config default, so the chain's quorum is
|
|
// fixed by the thing every validator agrees on rather than by each node's
|
|
// local config file.
|
|
if genesis.Policy.Valid() {
|
|
vm.config.Policy = genesis.Policy
|
|
}
|
|
|
|
// Open persisted state, resuming whatever this node already knows. State
|
|
// writes through the chain's view, so a key registration and the block that
|
|
// made it commit together or not at all.
|
|
vm.chain = chain.New[*Block](vm.db, vm.reloadRoot)
|
|
state, err := NewState(vm.chain.View(), vm.rt.ChainID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
vm.state = state
|
|
|
|
// Resume from the accepted tip, or install genesis on a fresh database.
|
|
// This is the difference between a chain and a cache: a node that restarts
|
|
// comes back at the height it left, not at zero. Recomputing genesis
|
|
// unconditionally would discard every accepted block and re-run history
|
|
// from an empty registry.
|
|
root := vm.state.Root()
|
|
genesisBlock := &Block{
|
|
BlockHeight: 0,
|
|
BlockTimestamp: genesis.Timestamp,
|
|
ParentID_: ids.Empty,
|
|
StateRoot: root,
|
|
vm: vm,
|
|
}
|
|
genesisBlock.ID_ = genesisBlock.computeID()
|
|
vm.genesisBlock = genesisBlock
|
|
|
|
at, fresh, err := vm.chain.Open(genesisBlock, vm.parseBlock)
|
|
if err != nil {
|
|
return fmt.Errorf("mpcvm: open chain state: %w", err)
|
|
}
|
|
if fresh {
|
|
// "Fresh" is a claim about the tip pointer, and the store reports it on
|
|
// ANY failure to read that pointer — a missing one and an unreadable one
|
|
// look the same from here. So it is checked against the thing it implies
|
|
// rather than believed: an empty chain has applied no operations, and
|
|
// the root of a chain that has applied none is exactly its genesis root.
|
|
//
|
|
// Without this, one transient read fault at boot re-installs genesis
|
|
// over a live custody registry, durably, and Initialize returns nil.
|
|
// Refusing to start is the only safe answer: the operator can look at
|
|
// the disk, and a node that will not start loses no funds.
|
|
if root != genesisRoot(vm.rt.ChainID) {
|
|
return fmt.Errorf("mpcvm: refusing to install genesis over applied state: "+
|
|
"the chain has no tip but its root is %x, not the genesis root %x",
|
|
root[:8], genesisRoot(vm.rt.ChainID))
|
|
}
|
|
// NewState seeded the genesis root through the view; commit it, so a
|
|
// node that accepts no block still comes back to the same root.
|
|
if err := vm.chain.Seed(func(database.Database) error { return nil }); err != nil {
|
|
return err
|
|
}
|
|
vm.log.Info("M-Chain genesis installed",
|
|
log.Stringer("blockID", genesisBlock.ID()),
|
|
log.String("stateRoot", fmt.Sprintf("%x", root[:8])),
|
|
)
|
|
} else {
|
|
vm.log.Info("M-Chain resumed",
|
|
log.Stringer("tip", at.ID()),
|
|
log.Uint64("height", at.Height()),
|
|
log.String("stateRoot", fmt.Sprintf("%x", root[:8])),
|
|
)
|
|
}
|
|
|
|
// Load this node's own key shares. Without them a restarted validator is a
|
|
// registry reader that cannot sign — it would still be counted as a
|
|
// committee member while being unable to contribute a partial signature.
|
|
if err := vm.loadShares(); err != nil {
|
|
return fmt.Errorf("mpcvm: load key shares: %w", err)
|
|
}
|
|
|
|
bound, unbound := 0, make([]string, 0, len(vm.config.AuthorizedChains))
|
|
for name, p := range vm.config.AuthorizedChains {
|
|
// A chainId that is not a chain id binds to nothing. The stock entries carry
|
|
// labels ("B-Chain"), which never equal a node's base58 chain id, so counting
|
|
// them as bound reported five authorized chains on a node where none were —
|
|
// the exact reading this warning exists to prevent.
|
|
if p != nil && p.ChainID != "" {
|
|
if _, err := ids.FromString(p.ChainID); err == nil {
|
|
bound++
|
|
continue
|
|
}
|
|
}
|
|
unbound = append(unbound, name)
|
|
}
|
|
sort.Strings(unbound)
|
|
|
|
vm.log.Info("M-Chain initialized",
|
|
log.String("policy", vm.config.Policy.String()),
|
|
log.String("party", string(vm.partyID)),
|
|
log.Int("sharesHeld", len(vm.shares)),
|
|
log.Int("authorizedChains", bound),
|
|
)
|
|
if len(unbound) > 0 {
|
|
// Say it plainly. A permission entry names a chain by label, and a label
|
|
// is not an identity — until an operator sets chainId, the entry grants
|
|
// custody to nobody. Silence here would read as "custody is configured"
|
|
// right up until the first release fails.
|
|
vm.log.Warn("custody entries bind to no chain id — set chainId to a real chain id on each to grant it",
|
|
log.Strings("chains", unbound),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// loadShares repopulates this node's secret shares from node-private state for
|
|
// every custody key it participates in.
|
|
func (vm *VM) loadShares() error {
|
|
keys, err := vm.state.Keys()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, rec := range keys {
|
|
raw, err := vm.state.GetShare(rec.KeyID)
|
|
if errors.Is(err, ErrShareNotHeld) {
|
|
continue // not a participant in this key; normal
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
share, err := parseHeldShare(rec.Kind, raw)
|
|
if err != nil {
|
|
return fmt.Errorf("mpcvm: share for %s: %w", rec.KeyID, err)
|
|
}
|
|
// A share that does not match the registry it is filed under is a
|
|
// corrupted store, not a recoverable state: signing with it would
|
|
// produce signatures that fail against the registered group key.
|
|
pub, degree, err := share.groupKeyAndDegree()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if string(pub) != string(rec.GroupPublicKey) {
|
|
return fmt.Errorf("mpcvm: stored share for %s belongs to a different group key", rec.KeyID)
|
|
}
|
|
if degree != rec.Degree() {
|
|
return fmt.Errorf("mpcvm: stored share for %s has degree %d, registry says policy %s (degree %d)",
|
|
rec.KeyID, degree, rec.Policy, rec.Degree())
|
|
}
|
|
vm.shares[rec.KeyID] = share
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (vm *VM) parseConfig(configBytes []byte) error {
|
|
if len(configBytes) == 0 {
|
|
// Default configuration
|
|
vm.config = ThresholdConfig{
|
|
// 3-of-5. Two seats no longer reach a quorum, which matters because
|
|
// seats are held by validators and validator entry is permissionless:
|
|
// under 2-of-3 an adversary that got two seats held a quorum of every
|
|
// key on the chain and could sign locally, off-chain, with nothing
|
|
// on-chain to observe. At 3-of-5 it must hold a majority of the custody
|
|
// set, and seats are allocated by stake (custodySet), so that costs
|
|
// what attacking consensus costs.
|
|
//
|
|
// Degree 2: two parties may be corrupt or offline. 2K > N holds, so the
|
|
// quorum is unique — two disjoint quorums cannot both sign.
|
|
Policy: quorum.MustNew(3, 5),
|
|
SessionTimeout: 5 * time.Minute,
|
|
DailySigningQuota: make(map[string]uint64),
|
|
AuthorizedChains: make(map[string]*ChainPermissions),
|
|
}
|
|
|
|
// Default authorized chains (all internal Lux chains)
|
|
vm.config.AuthorizedChains["X-Chain"] = &ChainPermissions{
|
|
ChainID: "X-Chain",
|
|
ChainName: "UTXO Chain",
|
|
CanSign: true,
|
|
CanKeygen: false,
|
|
CanReshare: false,
|
|
AllowedKeyTypes: []string{"secp256k1"},
|
|
MaxSigningSize: 256,
|
|
DailySigningLimit: 10000,
|
|
}
|
|
vm.config.AuthorizedChains["B-Chain"] = &ChainPermissions{
|
|
ChainID: "B-Chain",
|
|
ChainName: "Bridge Chain",
|
|
CanSign: true,
|
|
CanKeygen: true,
|
|
CanReshare: true,
|
|
AllowedKeyTypes: []string{"secp256k1"},
|
|
MaxSigningSize: 1024,
|
|
DailySigningLimit: 100000,
|
|
}
|
|
vm.config.AuthorizedChains["C-Chain"] = &ChainPermissions{
|
|
ChainID: "C-Chain",
|
|
ChainName: "Contract Chain",
|
|
CanSign: true,
|
|
CanKeygen: false,
|
|
CanReshare: false,
|
|
AllowedKeyTypes: []string{"secp256k1"},
|
|
MaxSigningSize: 256,
|
|
DailySigningLimit: 50000,
|
|
}
|
|
vm.config.AuthorizedChains["P-Chain"] = &ChainPermissions{
|
|
ChainID: "P-Chain",
|
|
ChainName: "Platform Chain",
|
|
CanSign: true,
|
|
CanKeygen: true,
|
|
CanReshare: true,
|
|
AllowedKeyTypes: []string{"secp256k1", "bls"},
|
|
MaxSigningSize: 512,
|
|
DailySigningLimit: 10000,
|
|
}
|
|
vm.config.AuthorizedChains["Q-Chain"] = &ChainPermissions{
|
|
ChainID: "Q-Chain",
|
|
ChainName: "Quantum Chain",
|
|
CanSign: true,
|
|
CanKeygen: true,
|
|
CanReshare: true,
|
|
AllowedKeyTypes: []string{"secp256k1", "dilithium"},
|
|
MaxSigningSize: 512,
|
|
DailySigningLimit: 10000,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if err := json.Unmarshal(configBytes, &vm.config); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A policy that does not decode is a hard failure, not a default. Falling
|
|
// back to a built-in quorum for a chain that holds bridged funds would mean
|
|
// the operator's intent and the deployed key silently differ.
|
|
if !vm.config.Policy.Valid() {
|
|
return fmt.Errorf("%w: %q is not a deployable policy (want the form \"3-of-5\")",
|
|
ErrInvalidThreshold, vm.config.Policy)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// Custody API — the one way in
|
|
// =============================================================================
|
|
//
|
|
// Every entry point below delegates to the ceremony lifecycle in custody.go.
|
|
// There is no second path that reaches a key or a signature, and no in-memory
|
|
// session state beside the chain: a parallel copy of who holds what would be
|
|
// unreplicated, and two answers to that question is one too many.
|
|
|
|
// StartKeygen generates a custody key using the chain's default policy.
|
|
func (vm *VM) StartKeygen(ctx context.Context, keyID string, by Caller) (*Operation, error) {
|
|
return vm.StartKeygenWithPolicy(ctx, keyID, vm.Policy(), by)
|
|
}
|
|
|
|
// StartKeygenWithPolicy generates a custody key under an explicit k-of-n policy.
|
|
//
|
|
// The policy is a quorum.Policy, not a pair of ints, so a caller cannot express
|
|
// the quorum ambiguously: "3-of-5" is the only spelling, and the polynomial
|
|
// degree is derived from it inside RunKeygen.
|
|
func (vm *VM) StartKeygenWithPolicy(ctx context.Context, keyID string, policy quorum.Policy, by Caller) (*Operation, error) {
|
|
if by.perms == nil || !by.perms.CanKeygen {
|
|
return nil, fmt.Errorf("%w: %s may not request key generation", ErrUnauthorizedChain, by.name)
|
|
}
|
|
requestedBy := by.name
|
|
ctx, cancel := context.WithTimeout(ctx, vm.sessionTimeout())
|
|
defer cancel()
|
|
op, err := vm.runKeygen(ctx, keyID, policy, requestedBy)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
vm.stats.mu.Lock()
|
|
vm.stats.TotalKeygens++
|
|
vm.stats.mu.Unlock()
|
|
return op, nil
|
|
}
|
|
|
|
// RequestSignature produces a threshold signature over messageHash with a
|
|
// registered custody key and stages it for the next block.
|
|
func (vm *VM) RequestSignature(ctx context.Context, by Caller, keyID string, messageHash []byte) (*Operation, error) {
|
|
perms := by.perms
|
|
if perms == nil || !perms.CanSign {
|
|
return nil, fmt.Errorf("%w: %s may not request signatures", ErrUnauthorizedChain, by.name)
|
|
}
|
|
requestingChain := by.name
|
|
if perms.MaxSigningSize > 0 && len(messageHash) > perms.MaxSigningSize {
|
|
return nil, fmt.Errorf("message too large: %d > %d", len(messageHash), perms.MaxSigningSize)
|
|
}
|
|
|
|
vm.mu.Lock()
|
|
vm.checkQuotaReset()
|
|
limit := perms.DailySigningLimit
|
|
if override := vm.config.DailySigningQuota[requestingChain]; override > 0 {
|
|
limit = override
|
|
}
|
|
if limit > 0 && vm.dailySigningCount[requestingChain] >= limit {
|
|
vm.mu.Unlock()
|
|
return nil, ErrQuotaExceeded
|
|
}
|
|
vm.mu.Unlock()
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, vm.sessionTimeout())
|
|
defer cancel()
|
|
op, err := vm.runSign(ctx, keyID, messageHash, requestingChain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vm.mu.Lock()
|
|
vm.dailySigningCount[requestingChain]++
|
|
vm.stats.mu.Lock()
|
|
vm.stats.TotalSignatures++
|
|
vm.stats.SignaturesByChain[requestingChain]++
|
|
vm.stats.mu.Unlock()
|
|
vm.mu.Unlock()
|
|
return op, nil
|
|
}
|
|
|
|
// Policy returns the chain's default signing policy.
|
|
func (vm *VM) Policy() quorum.Policy {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
return vm.config.Policy
|
|
}
|
|
|
|
// Key returns a registered custody key's public record.
|
|
func (vm *VM) Key(keyID string) (*KeyRecord, error) {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
return vm.state.GetKey(keyID)
|
|
}
|
|
|
|
// Keys returns every registered custody key.
|
|
func (vm *VM) Keys() ([]*KeyRecord, error) {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
return vm.state.Keys()
|
|
}
|
|
|
|
// Ceremony returns a recorded ceremony — the durable, replicated evidence that
|
|
// a signature was produced, including the signature itself.
|
|
func (vm *VM) Ceremony(id string) (*CeremonyRecord, error) {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
return vm.state.GetCeremony(id)
|
|
}
|
|
|
|
// Ceremonies returns the whole ceremony log.
|
|
func (vm *VM) Ceremonies() ([]*CeremonyRecord, error) {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
return vm.state.Ceremonies()
|
|
}
|
|
|
|
// StateRoot returns the current state root — the value two validators compare
|
|
// to know whether they agree about custody.
|
|
func (vm *VM) StateRoot() [32]byte {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
return vm.state.Root()
|
|
}
|
|
|
|
// PublicKey returns a custody key's compressed group public key.
|
|
func (vm *VM) PublicKey(keyID string) ([]byte, error) {
|
|
rec, err := vm.Key(keyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rec.GroupPublicKey, nil
|
|
}
|
|
|
|
// Address returns a custody key's external-chain address.
|
|
func (vm *VM) Address(keyID string) ([]byte, error) {
|
|
rec, err := vm.Key(keyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return rec.Address, nil
|
|
}
|
|
|
|
// permissions resolves a requesting chain's permissions.
|
|
func (vm *VM) permissions(chain string) (*ChainPermissions, bool) {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
p, ok := vm.config.AuthorizedChains[chain]
|
|
return p, ok
|
|
}
|
|
|
|
func (vm *VM) sessionTimeout() time.Duration {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
if vm.config.SessionTimeout <= 0 {
|
|
return 5 * time.Minute
|
|
}
|
|
return vm.config.SessionTimeout
|
|
}
|
|
|
|
// checkQuotaReset rolls the daily signing counters over. Caller holds vm.mu.
|
|
func (vm *VM) checkQuotaReset() {
|
|
if time.Now().Before(vm.quotaResetTime) {
|
|
return
|
|
}
|
|
vm.dailySigningCount = make(map[string]uint64)
|
|
vm.quotaResetTime = time.Now().Add(24 * time.Hour)
|
|
}
|
|
|
|
// BuildBlock implements the chain.ChainVM interface
|
|
func (vm *VM) BuildBlock(ctx context.Context) (vmchain.Block, error) {
|
|
built, err := vm.chain.Propose(func(parent *Block) (*Block, error) {
|
|
// Only include operations that still verify against current state. A
|
|
// ceremony can be staged and then invalidated by a block that landed first
|
|
// (the same key registered, the same ceremony recorded); proposing it
|
|
// anyway would build a block every peer rejects.
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
|
|
root := vm.state.Root()
|
|
|
|
// Build only on state this node has applied. The engine's preferred
|
|
// block may be one still in flight, and every check below — the
|
|
// registry, the ceremony log, the accumulated root — reads applied
|
|
// state. A block built on an unapplied parent therefore claims a root
|
|
// that skips the parent's operations: it VERIFIES on every peer, wins
|
|
// consensus, and then fails at Accept once the parent lands, which for
|
|
// a decided block halts the chain. Refusing to build is a wait; the
|
|
// ceremonies stay staged and the next proposal carries them.
|
|
if parent.StateRoot != root {
|
|
return nil, fmt.Errorf("%w: %s leaves root %x, this node is at %x",
|
|
ErrStaleParent, parent.ID(), parent.StateRoot[:8], root[:8])
|
|
}
|
|
|
|
staged := vm.staged.Take(maxOpsPerBlock)
|
|
if len(staged) == 0 {
|
|
return nil, errors.New("mpcvm: no completed ceremonies to include")
|
|
}
|
|
|
|
pending := make(map[string]*KeyRecord, len(staged))
|
|
operations := make([]*Operation, 0, len(staged))
|
|
var invalid []*Operation
|
|
for _, op := range staged {
|
|
if err := vm.verifyOperation(op, pending); err != nil {
|
|
vm.log.Debug("dropping staged ceremony",
|
|
log.String("ceremony", op.CeremonyID),
|
|
log.String("reason", err.Error()),
|
|
)
|
|
invalid = append(invalid, op)
|
|
continue
|
|
}
|
|
if op.Type == OpTypeKeygen {
|
|
pending[op.Key.KeyID] = op.Key
|
|
}
|
|
operations = append(operations, op)
|
|
root = advance(root, op.digest())
|
|
}
|
|
vm.staged.Drop(invalid)
|
|
if len(operations) == 0 {
|
|
return nil, errors.New("mpcvm: no completed ceremonies to include")
|
|
}
|
|
|
|
// The block timestamp never precedes its parent's; Verify enforces the
|
|
// same bound, so a clock that ran backwards must not produce an
|
|
// unverifiable block on the proposer's own machine.
|
|
ts := time.Now().Unix()
|
|
if ts < parent.BlockTimestamp {
|
|
ts = parent.BlockTimestamp
|
|
}
|
|
|
|
blk := &Block{
|
|
ParentID_: parent.ID(),
|
|
BlockHeight: parent.Height() + 1,
|
|
BlockTimestamp: ts,
|
|
StateRoot: root,
|
|
Operations: operations,
|
|
vm: vm,
|
|
}
|
|
blk.ID_ = blk.computeID()
|
|
|
|
vm.log.Info("built M-Chain block",
|
|
log.Stringer("blockID", blk.ID()),
|
|
log.Uint64("height", blk.BlockHeight),
|
|
log.Int("operations", len(operations)),
|
|
log.String("stateRoot", fmt.Sprintf("%x", root[:8])),
|
|
)
|
|
return blk, nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return built, nil
|
|
}
|
|
|
|
// GetBlock implements the chain.ChainVM interface
|
|
func (vm *VM) GetBlock(ctx context.Context, id ids.ID) (vmchain.Block, error) {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
return vm.loadBlock(id)
|
|
}
|
|
|
|
// ParseBlock implements the chain.ChainVM interface
|
|
func (vm *VM) ParseBlock(ctx context.Context, bytes []byte) (vmchain.Block, error) {
|
|
return vm.parseBlock(bytes)
|
|
}
|
|
|
|
// parseBlock decodes a block belonging to this VM. The store reads accepted
|
|
// blocks back through it, so there is one decoder rather than one per caller.
|
|
func (vm *VM) parseBlock(raw []byte) (*Block, error) {
|
|
blk := &Block{vm: vm}
|
|
if err := parseBlockBytes(raw, blk); err != nil {
|
|
return nil, err
|
|
}
|
|
blk.ID_ = blk.computeID()
|
|
return blk, nil
|
|
}
|
|
|
|
// reloadRoot puts the committed state root back after a block's writes have
|
|
// been discarded, so the root this node reports is one that is on disk. It runs
|
|
// inside Accept, under the chain's lock, and takes the VM's in that order.
|
|
func (vm *VM) reloadRoot() error {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
return vm.state.ReadRoot()
|
|
}
|
|
|
|
// SetPreference implements the chain.ChainVM interface
|
|
func (vm *VM) SetPreference(ctx context.Context, id ids.ID) error {
|
|
vm.chain.Prefer(id)
|
|
return nil
|
|
}
|
|
|
|
// LastAccepted implements the chain.ChainVM interface
|
|
func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
|
|
id, _ := vm.chain.Tip()
|
|
return id, nil
|
|
}
|
|
|
|
// CreateHandlers implements the common.VM interface
|
|
func (vm *VM) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) {
|
|
handlers := map[string]http.Handler{
|
|
"/rpc": vm.createRPCHandler(),
|
|
"/health": http.HandlerFunc(vm.handleHealth),
|
|
}
|
|
return handlers, nil
|
|
}
|
|
|
|
// HealthCheck implements the common.VM interface.
|
|
//
|
|
// Health is "can this node read its own state", not "does this node hold a
|
|
// key". M-Chain is a custody REGISTRY first and a signer second: a validator
|
|
// that holds no share still serves reads and still verifies every block, so
|
|
// gating health on key material would take healthy nodes out of rotation for
|
|
// doing their job correctly. What a share-less node cannot do — contribute a
|
|
// partial signature — is visible in sharesHeld.
|
|
func (vm *VM) HealthCheck(ctx context.Context) (vmchain.HealthResult, error) {
|
|
vm.mu.RLock()
|
|
defer vm.mu.RUnlock()
|
|
|
|
keys, err := vm.state.Keys()
|
|
if err != nil {
|
|
return vmchain.HealthResult{}, fmt.Errorf("mpcvm: read custody registry: %w", err)
|
|
}
|
|
root := vm.state.Root()
|
|
return vmchain.HealthResult{
|
|
Healthy: true,
|
|
Details: map[string]string{
|
|
"custodyKeys": fmt.Sprintf("%d", len(keys)),
|
|
"sharesHeld": fmt.Sprintf("%d", len(vm.shares)),
|
|
"stagedCeremonies": fmt.Sprintf("%d", vm.staged.Len()),
|
|
"stateRoot": hex.EncodeToString(root[:]),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Shutdown implements the common.VM interface.
|
|
//
|
|
// Nothing is flushed here. Every durable fact — registered keys, recorded
|
|
// ceremonies, key shares, the accepted tip — is written at the moment it
|
|
// becomes true, not at shutdown. A VM that persists its registry only on a
|
|
// clean shutdown loses it to any crash, kill or power cut, which for a custody
|
|
// chain means losing the record of who holds the funds.
|
|
func (vm *VM) Shutdown(ctx context.Context) error {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
if vm.pool != nil {
|
|
vm.pool.TearDown()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateStaticHandlers implements the common.VM interface
|
|
func (vm *VM) CreateStaticHandlers(ctx context.Context) (map[string]http.Handler, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// Connected implements the common.VM interface
|
|
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, nodeVersion *vmchain.VersionInfo) error {
|
|
return nil
|
|
}
|
|
|
|
// Disconnected implements the common.VM interface
|
|
func (vm *VM) Disconnected(ctx context.Context, nodeID ids.NodeID) error {
|
|
return nil
|
|
}
|
|
|
|
// Request implements the common.VM interface
|
|
func (vm *VM) Request(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, request []byte) error {
|
|
// Handle MPC protocol messages
|
|
return nil
|
|
}
|
|
|
|
// Response implements the common.VM interface
|
|
func (vm *VM) Response(ctx context.Context, nodeID ids.NodeID, requestID uint32, response []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// RequestFailed implements the common.VM interface
|
|
func (vm *VM) RequestFailed(ctx context.Context, nodeID ids.NodeID, requestID uint32, appErr *warp.Error) error {
|
|
return nil
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// It is also where a ceremony message becomes attributable. The threshold
|
|
// library carries the sender in the message body and does not authenticate it —
|
|
// it is written for an authenticated channel and expects its transport to
|
|
// supply that. This is the transport. The p2p layer authenticated nodeID, and
|
|
// party.ID IS the NodeID string on this chain, so a message whose From names a
|
|
// different party is one peer speaking as another. Delivering it would let any
|
|
// node that can reach this method inject rounds as any committee member:
|
|
// enough to abort every custody ceremony on demand, and enough to make the
|
|
// protocol's identifiable abort name an honest validator for it.
|
|
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
|
|
}
|
|
if ceremonyMsg.From != party.ID(nodeID.String()) {
|
|
vm.log.Warn("dropping ceremony message sent under another party's name",
|
|
log.String("sender", nodeID.String()),
|
|
log.String("claimed", string(ceremonyMsg.From)),
|
|
log.String("ceremony", sessionID),
|
|
)
|
|
return nil
|
|
}
|
|
vm.deliverCeremonyMessage(sessionID, ceremonyMsg)
|
|
return nil
|
|
}
|
|
|
|
// Version implements the common.VM interface
|
|
func (vm *VM) Version(ctx context.Context) (string, error) {
|
|
return Version.String(), nil
|
|
}
|
|
|
|
// CrossChainRequest implements the common.VM interface. This is how another
|
|
// chain (B-Chain for bridge custody) asks M-Chain for a ceremony.
|
|
//
|
|
// The ceremony runs to completion here and its result is staged for the next
|
|
// block; the requester reads the outcome from the ceremony log, which is
|
|
// replicated, rather than from a reply that only this node would remember.
|
|
func (vm *VM) CrossChainRequest(ctx context.Context, chainID ids.ID, requestID uint32, deadline time.Time, request []byte) error {
|
|
var req CrossChainMPCRequest
|
|
if err := parseCrossChainMPCRequest(request, &req); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Authorize from the chain id the TRANSPORT authenticated, never from
|
|
// req.RequestingChain. That field is written by the sender about itself, so
|
|
// reading it here would let any peer that can reach this method claim any
|
|
// chain's custody rights; it survives only as attribution on the recorded
|
|
// operation, where it is set from the authenticated name below.
|
|
by, err := vm.caller(chainID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch req.Type {
|
|
case "sign":
|
|
_, err := vm.RequestSignature(ctx, by, req.KeyID, req.MessageHash)
|
|
return err
|
|
case "keygen":
|
|
_, err := vm.StartKeygen(ctx, req.KeyID, by)
|
|
return err
|
|
default:
|
|
return fmt.Errorf("mpcvm: unknown cross-chain request type %q", req.Type)
|
|
}
|
|
}
|
|
|
|
// CrossChainResponse implements the common.VM interface
|
|
func (vm *VM) CrossChainResponse(ctx context.Context, chainID ids.ID, requestID uint32, response []byte) error {
|
|
return nil
|
|
}
|
|
|
|
// CrossChainRequestFailed implements the common.VM interface
|
|
func (vm *VM) CrossChainRequestFailed(ctx context.Context, chainID ids.ID, requestID uint32, appErr *warp.Error) error {
|
|
return nil
|
|
}
|
|
|
|
// GetBlockIDAtHeight implements the chain.HeightIndexedChainVM interface. The
|
|
// index is persisted, so it survives a restart — a purely in-memory height map
|
|
// answers "not found" for every accepted block after a reboot.
|
|
func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, error) {
|
|
return vm.chain.IDAtHeight(height)
|
|
}
|
|
|
|
// SetState implements the common.VM interface
|
|
func (vm *VM) SetState(ctx context.Context, state uint32) error {
|
|
return nil
|
|
}
|
|
|
|
// NewHTTPHandler returns HTTP handlers for the VM
|
|
func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
|
|
return vm.createRPCHandler(), nil
|
|
}
|
|
|
|
// WaitForEvent blocks until this VM has work for the engine.
|
|
//
|
|
// M-Chain is demand-driven: it builds a block only when a ceremony has
|
|
// completed and staged an operation. Returning eagerly would spin the engine
|
|
// (the flood loop in chains/manager.go); blocking forever would mean a
|
|
// completed ceremony never reaches a block unless some other chain happened to
|
|
// wake the builder.
|
|
func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
|
|
return vm.staged.Wait(ctx)
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
// loadBlock reads a block in flight or one read back from committed state, so
|
|
// an accepted ancestor is found after a restart rather than only while it
|
|
// happens to be in memory.
|
|
func (vm *VM) loadBlock(id ids.ID) (*Block, error) {
|
|
if vm.genesisBlock != nil && id == vm.genesisBlock.ID() {
|
|
return vm.genesisBlock, nil
|
|
}
|
|
return vm.chain.Block(id, vm.parseBlock)
|
|
}
|
|
|
|
// handleHealth serves the same result HealthCheck reports to the engine. One
|
|
// source of truth: an HTTP probe that computed health separately could say
|
|
// "healthy" while the engine was being told otherwise.
|
|
func (vm *VM) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
health, err := vm.HealthCheck(r.Context())
|
|
if err != nil {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(struct {
|
|
Healthy bool `json:"healthy"`
|
|
Details map[string]string `json:"details"`
|
|
}{health.Healthy, health.Details})
|
|
}
|
|
|
|
// CrossChainMPCRequest is the request format for cross-chain MPC operations
|
|
type CrossChainMPCRequest struct {
|
|
Type string `json:"type"` // sign, keygen, reshare
|
|
RequestingChain string `json:"requestingChain"`
|
|
KeyID string `json:"keyId"`
|
|
KeyType string `json:"keyType,omitempty"`
|
|
MessageHash []byte `json:"messageHash,omitempty"`
|
|
MessageType string `json:"messageType,omitempty"`
|
|
}
|
|
|
|
// Genesis represents the genesis state.
|
|
//
|
|
// Policy is here rather than only in each node's config file because the
|
|
// chain's quorum must be the same value on every validator: a policy that
|
|
// lives per-node can differ per-node, and the first symptom is a key whose
|
|
// declared quorum is not the quorum it was generated with. An absent or
|
|
// malformed policy leaves the config default in place (see Initialize).
|
|
type Genesis struct {
|
|
Timestamp int64 `json:"timestamp"`
|
|
Policy quorum.Policy `json:"policy,omitempty"`
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
// publicKeyToAddress derives the 20-byte external-chain custody address of a
|
|
// secp256k1 group key: keccak256(uncompressed X‖Y)[12:].
|
|
//
|
|
// This MUST be Keccak-256, not SHA-256. It is the address that actually holds
|
|
// bridged funds on Ethereum and every EVM chain, and it is what an external
|
|
// `ecrecover` produces from a signature by this key. Deriving it with SHA-256
|
|
// yields an address that no external chain associates with the group key: funds
|
|
// sent to it are unspendable, and a withdrawal signed by the group key appears
|
|
// to come from a different account than the one M-Chain published. There is no
|
|
// recovery from publishing the wrong custody address.
|
|
//
|
|
// Returns nil for an unparseable key; callers must treat nil as a hard failure
|
|
// rather than registering an empty address.
|
|
func publicKeyToAddress(pubKey []byte) []byte {
|
|
uncompressed := uncompressedXY(pubKey)
|
|
if uncompressed == nil {
|
|
return nil
|
|
}
|
|
return luxcrypto.Keccak256(uncompressed)[12:]
|
|
}
|
|
|
|
// uncompressedXY normalises any accepted secp256k1 public-key encoding to the
|
|
// bare 64-byte X‖Y form that address derivation hashes.
|
|
func uncompressedXY(pubKey []byte) []byte {
|
|
if x, y := secp256k1.DecompressPubkey(pubKey); x != nil && y != nil {
|
|
xb, yb := x.Bytes(), y.Bytes()
|
|
out := make([]byte, 64)
|
|
copy(out[32-len(xb):32], xb)
|
|
copy(out[64-len(yb):64], yb)
|
|
return out
|
|
}
|
|
switch len(pubKey) {
|
|
case 65:
|
|
if pubKey[0] != 0x04 {
|
|
return nil
|
|
}
|
|
return pubKey[1:]
|
|
case 64:
|
|
return pubKey
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Session-Ready: Attestation Domains
|
|
// =============================================================================
|
|
// QuantumVM threshold attests to:
|
|
// - Oracle observation commitments (oracle/write, oracle/read)
|
|
// - Session completion (session/complete)
|
|
// - Epoch beacon signatures (epoch/beacon)
|
|
// Domain separators prevent cross-protocol replay attacks.
|
|
|
|
// AttestationDomain defines the domain for a threshold attestation
|
|
type AttestationDomain string
|
|
|
|
const (
|
|
// DomainOracleWrite attests to external write request commitments
|
|
DomainOracleWrite AttestationDomain = "oracle/write"
|
|
// DomainOracleRead attests to external read request commitments
|
|
DomainOracleRead AttestationDomain = "oracle/read"
|
|
// DomainSessionComplete attests to session completion (output hash + oracle obs + receipts root)
|
|
DomainSessionComplete AttestationDomain = "session/complete"
|
|
// DomainEpochBeacon attests to epoch beacon signatures for randomness
|
|
DomainEpochBeacon AttestationDomain = "epoch/beacon"
|
|
)
|
|
|
|
// domainSeparators maps domains to their cryptographic separators
|
|
var domainSeparators = map[AttestationDomain][]byte{
|
|
DomainOracleWrite: []byte("LUX:QuantumAttest:oracle/write:v1"),
|
|
DomainOracleRead: []byte("LUX:QuantumAttest:oracle/read:v1"),
|
|
DomainSessionComplete: []byte("LUX:QuantumAttest:session/complete:v1"),
|
|
DomainEpochBeacon: []byte("LUX:QuantumAttest:epoch/beacon:v1"),
|
|
}
|
|
|
|
// QuantumAttestation represents a threshold attestation over a commitment.
|
|
type QuantumAttestation struct {
|
|
// Domain specifies what is being attested (oracle/write, session/complete, etc.)
|
|
Domain AttestationDomain `json:"domain"`
|
|
|
|
// AttestationID is a unique identifier for this attestation
|
|
AttestationID [32]byte `json:"attestationId"`
|
|
|
|
// SubjectID is the ID of what is being attested (request_id, session_id, epoch number)
|
|
SubjectID [32]byte `json:"subjectId"`
|
|
|
|
// CommitmentRoot is the Merkle root being attested
|
|
CommitmentRoot [32]byte `json:"commitmentRoot"`
|
|
|
|
// Epoch in which this attestation was created
|
|
Epoch uint64 `json:"epoch"`
|
|
|
|
// Timestamp when attestation was created
|
|
Timestamp time.Time `json:"timestamp"`
|
|
|
|
// KeyID of the custody key that signed.
|
|
KeyID string `json:"keyId"`
|
|
|
|
// CeremonyID is the ceremony that produced Signature — the primary key of
|
|
// the replicated ceremony log, so an attestation handed to another chain
|
|
// can be looked up and re-checked against M-Chain state.
|
|
CeremonyID string `json:"ceremonyId"`
|
|
|
|
// Policy is the key's quorum in operator form ("3-of-5"). It is what
|
|
// VerifyAttestation checks Signers against; a bare threshold number would
|
|
// be ambiguous between signer count and polynomial degree.
|
|
Policy quorum.Policy `json:"policy"`
|
|
|
|
// Signers are the parties that participated, canonically ordered.
|
|
Signers []party.ID `json:"signers"`
|
|
|
|
// Signature is the 65-byte r‖s‖v threshold signature over the attestation
|
|
// payload — the same encoding every ceremony artifact uses.
|
|
Signature []byte `json:"signature"`
|
|
}
|
|
|
|
// OracleCommitAttestation contains details for oracle commit attestations
|
|
type OracleCommitAttestation struct {
|
|
RequestID [32]byte `json:"requestId"`
|
|
Kind uint8 `json:"kind"` // 0 = write, 1 = read
|
|
Root [32]byte `json:"root"`
|
|
RecordCount uint32 `json:"recordCount"`
|
|
}
|
|
|
|
// SessionCompleteAttestation contains details for session completion attestations
|
|
type SessionCompleteAttestation struct {
|
|
SessionID [32]byte `json:"sessionId"`
|
|
OutputHash [32]byte `json:"outputHash"`
|
|
OracleRoot [32]byte `json:"oracleRoot"`
|
|
ReceiptsRoot [32]byte `json:"receiptsRoot"`
|
|
StepCount uint32 `json:"stepCount"`
|
|
}
|
|
|
|
// EpochBeaconAttestation contains details for epoch beacon attestations
|
|
type EpochBeaconAttestation struct {
|
|
Epoch uint64 `json:"epoch"`
|
|
Randomness [32]byte `json:"randomness"`
|
|
PreviousRef [32]byte `json:"previousRef"`
|
|
}
|
|
|
|
// ComputeAttestationPayload computes the payload to be signed for an attestation
|
|
func ComputeAttestationPayload(domain AttestationDomain, subjectID, commitmentRoot [32]byte, epoch uint64) [32]byte {
|
|
h := sha256.New()
|
|
|
|
// Domain separator
|
|
separator, ok := domainSeparators[domain]
|
|
if !ok {
|
|
separator = []byte("LUX:QuantumAttest:unknown:v1")
|
|
}
|
|
h.Write(separator)
|
|
|
|
// Subject ID (request_id, session_id, etc.)
|
|
h.Write(subjectID[:])
|
|
|
|
// Commitment root being attested
|
|
h.Write(commitmentRoot[:])
|
|
|
|
// Epoch for temporal binding
|
|
var epochBytes [8]byte
|
|
binary.BigEndian.PutUint64(epochBytes[:], epoch)
|
|
h.Write(epochBytes[:])
|
|
|
|
var result [32]byte
|
|
copy(result[:], h.Sum(nil))
|
|
return result
|
|
}
|
|
|
|
// attest is the ONE path from a domain-bound payload to an attestation: run
|
|
// the ceremony, then describe what it produced.
|
|
//
|
|
// There is no polling here and no "status" to interpret. RequestSignature
|
|
// returns when the ceremony has finished and the signature is in hand, so the
|
|
// three Attest* entry points below differ only in how they compute their
|
|
// subject and commitment — which is the only thing that actually differs
|
|
// between an oracle commit, a session completion and an epoch beacon.
|
|
func (vm *VM) attest(
|
|
ctx context.Context,
|
|
by Caller,
|
|
keyID string,
|
|
domain AttestationDomain,
|
|
subjectID [32]byte,
|
|
commitRoot [32]byte,
|
|
epoch uint64,
|
|
) (*QuantumAttestation, error) {
|
|
payload := ComputeAttestationPayload(domain, subjectID, commitRoot, epoch)
|
|
|
|
op, err := vm.RequestSignature(ctx, by, keyID, payload[:])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mpcvm: attest %s: %w", domain, err)
|
|
}
|
|
rec, err := vm.Key(keyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vm.log.Info("created attestation",
|
|
log.String("domain", string(domain)),
|
|
log.String("subject", hex.EncodeToString(subjectID[:])),
|
|
log.Uint64("epoch", epoch),
|
|
log.String("ceremony", op.CeremonyID),
|
|
)
|
|
return &QuantumAttestation{
|
|
Domain: domain,
|
|
// The id binds the payload to the ceremony that signed it, so two
|
|
// attestations over the same payload by different quorums are
|
|
// distinguishable.
|
|
AttestationID: sha256.Sum256(append(payload[:], op.CeremonyID...)),
|
|
SubjectID: subjectID,
|
|
CommitmentRoot: commitRoot,
|
|
Epoch: epoch,
|
|
Timestamp: time.Now(),
|
|
KeyID: keyID,
|
|
CeremonyID: op.CeremonyID,
|
|
Policy: rec.Policy,
|
|
Signers: op.Signers,
|
|
Signature: op.Artifact,
|
|
}, nil
|
|
}
|
|
|
|
// AttestOracleCommit creates a threshold attestation for an oracle commitment.
|
|
func (vm *VM) AttestOracleCommit(
|
|
ctx context.Context,
|
|
by Caller,
|
|
keyID string,
|
|
requestID [32]byte,
|
|
kind uint8, // 0 = write, 1 = read
|
|
commitRoot [32]byte,
|
|
epoch uint64,
|
|
) (*QuantumAttestation, error) {
|
|
domain := DomainOracleWrite
|
|
if kind != 0 {
|
|
domain = DomainOracleRead
|
|
}
|
|
return vm.attest(ctx, by, keyID, domain, requestID, commitRoot, epoch)
|
|
}
|
|
|
|
// AttestSessionComplete creates a threshold attestation for session completion.
|
|
func (vm *VM) AttestSessionComplete(
|
|
ctx context.Context,
|
|
by Caller,
|
|
keyID string,
|
|
sessionID [32]byte,
|
|
outputHash [32]byte,
|
|
oracleRoot [32]byte,
|
|
receiptsRoot [32]byte,
|
|
epoch uint64,
|
|
) (*QuantumAttestation, error) {
|
|
h := sha256.New()
|
|
h.Write(outputHash[:])
|
|
h.Write(oracleRoot[:])
|
|
h.Write(receiptsRoot[:])
|
|
var commitRoot [32]byte
|
|
copy(commitRoot[:], h.Sum(nil))
|
|
|
|
return vm.attest(ctx, by, keyID, DomainSessionComplete, sessionID, commitRoot, epoch)
|
|
}
|
|
|
|
// AttestEpochBeacon creates a threshold attestation for epoch beacon randomness.
|
|
func (vm *VM) AttestEpochBeacon(
|
|
ctx context.Context,
|
|
by Caller,
|
|
keyID string,
|
|
epoch uint64,
|
|
previousRef [32]byte,
|
|
) (*QuantumAttestation, error) {
|
|
var epochBytes [8]byte
|
|
binary.BigEndian.PutUint64(epochBytes[:], epoch)
|
|
|
|
h := sha256.New()
|
|
h.Write([]byte("LUX:EpochBeacon:"))
|
|
h.Write(epochBytes[:])
|
|
var subjectID [32]byte
|
|
copy(subjectID[:], h.Sum(nil))
|
|
|
|
// Chaining each beacon to its predecessor is what makes the sequence
|
|
// unforgeable as a sequence rather than as isolated signatures.
|
|
h2 := sha256.New()
|
|
h2.Write(previousRef[:])
|
|
h2.Write(epochBytes[:])
|
|
var commitRoot [32]byte
|
|
copy(commitRoot[:], h2.Sum(nil))
|
|
|
|
return vm.attest(ctx, by, keyID, DomainEpochBeacon, subjectID, commitRoot, epoch)
|
|
}
|
|
|
|
// VerifyAttestation checks an attestation against this node's registry: the
|
|
// domain is one M-Chain issues, the quorum satisfies the key's policy, and the
|
|
// signature verifies under the registered group key over the recomputed
|
|
// payload.
|
|
//
|
|
// It uses the same verifyGroupSignature that block.go uses to admit a ceremony
|
|
// to state, so an attestation cannot pass here under a rule that a block would
|
|
// have rejected.
|
|
func (vm *VM) VerifyAttestation(a *QuantumAttestation) error {
|
|
if a == nil {
|
|
return errors.New("mpcvm: nil attestation")
|
|
}
|
|
if _, ok := domainSeparators[a.Domain]; !ok {
|
|
return fmt.Errorf("mpcvm: unknown attestation domain %q", a.Domain)
|
|
}
|
|
// The registry decides the quorum, not the attestation.
|
|
//
|
|
// This used to compare len(a.Signers) against a.Policy — two fields of the
|
|
// same attacker-supplied document — so it held for every attestation ever
|
|
// written, including one declaring 1-of-1 with one signer. A quorum check
|
|
// against the checked party's own claim is not a check. Reading the key's
|
|
// record is: the policy a key was generated under is consensus state, and
|
|
// an attestation that misdeclares it is refused before its signature is
|
|
// even considered.
|
|
rec, err := vm.Key(a.KeyID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if a.Policy != rec.Policy {
|
|
return fmt.Errorf("%w: attestation declares policy %s, key %s is %s",
|
|
ErrInvalidOperation, a.Policy, a.KeyID, rec.Policy)
|
|
}
|
|
// K signers, not K-1: a set the size of the polynomial degree cannot
|
|
// produce a signature, so a claim that it did is a wrong-degree key.
|
|
if len(a.Signers) < rec.Policy.K {
|
|
return fmt.Errorf("%w: %d signers for policy %s", ErrQuorumTooSmall, len(a.Signers), rec.Policy)
|
|
}
|
|
// Canonical and real: without this, one signer repeated K times, or under K
|
|
// spellings, or K names that hold no share of this key, all count as a
|
|
// quorum. Block.verifySign holds the same three rules on the same set, so
|
|
// an attestation cannot pass here under a rule a block would have refused.
|
|
if !sortedUnique(a.Signers) {
|
|
return fmt.Errorf("%w: signer set is not canonical (sorted+unique)", ErrInvalidOperation)
|
|
}
|
|
members := make(map[party.ID]struct{}, len(rec.Participants))
|
|
for _, p := range rec.Participants {
|
|
members[p] = struct{}{}
|
|
}
|
|
for _, s := range a.Signers {
|
|
if _, ok := members[s]; !ok {
|
|
return fmt.Errorf("%w: %s is not a participant of key %s", ErrInvalidOperation, s, rec.KeyID)
|
|
}
|
|
}
|
|
payload := ComputeAttestationPayload(a.Domain, a.SubjectID, a.CommitmentRoot, a.Epoch)
|
|
if err := verifyGroupSignature(rec.GroupPublicKey, payload[:], a.Signature); err != nil {
|
|
return fmt.Errorf("%w: %w", ErrBadArtifact, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DetectEquivocation checks if two attestations represent equivocation (slashable)
|
|
// Two attestations are equivocating if they have the same domain, subject, and epoch
|
|
// but different commitment roots
|
|
func DetectEquivocation(a, b *QuantumAttestation) bool {
|
|
if a == nil || b == nil {
|
|
return false
|
|
}
|
|
|
|
// Must be same domain
|
|
if a.Domain != b.Domain {
|
|
return false
|
|
}
|
|
|
|
// Must be same subject
|
|
if a.SubjectID != b.SubjectID {
|
|
return false
|
|
}
|
|
|
|
// Must be same epoch
|
|
if a.Epoch != b.Epoch {
|
|
return false
|
|
}
|
|
|
|
// Equivocation if commitment roots differ
|
|
return a.CommitmentRoot != b.CommitmentRoot
|
|
}
|