mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
Solana addresses were correct and receive-only: ConsensusKeyInfoStore.Save was the only RegisterKey caller and hardcoded "secp256k1", and TriggerSign read the curve from that field, so no product entry point could reach Ed25519 signing. The hardcode is not fixed, it is UNREPRESENTABLE. transport.KeyInfo.KeyType is deleted — the record carries one public key per curve and a PublicKeyFor(curve) — and RegisterKey lost its keyType parameter, so there is no longer a place to state a curve wrongly. A scalar KeyType was the wrong shape anyway: one keygen ceremony produces BOTH a secp256k1 and an Ed25519 key, so "what curve is this wallet" has no single answer. The request carries the network instead, and ONE table maps it to a curve, with NO default arm — KeyTypeForNetwork returns ErrUnknownNetwork rather than guessing. That is the point: the old default returned secp256k1 for anything unrecognized, which is how a Solana request would have been signed with the wrong key. /sign's 32-byte clamp becomes the SCHEME's rule rather than the endpoint's: secp256k1 signs a digest and requires exactly 32 bytes, Ed25519 signs the message at any length. /sign no longer accepts key_type at all — two ways to say the curve is the misuse being removed — and Network is bound into the idempotency fingerprint, so the same bytes for SOL and ETH conflict instead of sharing one cached signature from a different key. ⚠ The trap this nearly shipped with: every pre-existing KeyInfo record has EMPTY public keys, because the old Save wrote "", "". A naive "does this wallet have this curve" gate would have refused every existing wallet on every curve. The check is gated on RecordsCurves() — a record naming no curve denies none, and the missing share stays the thing that fails closed. Verified end to end, not by mirroring: a Solana request through ConsensusMPCBackend.TriggerSign with a real 2-of-3 FROST ring produces a signature crypto/ed25519.Verify accepts, over a 63-byte message so any reintroduced clamp or pre-hash fails it; ETH/EVM/BTC/LUX still route to secp256k1 and must RECOVER to the wallet's key. Four mutations tried, all caught. Behavioral risk worth naming: an unrecognized chain string now fails its transaction closed rather than signing with a default. That is correct, and the real fix is validating chain at POST /v1/transactions.
366 lines
10 KiB
Go
366 lines
10 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package transport
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sync"
|
|
|
|
"github.com/luxfi/mpc/pkg/kvstore"
|
|
"github.com/luxfi/mpc/pkg/logger"
|
|
"github.com/luxfi/mpc/pkg/types"
|
|
)
|
|
|
|
// StateStore wraps kvstore.KVStore with consensus-based replication
|
|
// Local writes go to ZapDB, reads query consensus for latest state
|
|
type StateStore struct {
|
|
local kvstore.KVStore // Local ZapDB
|
|
transport *Transport
|
|
nodeID string
|
|
|
|
// Pending updates from consensus
|
|
pendingMu sync.RWMutex
|
|
pending map[string][]byte
|
|
}
|
|
|
|
// StateUpdate represents a state change to replicate
|
|
type StateUpdate struct {
|
|
Key string `json:"key"`
|
|
Value []byte `json:"value"`
|
|
Deleted bool `json:"deleted"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
NodeID string `json:"node_id"`
|
|
}
|
|
|
|
// NewStateStore creates a consensus-aware state store.
|
|
//
|
|
// transport may be nil, which yields a store that is purely local: no
|
|
// subscription, no replication. That is the single-node shape, and it is what
|
|
// lets the metadata store be exercised without standing up a consensus ring.
|
|
func NewStateStore(local kvstore.KVStore, transport *Transport, nodeID string) *StateStore {
|
|
ss := &StateStore{
|
|
local: local,
|
|
transport: transport,
|
|
nodeID: nodeID,
|
|
pending: make(map[string][]byte),
|
|
}
|
|
|
|
// Subscribe to state updates from other nodes
|
|
if transport != nil {
|
|
transport.Subscribe("mpc:state", ss.handleStateUpdate)
|
|
}
|
|
|
|
return ss
|
|
}
|
|
|
|
// replicate broadcasts a state update to peers, fire-and-forget. A nil
|
|
// transport is a local-only store and has nobody to tell.
|
|
func (s *StateStore) replicate(update StateUpdate) error {
|
|
if s.transport == nil {
|
|
return nil
|
|
}
|
|
payload, err := json.Marshal(update)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
go func() {
|
|
if err := s.transport.Publish("mpc:state", payload); err != nil {
|
|
logger.Warn("Failed to replicate state update", "key", update.Key, "err", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// Put stores a key-value pair and replicates to peers
|
|
func (s *StateStore) Put(key string, value []byte) error {
|
|
// Write to local store first
|
|
if err := s.local.Put(key, value); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Replicate to peers via consensus transport (eventual consistency)
|
|
return s.replicate(StateUpdate{
|
|
Key: key,
|
|
Value: value,
|
|
NodeID: s.nodeID,
|
|
Deleted: false,
|
|
})
|
|
}
|
|
|
|
// Get retrieves a value from local store
|
|
func (s *StateStore) Get(key string) ([]byte, error) {
|
|
// Check pending updates first
|
|
s.pendingMu.RLock()
|
|
if val, ok := s.pending[key]; ok {
|
|
s.pendingMu.RUnlock()
|
|
return val, nil
|
|
}
|
|
s.pendingMu.RUnlock()
|
|
|
|
// Read from local store
|
|
return s.local.Get(key)
|
|
}
|
|
|
|
// Delete removes a key and replicates
|
|
func (s *StateStore) Delete(key string) error {
|
|
if err := s.local.Delete(key); err != nil {
|
|
return err
|
|
}
|
|
|
|
return s.replicate(StateUpdate{
|
|
Key: key,
|
|
NodeID: s.nodeID,
|
|
Deleted: true,
|
|
})
|
|
}
|
|
|
|
// Close closes the state store
|
|
func (s *StateStore) Close() error {
|
|
return s.local.Close()
|
|
}
|
|
|
|
// Backup performs a backup of the local store
|
|
func (s *StateStore) Backup() error {
|
|
return s.local.Backup()
|
|
}
|
|
|
|
// handleStateUpdate processes state updates from peers
|
|
func (s *StateStore) handleStateUpdate(msg *Message) {
|
|
var update StateUpdate
|
|
if err := json.Unmarshal(msg.Data, &update); err != nil {
|
|
logger.Error("Failed to unmarshal state update", err)
|
|
return
|
|
}
|
|
|
|
// Ignore our own updates
|
|
if update.NodeID == s.nodeID {
|
|
return
|
|
}
|
|
|
|
logger.Debug("Received state update from peer",
|
|
"peer", update.NodeID,
|
|
"key", update.Key,
|
|
"deleted", update.Deleted,
|
|
)
|
|
|
|
// Apply update to local store
|
|
if update.Deleted {
|
|
if err := s.local.Delete(update.Key); err != nil {
|
|
logger.Error("Failed to apply delete", err, "key", update.Key)
|
|
}
|
|
} else {
|
|
if err := s.local.Put(update.Key, update.Value); err != nil {
|
|
logger.Error("Failed to apply put", err, "key", update.Key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Query queries a key from multiple peers for consensus
|
|
func (s *StateStore) Query(ctx context.Context, key string) ([]byte, error) {
|
|
// For now, just return local value
|
|
// In full implementation, this would query peers and use consensus
|
|
return s.Get(key)
|
|
}
|
|
|
|
// SyncWithPeers synchronizes state with connected peers
|
|
func (s *StateStore) SyncWithPeers(ctx context.Context) error {
|
|
// Request full state sync from a peer
|
|
// This would be used during node startup or recovery
|
|
// Implementation depends on the specific sync protocol needed
|
|
|
|
logger.Info("State sync with peers requested")
|
|
|
|
// For now, we rely on continuous replication
|
|
// Full sync would involve:
|
|
// 1. Request snapshot from a peer
|
|
// 2. Apply snapshot to local store
|
|
// 3. Subscribe to updates from that point forward
|
|
|
|
return nil
|
|
}
|
|
|
|
// KeyInfoStore provides key metadata storage using consensus
|
|
// This replaces the Consul-based keyinfo.Store
|
|
type KeyInfoStore struct {
|
|
state *StateStore
|
|
nodeID string
|
|
prefix string
|
|
}
|
|
|
|
// NewKeyInfoStore creates a consensus-backed key info store
|
|
func NewKeyInfoStore(state *StateStore, nodeID string) *KeyInfoStore {
|
|
return &KeyInfoStore{
|
|
state: state,
|
|
nodeID: nodeID,
|
|
prefix: "mpc/keys/",
|
|
}
|
|
}
|
|
|
|
// KeyInfo is the metadata recorded for a wallet's key set.
|
|
//
|
|
// There is deliberately no field naming "the" curve of a wallet. A single
|
|
// keygen ceremony mints a secp256k1 key (EVM, Bitcoin, Lux, XRPL) AND an
|
|
// Ed25519 key (Solana, TON), so "what curve is this wallet" has no single
|
|
// answer and any scalar answer is a lie that the signer would then act on.
|
|
// Each curve gets its own field; an empty field means that curve was never
|
|
// minted for this wallet.
|
|
type KeyInfo struct {
|
|
WalletID string `json:"wallet_id"`
|
|
Threshold int `json:"threshold"`
|
|
ECDSAPubKey string `json:"public_key"` // secp256k1 (CGGMP21), hex
|
|
EdDSAPubKey string `json:"eddsa_key"` // ed25519 (FROST), hex
|
|
KeyData []byte `json:"key_data"` // Additional data
|
|
CreatedAt int64 `json:"created_at"`
|
|
NodeID string `json:"node_id"` // Node that initiated keygen
|
|
}
|
|
|
|
// RecordsCurves reports whether this record names any curve at all.
|
|
//
|
|
// Records written before a wallet's key set was recorded carry no public keys.
|
|
// Such a record makes NO claim about which curves the wallet has, and must not
|
|
// be read as denying one — reading "no keys listed" as "no keys exist" would
|
|
// refuse every wallet minted before this field was populated. Callers gate the
|
|
// per-curve check on this, and let the missing share be the thing that fails
|
|
// closed for records that say nothing.
|
|
func (k KeyInfo) RecordsCurves() bool {
|
|
return k.ECDSAPubKey != "" || k.EdDSAPubKey != ""
|
|
}
|
|
|
|
// PublicKeyFor returns the wallet's public key on the named curve, and whether
|
|
// a key on that curve exists at all.
|
|
//
|
|
// This is the only place curve identity is turned into a field, so a caller
|
|
// asking "can this wallet sign for Solana" never has to know which field
|
|
// answers it. Meaningful only when RecordsCurves is true.
|
|
func (k KeyInfo) PublicKeyFor(keyType types.KeyType) (string, bool) {
|
|
switch keyType {
|
|
case types.KeyTypeSecp256k1:
|
|
return k.ECDSAPubKey, k.ECDSAPubKey != ""
|
|
case types.KeyTypeEd25519:
|
|
return k.EdDSAPubKey, k.EdDSAPubKey != ""
|
|
default:
|
|
// sr25519 and anything else: no keygen leg mints it, so no field
|
|
// records it. Reporting "absent" is the truth, and fails closed.
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
// RegisterKey upserts a wallet's key metadata.
|
|
//
|
|
// It merges rather than overwrites, because the record is written from two
|
|
// different moments of one keygen: the CGGMP21 ceremony knows the threshold
|
|
// but no public keys, and the keygen result knows the public keys but arrives
|
|
// later. Neither knows the other's fields, so a plain overwrite would have
|
|
// whichever ran last erase the other's contribution. Zero-valued arguments
|
|
// therefore mean "leave as recorded"; keys are only ever added to a wallet,
|
|
// never removed, so there is nothing a caller needs to clear.
|
|
//
|
|
// There is no keyType parameter. A wallet holds one key per curve, so there is
|
|
// nothing for a caller to name — and nothing for a caller to hardcode.
|
|
func (s *KeyInfoStore) RegisterKey(walletID string, threshold int, ecdsaKey, eddsaKey string, keyData []byte) error {
|
|
info := KeyInfo{WalletID: walletID, NodeID: s.nodeID}
|
|
if existing, err := s.Get(walletID); err == nil && existing != nil {
|
|
info = *existing
|
|
info.WalletID = walletID
|
|
info.NodeID = s.nodeID
|
|
}
|
|
|
|
if threshold != 0 {
|
|
info.Threshold = threshold
|
|
}
|
|
if ecdsaKey != "" {
|
|
info.ECDSAPubKey = ecdsaKey
|
|
}
|
|
if eddsaKey != "" {
|
|
info.EdDSAPubKey = eddsaKey
|
|
}
|
|
if len(keyData) != 0 {
|
|
info.KeyData = keyData
|
|
}
|
|
|
|
data, err := json.Marshal(info)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return s.state.Put(s.prefix+walletID, data)
|
|
}
|
|
|
|
// GetKey retrieves key metadata
|
|
func (s *KeyInfoStore) Get(walletID string) (*KeyInfo, error) {
|
|
data, err := s.state.Get(s.prefix + walletID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var info KeyInfo
|
|
if err := json.Unmarshal(data, &info); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &info, nil
|
|
}
|
|
|
|
// DeleteKey removes key metadata
|
|
func (s *KeyInfoStore) DeleteKey(walletID string) error {
|
|
return s.state.Delete(s.prefix + walletID)
|
|
}
|
|
|
|
// ListKeys returns all registered keys by scanning the underlying store for
|
|
// entries whose key begins with the KeyInfoStore prefix ("mpc/keys/").
|
|
// It leverages the kvstore.Store.Keys() method via a type assertion.
|
|
// If the store does not support key listing, only pending updates are searched.
|
|
func (s *KeyInfoStore) ListKeys() ([]KeyInfo, error) {
|
|
type keyLister interface {
|
|
Keys() ([]string, error)
|
|
}
|
|
|
|
lister, ok := s.state.local.(keyLister)
|
|
if !ok {
|
|
// Fallback: scan pending map only.
|
|
s.state.pendingMu.RLock()
|
|
defer s.state.pendingMu.RUnlock()
|
|
|
|
var keys []KeyInfo
|
|
for k, v := range s.state.pending {
|
|
if len(k) > len(s.prefix) && k[:len(s.prefix)] == s.prefix {
|
|
var ki KeyInfo
|
|
if err := json.Unmarshal(v, &ki); err != nil {
|
|
continue
|
|
}
|
|
keys = append(keys, ki)
|
|
}
|
|
}
|
|
return keys, nil
|
|
}
|
|
|
|
allKeys, err := lister.Keys()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var keys []KeyInfo
|
|
for _, k := range allKeys {
|
|
if len(k) <= len(s.prefix) || k[:len(s.prefix)] != s.prefix {
|
|
continue
|
|
}
|
|
|
|
data, err := s.state.Get(k)
|
|
if err != nil {
|
|
logger.Warn("Failed to read key info during list", "key", k, "err", err)
|
|
continue
|
|
}
|
|
|
|
var ki KeyInfo
|
|
if err := json.Unmarshal(data, &ki); err != nil {
|
|
logger.Warn("Failed to unmarshal key info during list", "key", k, "err", err)
|
|
continue
|
|
}
|
|
keys = append(keys, ki)
|
|
}
|
|
|
|
return keys, nil
|
|
}
|