Consensus mode learned only its OWN Ed25519 public key: peer keys arrive in
ReadySignal.PublicKey during the transport identity handshake (sendIdentity) but
were discarded — never fed to the ConsensusIdentityStore that VerifyWireMessage
consults. Result: every inbound CGGMP21/FROST/signing wire message from a peer
was dropped ("public key not found for node …") and keygen/signing stalled to a
60s timeout. The HTTP /keygen and /sign endpoints could not work in any real
multi-node ensemble.
Fix (orthogonal, no import cycle): transport.Config.OnPeerIdentity observer is
invoked (notifyPeerIdentity, with self/empty/length guards + defensive copy)
whenever a peer key is learned — from the MsgMPCReady handshake in handleMessage
and handleIncoming. The acceptor now also replies with its own identity so the
key exchange is symmetric on the single tie-broken connection. main.go wires the
observer to ConsensusIdentityStore.AddPeerPublicKey. This is the consensus-mode
analog of the legacy NATS path's peers.json + *_identity.json seeding.
Proof: e2e/live_http_sign_test.go (//go:build integration) builds mpcd from the
repo root, starts a real 3-node 2-of-3 consensus ensemble, and over the live
HTTP API proves: distributed CGGMP21 keygen (real evm address), a real threshold
signature, idempotency cached-match, anti-oracle 409 on key reuse with new
content, 400 on empty key, and no plaintext key in any response or node log.
Unit coverage for the observer guards in pkg/transport/peer_identity_test.go.
Also fixes a pre-existing standalone_test.go bug: it set only ZAPDB_PASSWORD but
the env HSM provider reads MPC_PASSWORD — set both so the node starts.
mpcd — MPC Daemon
mpcd is a threshold-signature daemon. It speaks ZAP wire protocol over
TLS 1.3 directly between peers — no NATS, no Consul, no PostgreSQL in the
critical path. The daemon is the same binary in every deployment shape;
the operating mode is selected entirely by flags.
Operating modes
There is exactly one start command (mpcd start) and the topology is
determined by the --peer and --threshold flags:
| Topology | Flags | Use |
|---|---|---|
| Standalone (1-of-1 self-loop) | --threshold 1 (no --peer) |
Smoke tests, dev sandbox |
| Networked t-of-n cluster | --threshold t --peer ... (n−1 peers) |
Production |
In all cases mpcd runs the consensus-embedded transport
(pkg/transport). The "consensus" here is local PoA membership over the
node's own Ed25519 identities — it is intentionally not the Lux mainnet
chain, because key shares must never leave the cluster.
Standalone (1-of-1)
mpcd start \
--node-id solo \
--listen :9999 \
--api :9800 \
--data /var/lib/mpcd \
--threshold 1
Starts mpcd with itself as the only quorum member. /healthz will report
signing_quorum: true immediately. Keygen and signing of fresh wallets
require ≥2 parties — CGGMP21 and FROST cannot produce shares with a
single party — so this mode exists only for boot-up smoke tests, e2e
harness fixtures, and verifying TLS / membership / API plumbing. For real
signing, run a multi-node cluster.
Networked (t-of-n)
# Node 0
mpcd start --node-id node0 --listen :9999 --api :9800 \
--data /data/node0 --threshold 2 \
--peer [email protected]:9999 --peer [email protected]:9999
# Node 1
mpcd start --node-id node1 --listen :9999 --api :9800 \
--data /data/node1 --threshold 2 \
--peer [email protected]:9999 --peer [email protected]:9999
# Node 2 — same pattern
The daemon expects the cluster size n = len(peers) + 1 and rejects start
when threshold > n. See pkg/transport/registry.go::HasSigningQuorum for
the quorum arithmetic.
Smoke test
go test -tags=integration ./cmd/mpcd/... -count=1 -timeout 90s
standalone_test.go builds mpcd, runs it with --threshold 1 on free
ports and a fresh data directory, polls /healthz until quorum holds,
and asserts the response shape. It does not exercise keygen — see e2e/
for that.