Files
zeekay a0e796b1fe bridgevm: the network declares its bridge limits, not the operator
The caps decide which transfers a block may carry — admissible() refuses a
transfer over the per-transfer cap and admit() refuses one over the daily cap,
both inside block verification. Reading them from the operator's chain config
meant two validators could hold different numbers and disagree about whether a
transfer was admissible, and the first one they disagreed about is a transfer
the builder keeps proposing and everyone else keeps refusing. A cap each
operator sets for themselves is not a cap.

Limits (minConfirmations, the two spend caps, the signer bond, the set size)
move to Genesis, where the network declares them once and every validator reads
the same numbers. BridgeConfig keeps only ExternalChains — endpoints and KMS
paths, how THIS node reaches the chains it was told to bridge, which cannot
change which transfers are valid. The dead BridgeFee field goes.

MaxSigners keeps its default: it is a capacity, not a risk decision.
2026-09-07 00:06:37 -07:00

973 lines
32 KiB
Go

// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/luxfi/ids"
)
// Service provides JSON-RPC endpoints for BridgeVM:
// - LP-333 signer-set management (RegisterValidator, GetSignerSetInfo, …)
// - Permissionless bridge settlement
// (EstimateFee, SubmitRequest, GetStatus, CancelRequest, …)
//
// Any client that can reach /v1/chain/B/rpc has equal authority — the
// daemon at cmd/bridge is one such client. There are no privileged
// methods on this surface; rate-limiting and auth (when desired) are
// applied at the ingress layer.
type Service struct {
vm *VM
}
// NewService returns a new Service instance
func NewService(vm *VM) *Service {
return &Service{vm: vm}
}
// =============================================================================
// LP-333 JSON-RPC Endpoints
// =============================================================================
// RegisterValidatorArgs are the arguments for bridge_registerValidator
type RegisterValidatorArgs struct {
NodeID string `json:"nodeId"`
BondAmount string `json:"bondAmount,omitempty"` // 100M LUX bond (slashable)
MPCPubKey string `json:"mpcPubKey,omitempty"`
}
// RegisterValidatorReply is the reply for bridge_registerValidator
type RegisterValidatorReply struct {
Success bool `json:"success"`
NodeID string `json:"nodeId"`
Registered bool `json:"registered"`
Waitlisted bool `json:"waitlisted"`
SignerIndex int `json:"signerIndex"`
WaitlistIndex int `json:"waitlistIndex,omitempty"`
TotalSigners int `json:"totalSigners"`
Threshold int `json:"threshold"`
ReshareNeeded bool `json:"reshareNeeded"`
CurrentEpoch uint64 `json:"currentEpoch"`
SetFrozen bool `json:"setFrozen"`
RemainingSlots int `json:"remainingSlots"`
Message string `json:"message"`
}
// RegisterValidator registers a validator as a bridge signer (LP-333 opt-in model)
// First 100 validators are accepted directly without reshare.
// After 100 signers, new validators go to waitlist.
func (s *Service) RegisterValidator(_ *http.Request, args *RegisterValidatorArgs, reply *RegisterValidatorReply) error {
input := &RegisterValidatorInput{
NodeID: args.NodeID,
BondAmount: args.BondAmount,
MPCPubKey: args.MPCPubKey,
}
result, err := s.vm.RegisterValidator(input)
if err != nil {
return err
}
reply.Success = result.Success
reply.NodeID = result.NodeID
reply.Registered = result.Registered
reply.Waitlisted = result.Waitlisted
reply.SignerIndex = result.SignerIndex
reply.WaitlistIndex = result.WaitlistIndex
reply.TotalSigners = result.TotalSigners
reply.Threshold = result.Threshold
reply.ReshareNeeded = result.ReshareNeeded
reply.CurrentEpoch = result.CurrentEpoch
reply.SetFrozen = result.SetFrozen
reply.RemainingSlots = result.RemainingSlots
reply.Message = result.Message
return nil
}
// GetSignerSetInfoArgs are the arguments for bridge_getSignerSetInfo (empty)
type GetSignerSetInfoArgs struct{}
// GetSignerSetInfoReply is the reply for bridge_getSignerSetInfo
type GetSignerSetInfoReply struct {
TotalSigners int `json:"totalSigners"`
Threshold int `json:"threshold"`
MaxSigners int `json:"maxSigners"`
CurrentEpoch uint64 `json:"currentEpoch"`
SetFrozen bool `json:"setFrozen"`
RemainingSlots int `json:"remainingSlots"`
WaitlistSize int `json:"waitlistSize"`
Signers []SignerInfoReply `json:"signers"`
PublicKey string `json:"publicKey,omitempty"`
}
// SignerInfoReply contains signer information for RPC replies
type SignerInfoReply struct {
NodeID string `json:"nodeId"`
PartyID string `json:"partyId"`
BondAmount uint64 `json:"bondAmount"` // 100M LUX bond (slashable)
Active bool `json:"active"`
JoinedAt string `json:"joinedAt"`
SlotIndex int `json:"slotIndex"`
Slashed bool `json:"slashed"`
SlashCount int `json:"slashCount"`
}
// GetSignerSetInfo returns information about the current signer set (LP-333)
func (s *Service) GetSignerSetInfo(_ *http.Request, _ *GetSignerSetInfoArgs, reply *GetSignerSetInfoReply) error {
info := s.vm.GetSignerSetInfo()
reply.TotalSigners = info.TotalSigners
reply.Threshold = info.Threshold
reply.MaxSigners = info.MaxSigners
reply.CurrentEpoch = info.CurrentEpoch
reply.SetFrozen = info.SetFrozen
reply.RemainingSlots = info.RemainingSlots
reply.WaitlistSize = info.WaitlistSize
reply.PublicKey = info.PublicKey
reply.Signers = make([]SignerInfoReply, len(info.Signers))
for i, signer := range info.Signers {
reply.Signers[i] = SignerInfoReply{
NodeID: signer.NodeID.String(),
PartyID: string(signer.PartyID),
BondAmount: signer.BondAmount,
Active: signer.Active,
JoinedAt: signer.JoinedAt.Format("2006-01-02T15:04:05Z"),
SlotIndex: signer.SlotIndex,
Slashed: signer.Slashed,
SlashCount: signer.SlashCount,
}
}
return nil
}
// ReplaceSignerArgs are the arguments for bridge_replaceSigner
type ReplaceSignerArgs struct {
NodeID string `json:"nodeId"` // Signer to remove
ReplacementNodeID string `json:"replacementNodeId"` // Explicit replacement (optional, uses waitlist if empty)
}
// ReplaceSignerReply is the reply for bridge_replaceSigner
type ReplaceSignerReply struct {
Success bool `json:"success"`
RemovedNodeID string `json:"removedNodeId,omitempty"`
ReplacementNodeID string `json:"replacementNodeId,omitempty"`
ReshareSession string `json:"reshareSession,omitempty"`
NewEpoch uint64 `json:"newEpoch"`
ActiveSigners int `json:"activeSigners"`
Threshold int `json:"threshold"`
Message string `json:"message"`
}
// ReplaceSigner removes a failed signer and triggers reshare (LP-333)
// This is the ONLY operation that triggers a reshare.
func (s *Service) ReplaceSigner(_ *http.Request, args *ReplaceSignerArgs, reply *ReplaceSignerReply) error {
nodeID, err := ids.NodeIDFromString(args.NodeID)
if err != nil {
return err
}
var replacementNodeID *ids.NodeID
if args.ReplacementNodeID != "" {
rid, err := ids.NodeIDFromString(args.ReplacementNodeID)
if err != nil {
return err
}
replacementNodeID = &rid
}
result := s.vm.RemoveSigner(nodeID, replacementNodeID)
reply.Success = result.Success
reply.RemovedNodeID = result.RemovedNodeID
reply.ReplacementNodeID = result.ReplacementNodeID
reply.ReshareSession = result.ReshareSession
reply.NewEpoch = result.NewEpoch
reply.ActiveSigners = result.ActiveSigners
reply.Threshold = result.Threshold
reply.Message = result.Message
return nil
}
// HasSignerArgs are the arguments for bridge_hasSigner
type HasSignerArgs struct {
NodeID string `json:"nodeId"`
}
// HasSignerReply is the reply for bridge_hasSigner
type HasSignerReply struct {
IsSigner bool `json:"isSigner"`
}
// HasSigner checks if a node is in the active signer set
func (s *Service) HasSigner(_ *http.Request, args *HasSignerArgs, reply *HasSignerReply) error {
nodeID, err := ids.NodeIDFromString(args.NodeID)
if err != nil {
return err
}
reply.IsSigner = s.vm.HasSigner(nodeID)
return nil
}
// GetWaitlistArgs are the arguments for bridge_getWaitlist (empty)
type GetWaitlistArgs struct{}
// GetWaitlistReply is the reply for bridge_getWaitlist
type GetWaitlistReply struct {
WaitlistSize int `json:"waitlistSize"`
NodeIDs []string `json:"nodeIds"`
}
// GetWaitlist returns the current waitlist of validators waiting for signer slots
func (s *Service) GetWaitlist(_ *http.Request, _ *GetWaitlistArgs, reply *GetWaitlistReply) error {
s.vm.mu.RLock()
defer s.vm.mu.RUnlock()
reply.WaitlistSize = len(s.vm.signerSet.Waitlist)
reply.NodeIDs = make([]string, len(s.vm.signerSet.Waitlist))
for i, nodeID := range s.vm.signerSet.Waitlist {
reply.NodeIDs[i] = nodeID.String()
}
return nil
}
// GetCurrentEpochArgs are the arguments for bridge_getCurrentEpoch (empty)
type GetCurrentEpochArgs struct{}
// GetCurrentEpochReply is the reply for bridge_getCurrentEpoch
type GetCurrentEpochReply struct {
Epoch uint64 `json:"epoch"`
TotalSigners int `json:"totalSigners"`
Threshold int `json:"threshold"`
SetFrozen bool `json:"setFrozen"`
}
// GetCurrentEpoch returns the current epoch (incremented only on reshare)
func (s *Service) GetCurrentEpoch(_ *http.Request, _ *GetCurrentEpochArgs, reply *GetCurrentEpochReply) error {
s.vm.mu.RLock()
defer s.vm.mu.RUnlock()
reply.Epoch = s.vm.signerSet.CurrentEpoch
reply.TotalSigners = len(s.vm.signerSet.Signers)
reply.Threshold = s.vm.signerSet.ThresholdT
reply.SetFrozen = s.vm.signerSet.SetFrozen
return nil
}
// SlashSignerArgs are the arguments for bridge_slashSigner
type SlashSignerArgs struct {
NodeID string `json:"nodeId"`
Reason string `json:"reason"`
SlashPercent int `json:"slashPercent"` // Percentage of bond to slash (1-100)
Evidence string `json:"evidence"` // Hex-encoded proof of misbehavior
}
// SlashSignerReply is the reply for bridge_slashSigner
type SlashSignerReply struct {
Success bool `json:"success"`
NodeID string `json:"nodeId"`
SlashedAmount uint64 `json:"slashedAmount"`
RemainingBond uint64 `json:"remainingBond"`
TotalSlashCount int `json:"totalSlashCount"`
RemovedFromSet bool `json:"removedFromSet"`
Message string `json:"message"`
}
// SlashSigner slashes a misbehaving bridge signer's bond
// The bond is NOT stake - it's a slashable deposit that can be partially or fully seized
func (s *Service) SlashSigner(_ *http.Request, args *SlashSignerArgs, reply *SlashSignerReply) error {
nodeID, err := ids.NodeIDFromString(args.NodeID)
if err != nil {
return err
}
input := &SlashSignerInput{
NodeID: nodeID,
Reason: args.Reason,
SlashPercent: args.SlashPercent,
Evidence: []byte(args.Evidence),
}
result, err := s.vm.SlashSigner(input)
if err != nil {
return err
}
reply.Success = result.Success
reply.NodeID = result.NodeID
reply.SlashedAmount = result.SlashedAmount
reply.RemainingBond = result.RemainingBond
reply.TotalSlashCount = result.TotalSlashCount
reply.RemovedFromSet = result.RemovedFromSet
reply.Message = result.Message
return nil
}
// =============================================================================
// Permissionless settlement RPC
// =============================================================================
// EstimateFeeArgs are the bridge_estimateFee request body.
type EstimateFeeArgs struct {
SourceChain string `json:"sourceChain"`
DestChain string `json:"destChain"`
SourceAsset string `json:"sourceAsset"`
DestAsset string `json:"destAsset"`
Amount string `json:"amount"`
Refuel bool `json:"refuel,omitempty"`
}
// EstimateFeeReply is the bridge_estimateFee response.
type EstimateFeeReply struct {
FeeAmount string `json:"feeAmount"`
NetAmount string `json:"netAmount"`
EstimatedTime int `json:"estimatedTime"`
}
// EstimateFee answers bridge_estimateFee. Authoritative settlement
// math runs in the VM (see quote.go) so the result is what the
// chain will pay.
func (s *Service) EstimateFee(_ *http.Request, args *EstimateFeeArgs, reply *EstimateFeeReply) error {
if s.vm == nil || s.vm.quoteEngine == nil {
return errors.New("bridgevm: quote engine not configured")
}
amt, err := strconv.ParseFloat(strings.TrimSpace(args.Amount), 64)
if err != nil || amt <= 0 {
return fmt.Errorf("bridgevm: amount must be a positive number (got %q)", args.Amount)
}
res, err := s.vm.quoteEngine.Quote(QuoteInput{
Amount: amt,
SourceNetwork: args.SourceChain,
SourceAsset: args.SourceAsset,
DestinationNetwork: args.DestChain,
DestinationAsset: args.DestAsset,
Refuel: args.Refuel,
})
if err != nil {
return err
}
reply.FeeAmount = formatAmount(res.ServiceFee)
reply.NetAmount = formatAmount(res.ReceiveAmount)
reply.EstimatedTime = res.EstimatedTime
return nil
}
// SubmitRequestArgs is the bridge_submitRequest request body.
type SubmitRequestArgs struct {
SourceChain string `json:"sourceChain"`
DestChain string `json:"destChain"`
SourceAsset string `json:"sourceAsset"`
DestAsset string `json:"destAsset"`
Amount string `json:"amount"`
Recipient string `json:"recipient"`
Sender string `json:"sender"`
Refuel bool `json:"refuel,omitempty"`
}
// SubmitRequestReply is the bridge_submitRequest response.
type SubmitRequestReply BridgeRequestRecord
// SubmitRequest creates a new bridge request server-side and snapshots
// the quote into the record so the daemon's signing pipeline pays out
// what the chain committed to.
func (s *Service) SubmitRequest(_ *http.Request, args *SubmitRequestArgs, reply *SubmitRequestReply) error {
if s.vm == nil || s.vm.swapStore == nil {
return errors.New("bridgevm: swap store not configured")
}
if args.SourceChain == "" || args.DestChain == "" || args.Recipient == "" {
return errors.New("bridgevm: missing required field (sourceChain, destChain, recipient)")
}
amt, err := strconv.ParseFloat(strings.TrimSpace(args.Amount), 64)
if err != nil || amt <= 0 {
return fmt.Errorf("bridgevm: amount must be a positive number (got %q)", args.Amount)
}
// Snapshot the quote — chain commits to these economics at create
// time so post-create price flapping does not change the payout.
res, err := s.vm.quoteEngine.Quote(QuoteInput{
Amount: amt,
SourceNetwork: args.SourceChain,
SourceAsset: args.SourceAsset,
DestinationNetwork: args.DestChain,
DestinationAsset: args.DestAsset,
Refuel: args.Refuel,
})
if err != nil {
return err
}
rec := &BridgeRequestRecord{
SourceChain: args.SourceChain,
DestChain: args.DestChain,
SourceAsset: args.SourceAsset,
DestAsset: args.DestAsset,
Amount: strings.TrimSpace(args.Amount),
Recipient: args.Recipient,
Sender: args.Sender,
Status: StatusPending,
FeeAmount: formatAmount(res.ServiceFee),
NetAmount: formatAmount(res.ReceiveAmount),
}
if err := s.vm.swapStore.Put(rec); err != nil {
return err
}
*reply = SubmitRequestReply(*rec)
return nil
}
// GetStatusArgs is the bridge_getStatus request body.
type GetStatusArgs struct {
RequestID string `json:"requestId"`
}
// GetStatusReply is the bridge_getStatus response.
type GetStatusReply BridgeRequestRecord
// GetStatus answers bridge_getStatus from the authoritative swap store.
func (s *Service) GetStatus(_ *http.Request, args *GetStatusArgs, reply *GetStatusReply) error {
if s.vm == nil || s.vm.swapStore == nil {
return errors.New("bridgevm: swap store not configured")
}
rec, err := s.vm.swapStore.Get(args.RequestID)
if err != nil {
return err
}
*reply = GetStatusReply(*rec)
return nil
}
// CancelRequestArgs is the bridge_cancelRequest request body.
type CancelRequestArgs struct {
RequestID string `json:"requestId"`
}
// CancelRequestReply is the bridge_cancelRequest response.
type CancelRequestReply struct {
Success bool `json:"success"`
}
// CancelRequest answers bridge_cancelRequest. Idempotent — cancelling
// an already-terminal swap is a no-op success so retries are safe.
func (s *Service) CancelRequest(_ *http.Request, args *CancelRequestArgs, reply *CancelRequestReply) error {
if s.vm == nil || s.vm.swapStore == nil {
return errors.New("bridgevm: swap store not configured")
}
rec, err := s.vm.swapStore.Get(args.RequestID)
if err != nil {
return err
}
if rec.Status == StatusCompleted ||
rec.Status == StatusFailed ||
rec.Status == StatusCancelled {
reply.Success = true
return nil
}
if _, err := s.vm.swapStore.Patch(args.RequestID, func(r *BridgeRequestRecord) {
r.Status = StatusCancelled
}); err != nil {
return err
}
reply.Success = true
return nil
}
// HealthArgs are empty (no params).
type HealthArgs struct{}
// HealthReply is the bridge_health response.
type HealthReply struct {
Status string `json:"status"`
MPCReady bool `json:"mpcReady"`
Ready bool `json:"ready"`
Chains int `json:"chains"`
}
// Health answers bridge_health. Liveness probe used by daemons + load
// balancers before routing traffic at this node.
func (s *Service) Health(_ *http.Request, _ *HealthArgs, reply *HealthReply) error {
if s.vm == nil {
reply.Status = "no vm"
return nil
}
reply.MPCReady = len(s.vm.mpcGroupPublicKey()) > 0
reply.Ready, reply.Status, reply.Chains = s.vm.readiness()
return nil
}
// GetMPCPublicKeyArgs are empty.
type GetMPCPublicKeyArgs struct{}
// GetMPCPublicKeyReply is the bridge_getMPCPublicKey response.
type GetMPCPublicKeyReply struct {
PublicKey string `json:"publicKey"`
}
// GetMPCPublicKey answers bridge_getMPCPublicKey with the active
// threshold-signing group public key.
func (s *Service) GetMPCPublicKey(_ *http.Request, _ *GetMPCPublicKeyArgs, reply *GetMPCPublicKeyReply) error {
if s.vm == nil {
return errors.New("bridgevm: VM not initialized")
}
key := s.vm.mpcGroupPublicKey()
if len(key) == 0 {
return errors.New("bridgevm: group public key not yet established")
}
reply.PublicKey = hexEncode(key)
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"`
Height uint64 `json:"height"`
}
// 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 key := s.vm.mpcGroupPublicKey(); len(key) > 0 {
reply.MPCReady = true
reply.MPCPublicKey = hexEncode(key)
}
// The threshold and the party count come from the signer set, which is
// the set that signs. Reporting a configured number beside it gave one
// question two answers, and the configured one was never anything but its
// default.
info := s.vm.GetSignerSetInfo()
reply.Threshold = info.Threshold
reply.TotalParties = info.TotalSigners
// TotalBridged is what has moved TODAY, summed over the destinations this
// bridge serves, read from the same durable counter the daily cap is
// enforced against. Stringified so the daemon's bigint-safe decode
// (parseAmount) consumes them directly.
var totalBridged uint64
now := time.Now().Unix()
for _, cfg := range s.vm.config.ExternalChains {
reply.SupportedChains = append(reply.SupportedChains, cfg.Name)
moved, err := s.vm.movedToday(now, uint32(cfg.ChainID))
if err != nil {
return err
}
totalBridged += moved
}
reply.TotalBridged = strconv.FormatUint(totalBridged, 10)
// This chain charges its fee at admission, not out of the transfer, so
// nothing is accumulated here. Reported explicitly so the daemon's JSON
// decode does not surface an "empty result" error.
reply.TotalFees = "0"
_, reply.Height = s.vm.chain.Tip()
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 from the ONE
// declaration of which chains this bridge serves — the same ExternalChains the
// release path routes by. A second list of chain names, declared beside it and
// used only here, could name a chain the bridge cannot reach or omit one it
// releases to.
//
// The gas-key KMS path is deliberately not reported: it names where a secret
// lives, and this surface is unauthenticated.
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.ExternalChains))
for _, cfg := range s.vm.config.ExternalChains {
out = append(out, s.chainConfig(cfg))
}
reply.Chains = out
return nil
}
func (s *Service) chainConfig(cfg ExternalChainConfig) ChainConfigReply {
return ChainConfigReply{
ChainID: strconv.FormatUint(cfg.ChainID, 10),
ChainName: cfg.Name,
BridgeContract: cfg.Gateway,
Confirmations: int(s.vm.limits.MinConfirmations),
Enabled: true,
}
}
// 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, by its numeric
// id or by its name. Returns an explicit error when this bridge does not serve
// the chain, 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 _, cfg := range s.vm.config.ExternalChains {
if strings.EqualFold(cfg.Name, want) || strconv.FormatUint(cfg.ChainID, 10) == want {
*reply = GetChainConfigReply(s.chainConfig(cfg))
return nil
}
}
return fmt.Errorf("bridgevm: chain %q is not one this bridge serves", 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 {
const hexChars = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, v := range b {
out[i*2] = hexChars[v>>4]
out[i*2+1] = hexChars[v&0x0f]
}
return string(out)
}
// =============================================================================
// HTTP Handler Integration
// =============================================================================
// rpcPath is where the bridge's API is served, and the only place it is.
const rpcPath = "/rpc"
// CreateRPCHandlers creates HTTP handlers for JSON-RPC endpoints
func (vm *VM) CreateRPCHandlers() (map[string]http.Handler, error) {
return map[string]http.Handler{rpcPath: &jsonRPCHandler{service: NewService(vm)}}, nil
}
// jsonRPCHandler handles JSON-RPC requests
type jsonRPCHandler struct {
service *Service
}
// jsonRPCRequest represents a JSON-RPC request
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
ID interface{} `json:"id"`
}
// jsonRPCResponse represents a JSON-RPC response
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
Result interface{} `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
ID interface{} `json:"id"`
}
// jsonRPCError represents a JSON-RPC error
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
func (h *jsonRPCHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req jsonRPCRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
h.writeError(w, nil, -32700, "parse error", err)
return
}
var result interface{}
var err error
switch req.Method {
case "bridge_registerValidator", "bridge.registerValidator":
var args RegisterValidatorArgs
if err := json.Unmarshal(req.Params, &args); err != nil {
h.writeError(w, req.ID, -32602, "invalid params", err)
return
}
var reply RegisterValidatorReply
err = h.service.RegisterValidator(r, &args, &reply)
result = reply
case "bridge_getSignerSetInfo", "bridge.getSignerSetInfo":
var reply GetSignerSetInfoReply
err = h.service.GetSignerSetInfo(r, &GetSignerSetInfoArgs{}, &reply)
result = reply
case "bridge_replaceSigner", "bridge.replaceSigner":
var args ReplaceSignerArgs
if err := json.Unmarshal(req.Params, &args); err != nil {
h.writeError(w, req.ID, -32602, "invalid params", err)
return
}
var reply ReplaceSignerReply
err = h.service.ReplaceSigner(r, &args, &reply)
result = reply
case "bridge_hasSigner", "bridge.hasSigner":
var args HasSignerArgs
if err := json.Unmarshal(req.Params, &args); err != nil {
h.writeError(w, req.ID, -32602, "invalid params", err)
return
}
var reply HasSignerReply
err = h.service.HasSigner(r, &args, &reply)
result = reply
case "bridge_getWaitlist", "bridge.getWaitlist":
var reply GetWaitlistReply
err = h.service.GetWaitlist(r, &GetWaitlistArgs{}, &reply)
result = reply
case "bridge_getCurrentEpoch", "bridge.getCurrentEpoch":
var reply GetCurrentEpochReply
err = h.service.GetCurrentEpoch(r, &GetCurrentEpochArgs{}, &reply)
result = reply
case "bridge_slashSigner", "bridge.slashSigner":
var args SlashSignerArgs
if err := json.Unmarshal(req.Params, &args); err != nil {
h.writeError(w, req.ID, -32602, "invalid params", err)
return
}
var reply SlashSignerReply
err = h.service.SlashSigner(r, &args, &reply)
result = reply
case "bridge_estimateFee", "bridge.estimateFee":
var args EstimateFeeArgs
if err := json.Unmarshal(req.Params, &args); err != nil {
h.writeError(w, req.ID, -32602, "invalid params", err)
return
}
var reply EstimateFeeReply
err = h.service.EstimateFee(r, &args, &reply)
result = reply
case "bridge_submitRequest", "bridge.submitRequest":
var args SubmitRequestArgs
if err := json.Unmarshal(req.Params, &args); err != nil {
h.writeError(w, req.ID, -32602, "invalid params", err)
return
}
var reply SubmitRequestReply
err = h.service.SubmitRequest(r, &args, &reply)
result = reply
case "bridge_getStatus", "bridge.getStatus":
var args GetStatusArgs
if err := json.Unmarshal(req.Params, &args); err != nil {
h.writeError(w, req.ID, -32602, "invalid params", err)
return
}
var reply GetStatusReply
err = h.service.GetStatus(r, &args, &reply)
result = reply
case "bridge_cancelRequest", "bridge.cancelRequest":
var args CancelRequestArgs
if err := json.Unmarshal(req.Params, &args); err != nil {
h.writeError(w, req.ID, -32602, "invalid params", err)
return
}
var reply CancelRequestReply
err = h.service.CancelRequest(r, &args, &reply)
result = reply
case "bridge_health", "bridge.health":
var reply HealthReply
err = h.service.Health(r, &HealthArgs{}, &reply)
result = reply
case "bridge_getMPCPublicKey", "bridge.getMPCPublicKey":
var reply GetMPCPublicKeyReply
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
}
if err != nil {
// Surface the actual error in the message so callers can
// dispatch on it (e.g. "swap not found" vs "price unknown")
// rather than parsing the opaque `data` envelope.
h.writeError(w, req.ID, -32000, err.Error(), nil)
return
}
h.writeResult(w, req.ID, result)
}
func (h *jsonRPCHandler) writeResult(w http.ResponseWriter, id interface{}, result interface{}) {
resp := jsonRPCResponse{
JSONRPC: "2.0",
Result: result,
ID: id,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
func (h *jsonRPCHandler) writeError(w http.ResponseWriter, id interface{}, code int, message string, data interface{}) {
resp := jsonRPCResponse{
JSONRPC: "2.0",
Error: &jsonRPCError{
Code: code,
Message: message,
Data: data,
},
ID: id,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}