mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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.
135 lines
4.1 KiB
Go
135 lines
4.1 KiB
Go
// 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. The `env`
|
|
// provider reads MPC_PASSWORD (its native var); ZAPDB_PASSWORD is
|
|
// the viper-config fallback. Set BOTH so the password resolves
|
|
// regardless of which path the startup takes (the env provider is
|
|
// tried first, so MPC_PASSWORD is what actually unlocks ZapDB).
|
|
"MPC_HSM_PROVIDER=env",
|
|
"MPC_PASSWORD=smoke-test-password",
|
|
"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)
|
|
}
|