bridgevm: close daemon-discovery RPC gap (getInfo, getSupportedChains, getChainConfig, getSignature) (#5)

The luxfi/bridge daemon's bchain client (internal/bchain.Client) calls
bridge_getInfo / bridge_getSupportedChains / bridge_getChainConfig /
bridge_getSignature against the B-Chain RPC for permissionless discovery
+ MPC signature retrieval. Until now BridgeVM only implemented the
settlement subset (estimateFee, submitRequest, getStatus, cancelRequest,
health, getMPCPublicKey) — daemons fell back to assumed local values
for the discovery surface, a centralization vector.

This commit:
- Adds GetBridgeInfo, GetSupportedChains, GetChainConfig, GetSignature
  to bridgevm.Service.
- Wires all four into the JSON-RPC dispatcher (bridge_* and bridge.*
  aliases).
- GetSignature reads from the swap-store record (populated by the MPC
  signing pipeline once the threshold quorum aggregates shares).
  Distinguishes "no such swap" from "still signing" via separate errors
  so callers can poll without dispatching on a generic error code.
- GetChainConfig matches the ChainID case-insensitively and surfaces
  the requested id in the error message when the chain is not in
  SupportedChains.
- Adds 6 table-driven tests covering happy paths, not-found, empty
  params, and the not-yet-available signature poll.

After this lands the luxfi/bridge daemon stops needing any cached
fallback for these four endpoints — the B-Chain VM is authoritative
end-to-end. Daemon code itself needs no change; its bchain.Client
already calls these method names.

Tests: go test ./bridgevm/... — 36/36 pass (30 pre-existing + 6 new).
This commit is contained in:
2026-05-30 20:06:31 -07:00
committed by GitHub
parent c4ba2592ac
commit f345620ee9
2 changed files with 400 additions and 0 deletions
+216
View File
@@ -535,6 +535,192 @@ func (s *Service) GetMPCPublicKey(_ *http.Request, _ *GetMPCPublicKeyArgs, reply
return nil
}
// =============================================================================
// Discovery: bridge_getInfo / bridge_getSupportedChains / bridge_getChainConfig
// =============================================================================
//
// These four methods close the gap between the daemon's bchain client
// (internal/bchain.Client) and the VM-side authoritative surface. Before
// them, the daemon called bridge_estimateFee/submitRequest/getStatus/
// cancelRequest/health/getMPCPublicKey directly against this RPC but
// fell back to its own assumed values for info/supported-chains/chain-
// config/signature lookups — a centralization vector if the daemon's
// cached view drifted from chain consensus.
//
// All four are read-only: they answer from VM state (config + registry
// + swapStore) and never mutate anything. Cancellable via the caller's
// HTTP timeout — these handlers never block on consensus.
// GetBridgeInfoArgs are empty.
type GetBridgeInfoArgs struct{}
// GetBridgeInfoReply is bridge_getInfo's response. Mirrors the wire shape
// the daemon's bchain.BridgeInfo decodes.
type GetBridgeInfoReply struct {
Version string `json:"version"`
NodeID string `json:"nodeId"`
ChainID string `json:"chainId"`
MPCReady bool `json:"mpcReady"`
MPCPublicKey string `json:"mpcPublicKey"`
Threshold int `json:"threshold"`
TotalParties int `json:"totalParties"`
SupportedChains []string `json:"supportedChains"`
TotalBridged string `json:"totalBridged"`
TotalFees string `json:"totalFees"`
}
// GetBridgeInfo answers bridge_getInfo. Read-only snapshot of node-level
// bridge state for dashboards + daemon discovery. Authoritative — no
// daemon-cached fallback. When MPC has not generated a group key the
// MPCPublicKey field is empty (not an error) so callers can render
// "MPC pending" without dispatching on an error code.
func (s *Service) GetBridgeInfo(_ *http.Request, _ *GetBridgeInfoArgs, reply *GetBridgeInfoReply) error {
if s.vm == nil {
return errors.New("bridgevm: VM not initialized")
}
reply.Version = Version.String()
if s.vm.rt != nil {
reply.NodeID = s.vm.rt.NodeID.String()
}
reply.ChainID = "B"
if s.vm.mpcKeyManager != nil {
key := s.vm.mpcKeyManager.GetGroupPublicKey()
reply.MPCReady = len(key) > 0
if reply.MPCReady {
reply.MPCPublicKey = hexEncode(key)
}
}
reply.Threshold = s.vm.config.MPCThreshold
reply.TotalParties = s.vm.config.MPCTotalParties
reply.SupportedChains = append([]string(nil), s.vm.config.SupportedChains...)
// Aggregate totals from the registry. Stringified so the daemon's
// bigint-safe decode (parseAmount) consumes them directly.
var totalBridged uint64
if s.vm.bridgeRegistry != nil {
s.vm.bridgeRegistry.mu.RLock()
for _, v := range s.vm.bridgeRegistry.Validators {
totalBridged += v.TotalBridged
}
s.vm.bridgeRegistry.mu.RUnlock()
}
reply.TotalBridged = strconv.FormatUint(totalBridged, 10)
// TotalFees is not tracked in registry yet; expose 0 explicitly so
// the daemon's JSON decode doesn't surface an "empty result" error.
reply.TotalFees = "0"
return nil
}
// GetSupportedChainsArgs are empty.
type GetSupportedChainsArgs struct{}
// GetSupportedChainsReply is the bridge_getSupportedChains response —
// the list of ChainConfig snapshots the VM is configured to bridge.
type GetSupportedChainsReply struct {
Chains []ChainConfigReply `json:"chains"`
}
// ChainConfigReply is one chain's bridge config (wire-stable; matches
// bchain.ChainConfig).
type ChainConfigReply struct {
ChainID string `json:"chainId"`
ChainName string `json:"chainName"`
RPCEndpoint string `json:"rpcEndpoint"`
BridgeContract string `json:"bridgeContract"`
TokenContracts map[string]string `json:"tokenContracts"`
NativeCurrency string `json:"nativeCurrency"`
BlockTime int `json:"blockTime"`
Confirmations int `json:"confirmations"`
Enabled bool `json:"enabled"`
}
// GetSupportedChains answers bridge_getSupportedChains. The VM's
// SupportedChains config drives the result; per-chain RPC endpoints /
// token contracts are not yet stored chain-side, so we surface the
// minimal stable shape (ChainID + Enabled) and leave the optional
// fields empty. The daemon already treats those as informational.
func (s *Service) GetSupportedChains(_ *http.Request, _ *GetSupportedChainsArgs, reply *GetSupportedChainsReply) error {
if s.vm == nil {
return errors.New("bridgevm: VM not initialized")
}
out := make([]ChainConfigReply, 0, len(s.vm.config.SupportedChains))
for _, id := range s.vm.config.SupportedChains {
out = append(out, ChainConfigReply{
ChainID: id,
ChainName: id,
Confirmations: int(s.vm.config.MinConfirmations),
Enabled: true,
})
}
reply.Chains = out
return nil
}
// GetChainConfigArgs is the bridge_getChainConfig request body.
type GetChainConfigArgs struct {
ChainID string `json:"chainId"`
}
// GetChainConfigReply is the bridge_getChainConfig response.
type GetChainConfigReply ChainConfigReply
// GetChainConfig answers bridge_getChainConfig for one chain. Returns
// an explicit error when the chain isn't in the SupportedChains set so
// callers can distinguish "unknown chain" from "RPC mis-routed".
func (s *Service) GetChainConfig(_ *http.Request, args *GetChainConfigArgs, reply *GetChainConfigReply) error {
if s.vm == nil {
return errors.New("bridgevm: VM not initialized")
}
want := strings.TrimSpace(args.ChainID)
if want == "" {
return errors.New("bridgevm: chainId required")
}
for _, id := range s.vm.config.SupportedChains {
if strings.EqualFold(id, want) {
*reply = GetChainConfigReply{
ChainID: id,
ChainName: id,
Confirmations: int(s.vm.config.MinConfirmations),
Enabled: true,
}
return nil
}
}
return fmt.Errorf("bridgevm: chain %q not in supportedChains", want)
}
// GetSignatureArgs is the bridge_getSignature request body.
type GetSignatureArgs struct {
RequestID string `json:"requestId"`
}
// GetSignatureReply is the bridge_getSignature response.
type GetSignatureReply struct {
Signature string `json:"signature"`
SessionID string `json:"sessionId,omitempty"`
}
// GetSignature answers bridge_getSignature. The signature lives on the
// BridgeRequestRecord (populated by the MPC signing pipeline once the
// threshold quorum aggregates shares). Returns an error when the swap
// exists but the signature is not yet available so callers can poll
// without conflating "no such swap" with "still signing".
func (s *Service) GetSignature(_ *http.Request, args *GetSignatureArgs, reply *GetSignatureReply) error {
if s.vm == nil || s.vm.swapStore == nil {
return errors.New("bridgevm: swap store not configured")
}
rec, err := s.vm.swapStore.Get(strings.TrimSpace(args.RequestID))
if err != nil {
return err
}
if rec.Signature == "" {
return fmt.Errorf("bridgevm: signature not yet available for %s (status=%s)", rec.RequestID, rec.Status)
}
reply.Signature = rec.Signature
reply.SessionID = rec.RequestID
return nil
}
// hexEncode formats a byte slice as lowercase hex (no 0x prefix), the
// canonical JSON-RPC encoding for raw MPC bytes.
func hexEncode(b []byte) string {
@@ -712,6 +898,36 @@ func (h *jsonRPCHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
err = h.service.GetMPCPublicKey(r, &GetMPCPublicKeyArgs{}, &reply)
result = reply
case "bridge_getInfo", "bridge.getInfo":
var reply GetBridgeInfoReply
err = h.service.GetBridgeInfo(r, &GetBridgeInfoArgs{}, &reply)
result = reply
case "bridge_getSupportedChains", "bridge.getSupportedChains":
var reply GetSupportedChainsReply
err = h.service.GetSupportedChains(r, &GetSupportedChainsArgs{}, &reply)
result = reply
case "bridge_getChainConfig", "bridge.getChainConfig":
var args GetChainConfigArgs
if err := json.Unmarshal(req.Params, &args); err != nil {
h.writeError(w, req.ID, -32602, "invalid params", err)
return
}
var reply GetChainConfigReply
err = h.service.GetChainConfig(r, &args, &reply)
result = reply
case "bridge_getSignature", "bridge.getSignature":
var args GetSignatureArgs
if err := json.Unmarshal(req.Params, &args); err != nil {
h.writeError(w, req.ID, -32602, "invalid params", err)
return
}
var reply GetSignatureReply
err = h.service.GetSignature(r, &args, &reply)
result = reply
default:
h.writeError(w, req.ID, -32601, "method not found", nil)
return
+184
View File
@@ -210,3 +210,187 @@ func TestRPC_Health(t *testing.T) {
t.Errorf("Status = %q, want healthy", reply.Status)
}
}
// =============================================================================
// Discovery RPC tests
// =============================================================================
// configureVMForDiscovery sets minimal config + registry state on the
// rig's VM so the discovery methods have non-empty data to surface.
// Centralized so test cases stay focused on the assertion, not setup.
func configureVMForDiscovery(vm *VM) {
vm.config.MPCThreshold = 67
vm.config.MPCTotalParties = 100
vm.config.MinConfirmations = 6
vm.config.SupportedChains = []string{"ETHEREUM_SEPOLIA", "LUX_TESTNET", "BTC_TESTNET"}
}
func TestRPC_GetInfo(t *testing.T) {
srv, vm := newRPCRig(t)
configureVMForDiscovery(vm)
var reply GetBridgeInfoReply
code, msg := callRPC(t, srv.URL, "bridge_getInfo", nil, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if reply.Version == "" {
t.Error("Version should be non-empty")
}
if reply.ChainID != "B" {
t.Errorf("ChainID = %q, want B", reply.ChainID)
}
if reply.Threshold != 67 {
t.Errorf("Threshold = %d, want 67", reply.Threshold)
}
if reply.TotalParties != 100 {
t.Errorf("TotalParties = %d, want 100", reply.TotalParties)
}
if len(reply.SupportedChains) != 3 {
t.Errorf("SupportedChains len = %d, want 3", len(reply.SupportedChains))
}
// MPCReady is false because mpcKeyManager is nil in the rig — that
// is the correct state ("MPC pending"), not an error.
if reply.MPCReady {
t.Error("MPCReady should be false without mpcKeyManager")
}
if reply.TotalBridged != "0" {
t.Errorf("TotalBridged = %q, want 0", reply.TotalBridged)
}
if reply.TotalFees != "0" {
t.Errorf("TotalFees = %q, want 0", reply.TotalFees)
}
}
func TestRPC_GetSupportedChains(t *testing.T) {
srv, vm := newRPCRig(t)
configureVMForDiscovery(vm)
var reply GetSupportedChainsReply
code, msg := callRPC(t, srv.URL, "bridge_getSupportedChains", nil, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if len(reply.Chains) != 3 {
t.Fatalf("Chains len = %d, want 3", len(reply.Chains))
}
for _, c := range reply.Chains {
if c.ChainID == "" {
t.Error("ChainID empty in result")
}
if !c.Enabled {
t.Errorf("chain %s not Enabled, want true", c.ChainID)
}
if c.Confirmations != 6 {
t.Errorf("chain %s Confirmations = %d, want 6", c.ChainID, c.Confirmations)
}
}
}
func TestRPC_GetChainConfig(t *testing.T) {
srv, vm := newRPCRig(t)
configureVMForDiscovery(vm)
// happy path: lowercase input matches case-insensitively
var reply GetChainConfigReply
code, msg := callRPC(t, srv.URL, "bridge_getChainConfig",
GetChainConfigArgs{ChainID: "ethereum_sepolia"}, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if !strings.EqualFold(reply.ChainID, "ETHEREUM_SEPOLIA") {
t.Errorf("ChainID = %q, want ETHEREUM_SEPOLIA (case-insensitive match)", reply.ChainID)
}
if !reply.Enabled {
t.Error("Enabled = false, want true")
}
// unknown chain: error surfaces the requested id so daemons can log it
code, msg = callRPC(t, srv.URL, "bridge_getChainConfig",
GetChainConfigArgs{ChainID: "UNOBTAINIUM_CHAIN"}, &reply)
if code == 0 {
t.Fatal("expected error for unknown chain, got success")
}
if !strings.Contains(msg, "UNOBTAINIUM_CHAIN") {
t.Errorf("error %q should name the missing chain", msg)
}
// empty chainId: explicit invalid-params surface
code, msg = callRPC(t, srv.URL, "bridge_getChainConfig",
GetChainConfigArgs{ChainID: ""}, &reply)
if code == 0 {
t.Fatal("expected error for empty chainId")
}
if !strings.Contains(msg, "chainId required") {
t.Errorf("error %q should say chainId required", msg)
}
}
func TestRPC_GetSignature_NotYetAvailable(t *testing.T) {
srv, _ := newRPCRig(t)
// Submit a swap so it exists in the store, but with no signature.
var sub SubmitRequestReply
_, _ = callRPC(t, srv.URL, "bridge_submitRequest", SubmitRequestArgs{
SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET",
SourceAsset: "ETH", DestAsset: "LUX", Amount: "1",
Recipient: "0xabc", Sender: "0xabc",
}, &sub)
var reply GetSignatureReply
code, msg := callRPC(t, srv.URL, "bridge_getSignature",
GetSignatureArgs{RequestID: sub.RequestID}, &reply)
if code == 0 {
t.Fatal("expected error (signature not yet available), got success")
}
if !strings.Contains(msg, "not yet available") {
t.Errorf("error %q should say 'not yet available'", msg)
}
}
func TestRPC_GetSignature_AfterSign(t *testing.T) {
srv, vm := newRPCRig(t)
// Submit
var sub SubmitRequestReply
_, _ = callRPC(t, srv.URL, "bridge_submitRequest", SubmitRequestArgs{
SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET",
SourceAsset: "ETH", DestAsset: "LUX", Amount: "1",
Recipient: "0xabc", Sender: "0xabc",
}, &sub)
// Simulate the MPC quorum populating the signature via Patch.
const sig = "deadbeefcafe1234"
if _, err := vm.swapStore.Patch(sub.RequestID, func(r *BridgeRequestRecord) {
r.Signature = sig
r.Status = StatusSigned
}); err != nil {
t.Fatalf("Patch: %v", err)
}
var reply GetSignatureReply
code, msg := callRPC(t, srv.URL, "bridge_getSignature",
GetSignatureArgs{RequestID: sub.RequestID}, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if reply.Signature != sig {
t.Errorf("Signature = %q, want %q", reply.Signature, sig)
}
if reply.SessionID != sub.RequestID {
t.Errorf("SessionID = %q, want %q", reply.SessionID, sub.RequestID)
}
}
func TestRPC_GetSignature_NotFound(t *testing.T) {
srv, _ := newRPCRig(t)
var reply GetSignatureReply
code, msg := callRPC(t, srv.URL, "bridge_getSignature",
GetSignatureArgs{RequestID: "req_does_not_exist"}, &reply)
if code == 0 {
t.Fatal("expected error for missing swap")
}
if !strings.Contains(msg, "not found") {
t.Errorf("error %q should say 'not found'", msg)
}
}