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.
42 KiB
LLM.md - MPC Signer Architecture & Development Guide
This document provides comprehensive guidance for AI assistants working with the MPC (Multi-Party Computation) codebase.
✅ eddsa_pub_key / sol_address are LIVE — Ed25519, and only from KeygenEd25519
Solana/TON keys are real as of luxfi/threshold v1.12.7, which added the
FROST(Ed25519, SHA-512) ciphersuite of RFC 9591. This section previously said the
opposite and told you to leave eddsa_pub_key empty; that blocker is cleared.
The hazard it replaced, which has NOT gone away as a class. The sessions named
"EdDSA" used to call frost.KeygenTaproot — BIP-340 over secp256k1. Its public key
is also 32 bytes, so it passed mpcd's len(result.EDDSAPubKey) == 32 gate and
base58-encoded into a real-looking Solana address the ring could never sign for.
Deposits to such an address are unrecoverable.
The rules that keep it fixed:
- Any value reaching
EDDSAPubKeyMUST come fromfrost.KeygenEd25519and nothing else.KeygenTaproot(secp256k1) andfrost.Keygen(curve.Ed25519{}, …)(BLAKE3 challenge, big-endianS) both produce plausible-looking 32-byte keys that no chain accepts. - Sign with
frost.SignEd25519only. Genericfrost.Signover the same Ed25519 config returns 64 bytes shaped exactly like a signature, with a BLAKE3 challenge and big-endianS.SignEd25519'sResult()is a value (frost.Ed25519Signature), not a pointer. - Never pre-hash. PureEdDSA hashes the message inside the challenge. The old signing session SHA-256'd anything that was not already 32 bytes (a BIP-340 requirement); that is removed, and re-adding it signs a digest of a digest.
- Provenance is the defence, not validation.
pkg/address.Solanachecks the bytes decode as a prime-order edwards25519 point, but that is only a backstop: measured, it refuses ~93.4% of secp256k1 x-only keys, because about 1 in 16 of them is genuinely a valid Ed25519 key too. No function of the bytes alone can do better. It also rejects the identity point, for which any signature verifies — the DKG does not. - When anything is uncertain, emit nothing. An absent
sol_addressis recoverable; a wrong one is not. The Ed25519 keygen leg is deliberately non-fatal for this reason.
Layout. Keygen pkg/mpc/frost_keygen_session.go → share at ed25519:<walletID>
(Ed25519ShareKey, CBOR via ed25519_config_marshal.go), topics keygen:*:ed25519:*.
Signing pkg/mpc/signing_session_frost.go reads the same key. Orchestration is
handleKeyGenEvent in pkg/eventconsumer/event_consumer.go, which runs the secp256k1 leg
then the Ed25519 leg and publishes one result. That order is load-bearing: only the
CGGMP21 session writes keyinfo, and CreateEdDSASignSession reads it to count signers,
so the Ed25519 leg must never run first or alone. Shares written under the old frost:
prefix were secp256k1 and are intentionally unreachable.
Pinned by pkg/mpc/ed25519_spendable_test.go, which runs the real ceremony, round-trips
every share through the production codec, derives the address, decodes it back, and
verifies a threshold signature with crypto/ed25519 against the key the address decodes
to — plus pkg/address, cmd/mpcd, and pkg/eventconsumer guards.
Still open: TON addresses are NOT emitted. The Ed25519 key is the correct TON key;
only the address encoding is missing (workchain ‖ SHA-256 of a wallet contract's StateInit
cell, base64url + CRC16 — and v4r2 vs W5 give different addresses for the same key).
Guessing it recreates exactly the failure above. Also open: resharing an Ed25519 wallet
(eddsa_resharing_session.go + pkg/protocol/frost/adapter.go are still Taproot/secp256k1
and read the bare walletID key, so they never touch the Ed25519 share); and sr25519
(DOT/KSM) still has no keygen leg.
Curve selection: a property of the REQUEST, from the network, in one place
A wallet is not on a curve. One keygen mints a secp256k1 key (EVM/BTC/Lux/XRPL) and
an Ed25519 key (Solana/TON), so "what curve is this wallet" has no answer — and any
scalar answer is a lie the signer then acts on. transport.KeyInfo therefore has no
KeyType field; it has one public-key field per curve, and RegisterKey has no keyType
parameter for anyone to hardcode. It merges, because one keygen writes the record
twice: the CGGMP21 ceremony knows the threshold and no keys, TriggerKeygen knows the
keys and arrives later.
The curve comes from the network, resolved in exactly one function —
types.KeyTypeForNetwork over the table in pkg/types/networks.go. There is no default
arm: an unrecorded network yields types.ErrUnknownNetwork and the request is refused
before it touches any signing machinery. types.ParseNetwork is the one lookup from a
free-form chain string ("evm", "solana", "base") to a canonical code, likewise closed —
an unknown chain fails closed rather than being pattern-matched into a family.
MPCBackend.TriggerSign(orgID, walletID, network, payload) is the single funnel. Entry
points that carry a network state it (/v1/mpc/sign, /v1/mpc/sign/settlement, the ZAP
sign op, the transaction pipeline via tx.Chain); entry points bound to one runtime by
construction name it as a constant, because no other payload can reach them — Safe
EIP-712 and ERC-4337 hashes and the bridge keccak digest are NetworkEVM; the KMS
surfaces sign Lux validator key sets and are NetworkLUX.
/sign's payload rule is now the scheme's, not the endpoint's: secp256k1 requires
exactly 32 bytes (it signs a digest), Ed25519 accepts the message at any non-empty
length (PureEdDSA hashes it inside the challenge). The old unconditional 32-byte clamp
made a Solana transaction impossible to express over HTTP. /sign takes network and
no longer takes key_type — two ways to say the curve is the misuse being removed.
lux/wallet's custody adapter must send network; it currently sends key_type
plus a numeric chain_id, and its requests are refused until it does.
Pinned end-to-end by cmd/mpcd/sign_curve_routing_test.go, which drives
ConsensusMPCBackend.TriggerSign with a real threshold ring behind it and checks the
result with crypto/ed25519.Verify (Solana) and public-key recovery (EVM). The
in-process ceremony driver is shared at internal/ceremony.
🔴 v1.17.12 — CRITICAL threshold-degree fix (was silently 1-of-n) + BRIDGE-ORACLE finding (2026-07-15)
The bug (fixed by commit 1e1d318, in tag v1.17.12): --threshold=N only drove the
HasSigningQuorum health gate + status display. The CGGMP21 keygen DEGREE was read
separately from viper key mpc_threshold, which nothing ever set (mpcd never calls
InitViperConfig, so AutomaticEnv was off), so viper.GetInt("mpc_threshold") was
always 0 → polynomial degree 0 → 1-of-n: any single share produced a valid signature,
with zero threshold protection against single-node compromise. Every wallet keyed by
every ring before this fix is degree-0.
The fix: keygenDegreeForThreshold(threshold) = threshold-1 (floored at 0), wired via
viper.Set("mpc_threshold", …) at consensus start. --threshold=3 → degree 2 → 3-of-5;
--threshold=2 → degree 1 → 2-of-3. Named + unit-tested (cmd/mpcd/threshold_test.go)
so the security-critical off-by-one is explicit. Takes effect for NEW keygens ONLY —
existing wallets must be RE-KEYED to gain threshold security (re-keying changes the
wallet's address, so live wallets need a coordinated migration, never re-key in place).
Enforcement is two-layer + per-key: the KEY's degree is stored as
keyinfo.KeyInfo.Threshold (registry.go:248) at keygen (keygen_session.go:418). Every
sign: node.go CreateSignSession rejects len(signers) < keyInfo.Threshold+1
(ErrNotEnoughParticipants); the cmp library independently rejects it at
cmp.Sign → sign.StartSign → config.CanSign → ValidThreshold(t, n) (false when t > n-1).
Property pinned by luxfi/threshold test protocols/cmp/degree2_proof_test.go (degree-2:
2 signers refused, 3 verify).
Ring status (2026-07-15)
| Ring | ns / ctx | image | degree | status |
|---|---|---|---|---|
| zoo-mpc | zoo-mpc / do-sfo3-zoo-k8s | zooai/mpc:1.17.12 | degree 2 (3-of-5) | ✅ FIXED + PROVEN. Fresh wallet zoo-threshold-proof-v1 (pub 02f21a7a…, EVM 0x394d6480…) real sign verifies; KMS-driven wallet 713f5c2d… (pub 0360ac6b…) also degree-2, KMS sign verifies. |
| hanzo-mpc | hanzo-mpc / do-sfo3-hanzo-k8s | →hanzoai/mpc:1.17.12 | degree 1 (2-of-3) | deploy in progress (separate agent) |
| bridge-mpc | bridge-mpc / do-sfo3-lux-k8s | luxfi/mpc:v1.17.9 | degree 0 (1-of-5) 🔴 | VULNERABLE — see below |
| lux-mpc | lux-mpc / do-sfo3-lux-k8s | luxfi/mpc:v1.10.0 | degree 0 (1-of-n) 🔴 | STAGED (7-version jump = risky; 5 PVC snapshots mpc-node-{0..4}-prefix1712 ReadyToUse) |
| lux-bridge (own 3-node ring) | lux-bridge / do-sfo3-lux-k8s | luxfi/mpc:v1.5.3 | — | dormant, 0 keygens, holds NO keys → not a vuln |
🔴 HIGH-STAKES BRIDGE-ORACLE FINDING
The LUX↔ZOO corridor signer keys are degree-0 (1-of-n): a SINGLE compromised MPC node can forge a valid bridge-oracle signature and drain the corridor.
bridge-mpc/bridge-oracle— keygen logthreshold=0on all 5 nodes (2026-07-13); pubb534f9b5…; derives to EVM0x46457f86bb0e8b3013eaae4699d529a98471c511(= the task's0x46457f86). CONFIRMED 1-of-5.lux-mpcwallet17727859650255619160017— the wallet the livebridge-serveractually signs with (bridge-serverenv:MPC_API_URL=https://mpc.lux.network,MPC_WALLET_ID=17727859650255619160017,MPC_KEY_PREFIX=bridge/mpc). lux-mpc runs v1.10.0 which provably has the degree-0 bug (keygen readsviper mpc_threshold, nothing sets it) → degree 0 = 1-of-n. Likely the task's second oracle address0x086f4aa1.
Remediation (do NOT re-key the live oracle in place — it authorizes real value + changing its address orphans the corridor mapping):
- Roll the fixed image (v1.17.12) onto bridge-mpc + lux-mpc in a maintenance window. lux-mpc is a 7-minor-version jump (v1.10.0→v1.17.12) — canary the zapdb/wire compat on a restored snapshot copy first; snapshots are ReadyToUse for rollback.
- Re-key a NEW oracle wallet at genuine degree 2 (3-of-5) on the fixed ring → NEW address.
- Migrate bridge-oracle authority old→new address via a coordinated bridge-contract owner rotation on BOTH LUX and ZOO sides; pause/drain high-value transfers during the cutover.
- Until migrated: the corridor is protected ONLY by perimeter (NetworkPolicy / node isolation), NOT by threshold. Treat any single bridge-mpc/lux-mpc node compromise as full oracle-key compromise.
v1.14.0 — pkg/zapauth (LP-103)
Bearer-token gate on the KMS-facing ZAP server. JWKS-validated JWT
presented in OpAuthHello (0x00EF) BEFORE the X25519+ML-KEM-768
handshake. Configured via ZAP_JWKS_URL, ZAP_EXPECTED_ISS,
ZAP_EXPECTED_AUDIENCES, ZAP_AUTH_REQUIRED. See docs/zap-auth.md.
Backwards compatible: defaults to auth=off; flips to auth=required
once every KMS deploys with bearer minting (luxfi/kms v1.9.0
pkg/iamclient).
🏭 Production Deployment State (2026-03-02)
Namespaces
lux-mpc— PRIMARY production consensus-mode MPC (3 nodes, dashboard API, postgres, valkey)lux-bridge— Bridge-specific MPC (3 consensus-mode nodes, dashboard API using bridge postgres)hanzo— Legacy NATS/Consul MPC nodes (5 nodes, older deployment)
Key Decisions
- Storage: ZapDB (
github.com/luxfi/database/zapdb) — our fork, NOT badger directly - Encryption:
encdb.New(password, rawDB)wraps ZapDB with ChaCha20-Poly1305; backups contain pre-encrypted values, restore into raw zapdb - Dashboard API: Port 8081, enabled by
MPC_API_DBenv var; uses ORM's_entitiesJSONB table - Multi-tenancy: One postgres instance, one
_entitiestable,kind+orgIdin JSONB data - Binary distribution: S3 bucket
lux-mpc-backups/binaries/(public read); startup script promotes/data/mpcd.new - S3 address: Use ClusterIP
10.124.44.247:9000from lux-bridge pods (cross-namespace DNS fails); internal:s3.hanzo.svc.cluster.local:9000works only from hanzo namespace
Infrastructure Principles (keep it light)
- 1 postgres per cluster (MPC gets
mpc_apidb, bridge getsbridgedb) - 1 valkey/redis per cluster for KV cache
- 1 S3 bucket with org-prefixed paths for all backups
- ZapDB passwords via HSM PasswordProvider (
--hsm-provider=aws|gcp|azure|env|file, defaultenvfor backward compat). Both legacy and consensus modes useresolveZapDBPassword()incmd/mpcd/main.go. - Customer-controlled encryption: each org's ZapDB encrypted with org's KMS key — FUTURE WORK
ORM Filter Behavior
Filter("keyHash=", value)→ SQL:WHERE data->>'keyHash' = $1(camelCase from Go field name)Filter("key_hash=", value)→ SQL:WHERE data->>'key_hash' = $1(no conversion)- ALWAYS use camelCase matching the struct's
json:""tag when filtering
📚 Overview
MPC is a threshold signing service that provides:
- ECDSA (secp256k1) for Bitcoin/Ethereum/EVM chains
- EdDSA (Ed25519) for Solana/Polkadot/Sui
- Threshold signatures (t-of-n) with CGGMP21 protocol
- Key resharing for rotation without changing addresses
Architecture Position
MPC is designed as a pluggable signer backend for KMS:
┌─────────────────────────────────────────────────────────────────┐
│ KMS (Control Plane) │
│ ┌──────────┬─────────────┬──────────────┬─────────────────┐ │
│ │ Policy │ Approvals │ Audit Log │ Key Registry │ │
│ └────┬─────┴──────┬──────┴───────┬──────┴───────┬─────────┘ │
│ │ │ │ │ │
│ ┌────▼────────────▼──────────────▼──────────────▼─────────┐ │
│ │ Unified Signing API │ │
│ └────┬────────────┬──────────────┬──────────────┬─────────┘ │
│ │ │ │ │ │
│ ┌────▼────┐ ┌────▼────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │ HSM │ │ MPC │ │ Software │ │ Remote │ │
│ │ Signer │ │ Signer │ │ Signer │ │ Signer │ │
│ └─────────┘ └─────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────┘
Product Architecture
-
KMS Platform (Control Plane)
- Key registry + metadata
- Policy + workflow (quorum, time locks, spend limits, allowlists)
- Audit log
- Unified API
- Secrets manager
-
MPC Signer (This Project - Data Plane)
- DKG / key share management
- Threshold signing sessions
- Reshare/rotate shares
- Optional hardware-rooted modes
-
Hanzo HSM (Alternative Signer)
- HSM-backed keys for classic KMS workloads
- HSM-sealed share storage for MPC nodes
-
Hanzo Treasury (Optional UI)
- Transaction building + chain adapters
- Simulation / policy previews
- Approvals UI (backed by KMS workflow engine)
🚀 Quick Start
Build and Install
# Build binaries
make build
# Or install directly (for consensus-embedded mode)
go install ./cmd/mpcd
# Or for legacy NATS/Consul mode
go install ./cmd/mpc
Consensus Mode (NEW - Recommended)
# Start MPC node in consensus mode (no external dependencies)
mpcd start --mode consensus \
--node-id node0 \
--listen :9999 \
--api :9800 \
--data /data/mpc/node0 \
--threshold 2 \
--peer [email protected]:9999 \
--peer [email protected]:9999
# Or via lux CLI
lux mpc init --threshold 2 --nodes 3
lux mpc start
Legacy Mode (NATS + Consul)
# Generate peers configuration
mpc generate-peers -n 3
# Register peers to Consul
mpc register-peers
# Generate event initiator
mpc generate-initiator
# Generate node identity
mpc generate-identity --node node0
# Start MPC node in legacy mode
mpcd start --mode legacy -n node0
📁 Project Structure
/Users/z/work/lux/mpc/
├── cmd/ # Command-line applications
│ ├── mpcd/ # Main MPC daemon (consensus-embedded)
│ └── mpc/ # CLI tools for configuration
├── pkg/ # Core packages
│ ├── client/ # Go client library
│ ├── mpc/ # MPC implementation (TSS)
│ ├── kvstore/ # BadgerDB storage
│ ├── transport/ # Consensus-embedded transport (ZAP + PoA)
│ ├── messaging/ # NATS messaging (DEPRECATED - use transport)
│ ├── infra/ # Consul integration (DEPRECATED - use transport)
│ ├── identity/ # Ed25519 identity management
│ └── eventconsumer/ # Event processing
├── e2e/ # End-to-end tests
├── examples/ # Usage examples
└── scripts/ # Utility scripts
🏗️ Core Components
1. MPC Engine
Based on threshold cryptography:
- CGGMP21 protocol for ECDSA (secp256k1) - IMPLEMENTED & TESTED
- FROST protocol for EdDSA (Ed25519) - IMPLEMENTED & TESTED (keygen generates both ECDSA and EdDSA keys)
- Configurable threshold (t-of-n)
- Default: t = ⌊n/2⌋ + 1 (majority)
2. Storage Layer: BadgerDB
- AES-256 encrypted key shares
- Session data persistence
- Automatic backups
3. Transport Layer (NEW - Jan 2026)
The MPC daemon now supports consensus-embedded transport that eliminates external dependencies:
┌─────────────────────────────────────────────────────────────────┐
│ MPC Node (Consensus-Embedded) │
│ ┌──────────┬─────────────┬──────────────┬─────────────────┐ │
│ │ PubSub │ MessageQ │ Registry │ KeyInfoStore │ │
│ └────┬─────┴──────┬──────┴───────┬──────┴───────┬─────────┘ │
│ │ │ │ │ │
│ ┌────▼────────────▼──────────────▼──────────────▼─────────┐ │
│ │ ZAP Transport (Wire Protocol) │ │
│ └────┬────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────▼─────────────────────────────────────────────────────┐ │
│ │ Membership (Ed25519 PoA Validators) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌────▼─────────────────────────────────────────────────────┐ │
│ │ StateStore (BadgerDB + Replication) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
What was replaced:
- NATS → ZAP Transport with
Broadcast()/Query() - Consul → Consensus
Membershipwith Ed25519 keys as PoA validators - PostgreSQL →
StateStore+ BadgerDB for replicated state - Redis → Consensus state queries via
Transport.Query()
What remains:
- BadgerDB → Local encrypted key share storage (unchanged)
- Ed25519 identities → Now serve as PoA validator keys
Usage (pkg/transport):
factory, err := transport.NewFactory(transport.FactoryConfig{
NodeID: "node0",
ListenAddr: ":9999",
Peers: map[string]string{"node0": ":9999", "node1": ":9999", "node2": ":9999"},
PrivateKey: privateKey,
PublicKey: publicKey,
BadgerPath: "/data/mpc/node0",
BadgerPassword: "secure-password",
})
ctx := context.Background()
factory.Start(ctx)
// Use these instead of NATS/Consul:
pubSub := factory.PubSub() // replaces messaging.PubSub
registry := factory.Registry() // replaces mpc.PeerRegistry
kvstore := factory.KVStore() // local BadgerDB
keyinfo := factory.KeyInfoStore() // replaces Consul-based keyinfo.Store
3b. Messaging: NATS JetStream (DEPRECATED)
- Pub/sub for broadcasts
- Direct messaging for P2P
- Message persistence
- ⚠️ Use
pkg/transportinstead for new deployments
4. Service Discovery: Consul (DEPRECATED)
- Node registration
- Health checking
- Configuration management
- ⚠️ Use
pkg/transportMembership instead for new deployments
5. Identity: Ed25519 keypairs
- Node authentication
- Message signing/verification
- Encrypted with Age
🔧 Configuration
Consensus-Embedded Mode (NEW - Jan 2026)
# config.yaml
environment: development
transport:
listen_addr: ":9999"
peers:
node0: "10.0.0.1:9999"
node1: "10.0.0.2:9999"
node2: "10.0.0.3:9999"
badger:
path: "/data/mpc"
password: "secure-password"
backup_dir: "/data/mpc/backups"
identity:
key_file: "node0_identity.json"
event_initiator_pubkey: "hex-encoded-pubkey"
Legacy Mode (NATS + Consul)
# config.yaml
environment: development
consul:
address: localhost:8500
nats:
url: nats://localhost:4222
badger_password: "secure-password"
event_initiator_pubkey: "hex-encoded-pubkey"
Environment Variables
MPC_CONFIG- Path to config.yamlLUX_MPC_BACKUP- Backup file magic (on-disk format identifier; NOT an env var)MODE- "consensus" (new) or "legacy" (NATS/Consul) (Viper config keymode, override via env)
/v1/mpc/* Spec Surface (MPC API)
The /v1/mpc/* subrouter implements the frozen spec at
the MPC OpenAPI spec. One and only one canonical path per
operation — no aliases, no backwards compatibility layers.
Mount point: pkg/api/server.go inside the authenticated /v1 group under
r.Route("/mpc", ...).
Routes:
GET|POST /v1/mpc/wallets,GET /v1/mpc/wallets/{id},PATCH /v1/mpc/wallets/{id}/default,GET /v1/mpc/wallets/balances,POST /v1/mpc/wallets/sweep,GET|POST /v1/mpc/wallet,GET /v1/mpc/balances/{address},GET /v1/mpc/crypto/wallet/{asset}— wallet surface (handlers_mpc.go).POST /v1/mpc/sign,POST /v1/mpc/settlement/sign— signing (handlers_mpc.go). Rate-limited 20 RPM/IP.POST /v1/mpc/webauthn/challenge|verify,POST /v1/mpc/biometric/enroll,GET /v1/mpc/biometric/status— authenticator flows.GET|POST /v1/mpc/wallets/{id}/sessions,GET|DELETE /v1/mpc/wallets/{id}/sessions/{sessionId}— sessions (handlers_sessions.go). Sessions are time-bounded signing grants withoperationLimit+valueLimitenforced byconsumeSessionForSign.GET /v1/mpc/operations,GET /v1/mpc/operations/{operationId},POST /v1/mpc/operations/{operationId}/approve|reject— unified view overdb.Transactionwithkind ∈ {sign,send,mint,burn,transfer,contract_call}(handlers_operations.go).GET|POST /v1/mpc/policies,GET|PATCH|DELETE /v1/mpc/policies/{policyId}— spending/approval policies (handlers_policy.go).GET /v1/mpc/audit— audit trail (handlers_audit.go).
Session enforcement
db.Session (ORM-registered as "mpc-session") is a specialized short-lived
Policy. Every /sign and /settlement/sign call that carries a sessionId
must transactionally consume one operation + accumulate value via
Server.consumeSessionForSign. Sessions expire on any of:
- wall-clock expiry (
ExpiresAt) - operation count exhaustion (
OperationsUsed >= OperationLimit) - cumulative value exhaustion (
ValueAccum + value > ValueLimit) - explicit
DELETErevocation
Migration notes (Apr 2026)
/v1/policies→/v1/mpc/policies(top-level/v1/policiesremoved)./v1/audit→/v1/mpc/audit(top-level/v1/auditremoved)./v1/transactionsPOST retained as an internal entry point; the read surface is/v1/mpc/operations. AllTransactionrecords are projected into theOperationview viatxTypediscriminator.- Policy handlers now accept both
{id}(legacy internal) and{policyId}(spec) viaurlParamfallback — internal callers unaffected.
🔐 Security Model
- Threshold Security: No single node has the complete key
- Message Authentication: All messages signed with Ed25519
- Storage Encryption: BadgerDB encrypted with user password
- Network Security: TLS + mutual authentication
- Key Rotation: Supports resharing without changing addresses
📊 Performance
- Key Generation: ~30s for 3 nodes
- Signing: <1s for threshold signatures
- Storage: ~100MB per node (with backups)
- Network: Low bandwidth, resilient to failures
🔗 Integration with Hanzo Commerce
The MPC Signer integrates with Commerce for crypto payments:
// Commerce uses MPC via the processor interface
type MPCProcessor struct {
kmsClient *kms.Client // KMS for policy/approval
mpcClient *mpc.Client // MPC for signing
}
func (p *MPCProcessor) Charge(ctx context.Context, req PaymentRequest) (*PaymentResult, error) {
// 1. KMS validates policy and approvals
// 2. MPC signs the transaction
// 3. Transaction broadcast to blockchain
}
🔧 Development Workflow
Testing
# Run unit tests
make test
# Run with coverage
make test-coverage
# Run E2E tests
make e2e-test
Common Tasks
-
Generate 3-node test cluster:
./setup_identities.sh -
Recover from backup:
hanzo-mpc-cli recover --backup-dir ./backups --recovery-path ./recovered-db -
Production deployment:
- Use
--encryptflag for identity generation - Enable TLS on all services
- Use
--prompt-credentialsto avoid hardcoded passwords
- Use
🐛 Common Issues
- Port conflicts: Default ports are 4222 (NATS), 8500 (Consul)
- Database locks: Ensure single process per node
- Network delays: Check NATS/Consul connectivity
- Backup failures: Verify disk space and permissions
CGGMP21 Protocol Issues (Debugged Jan 2026)
-
Protocol message serialization: Protocol messages MUST use
MarshalBinary/UnmarshalBinaryto preserve all fields (SSID, RoundNumber, etc.). Raw JSON marshaling loses critical protocol state. -
Party ID ordering: Party IDs must be sorted consistently across all nodes. The
GetReadyPeersIncludeSelf()function inregistry.gosorts peer IDs to ensure deterministic ordering. -
NATS topic naming: Result topics must match JetStream stream configuration:
- Keygen results:
mpc.mpc_keygen_result.<walletID>(note thempc.mpc_prefix) - Signing results:
mpc.mpc_signing_result.<walletID> - Stream expects pattern:
mpc.mpc_*_result.*
- Keygen results:
-
Self-message rejection: It's NORMAL for nodes to log "Handler cannot accept message" warnings when they receive their own broadcast messages back. This is expected behavior in pub/sub systems.
-
Binary rebuild for e2e tests: E2E tests use
hanzo-mpcfrom PATH. After code changes, rungo install ./cmd/hanzo-mpc && go install ./cmd/hanzo-mpc-clito update the installed binaries. -
Session result publishing pattern: Individual protocol sessions (CGGMP21, FROST) should NOT publish success events directly to the result queue. The handler (
keygen_handler_cggmp21.go) is responsible for publishing the combined result with both ECDSA and EdDSA keys. Sessions should only:- Publish FAILURE events to the queue (for immediate error notification)
- Send success pubkey via
externalFinishChansoWaitForFinish()returns - Always send to
externalFinishChan(even empty string for errors) to prevent blocking
-
Dual keygen architecture: The
handleKeyGenEventCGGMP21function runs both ECDSA (CGGMP21) and EdDSA (FROST) keygen protocols in parallel via goroutines with WaitGroup. Both sessions must complete before the handler publishes the combined result containing both public keys.
FROST Signing Issues (Debugged Jan 2026)
-
FROST config serialization (CRITICAL):
frost.TaprootConfigcontains crypto types (*curve.Secp256k1Scalar,*curve.Secp256k1Point) that do NOT have JSON marshalers. Usingjson.Marshal()corrupts the key shares. MUST use CBOR serialization viaMarshalFROSTConfig()andUnmarshalFROSTConfig()infrost_config_marshal.go. -
FROST signing result type: The FROST Taproot signing protocol returns
taproot.Signature(which is[]byteof 64 bytes), NOT*frost.Signature. Thesigning_session_frost.gohandles this correctly with:s.signature = result.(taproot.Signature). -
BIP-340/Taproot signature format: FROST signing produces BIP-340 compatible signatures (64 bytes: R_x || s). The
taproot.Signaturetype is already in this format, so no additional conversion is needed inpublishResult().
LSS Protocol Issues (Fixed Jan 2026)
-
LSS config serialization (CRITICAL - FIXED): Similar to FROST,
lssConfig.Configcontains crypto types (curve.Scalar,curve.Point) that do NOT have JSON marshalers. Fixed by implementingMarshalLSSConfig()andUnmarshalLSSConfig()inlss_config_marshal.gousing CBOR serialization. -
LSS capabilities vs CGGMP21: LSS supports dynamic resharing (change T-of-N without reconstructing keys), threshold changes, and adding/removing participants. CGGMP21 only supports refresh (same committee). Both produce valid ECDSA signatures.
Security Audit Findings (Jan 2026)
-
Message authentication: Protocol messages between nodes are not signed. Ed25519 signing code exists but is disabled. Consider re-enabling for production deployments.
-
Deduplication map cleanup: The
processingmap used for deduplication grows unbounded. Recommend adding TTL-based cleanup for long-running sessions. -
Protocol timeouts: No timeout enforcement on protocol handlers. Recommend adding context with timeout to prevent indefinite hangs from stalling parties.
Security Hardening (Apr 2026)
-
Internal API auth (port 9800): All mutating endpoints (/keygen, /keys, /backup) now require
Authorization: Bearer <token>. Token sourced fromMPC_INTERNAL_API_KEYenv var (KMS-synced). Falls back to deterministic derivation from Ed25519 node identity for dev. /health remains unauthenticated for K8s probes. -
TLS-only transport:
DualModeListenerreplaced byTLSOnlyListener. Plaintext connections are rejected. TLS 1.3 with PQ key exchange (X25519MLKEM768) is mandatory for all peer-to-peer communication. -
Sign rate limiting: Bridge signing and intent signing endpoints have a dedicated 20 RPM per-IP rate limiter, separate from the global 100 RPM limit.
-
Internal API hardening: Port 9800 now has body size limits (1 MB), read/write timeouts, and 10 RPM rate limiting on mutating endpoints.
-
Network egress restriction: K8s NetworkPolicy for MPC nodes now restricts egress to peer nodes, postgres, valkey, DNS, and HTTPS (S3 backup) only. No unrestricted egress.
-
Compose.yml secrets: JWT_SECRET now uses
${JWT_SECRET:?}(required) instead of a hardcoded dev default. -
Cluster API key forwarding: api-deployment now passes
--cluster-api-key $(MPC_CLUSTER_API_KEY)when proxying to internal MPC API. TheapiOnlyMPCBackend.doRequestsends it asAuthorization: Bearerheader. -
R2-1 Biometric enroll hardened:
handleBiometricEnrollnow verifies a WebAuthn create ceremony AND a provider-signed liveness attestation.pkg/webauthn/verify.gocentralizes: challenge comparison against the random bytes we issued (not the DB row ID), origin allowlist, rpIDHash check, UP+UV flag enforcement.pkg/webauthn/liveness.goverifies Ed25519-signedLivenessAttestationenvelopes againstMPC_LIVENESS_PUBKEY_ED25519; body-supplied scores are rejected. If pubkey is unconfigured, enroll returns 503. -
R2-2 CAS under Postgres: Session consume + operation approve now use
orm.GetForUpdate(SELECT ... FOR UPDATE) inside READ COMMITTED tx viaorm.RunInTransactionWith(IsolationReadCommitted). Requireshanzoai/[email protected]. Postgres-backed regression tests incas_postgres_test.go— skipped when no Postgres available,TEST_PG_DSNoverrides DSN.pkg/db.Newnow acceptspostgres://...DSNs alongside SQLite. -
R2-3 Tightened API-key gate:
requireRoleOrAPIPermission([]string{"owner","admin","signer"}, "mpc:sign")replacesrequireRole("owner","admin","signer","api")on/v1/mpc/signand/v1/mpc/settlement/sign. Roleapipasses only if the key holds the named permission. A bare API key with permissions=[] is rejected. -
R3-2 No
*wildcard in API-key permissions:hasPermissionno longer honors*. Every API key must enumerate explicit permissions. TestsTestRequireRoleOrAPIPermission_WildcardRejectedOnSensitive+TestRequirePermission_WildcardRejectedassertpermissions=["*"]is REJECTED for every sensitive name (mpc:sign,mpc:settlement:sign,mpc:operations:approve,mpc:wallet:sweep,mpc:policy:write, plustrade:submit). Breaking change: re-issue keys with explicit permissions; bump to v1.7.0. -
R3-8 Liveness envelope bound to enrollment:
LivenessAttestationnow carriescredentialHash = sha256(pubKey)and/orchallengeId.handleBiometricEnrollpasses expected values toVerifyLivenessso a stolen envelope cannot be replayed against a different WebAuthn public key. Mode toggle viaMPC_LIVENESS_BINDING=strict|lax(defaultstrict). Blocker for mainnet biometric enroll: the liveness provider must ship the extended envelope; while in LAX the server emits ampc.biometric.binding_warnlog on every permissive accept. -
F4 (2026-04-18) LAX tightening — credentialHash floor + both-must-match: Red round 4 flagged that the previous LAX mode accepted envelopes with ONLY
challengeId(nocredentialHash), enabling a cross-ceremony replay: an attacker observing a fresh challenge could trick the liveness provider into signing an envelope binding to the challenge alone and replay it against any enrollment for the sameuserId. Two tightenings inVerifyLiveness:- LAX + server expects
credentialHash+ envelope omits it → reject (credentialHash required in LAX mode).credentialHashis now a hard floor —challengeIdalone cannot satisfy the binding requirement in LAX. - Envelope supplies BOTH
credentialHashANDchallengeIdAND server expects both → require BOTH to match (no more "either-or" loophole).
LAX warn-on-missing still works when the call site does NOT commit a
credentialHashexpectation. Tests inpkg/webauthn/liveness_test.go:TestLiveness_R38_LaxRejectsMissingCredentialHash— challengeId-only envelope rejectedTestLiveness_R38_LaxAcceptsBothMatch— happy path (extended envelope after the liveness provider rollout)TestLiveness_R38_LaxRejectsMismatchedCredentialHashBothPresentTestLiveness_R38_LaxRejectsMismatchedChallengeIDBothPresentTestLiveness_R38_LaxAcceptsCredentialHashOnly— current provider envelope shapeTestLiveness_R38_LaxRejectsMissingBindingWhenCredentialHashExpected— replaces the old lax-warns-missing pathTestLiveness_R38_LaxWarnsOnMissing_ChallengeIDOnly— warn path still available whencredentialHashnot expected
handleBiometricEnrollalways commitsExpectedCredentialHash(derived from the enrollment pubKey viasha256), so the LAX floor always engages for WebAuthn enrollment.v1.7.1followsv1.7.0. - LAX + server expects
-
Pluggable liveness Verifier (vendor-neutral): lux OSS names NO PAD-2 / liveness vendor.
pkg/webauthnexposes the extension seam:Verifierinterface —Verify(envelope string, b Binding) (*LivenessAttestation, error).Ed25519EnvelopeVerifier{PubKey, ProviderID}— the one OSS-shipped implementation, delegating to the auditedVerifyLiveness. Zero-code path for any vendor whose attestation is an Ed25519-signed envelope.RegisterVerifier(providerID, Verifier)/LookupVerifier(providerID)— registry. A white-label deployment (e.g. a regulated broker wiring a regulated PAD-2 vendor) registers its ownVerifierbefore boot.
pkg/api/server.goresolves the active verifier at boot fromMPC_LIVENESS_PROVIDER_ID: a registered customVerifierwins; else, ifMPC_LIVENESS_PUBKEY_ED25519is set, the OSS-default Ed25519 envelope verifier is used; with neither,livenessVerifieris nil and/v1/mpc/biometric/enrollfails closed (503).handleBiometricEnrollcallss.livenessVerifier.Verify(envelope, webauthn.Binding{...})— no vendor code in lux. To plug a vendor in from a downstream binary:import "github.com/luxfi/mpc/pkg/webauthn" // Ed25519-envelope vendor — just supply the public key: webauthn.RegisterVerifier("acme-pad2", webauthn.Ed25519EnvelopeVerifier{ PubKey: acmePubKey, ProviderID: "acme-pad2", }) // or a wholly different scheme — implement webauthn.Verifier.
Consensus-Embedded Transport (Jan 2026)
-
ZAP Message Types: MPC uses ZAP wire protocol message types 60-79:
MsgMPCBroadcast (60)- Pub/sub broadcastsMsgMPCDirect (61)- Point-to-point messagingMsgMPCReady (62)- Peer registry readinessMsgMPCKeygen (64)- DKG protocol messagesMsgMPCSign (65)- Signing protocol messagesMsgMPCReshare (66)- Key resharing messagesMsgMPCResult (67)- Session result messages
-
PoA Membership: Ed25519 public keys are used as Proof-of-Authority validators. VoterIDs are derived via
SHA256("MPC/Ed25519" || pubkey). -
State Replication: Key metadata is replicated via consensus transport. Local BadgerDB stores encrypted key shares (not replicated for security).
🌐 Blockchain Support
| Blockchain | Support | Curve | Protocol |
|---|---|---|---|
| Bitcoin (Legacy/SegWit) | ✅ Full | secp256k1 | CGGMP21/LSS |
| Bitcoin (Taproot) | ✅ Full | secp256k1 | FROST |
| Ethereum/EVM | ✅ Full | secp256k1 | CGGMP21/LSS |
| XRPL (XRP Ledger) | ✅ Full | secp256k1 | CGGMP21/LSS |
| Lux Network | ✅ Full | secp256k1 | CGGMP21/LSS |
| Polkadot/Kusama | ✅ Full | ristretto255 | FROST (SR25519) |
| Solana | ⚠️ Partial | Ed25519 | FROST (Taproot mode) |
| TON | ⚠️ Partial | Ed25519 | FROST (Taproot mode) |
Note: Solana/TON use Ed25519 natively but our FROST implementation produces Taproot/BIP-340 signatures. Native Ed25519 support requires implementing the Ed25519 FROST variant.
SR25519 (Ristretto255/Schnorrkel) Implementation
SR25519 threshold signing uses the generic FROST protocol over the ristretto255 prime-order group:
- Keygen:
frost.Keygen(Ristretto255{}, ...)produces*frost.Configwith ristretto255 curve types - Signing:
frost.Sign(config, signers, message)with signing context prepended to message - Signing context: Default "substrate", configurable per-session (Schnorrkel convention)
- Storage: Key shares stored with
sr25519:prefix in BadgerDB, serialized via CBOR - Signature format: R (32 bytes, ristretto point) || z (32 bytes, ristretto scalar) = 64 bytes
- Ristretto255 curve types: Implemented locally in
pkg/mpc/ristretto255.gousinggtank/ristretto255library, satisfying the threshold library'scurve.Curveinterface. Will be replaced when threshold library publishes native Ristretto255 support.
🎯 Best Practices
- Always backup BadgerDB before major operations
- Test locally with 3-node setup before production
- Monitor health via Consul UI (http://localhost:8500)
- Rotate keys periodically using reshare functionality
- Use Age encryption for production identities
- Keep logs for debugging MPC rounds
Context for All AI Assistants
This file (LLM.md) is symlinked as:
.AGENTS.mdCLAUDE.mdQWEN.mdGEMINI.md
All files reference the same knowledge base. Updates here propagate to all AI systems.
Rules for AI Assistants
- ALWAYS update LLM.md with significant discoveries
- NEVER commit symlinked files (.AGENTS.md, CLAUDE.md, etc.) - they're in .gitignore
- NEVER create random summary files - update THIS file