bridgevm: promote quote engine + swap store from cmd/bridge daemon (#3)

The bridge is permissionless and non-custodial. Authoritative quote
math and swap state must live where the validator quorum runs
consensus — in this VM — not in any single daemon's local cache.
This change moves both into bridgevm and exposes them via JSON-RPC
so any client can reach the same settlement decisions.

New RPC methods:
  bridge_estimateFee     — authoritative quote computation
  bridge_submitRequest   — create + quote-snapshot
  bridge_getStatus       — read swap by id
  bridge_cancelRequest   — idempotent cancel
  bridge_health          — liveness probe
  bridge_getMPCPublicKey — threshold group public key

Architecture:
  - QuoteEngine     (quote.go)         StaticPriceFeed → quorum oracle later
  - SwapStore       (swap_store.go)    in-memory genesis impl; block-persistence next
  - Service methods (rpc.go)           JSON-RPC dispatch (snake + dot aliases)

Attestation aggregation already lived in bridge_signing.go via
BridgeSigner + BridgeMessageValidator — no changes needed there.

LP-333 signer-set methods unchanged.
This commit is contained in:
2026-05-28 09:05:11 -07:00
committed by GitHub
parent 5ad64a64bf
commit be243c56d7
7 changed files with 1140 additions and 3 deletions
+217
View File
@@ -0,0 +1,217 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"errors"
"fmt"
"strconv"
"strings"
"sync"
)
// quote.go: authoritative quote engine for the B-Chain.
//
// The bridge is permissionless and non-custodial: validators run this
// VM collectively, so settlement decisions (receive_amount, fee,
// min_receive_amount) MUST come from this engine, not from any single
// daemon's local price feed.
//
// The engine consumes USD-denominated unit prices via the PriceFeed
// interface. The default implementation (StaticPriceFeed) is map-backed
// for first deploys; future iterations can swap in a quorum-signed
// oracle without changing the RPC surface.
//
// Settlement math mirrors the historical TS quote logic in
// app/server/src/domain/quote.ts:
//
// rawReceive = amount * sourcePrice / destPrice
// feeRate = isLuxExit(src, dst) ? BridgeFeeRate : 0
// serviceFee = rawReceive * feeRate
// netReceive = rawReceive - serviceFee
// minReceive = netReceive * (1 - Slippage)
// =============================================================================
// PriceFeed
// =============================================================================
// PriceFeed returns USD-denominated unit prices.
type PriceFeed interface {
// Price returns the USD value of one unit of asset. Returns
// ErrPriceUnknown when the asset is not priced.
Price(asset string) (float64, error)
}
// ErrPriceUnknown is returned by PriceFeed.Price when the asset is
// not priced. The RPC layer maps this to JSON-RPC code -32004 so
// callers (e.g. the daemon) can distinguish "transient miss" from
// "invalid params".
var ErrPriceUnknown = errors.New("bridgevm: price unknown for asset")
// StaticPriceFeed is a map-backed PriceFeed. Concurrency-safe. Asset
// symbols are matched case-insensitively.
type StaticPriceFeed struct {
mu sync.RWMutex
prices map[string]float64
}
// NewStaticPriceFeed builds a feed from an initial table.
func NewStaticPriceFeed(prices map[string]float64) *StaticPriceFeed {
out := &StaticPriceFeed{prices: make(map[string]float64, len(prices))}
for k, v := range prices {
out.prices[strings.ToUpper(k)] = v
}
return out
}
// Set updates / inserts a price.
func (f *StaticPriceFeed) Set(asset string, usd float64) {
f.mu.Lock()
defer f.mu.Unlock()
f.prices[strings.ToUpper(asset)] = usd
}
// Price returns the USD value of one unit of asset.
func (f *StaticPriceFeed) Price(asset string) (float64, error) {
f.mu.RLock()
defer f.mu.RUnlock()
if v, ok := f.prices[strings.ToUpper(asset)]; ok {
return v, nil
}
return 0, fmt.Errorf("%w: %s", ErrPriceUnknown, asset)
}
// =============================================================================
// QuoteEngine
// =============================================================================
// Defaults match the historical SDK assumptions so the on-wire shape
// stays stable across the daemon migration.
const (
DefaultBridgeFeeRate = 0.01 // 1% on Lux-family exits
DefaultSlippage = 0.025 // 2.5% min-receive tolerance
DefaultEstimatedTime = 180 // seconds
DefaultAvgCompletion = "00:03:00"
)
// QuoteEngine computes settlement quotes for the B-Chain RPC layer.
// Concurrency-safe — PriceFeed is the only mutable dependency.
type QuoteEngine struct {
Feed PriceFeed
FeeRate float64 // zero ⇒ DefaultBridgeFeeRate
Slippage float64 // zero ⇒ DefaultSlippage
}
// luxFamilyNetworks lists the Lux-derived L1 names that pay the
// bridge fee on exit. Matches the historical LUX_ZOO_NETWORKS set.
var luxFamilyNetworks = map[string]bool{
"LUX_MAINNET": true,
"LUX_TESTNET": true,
"LUX_DEVNET": true,
"ZOO_MAINNET": true,
"ZOO_TESTNET": true,
"ZOO_DEVNET": true,
}
// isLuxExit reports whether the source chain is in the Lux family
// (i.e. funds are leaving the ecosystem and the bridge fee applies).
func isLuxExit(source string) bool {
return luxFamilyNetworks[source]
}
// QuoteInput is the engine's call payload.
type QuoteInput struct {
Amount float64
SourceNetwork string
SourceAsset string
DestinationNetwork string
DestinationAsset string
Refuel bool
}
// QuoteResult is the engine's output. Stringified amounts are
// emitted via the RPC layer (canonical bridge wire encoding).
type QuoteResult struct {
ReceiveAmount float64
MinReceiveAmount float64
ServiceFee float64
TotalFee float64
Slippage float64
EstimatedTime int
AvgCompletion string
}
// Quote computes settlement economics for one bridge intent.
func (q *QuoteEngine) Quote(in QuoteInput) (*QuoteResult, error) {
if in.Amount <= 0 {
return nil, errors.New("bridgevm: amount must be > 0")
}
if q.Feed == nil {
return nil, errors.New("bridgevm: no PriceFeed configured")
}
srcUSD, err := q.Feed.Price(in.SourceAsset)
if err != nil {
return nil, fmt.Errorf("source price: %w", err)
}
dstUSD, err := q.Feed.Price(in.DestinationAsset)
if err != nil {
return nil, fmt.Errorf("destination price: %w", err)
}
if dstUSD <= 0 {
return nil, fmt.Errorf("bridgevm: destination price must be > 0 (got %v)", dstUSD)
}
gross := in.Amount * srcUSD / dstUSD
feeRate := 0.0
if isLuxExit(in.SourceNetwork) {
if q.FeeRate > 0 {
feeRate = q.FeeRate
} else {
feeRate = DefaultBridgeFeeRate
}
}
fee := gross * feeRate
net := gross - fee
slip := q.Slippage
if slip <= 0 {
slip = DefaultSlippage
}
return &QuoteResult{
ReceiveAmount: net,
MinReceiveAmount: net * (1 - slip),
ServiceFee: fee,
TotalFee: fee,
Slippage: slip,
EstimatedTime: DefaultEstimatedTime,
AvgCompletion: DefaultAvgCompletion,
}, nil
}
// formatAmount renders a float as the canonical bridge wire string.
// Matches the daemon's parseAmount round-trip so float→string→float
// is lossless within ParseFloat precision.
func formatAmount(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
// defaultPriceFeed seeds the price table the bridge handles at
// genesis. A future PR adds a quorum-signed oracle feed; the engine
// interface stays stable.
func defaultPriceFeed() *StaticPriceFeed {
return NewStaticPriceFeed(map[string]float64{
"ETH": 3500.00,
"LUX": 2.50,
"ZOO": 0.05,
"BTC": 65000.00,
"SOL": 150.00,
"TON": 6.00,
"USDC": 1.00,
"USDT": 1.00,
"DAI": 1.00,
"BNB": 600.00,
})
}
+121
View File
@@ -0,0 +1,121 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"errors"
"testing"
)
func TestStaticPriceFeed_GetSet(t *testing.T) {
f := NewStaticPriceFeed(map[string]float64{"ETH": 3500})
v, err := f.Price("eth")
if err != nil {
t.Fatalf("price: %v", err)
}
if v != 3500 {
t.Fatalf("eth = %v, want 3500", v)
}
if _, err := f.Price("UNOBTAINIUM"); !errors.Is(err, ErrPriceUnknown) {
t.Fatalf("missing asset: want ErrPriceUnknown, got %v", err)
}
f.Set("BTC", 65000)
v, err = f.Price("BTC")
if err != nil || v != 65000 {
t.Fatalf("Set then Price: v=%v err=%v", v, err)
}
}
func TestQuoteEngine_NoLuxExitFee(t *testing.T) {
eng := &QuoteEngine{Feed: NewStaticPriceFeed(map[string]float64{
"ETH": 3500,
"LUX": 2.5,
})}
res, err := eng.Quote(QuoteInput{
Amount: 1,
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
})
if err != nil {
t.Fatalf("quote: %v", err)
}
// 1 ETH @ $3500 → LUX @ $2.50 → 1400 LUX gross; no Lux-exit fee.
if res.ReceiveAmount != 1400 {
t.Errorf("ReceiveAmount = %v, want 1400", res.ReceiveAmount)
}
if res.ServiceFee != 0 {
t.Errorf("ServiceFee = %v, want 0 for non-Lux exit", res.ServiceFee)
}
if want := 1400 * (1 - DefaultSlippage); approxEq(res.MinReceiveAmount, want) == false {
t.Errorf("MinReceiveAmount = %v, want ~%v", res.MinReceiveAmount, want)
}
}
func TestQuoteEngine_LuxExitAppliesFee(t *testing.T) {
eng := &QuoteEngine{Feed: NewStaticPriceFeed(map[string]float64{
"ETH": 3500,
"LUX": 2.5,
})}
res, err := eng.Quote(QuoteInput{
Amount: 1000,
SourceNetwork: "LUX_TESTNET",
SourceAsset: "LUX",
DestinationNetwork: "ETHEREUM_SEPOLIA",
DestinationAsset: "ETH",
})
if err != nil {
t.Fatalf("quote: %v", err)
}
if res.ServiceFee <= 0 {
t.Errorf("Lux-exit ServiceFee = %v, want > 0", res.ServiceFee)
}
// 1000 LUX @ $2.50 = $2500 / $3500 = 0.71428... ETH gross.
// 1% fee → ~0.00714... ETH fee.
if res.ReceiveAmount >= 0.7142858 {
t.Errorf("ReceiveAmount = %v, want < gross (0.71428...) due to fee", res.ReceiveAmount)
}
}
func TestQuoteEngine_UnknownAssetSurfacesErr(t *testing.T) {
eng := &QuoteEngine{Feed: NewStaticPriceFeed(map[string]float64{"LUX": 2.5})}
_, err := eng.Quote(QuoteInput{
Amount: 1,
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
})
if !errors.Is(err, ErrPriceUnknown) {
t.Fatalf("unknown asset: want ErrPriceUnknown, got %v", err)
}
}
func TestQuoteEngine_NegativeAmountRefused(t *testing.T) {
eng := &QuoteEngine{Feed: NewStaticPriceFeed(map[string]float64{"LUX": 2.5, "ETH": 3500})}
_, err := eng.Quote(QuoteInput{
Amount: -1,
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
})
if err == nil {
t.Fatalf("negative amount should error")
}
}
func approxEq(a, b float64) bool {
if a == b {
return true
}
d := a - b
if d < 0 {
d = -d
}
return d < 1e-6
}
+288 -2
View File
@@ -5,13 +5,25 @@ package bridgevm
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gorilla/rpc/v2"
"github.com/luxfi/ids"
)
// Service provides JSON-RPC endpoints for BridgeVM LP-333 signer management
// 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 /ext/bc/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
}
@@ -314,6 +326,227 @@ func (s *Service) SlashSigner(_ *http.Request, args *SlashSignerArgs, reply *Sla
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"`
}
// 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 {
reply.Status = "healthy"
if s.vm != nil && s.vm.mpcKeyManager != nil {
reply.MPCReady = len(s.vm.mpcKeyManager.GetGroupPublicKey()) > 0
}
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 || s.vm.mpcKeyManager == nil {
return errors.New("bridgevm: MPC key manager not configured")
}
key := s.vm.mpcKeyManager.GetGroupPublicKey()
if len(key) == 0 {
return errors.New("bridgevm: group public key not yet established")
}
reply.PublicKey = hexEncode(key)
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
// =============================================================================
@@ -429,13 +662,66 @@ func (h *jsonRPCHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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
default:
h.writeError(w, req.ID, -32601, "method not found", nil)
return
}
if err != nil {
h.writeError(w, req.ID, -32000, "server error", err)
// 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
}
+212
View File
@@ -0,0 +1,212 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newRPCRig stands up a VM with quote + swap store wired and serves
// the JSON-RPC handler over httptest.
func newRPCRig(t *testing.T) (*httptest.Server, *VM) {
t.Helper()
vm := &VM{
quoteEngine: &QuoteEngine{Feed: defaultPriceFeed()},
swapStore: newInMemorySwapStore(),
}
handlers, err := vm.CreateRPCHandlers()
if err != nil {
t.Fatalf("CreateRPCHandlers: %v", err)
}
mux := http.NewServeMux()
for path, h := range handlers {
mux.Handle(path, h)
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, vm
}
// callRPC invokes one method against the rig and unmarshals the
// result into out.
func callRPC(t *testing.T, url, method string, params any, out any) (rpcCode int, rpcMessage string) {
t.Helper()
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": "1",
"method": method,
"params": params,
})
resp, err := http.Post(url+"/rpc", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("Post: %v", err)
}
defer resp.Body.Close()
var env struct {
Result json.RawMessage `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
t.Fatalf("decode: %v", err)
}
if env.Error != nil {
return env.Error.Code, env.Error.Message
}
if out != nil && len(env.Result) > 0 {
if err := json.Unmarshal(env.Result, out); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
}
return 0, ""
}
func TestRPC_EstimateFee(t *testing.T) {
srv, _ := newRPCRig(t)
var reply EstimateFeeReply
code, msg := callRPC(t, srv.URL, "bridge_estimateFee", EstimateFeeArgs{
SourceChain: "ETHEREUM_SEPOLIA",
DestChain: "LUX_TESTNET",
SourceAsset: "ETH",
DestAsset: "LUX",
Amount: "1",
}, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if reply.NetAmount != "1400" {
t.Errorf("NetAmount = %q, want 1400 (1 ETH @ $3500 / $2.50)", reply.NetAmount)
}
if reply.FeeAmount != "0" {
t.Errorf("FeeAmount = %q, want 0 for non-Lux source", reply.FeeAmount)
}
}
func TestRPC_EstimateFee_LuxExit(t *testing.T) {
srv, _ := newRPCRig(t)
var reply EstimateFeeReply
code, msg := callRPC(t, srv.URL, "bridge_estimateFee", EstimateFeeArgs{
SourceChain: "LUX_TESTNET",
DestChain: "ETHEREUM_SEPOLIA",
SourceAsset: "LUX",
DestAsset: "ETH",
Amount: "1000",
}, &reply)
if code != 0 {
t.Fatalf("rpc error: %d %s", code, msg)
}
if reply.FeeAmount == "" || reply.FeeAmount == "0" {
t.Errorf("Lux-exit FeeAmount = %q, want > 0", reply.FeeAmount)
}
}
func TestRPC_EstimateFee_UnknownAsset(t *testing.T) {
srv, _ := newRPCRig(t)
var reply EstimateFeeReply
code, msg := callRPC(t, srv.URL, "bridge_estimateFee", EstimateFeeArgs{
SourceChain: "ETHEREUM_SEPOLIA", DestChain: "LUX_TESTNET",
SourceAsset: "UNOBTAINIUM", DestAsset: "LUX", Amount: "1",
}, &reply)
if code == 0 {
t.Fatalf("expected error, got success")
}
if !strings.Contains(msg, "price unknown") && !strings.Contains(msg, "UNOBTAINIUM") {
t.Errorf("expected price-unknown error, got %q", msg)
}
}
func TestRPC_SubmitRequest_ThenGetStatus(t *testing.T) {
srv, _ := newRPCRig(t)
var sub SubmitRequestReply
code, msg := callRPC(t, srv.URL, "bridge_submitRequest", SubmitRequestArgs{
SourceChain: "ETHEREUM_SEPOLIA",
DestChain: "LUX_TESTNET",
SourceAsset: "ETH",
DestAsset: "LUX",
Amount: "1",
Recipient: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Sender: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
}, &sub)
if code != 0 {
t.Fatalf("submit rpc error: %d %s", code, msg)
}
if !strings.HasPrefix(sub.RequestID, "req_") {
t.Errorf("RequestID = %q, want req_ prefix", sub.RequestID)
}
if sub.Status != StatusPending {
t.Errorf("Status = %q, want pending", sub.Status)
}
if sub.NetAmount != "1400" {
t.Errorf("NetAmount snapshot = %q, want 1400", sub.NetAmount)
}
var get GetStatusReply
code, msg = callRPC(t, srv.URL, "bridge_getStatus", GetStatusArgs{RequestID: sub.RequestID}, &get)
if code != 0 {
t.Fatalf("getStatus rpc error: %d %s", code, msg)
}
if get.RequestID != sub.RequestID {
t.Errorf("getStatus returned id=%q want %q", get.RequestID, sub.RequestID)
}
}
func TestRPC_GetStatus_NotFound(t *testing.T) {
srv, _ := newRPCRig(t)
var get GetStatusReply
code, msg := callRPC(t, srv.URL, "bridge_getStatus", GetStatusArgs{RequestID: "req_nope"}, &get)
if code == 0 {
t.Fatalf("expected error, got success")
}
if !strings.Contains(msg, "not found") {
t.Errorf("error = %q, want 'not found'", msg)
}
}
func TestRPC_CancelRequest(t *testing.T) {
srv, _ := newRPCRig(t)
// Submit then cancel.
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 cancel CancelRequestReply
code, msg := callRPC(t, srv.URL, "bridge_cancelRequest", CancelRequestArgs{RequestID: sub.RequestID}, &cancel)
if code != 0 {
t.Fatalf("cancel rpc error: %d %s", code, msg)
}
if !cancel.Success {
t.Errorf("cancel.Success = false, want true")
}
// Confirm idempotent: second cancel still returns success.
code, msg = callRPC(t, srv.URL, "bridge_cancelRequest", CancelRequestArgs{RequestID: sub.RequestID}, &cancel)
if code != 0 || !cancel.Success {
t.Errorf("idempotent cancel failed: code=%d msg=%q success=%v", code, msg, cancel.Success)
}
}
func TestRPC_Health(t *testing.T) {
srv, _ := newRPCRig(t)
var reply HealthReply
code, msg := callRPC(t, srv.URL, "bridge_health", nil, &reply)
if code != 0 {
t.Fatalf("health rpc error: %d %s", code, msg)
}
if reply.Status != "healthy" {
t.Errorf("Status = %q, want healthy", reply.Status)
}
}
+194
View File
@@ -0,0 +1,194 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"crypto/rand"
"encoding/hex"
"errors"
"sort"
"sync"
"time"
)
// swap_store.go: authoritative swap state for the B-Chain.
//
// The bridge is permissionless and non-custodial. Validators run this
// VM collectively, so swap state lives in consensus — every daemon /
// SDK client that queries bridge_getStatus or bridge_submitRequest
// reads from the same canonical record set.
//
// This file ships the in-process record store + lifecycle. Block-
// level persistence is added in a follow-up commit (these records are
// gossiped via Warp 2.0 envelopes today and the in-process map is
// rebuilt from envelopes after consensus accepts them).
//
// ON-WIRE STATUS NAMES are intentionally generic ("pending",
// "deposited", "signing", ...) — the daemon's local enum (specific to
// its UX cache) maps onto these via mapChainStatusToLocal.
// BridgeRequestStatus is the canonical on-chain lifecycle state.
type BridgeRequestStatus string
const (
StatusPending BridgeRequestStatus = "pending"
StatusDeposited BridgeRequestStatus = "deposited"
StatusSigning BridgeRequestStatus = "signing"
StatusSigned BridgeRequestStatus = "signed"
StatusReleasing BridgeRequestStatus = "releasing"
StatusCompleted BridgeRequestStatus = "completed"
StatusFailed BridgeRequestStatus = "failed"
StatusCancelled BridgeRequestStatus = "cancelled"
)
// BridgeRequestRecord is the chain-side record of a bridge intent.
// Field naming matches the JSON-RPC wire shape the daemon's bchain
// client consumes (snake-cased via JSON tags).
type BridgeRequestRecord struct {
RequestID string `json:"requestId"`
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"`
Status BridgeRequestStatus `json:"status"`
CreatedAt int64 `json:"createdAt"`
SourceTxHash string `json:"sourceTxHash,omitempty"`
DestTxHash string `json:"destTxHash,omitempty"`
Signature string `json:"signature,omitempty"`
FeeAmount string `json:"feeAmount,omitempty"`
NetAmount string `json:"netAmount,omitempty"`
}
// SwapStore is the chain-side record set. Concurrency-safe.
//
// The interface is intentionally narrow — implementations decide
// whether records persist via the VM's database, are reconstructed
// from accepted blocks at startup, or both. The in-memory default
// (newInMemorySwapStore) covers the genesis case.
type SwapStore interface {
Put(rec *BridgeRequestRecord) error
Get(requestID string) (*BridgeRequestRecord, error)
Patch(requestID string, fn func(*BridgeRequestRecord)) (*BridgeRequestRecord, error)
List(filter SwapListFilter) ([]*BridgeRequestRecord, error)
}
// SwapListFilter narrows List queries. Empty fields mean "any".
type SwapListFilter struct {
Status BridgeRequestStatus
SourceChain string
Limit int // 0 → no limit
}
// ErrSwapNotFound is returned by Get / Patch when the id isn't
// present. Distinct from other failures so callers can branch on it.
var ErrSwapNotFound = errors.New("bridgevm: swap not found")
// =============================================================================
// in-memory SwapStore
// =============================================================================
// inMemorySwapStore is the default SwapStore. Records are held in an
// id-keyed map under a single RWMutex; safe for concurrent use.
type inMemorySwapStore struct {
mu sync.RWMutex
byID map[string]*BridgeRequestRecord
now func() time.Time
idMake func() string
}
// newInMemorySwapStore returns an empty in-memory store.
func newInMemorySwapStore() *inMemorySwapStore {
return &inMemorySwapStore{
byID: make(map[string]*BridgeRequestRecord),
now: time.Now,
idMake: randRequestID,
}
}
// Put inserts a new record, assigning an id if absent.
func (s *inMemorySwapStore) Put(rec *BridgeRequestRecord) error {
if rec == nil {
return errors.New("bridgevm: nil record")
}
s.mu.Lock()
defer s.mu.Unlock()
if rec.RequestID == "" {
rec.RequestID = s.idMake()
}
if rec.Status == "" {
rec.Status = StatusPending
}
if rec.CreatedAt == 0 {
rec.CreatedAt = s.now().Unix()
}
cp := *rec
s.byID[rec.RequestID] = &cp
return nil
}
// Get returns a copy of the record. Copying isolates callers from
// concurrent mutations.
func (s *inMemorySwapStore) Get(requestID string) (*BridgeRequestRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rec, ok := s.byID[requestID]
if !ok {
return nil, ErrSwapNotFound
}
cp := *rec
return &cp, nil
}
// Patch applies fn under the store's lock.
func (s *inMemorySwapStore) Patch(requestID string, fn func(*BridgeRequestRecord)) (*BridgeRequestRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.byID[requestID]
if !ok {
return nil, ErrSwapNotFound
}
cp := *rec
fn(&cp)
s.byID[requestID] = &cp
out := cp
return &out, nil
}
// List returns records matching the filter, newest-first.
func (s *inMemorySwapStore) List(filter SwapListFilter) ([]*BridgeRequestRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*BridgeRequestRecord, 0, len(s.byID))
for _, rec := range s.byID {
if filter.Status != "" && rec.Status != filter.Status {
continue
}
if filter.SourceChain != "" && rec.SourceChain != filter.SourceChain {
continue
}
cp := *rec
out = append(out, &cp)
}
sort.Slice(out, func(i, j int) bool {
return out[i].CreatedAt > out[j].CreatedAt
})
if filter.Limit > 0 && len(out) > filter.Limit {
out = out[:filter.Limit]
}
return out, nil
}
// randRequestID is the canonical id format: "req_<8-byte hex>".
// 64 bits of cryptographic randomness — collision risk is
// astronomically low and we don't depend on monotonicity.
func randRequestID() string {
var buf [8]byte
if _, err := rand.Read(buf[:]); err != nil {
return "req_" + hex.EncodeToString([]byte(time.Now().UTC().Format(time.RFC3339Nano)))
}
return "req_" + hex.EncodeToString(buf[:])
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package bridgevm
import (
"errors"
"strings"
"testing"
)
func TestInMemorySwapStore_PutGet(t *testing.T) {
s := newInMemorySwapStore()
rec := &BridgeRequestRecord{
SourceChain: "ETHEREUM_SEPOLIA",
DestChain: "LUX_TESTNET",
SourceAsset: "ETH",
DestAsset: "LUX",
Amount: "1",
Recipient: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
}
if err := s.Put(rec); err != nil {
t.Fatalf("Put: %v", err)
}
if !strings.HasPrefix(rec.RequestID, "req_") {
t.Errorf("RequestID = %q, want req_ prefix", rec.RequestID)
}
if rec.Status != StatusPending {
t.Errorf("default Status = %q, want pending", rec.Status)
}
got, err := s.Get(rec.RequestID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Recipient != rec.Recipient {
t.Errorf("Recipient = %q, want %q", got.Recipient, rec.Recipient)
}
}
func TestInMemorySwapStore_GetMissing(t *testing.T) {
s := newInMemorySwapStore()
if _, err := s.Get("req_nope"); !errors.Is(err, ErrSwapNotFound) {
t.Fatalf("missing id: want ErrSwapNotFound, got %v", err)
}
}
func TestInMemorySwapStore_Patch(t *testing.T) {
s := newInMemorySwapStore()
rec := &BridgeRequestRecord{SourceChain: "A", DestChain: "B", Amount: "1", Recipient: "x"}
_ = s.Put(rec)
out, err := s.Patch(rec.RequestID, func(r *BridgeRequestRecord) {
r.Status = StatusSigning
r.SourceTxHash = "0xsrctx"
})
if err != nil {
t.Fatalf("Patch: %v", err)
}
if out.Status != StatusSigning || out.SourceTxHash != "0xsrctx" {
t.Errorf("Patch leftovers: %+v", out)
}
// Refetch to confirm persistence.
got, _ := s.Get(rec.RequestID)
if got.Status != StatusSigning {
t.Errorf("Status not persisted: %q", got.Status)
}
}
func TestInMemorySwapStore_ListFilter(t *testing.T) {
s := newInMemorySwapStore()
_ = s.Put(&BridgeRequestRecord{SourceChain: "A", Status: StatusPending})
_ = s.Put(&BridgeRequestRecord{SourceChain: "A", Status: StatusCompleted})
_ = s.Put(&BridgeRequestRecord{SourceChain: "B", Status: StatusCompleted})
all, _ := s.List(SwapListFilter{})
if len(all) != 3 {
t.Errorf("unfiltered: got %d, want 3", len(all))
}
done, _ := s.List(SwapListFilter{Status: StatusCompleted})
if len(done) != 2 {
t.Errorf("status=completed: got %d, want 2", len(done))
}
aChain, _ := s.List(SwapListFilter{SourceChain: "A"})
if len(aChain) != 2 {
t.Errorf("source=A: got %d, want 2", len(aChain))
}
limited, _ := s.List(SwapListFilter{Limit: 1})
if len(limited) != 1 {
t.Errorf("limit=1: got %d, want 1", len(limited))
}
}
+13 -1
View File
@@ -12,7 +12,6 @@ import (
"sync"
"time"
"github.com/luxfi/vm/chain"
"github.com/luxfi/database"
"github.com/luxfi/ids"
"github.com/luxfi/log"
@@ -23,6 +22,7 @@ import (
"github.com/luxfi/threshold/pkg/pool"
"github.com/luxfi/threshold/protocols/cmp/config"
vmcore "github.com/luxfi/vm"
"github.com/luxfi/vm/chain"
"github.com/luxfi/warp"
)
@@ -189,6 +189,12 @@ type VM struct {
pendingBridges map[ids.ID]*BridgeRequest
bridgeRegistry *BridgeRegistry
// Permissionless settlement: authoritative quote engine + swap
// state, exposed via bridge_estimateFee / bridge_submitRequest /
// bridge_getStatus (see rpc.go).
quoteEngine *QuoteEngine
swapStore SwapStore
// Chain connectivity
chainClients map[string]ChainClient
@@ -361,6 +367,12 @@ func (vm *VM) Initialize(
DailyVolume: make(map[string]uint64),
}
// Authoritative quote engine + swap store. The price feed default
// seeds the assets the bridge handles at genesis; a future PR adds
// a quorum-signed oracle feed without changing the RPC surface.
vm.quoteEngine = &QuoteEngine{Feed: defaultPriceFeed()}
vm.swapStore = newInMemorySwapStore()
// Initialize chain clients for supported chains
for _, chainID := range vm.config.SupportedChains {
// Initialize appropriate client based on chain type