Keygen reached round 2 and every node refused every message, including from legitimate peers, so each attempt sat until the 300s timeout and every crypto deposit answered 503 "the custody service is not accepting new addresses". The handler admits a message only from an id in its OWN party set, and the fleet had built three sets that disagreed: partyIDs=["mpc-node-0:keygen:1","mpc-node-1:keygen:1","peer-2:keygen:1"] while node-2 called itself mpc-node-2:keygen:1. Nobody answers to "peer-2", so its share never arrived and the ceremony could not complete. The synthetic name is what an unprefixed --peer produces: the deployment passes addresses only, self is dropped by address, and everything left took a POSITIONAL id. The comment above that line already said such an id "cannot match a real node id by construction" — it was describing this bug without following it through. So a peer is now named the same way self is recognised: the first DNS label, which IS the node id under StatefulSet naming (mpc-node-2.mpc-node-headless.ns.svc.cluster.local -> mpc-node-2). Deriving both sides by one rule is what makes the sets agree. A bare IP has no label to read and keeps the positional id — the caller must then use nodeID@host:port, which states the name outright. The test asserts the invariant that was violated rather than the helper's return value: the id a node derives for a peer must equal the id that peer uses for itself.
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.