mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user