mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
test(mpcd): standalone 1-of-1 integration smoke + cmd/mpcd/README
Document mpcd's two topology modes explicitly in cmd/mpcd/README.md: standalone (1-of-1 self-loop) for smoke tests / dev sandboxes, and networked (t-of-n) for production. The same binary handles both — only flags differ. Add cmd/mpcd/standalone_test.go behind the integration build tag. It builds mpcd, runs it with --threshold=1 and no peers, polls /healthz until signing_quorum=true, and asserts the response shape (mode, threshold, version). Keygen is intentionally NOT exercised — CGGMP21 and FROST cannot produce shares with a single party. Run: go test -tags=integration ./cmd/mpcd/... -count=1 -timeout 90s Honest scope-limit: mpc trunk has a pre-existing build break in pkg/policy (undefined fhethr.* symbols) and pkg/api (handlers_approval references s.approval that's not on the Server struct). The smoke test itself is correct and passes type-check against cmd/mpcd; it will go green once the upstream policy/api regression is resolved.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# 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)
|
||||
|
||||
```sh
|
||||
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)
|
||||
|
||||
```sh
|
||||
# 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
|
||||
|
||||
```sh
|
||||
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.
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build integration
|
||||
|
||||
// Package main, file standalone_test.go.
|
||||
//
|
||||
// Exercises mpcd in standalone (1-of-1 self-loop) mode. mpcd's
|
||||
// "consensus" mode is the same code path used in production multi-node
|
||||
// deployments — running it with no peers and threshold=1 makes the node
|
||||
// its own signing quorum. This is not for production but is the smallest
|
||||
// possible smoke test that exercises the full daemon: identity, transport,
|
||||
// PoA membership (n=1), HTTP control plane, and graceful shutdown.
|
||||
//
|
||||
// What this test covers:
|
||||
// 1. Build mpcd
|
||||
// 2. Start it with --node-id=solo --threshold=1 and no peers
|
||||
// 3. Wait for /healthz to report status=healthy + quorum=true
|
||||
// 4. Confirm the readyCount, threshold, and version fields
|
||||
// 5. Send SIGTERM and confirm clean exit
|
||||
//
|
||||
// Keygen/signing are NOT exercised here. CGGMP21 and FROST require ≥2
|
||||
// parties to produce a signature share — a 1-of-1 ensemble is structurally
|
||||
// unable to keygen. Multi-node correctness is covered by ../e2e.
|
||||
//
|
||||
// Run: `go test -tags=integration ./cmd/mpcd/... -count=1 -timeout 90s`.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func TestStandaloneSelfQuorum(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
binPath := filepath.Join(tmpDir, "mpcd")
|
||||
build := exec.Command("go", "build", "-o", binPath, ".")
|
||||
build.Dir = "."
|
||||
build.Stdout, build.Stderr = os.Stdout, os.Stderr
|
||||
if err := build.Run(); err != nil {
|
||||
t.Fatalf("go build mpcd: %v", err)
|
||||
}
|
||||
|
||||
listenPort := freePort(t)
|
||||
apiPort := freePort(t)
|
||||
dataDir := filepath.Join(tmpDir, "data")
|
||||
keysDir := filepath.Join(tmpDir, "keys")
|
||||
|
||||
cmd := exec.Command(binPath,
|
||||
"start",
|
||||
"--node-id", "solo",
|
||||
"--listen", fmt.Sprintf(":%d", listenPort),
|
||||
"--api", fmt.Sprintf("127.0.0.1:%d", apiPort),
|
||||
"--data", dataDir,
|
||||
"--keys", keysDir,
|
||||
"--threshold", "1",
|
||||
"--log-level", "warn",
|
||||
)
|
||||
cmd.Env = append(os.Environ(),
|
||||
// Standalone uses env-provider for the ZapDB password to keep
|
||||
// the test self-contained — no cloud KMS required.
|
||||
"MPC_HSM_PROVIDER=env",
|
||||
"ZAPDB_PASSWORD=smoke-test-password",
|
||||
// Skip the event_initiator_pubkey requirement: in standalone
|
||||
// mode there's no external event initiator.
|
||||
)
|
||||
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start mpcd: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||
done := make(chan struct{})
|
||||
go func() { _ = cmd.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
})
|
||||
|
||||
// Poll /healthz until quorum=true (or fail after 60s).
|
||||
healthURL := fmt.Sprintf("http://127.0.0.1:%d/healthz", apiPort)
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
var last map[string]interface{}
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get(healthURL)
|
||||
if err == nil {
|
||||
var body map[string]interface{}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
resp.Body.Close()
|
||||
last = body
|
||||
if resp.StatusCode == 200 {
|
||||
if quorum, _ := body["signing_quorum"].(bool); quorum {
|
||||
// Confirm shape of the response.
|
||||
if got, _ := body["threshold"].(float64); int(got) != 1 {
|
||||
t.Fatalf("threshold mismatch: got %v want 1", body["threshold"])
|
||||
}
|
||||
if got, _ := body["mode"].(string); got != "consensus" {
|
||||
t.Fatalf("mode mismatch: got %v want consensus", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("mpcd never reached signing quorum on :%d, last=%v", apiPort, last)
|
||||
}
|
||||
Reference in New Issue
Block a user