merge: z/mchain-btc-pool

This commit is contained in:
2026-05-29 10:07:36 -07:00
4 changed files with 410 additions and 0 deletions
+127
View File
@@ -166,6 +166,94 @@ type BridgeSignature struct {
SessionID string `json:"sessionId"`
}
// =============================================================================
// LP-333: signer set + epoch coordination
// =============================================================================
//
// LP-333 makes BridgeVM (b-chain) authoritative on which MPC nodes are in
// the active signer set, the threshold, and epoch transitions. The
// bridge (this binary) is a CONSUMER of this state — it does not vote
// on rotations; it observes them and points new keygen/signing at the
// current epoch's quorum. mpcd remains authoritative on key share
// material itself; b-chain is the membership + epoch ledger.
//
// The methods land on the same JSON-RPC endpoint as bridge_getInfo (the
// BridgeVM RPC at `${nodeUrl}/ext/bc/B/rpc`), not on ThresholdVM. They
// are read-only — the bridge never proposes a rotation; node operators
// do that out-of-band via BridgeVM governance.
// SignerMember is one node in the active signer set.
type SignerMember struct {
// NodeID is the BridgeVM node identifier (e.g. "node-0",
// "NodeID-7Xhw..."). Operators correlate this with their MPC
// cluster identity.
NodeID string `json:"nodeId"`
// PublicKey is the node's announced MPC verifying-key share
// (hex-encoded). The composition rule (Schnorr sum, BLS aggregate)
// is implied by the threshold protocol on the cluster — out of
// scope for the bridge.
PublicKey string `json:"publicKey"`
// Address is the chain-format address corresponding to the
// PublicKey on whichever family the operator cares to display.
// May be empty when the BridgeVM has not surfaced it.
Address string `json:"address,omitempty"`
}
// SignerSetInfo is the bridge_getSignerSetInfo result — a snapshot of
// the active MPC signer set at the current epoch. Bridge consumers
// scrape this to surface "is the quorum the one I expect?" on dashboards.
type SignerSetInfo struct {
// Members is the active signer-set roster. Order is meaningful:
// it matches the MPC protocol's index assignment, so logs that
// reference "party 0", "party 1" etc. line up with this slice.
Members []SignerMember `json:"members"`
// Threshold is the minimum number of members that must cooperate
// to produce a valid signature (the "t" in t-of-n). At least 1,
// at most len(Members).
Threshold int `json:"threshold"`
// Total is the cardinality of the signer set (len(Members)).
// Reported separately so a caller can detect a partially-populated
// Members slice without recounting.
Total int `json:"total"`
// Epoch is the current LP-333 epoch number. Increments monotonically
// on every rotation. The bridge keys metrics + logs on this so an
// operator can correlate a sudden refund-rate spike with an epoch
// transition.
Epoch uint64 `json:"epoch"`
// SignerSetHash is a fingerprint of the (sorted) Members + Threshold,
// useful for fast diff-detection on the consumer side without
// re-comparing the full roster. Hex-encoded.
SignerSetHash string `json:"signerSetHash,omitempty"`
// LastRotationAt is the unix-seconds timestamp of the most recent
// epoch transition. Zero when never rotated (epoch 0 / genesis).
LastRotationAt int64 `json:"lastRotationAt,omitempty"`
}
// CurrentEpoch is the bridge_getCurrentEpoch result — the smaller
// payload meant for high-frequency polling (think 5-second cadence).
// SignerSetInfo is the snapshot for dashboards; CurrentEpoch is for
// "did anything change?" checks.
type CurrentEpoch struct {
// Epoch is the current epoch number; same value as
// SignerSetInfo.Epoch.
Epoch uint64 `json:"epoch"`
// SignerSetHash matches SignerSetInfo.SignerSetHash. Operators
// alert on this changing without an expected operator action.
SignerSetHash string `json:"signerSetHash,omitempty"`
// StartedAt is the unix-seconds timestamp at which the current
// epoch began (or 0 for genesis).
StartedAt int64 `json:"startedAt,omitempty"`
}
// CancelResult is the bridge_cancelRequest result.
type CancelResult struct {
Success bool `json:"success"`
@@ -531,3 +619,42 @@ func (c *Client) GetBridgeSignature(ctx context.Context, requestID string) (*Bri
}
return &out, nil
}
// =============================================================================
// LP-333: signer set + epoch coordination
// =============================================================================
// GetSignerSetInfo reads the active LP-333 signer set: roster, threshold,
// epoch, and a content hash for fast diff-detection. Operators use this
// to confirm the bridge is pointed at the expected MPC cluster identity
// after a rotation; on-call alerts on SignerSetHash drift between scrapes.
//
// Returns an *RPCError with Code -32601 when the upstream BridgeVM
// doesn't implement LP-333 yet — callers should treat that as "b-chain
// is not authoritative on signer set here; fall back to assumed state."
func (c *Client) GetSignerSetInfo(ctx context.Context) (*SignerSetInfo, error) {
var out SignerSetInfo
if err := c.bridgeCall(ctx, "bridge_getSignerSetInfo", nil, &out); err != nil {
return nil, err
}
// Defensive: if the upstream returned Members without Total, populate
// it from len(Members) so consumers don't have to recount.
if out.Total == 0 && len(out.Members) > 0 {
out.Total = len(out.Members)
}
return &out, nil
}
// GetCurrentEpoch reads the current LP-333 epoch number + signer-set
// hash. Cheap to poll — meant to drive a "did anything change?" gauge
// without re-fetching the full roster.
//
// Returns an *RPCError with Code -32601 when the upstream BridgeVM
// doesn't implement LP-333 yet. Same fallback contract as GetSignerSetInfo.
func (c *Client) GetCurrentEpoch(ctx context.Context) (*CurrentEpoch, error) {
var out CurrentEpoch
if err := c.bridgeCall(ctx, "bridge_getCurrentEpoch", nil, &out); err != nil {
return nil, err
}
return &out, nil
}
+124
View File
@@ -560,6 +560,130 @@ func TestRPCRoundTrip_RequestShape(t *testing.T) {
}
}
// =============================================================================
// LP-333: signer set + epoch coordination
// =============================================================================
func TestGetSignerSetInfo_HappyPath(t *testing.T) {
m := newMockNode(t)
m.onResult("bridge_getSignerSetInfo", SignerSetInfo{
Members: []SignerMember{
{NodeID: "node-0", PublicKey: "0x" + strings.Repeat("a", 64), Address: "0xAAAA"},
{NodeID: "node-1", PublicKey: "0x" + strings.Repeat("b", 64), Address: "0xBBBB"},
{NodeID: "node-2", PublicKey: "0x" + strings.Repeat("c", 64), Address: "0xCCCC"},
},
Threshold: 2,
Total: 3,
Epoch: 7,
SignerSetHash: "0xdeadbeef",
LastRotationAt: 1730000000,
})
c := clientForMock(m)
got, err := c.GetSignerSetInfo(context.Background())
if err != nil {
t.Fatalf("GetSignerSetInfo err: %v", err)
}
if got.Threshold != 2 {
t.Errorf("Threshold = %d, want 2", got.Threshold)
}
if got.Total != 3 {
t.Errorf("Total = %d, want 3", got.Total)
}
if got.Epoch != 7 {
t.Errorf("Epoch = %d, want 7", got.Epoch)
}
if got.SignerSetHash != "0xdeadbeef" {
t.Errorf("SignerSetHash = %q, want 0xdeadbeef", got.SignerSetHash)
}
if len(got.Members) != 3 || got.Members[0].NodeID != "node-0" {
t.Errorf("Members roster wrong: %+v", got.Members)
}
}
// TestGetSignerSetInfo_DerivesTotalFromMembers covers the defensive
// path: an upstream that returns the Members roster but doesn't fill
// Total should get a client-side fill-in so consumers don't have to
// recount.
func TestGetSignerSetInfo_DerivesTotalFromMembers(t *testing.T) {
m := newMockNode(t)
m.onResult("bridge_getSignerSetInfo", SignerSetInfo{
Members: []SignerMember{
{NodeID: "node-0"}, {NodeID: "node-1"}, {NodeID: "node-2"}, {NodeID: "node-3"},
},
Threshold: 3,
// Total intentionally omitted.
Epoch: 12,
})
c := clientForMock(m)
got, err := c.GetSignerSetInfo(context.Background())
if err != nil {
t.Fatalf("GetSignerSetInfo err: %v", err)
}
if got.Total != 4 {
t.Errorf("Total = %d, want 4 (derived from len(Members))", got.Total)
}
}
// TestGetSignerSetInfo_MethodNotFound documents the back-compat
// contract: an upstream BridgeVM without LP-333 returns -32601, which
// the client surfaces as a typed RPCError. Callers fall back to
// "assumed state" mode.
func TestGetSignerSetInfo_MethodNotFound(t *testing.T) {
m := newMockNode(t)
m.onError("bridge_getSignerSetInfo", -32601, "method not found")
c := clientForMock(m)
_, err := c.GetSignerSetInfo(context.Background())
if err == nil {
t.Fatal("expected RPCError, got nil")
}
var rpcErr *RPCError
if !errors.As(err, &rpcErr) {
t.Fatalf("expected *RPCError, got %T", err)
}
if rpcErr.Code != -32601 {
t.Errorf("Code = %d, want -32601 (method not found)", rpcErr.Code)
}
}
func TestGetCurrentEpoch_HappyPath(t *testing.T) {
m := newMockNode(t)
m.onResult("bridge_getCurrentEpoch", CurrentEpoch{
Epoch: 9,
SignerSetHash: "0xfeedface",
StartedAt: 1730000000,
})
c := clientForMock(m)
got, err := c.GetCurrentEpoch(context.Background())
if err != nil {
t.Fatalf("GetCurrentEpoch err: %v", err)
}
if got.Epoch != 9 {
t.Errorf("Epoch = %d, want 9", got.Epoch)
}
if got.SignerSetHash != "0xfeedface" {
t.Errorf("SignerSetHash = %q, want 0xfeedface", got.SignerSetHash)
}
if got.StartedAt != 1730000000 {
t.Errorf("StartedAt = %d, want 1730000000", got.StartedAt)
}
}
func TestGetCurrentEpoch_MethodNotFound(t *testing.T) {
m := newMockNode(t)
m.onError("bridge_getCurrentEpoch", -32601, "method not found")
c := clientForMock(m)
_, err := c.GetCurrentEpoch(context.Background())
var rpcErr *RPCError
if !errors.As(err, &rpcErr) || rpcErr.Code != -32601 {
t.Errorf("expected -32601 RPCError, got %v", err)
}
}
func TestRPCError_String(t *testing.T) {
cases := []struct {
name string
+81
View File
@@ -0,0 +1,81 @@
package mchain
// pool.go — layered MPC routing.
//
// REQUIREMENTS.md §6 + the SDK's BridgeMPCConfig declare two MPC URL
// slots:
//
// publicUrl — m-chain, public/permissionless validator MPC. Per-swap
// deposit-wallet keygen + refund signing run here. Blast
// radius on compromise is a single in-flight swap because
// a fresh wallet is minted every time.
//
// privateUrl — Lux treasury cluster, smaller internal quorum. Long-
// lived release-wallet keygen + settlement signing run
// here. These wallets hold operator-funded liquidity, so
// they need both a smaller-quorum threshold (faster +
// tighter access) and persistence so funds don't strand
// across restarts.
//
// The role-based split lets a tenant point the layered cosigner stack
// at the right cluster for the right operation without the bridge
// embedding any "is this a treasury op?" decision in the call path.
//
// Back-compat: when only publicUrl is configured, NewPool sets Private
// to the same *Client. Every existing single-cluster deploy keeps
// working without flag changes; the split is purely additive.
// Pool routes MPC calls between the public (m-chain) and private
// (treasury) clusters. Both fields are non-nil after construction via
// NewPool / NewSplitPool — single-cluster deploys get Private == Public.
//
// Pool is concurrency-safe iff both Client values are (which they are —
// *Client docstring guarantees this).
type Pool struct {
// Public handles per-swap deposit-wallet keygen + signing FROM the
// deposit wallet (i.e. refund-leg signing). User-funded; ephemeral.
Public *Client
// Private handles release-wallet keygen + signing FROM the release
// wallet (i.e. settlement-leg signing). Treasury-funded; long-lived.
// In single-cluster deploys, Private == Public.
Private *Client
}
// NewPool constructs a single-cluster pool. Both Public and Private
// point at the same *Client. Use this when only the legacy --mpc-url
// flag is set; the operator has not opted into a treasury cluster yet.
//
// Passing a nil Client returns nil — callers should treat the pool as
// "MPC disabled" the same way they treat a nil *Client today.
func NewPool(c *Client) *Pool {
if c == nil {
return nil
}
return &Pool{Public: c, Private: c}
}
// NewSplitPool constructs a two-cluster pool. When private is nil it
// falls back to single-cluster (Private == Public), matching NewPool.
// Passing a nil public returns nil — the public cluster is mandatory
// for any swap to proceed (deposit wallet keygen lives there).
func NewSplitPool(public, private *Client) *Pool {
if public == nil {
return nil
}
if private == nil {
return &Pool{Public: public, Private: public}
}
return &Pool{Public: public, Private: private}
}
// IsSplit reports whether the pool has distinct public + private
// clients. False when single-cluster (Private == Public). Surfaced
// for startup logging + /health so operators can confirm their
// --mpc-private-url flag actually took effect.
func (p *Pool) IsSplit() bool {
if p == nil {
return false
}
return p.Public != p.Private
}
+78
View File
@@ -0,0 +1,78 @@
package mchain
import (
"testing"
"time"
)
func TestNewPool_SingleCluster(t *testing.T) {
c := New("http://mpc:9800", 30*time.Second)
p := NewPool(c)
if p == nil {
t.Fatal("NewPool returned nil for non-nil client")
}
if p.Public != c {
t.Errorf("Public = %p, want %p", p.Public, c)
}
if p.Private != c {
t.Errorf("Private = %p, want %p (single-cluster fallback)", p.Private, c)
}
if p.IsSplit() {
t.Error("IsSplit() = true, want false for single-cluster pool")
}
}
func TestNewPool_NilClient(t *testing.T) {
if p := NewPool(nil); p != nil {
t.Errorf("NewPool(nil) = %+v, want nil — MPC-disabled path", p)
}
}
func TestNewSplitPool_TwoClusters(t *testing.T) {
pub := New("http://public-mpc:9800", 30*time.Second)
priv := New("http://private-mpc:9800", 30*time.Second)
p := NewSplitPool(pub, priv)
if p == nil {
t.Fatal("NewSplitPool returned nil for two non-nil clients")
}
if p.Public != pub {
t.Errorf("Public = %p, want %p", p.Public, pub)
}
if p.Private != priv {
t.Errorf("Private = %p, want %p", p.Private, priv)
}
if !p.IsSplit() {
t.Error("IsSplit() = false, want true for two-cluster pool")
}
}
func TestNewSplitPool_NilPrivateFallsBackToPublic(t *testing.T) {
pub := New("http://public-mpc:9800", 30*time.Second)
p := NewSplitPool(pub, nil)
if p == nil {
t.Fatal("NewSplitPool(pub, nil) returned nil — should fall back to single-cluster")
}
if p.Public != pub {
t.Errorf("Public = %p, want %p", p.Public, pub)
}
if p.Private != pub {
t.Errorf("Private = %p, want %p (nil-private fallback)", p.Private, pub)
}
if p.IsSplit() {
t.Error("IsSplit() = true, want false when private fell back to public")
}
}
func TestNewSplitPool_NilPublicReturnsNil(t *testing.T) {
priv := New("http://private-mpc:9800", 30*time.Second)
if p := NewSplitPool(nil, priv); p != nil {
t.Errorf("NewSplitPool(nil, priv) = %+v, want nil — public is mandatory", p)
}
}
func TestPool_NilSafeIsSplit(t *testing.T) {
var p *Pool
if p.IsSplit() {
t.Error("(*Pool)(nil).IsSplit() = true, want false")
}
}