Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions bridgevm/quote.go
Original file line number Diff line number Diff line change
@@ -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 changes: 121 additions & 0 deletions bridgevm/quote_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading