mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
cmd/bridge: thin daemon to REST/observer/broadcaster shim (#402)
The bridge is permissionless and non-custodial. Authoritative quote
and swap state belong in chains/bridgevm (the B-Chain VM the
validator quorum runs); threshold key shares belong in T-Chain
across the validator set. The daemon's role is observe + broadcast —
not orchestrate, not custody.
Deleted (net -664 LOC):
- cmd/bridge/quote_engine.go (settlement math moved to bridgevm)
- cmd/bridge/coingecko_price_feed.go (no daemon-local price feed)
- cmd/bridge/coingecko_price_feed_test.go
Added (+290 LOC):
- cmd/bridge/quote_handler.go (REST → bchain.EstimateFee pass-through)
- cmd/bridge/resync_swaps.go (--resync-swaps reconciles local UX cache
against authoritative B-Chain state)
Updated:
- swap_store.go: declares the local store as a UX cache; B-Chain wins
on conflict. Header doc states the contract.
- signing_driver.go: header rewritten to declare observe + broadcast
role, no orchestration. Pipeline logic unchanged — it was already
a thin orchestrator, the docs lagged the architecture.
- swaps_handler.go: quote snapshot at create time calls bchain
bridge_estimateFee; no daemon-local QuoteEngine.
- api.go: NewAPI signature drops *QuoteEngine. Native routes register
when SwapStore + bchain client are both wired.
- main.go: dropped --coingecko* flags + price-feed wiring; added
--resync-swaps flag.
Tests updated to use an in-process mock B-Chain RPC for quote
snapshot. 32 cmd/bridge tests pass (15 new resync + bchain-mock).
BREAKING:
- --coingecko, --coingecko-url, --coingecko-api-key, --coingecko-cache-ttl,
--coingecko-timeout flags removed.
- --bchain-url is now required for /v1/bridge/quote + /v1/bridge/swaps
native registration. Without it those routes fall back to the
legacy reverse-proxy (or 503 when --backend is also unset).
- NewAPI signature lost its *QuoteEngine parameter.
This commit is contained in:
+10
-15
@@ -53,13 +53,11 @@ type API struct {
|
||||
// store fixes).
|
||||
releaseStore mchain.ReleaseWalletStore
|
||||
|
||||
// Native swap CRUD. The Go binary owns these — BridgeVM is the
|
||||
// LP-333 signer-set manager, not a swap API (see
|
||||
// architecture_go_bridge_stack memory). When both store and
|
||||
// quote are non-nil the native handlers register and replace the
|
||||
// legacy reverse-proxy.
|
||||
// Native swap CRUD. The B-Chain VM owns authoritative settlement;
|
||||
// this store is a UX cache (see swap_store.go). When store and
|
||||
// bchain are both non-nil the native handlers register and replace
|
||||
// the legacy reverse-proxy.
|
||||
store SwapStore
|
||||
quote *QuoteEngine
|
||||
}
|
||||
|
||||
func NewAPI(
|
||||
@@ -69,7 +67,6 @@ func NewAPI(
|
||||
mchainClient *mchain.Client,
|
||||
depCheckClient *depositcheck.Client,
|
||||
store SwapStore,
|
||||
quote *QuoteEngine,
|
||||
) *API {
|
||||
a := &API{
|
||||
cfg: cfg,
|
||||
@@ -78,7 +75,6 @@ func NewAPI(
|
||||
mchain: mchainClient,
|
||||
depcheck: depCheckClient,
|
||||
store: store,
|
||||
quote: quote,
|
||||
}
|
||||
if backendURL != "" {
|
||||
u, err := url.Parse(backendURL)
|
||||
@@ -139,12 +135,11 @@ func (a *API) Register(app *zip.App) {
|
||||
// Prometheus metrics including bridge_classical_compat_total.
|
||||
app.Get("/metrics", a.metrics)
|
||||
|
||||
// Native swap CRUD takes precedence when a SwapStore + QuoteEngine
|
||||
// are configured. Falls back to the legacy reverse-proxy / 503
|
||||
// otherwise. Per LP-134 / LP-333 the native handlers DO NOT call
|
||||
// BridgeVM for swap-API methods — those don't exist on the chain.
|
||||
// BridgeVM is queried only for signer-set introspection via
|
||||
// /v1/bridge/info when a bchain client is wired in.
|
||||
// Native swap CRUD takes precedence when a SwapStore + bchain
|
||||
// client are configured. Falls back to the legacy reverse-proxy /
|
||||
// 503 otherwise. The B-Chain VM (chains/bridgevm) owns
|
||||
// authoritative quote + settlement; native handlers cache locally
|
||||
// and refresh from chain on demand.
|
||||
//
|
||||
// We mount each handler at TWO prefixes:
|
||||
// - /v1/bridge/* — the externally-routed path (ingress, public docs).
|
||||
@@ -156,7 +151,7 @@ func (a *API) Register(app *zip.App) {
|
||||
// requests would fall through to the SPA catch-all
|
||||
// and return HTML instead of JSON.
|
||||
proxied := a.proxied()
|
||||
if a.store != nil && a.quote != nil {
|
||||
if a.store != nil && a.bchain != nil {
|
||||
app.Get("/v1/bridge/quote", a.quoteNative)
|
||||
app.Post("/v1/bridge/swaps", a.swapsCreateNative)
|
||||
app.Get("/v1/bridge/swaps", a.swapsListNative)
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// coingecko_price_feed.go: HTTP PriceFeed backed by CoinGecko's
|
||||
// simple/price endpoint. Drop-in replacement for StaticPriceFeed.
|
||||
//
|
||||
// Behavior:
|
||||
// - Symbol → CoinGecko ID translation via IDMap (e.g. "ETH" → "ethereum").
|
||||
// Symbols not in IDMap return ErrPriceUnknown (so a FallbackFeed can
|
||||
// route them to the static feed — useful for LUX/ZOO which CoinGecko
|
||||
// doesn't list).
|
||||
// - One batched HTTP call per TTL window covers every configured symbol.
|
||||
// Within the window, Price() reads the cache without hitting the
|
||||
// network. Default TTL: 30s, well inside CoinGecko's free-tier limits.
|
||||
// - On HTTP error (network down, 429, 5xx), all calls return the
|
||||
// underlying error. Wrap with FallbackFeed to keep the bridge live
|
||||
// during CoinGecko outages.
|
||||
|
||||
// =============================================================================
|
||||
// CoinGeckoFeed
|
||||
// =============================================================================
|
||||
|
||||
// DefaultCoinGeckoBaseURL is the public simple/price endpoint root.
|
||||
const DefaultCoinGeckoBaseURL = "https://api.coingecko.com/api/v3"
|
||||
|
||||
// DefaultCoinGeckoCacheTTL is the freshness window for cached prices.
|
||||
const DefaultCoinGeckoCacheTTL = 30 * time.Second
|
||||
|
||||
// DefaultCoinGeckoTimeout is the per-request HTTP timeout.
|
||||
const DefaultCoinGeckoTimeout = 5 * time.Second
|
||||
|
||||
// DefaultCoinGeckoIDMap is the symbol → CoinGecko ID map seeded for the
|
||||
// assets the bridge supports today. LUX and ZOO are deliberately absent;
|
||||
// CoinGecko does not list them, so they must come from a fallback feed.
|
||||
var DefaultCoinGeckoIDMap = map[string]string{
|
||||
"ETH": "ethereum",
|
||||
"BTC": "bitcoin",
|
||||
"SOL": "solana",
|
||||
"TON": "the-open-network",
|
||||
"USDC": "usd-coin",
|
||||
"USDT": "tether",
|
||||
"DAI": "dai",
|
||||
"BNB": "binancecoin",
|
||||
}
|
||||
|
||||
// CoinGeckoFeed pulls USD spot prices from CoinGecko's simple/price
|
||||
// endpoint. Concurrency-safe.
|
||||
type CoinGeckoFeed struct {
|
||||
// BaseURL is the API root, default DefaultCoinGeckoBaseURL.
|
||||
// Tests override this with httptest.NewServer.URL.
|
||||
BaseURL string
|
||||
// APIKey is the CoinGecko Pro key (x-cg-pro-api-key header). Empty
|
||||
// for the free tier; rate limits apply.
|
||||
APIKey string
|
||||
// HTTPClient is the transport. Defaults to a client with
|
||||
// DefaultCoinGeckoTimeout. Tests can swap in a custom client.
|
||||
HTTPClient *http.Client
|
||||
// CacheTTL is the freshness window. Zero ⇒ DefaultCoinGeckoCacheTTL.
|
||||
CacheTTL time.Duration
|
||||
// IDMap is the symbol → CoinGecko ID lookup. Zero-value ⇒
|
||||
// DefaultCoinGeckoIDMap. Symbol matching is case-insensitive.
|
||||
IDMap map[string]string
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]float64 // upper-case symbol → USD
|
||||
cachedAt time.Time
|
||||
inFlight chan struct{} // single-flight gate around HTTP fetch
|
||||
}
|
||||
|
||||
// NewCoinGeckoFeed builds a feed with sensible defaults. Pass nil for
|
||||
// idMap to use DefaultCoinGeckoIDMap.
|
||||
func NewCoinGeckoFeed(baseURL, apiKey string, idMap map[string]string) *CoinGeckoFeed {
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultCoinGeckoBaseURL
|
||||
}
|
||||
if idMap == nil {
|
||||
idMap = DefaultCoinGeckoIDMap
|
||||
}
|
||||
upper := make(map[string]string, len(idMap))
|
||||
for k, v := range idMap {
|
||||
upper[strings.ToUpper(k)] = v
|
||||
}
|
||||
return &CoinGeckoFeed{
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
HTTPClient: &http.Client{Timeout: DefaultCoinGeckoTimeout},
|
||||
IDMap: upper,
|
||||
}
|
||||
}
|
||||
|
||||
// Price returns the USD value of one unit of asset.
|
||||
func (f *CoinGeckoFeed) Price(ctx context.Context, asset string) (float64, error) {
|
||||
sym := strings.ToUpper(asset)
|
||||
if _, ok := f.IDMap[sym]; !ok {
|
||||
return 0, fmt.Errorf("%w: %s (not in CoinGecko ID map)", ErrPriceUnknown, asset)
|
||||
}
|
||||
|
||||
if v, ok := f.cachedFresh(sym); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
if err := f.refresh(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if v, ok := f.cachedFresh(sym); ok {
|
||||
return v, nil
|
||||
}
|
||||
return 0, fmt.Errorf("%w: %s (CoinGecko response missing this id)", ErrPriceUnknown, asset)
|
||||
}
|
||||
|
||||
func (f *CoinGeckoFeed) cachedFresh(sym string) (float64, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.cache == nil || time.Since(f.cachedAt) > f.ttl() {
|
||||
return 0, false
|
||||
}
|
||||
v, ok := f.cache[sym]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (f *CoinGeckoFeed) ttl() time.Duration {
|
||||
if f.CacheTTL > 0 {
|
||||
return f.CacheTTL
|
||||
}
|
||||
return DefaultCoinGeckoCacheTTL
|
||||
}
|
||||
|
||||
// refresh fetches every configured symbol's USD price in one HTTP call
|
||||
// and replaces the cache. Single-flight: concurrent refreshes coalesce
|
||||
// onto whichever goroutine got there first.
|
||||
func (f *CoinGeckoFeed) refresh(ctx context.Context) error {
|
||||
f.mu.Lock()
|
||||
if f.inFlight != nil {
|
||||
ch := f.inFlight
|
||||
f.mu.Unlock()
|
||||
select {
|
||||
case <-ch:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
ch := make(chan struct{})
|
||||
f.inFlight = ch
|
||||
f.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
f.mu.Lock()
|
||||
f.inFlight = nil
|
||||
close(ch)
|
||||
f.mu.Unlock()
|
||||
}()
|
||||
|
||||
ids := make([]string, 0, len(f.IDMap))
|
||||
for _, v := range f.IDMap {
|
||||
ids = append(ids, v)
|
||||
}
|
||||
sort.Strings(ids) // deterministic URL for caching layers + test reproducibility
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("ids", strings.Join(ids, ","))
|
||||
q.Set("vs_currencies", "usd")
|
||||
endpoint := strings.TrimRight(f.BaseURL, "/") + "/simple/price?" + q.Encode()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("coingecko: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if f.APIKey != "" {
|
||||
req.Header.Set("x-cg-pro-api-key", f.APIKey)
|
||||
}
|
||||
|
||||
client := f.HTTPClient
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: DefaultCoinGeckoTimeout}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("coingecko: http: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("coingecko: status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
var payload map[string]map[string]float64
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return fmt.Errorf("coingecko: decode: %w", err)
|
||||
}
|
||||
|
||||
next := make(map[string]float64, len(f.IDMap))
|
||||
for sym, id := range f.IDMap {
|
||||
if row, ok := payload[id]; ok {
|
||||
if usd, ok := row["usd"]; ok && usd > 0 {
|
||||
next[sym] = usd
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
f.cache = next
|
||||
f.cachedAt = time.Now()
|
||||
f.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FallbackFeed
|
||||
// =============================================================================
|
||||
|
||||
// FallbackFeed tries Primary first and falls back to Secondary on any
|
||||
// error. Use it to wrap a CoinGeckoFeed (which doesn't know about LUX /
|
||||
// ZOO) over a StaticPriceFeed seeded with the missing assets — and to
|
||||
// keep quoting live during CoinGecko outages.
|
||||
type FallbackFeed struct {
|
||||
Primary PriceFeed
|
||||
Secondary PriceFeed
|
||||
// OnFallback is called when Primary errors and Secondary is consulted.
|
||||
// Optional; used for ops logging. The error from Primary is passed in.
|
||||
OnFallback func(asset string, err error)
|
||||
}
|
||||
|
||||
// Price returns Primary's price, or Secondary's if Primary errored.
|
||||
func (f *FallbackFeed) Price(ctx context.Context, asset string) (float64, error) {
|
||||
if f.Primary != nil {
|
||||
v, err := f.Primary.Price(ctx, asset)
|
||||
if err == nil {
|
||||
return v, nil
|
||||
}
|
||||
if f.OnFallback != nil {
|
||||
f.OnFallback(asset, err)
|
||||
}
|
||||
if f.Secondary == nil {
|
||||
return 0, err
|
||||
}
|
||||
v2, err2 := f.Secondary.Price(ctx, asset)
|
||||
if err2 == nil {
|
||||
return v2, nil
|
||||
}
|
||||
// Both failed. If Primary said ErrPriceUnknown, the Secondary
|
||||
// error is what callers want (it explains why the fallback
|
||||
// also doesn't know). Otherwise surface the Primary error so
|
||||
// network/auth failures aren't masked by "unknown asset".
|
||||
if errors.Is(err, ErrPriceUnknown) {
|
||||
return 0, err2
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
if f.Secondary == nil {
|
||||
return 0, errors.New("fallback_feed: no feeds configured")
|
||||
}
|
||||
return f.Secondary.Price(ctx, asset)
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeCG builds an httptest.Server that answers /simple/price with
|
||||
// the supplied id→usd table and counts requests.
|
||||
func fakeCG(t *testing.T, prices map[string]float64) (*httptest.Server, *int32) {
|
||||
t.Helper()
|
||||
var calls int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
if !strings.HasSuffix(r.URL.Path, "/simple/price") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var b strings.Builder
|
||||
b.WriteString("{")
|
||||
first := true
|
||||
for id, usd := range prices {
|
||||
if !first {
|
||||
b.WriteString(",")
|
||||
}
|
||||
first = false
|
||||
fmt.Fprintf(&b, "%q:{\"usd\":%v}", id, usd)
|
||||
}
|
||||
b.WriteString("}")
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &calls
|
||||
}
|
||||
|
||||
func TestCoinGeckoFeed_ReturnsMappedPrice(t *testing.T) {
|
||||
srv, calls := fakeCG(t, map[string]float64{
|
||||
"ethereum": 4200.0,
|
||||
"bitcoin": 70000.0,
|
||||
})
|
||||
|
||||
f := NewCoinGeckoFeed(srv.URL, "", map[string]string{
|
||||
"ETH": "ethereum",
|
||||
"BTC": "bitcoin",
|
||||
})
|
||||
|
||||
v, err := f.Price(context.Background(), "ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("ETH: %v", err)
|
||||
}
|
||||
if v != 4200.0 {
|
||||
t.Errorf("ETH = %v, want 4200", v)
|
||||
}
|
||||
v, err = f.Price(context.Background(), "btc") // case-insensitive
|
||||
if err != nil {
|
||||
t.Fatalf("BTC: %v", err)
|
||||
}
|
||||
if v != 70000.0 {
|
||||
t.Errorf("BTC = %v, want 70000", v)
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Errorf("expected 1 batched HTTP call, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinGeckoFeed_UnknownSymbol_ReturnsErrPriceUnknown(t *testing.T) {
|
||||
srv, _ := fakeCG(t, map[string]float64{"ethereum": 4200})
|
||||
f := NewCoinGeckoFeed(srv.URL, "", map[string]string{"ETH": "ethereum"})
|
||||
|
||||
_, err := f.Price(context.Background(), "LUX")
|
||||
if !errors.Is(err, ErrPriceUnknown) {
|
||||
t.Fatalf("LUX expected ErrPriceUnknown, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinGeckoFeed_CachesWithinTTL(t *testing.T) {
|
||||
srv, calls := fakeCG(t, map[string]float64{"ethereum": 4200})
|
||||
f := NewCoinGeckoFeed(srv.URL, "", map[string]string{"ETH": "ethereum"})
|
||||
f.CacheTTL = 5 * time.Second
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := f.Price(context.Background(), "ETH"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Errorf("expected single fetch within TTL, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinGeckoFeed_HTTPError_Propagates(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
f := NewCoinGeckoFeed(srv.URL, "", map[string]string{"ETH": "ethereum"})
|
||||
_, err := f.Price(context.Background(), "ETH")
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 429, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "429") {
|
||||
t.Errorf("expected error to mention 429, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinGeckoFeed_SendsAPIKeyHeader(t *testing.T) {
|
||||
var seenKey string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seenKey = r.Header.Get("x-cg-pro-api-key")
|
||||
_, _ = w.Write([]byte(`{"ethereum":{"usd":1}}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
f := NewCoinGeckoFeed(srv.URL, "secret-key", map[string]string{"ETH": "ethereum"})
|
||||
if _, err := f.Price(context.Background(), "ETH"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seenKey != "secret-key" {
|
||||
t.Errorf("api key header = %q, want %q", seenKey, "secret-key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackFeed_PrimarySucceeds(t *testing.T) {
|
||||
p := NewStaticPriceFeed(map[string]float64{"ETH": 4000})
|
||||
s := NewStaticPriceFeed(map[string]float64{"ETH": 100})
|
||||
f := &FallbackFeed{Primary: p, Secondary: s}
|
||||
|
||||
v, err := f.Price(context.Background(), "ETH")
|
||||
if err != nil || v != 4000 {
|
||||
t.Errorf("ETH = %v err=%v, want 4000 nil", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackFeed_FallsBackOnUnknown(t *testing.T) {
|
||||
p := NewStaticPriceFeed(nil) // knows nothing
|
||||
s := NewStaticPriceFeed(map[string]float64{"LUX": 2.5})
|
||||
|
||||
called := false
|
||||
f := &FallbackFeed{
|
||||
Primary: p, Secondary: s,
|
||||
OnFallback: func(asset string, err error) { called = true },
|
||||
}
|
||||
v, err := f.Price(context.Background(), "LUX")
|
||||
if err != nil || v != 2.5 {
|
||||
t.Errorf("LUX = %v err=%v, want 2.5 nil", v, err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("OnFallback hook not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackFeed_BothFail_SurfacesNetworkError(t *testing.T) {
|
||||
// Primary errors with a non-Unknown error (simulating rate-limit /
|
||||
// network failure). Secondary doesn't know the asset either. The
|
||||
// FallbackFeed should surface the network error, not the asset-
|
||||
// unknown one — operators need to see CoinGecko is broken.
|
||||
primary := errFeed{err: errors.New("network down")}
|
||||
secondary := NewStaticPriceFeed(nil)
|
||||
f := &FallbackFeed{Primary: primary, Secondary: secondary}
|
||||
|
||||
_, err := f.Price(context.Background(), "ETH")
|
||||
if err == nil || !strings.Contains(err.Error(), "network down") {
|
||||
t.Errorf("expected network-down surfaced, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errFeed struct{ err error }
|
||||
|
||||
func (e errFeed) Price(_ context.Context, _ string) (float64, error) { return 0, e.err }
|
||||
@@ -95,8 +95,10 @@ func newIntegrationRig(t *testing.T) *integrationRig {
|
||||
|
||||
cfg, _ := LoadConfig("")
|
||||
store := NewInMemoryStore()
|
||||
feed := NewStaticPriceFeed(defaultPrices())
|
||||
engine := &QuoteEngine{Feed: feed}
|
||||
// Integration tests use a canonical in-process B-Chain mock so the
|
||||
// daemon registers /v1/bridge/swaps natively. The signing driver
|
||||
// receives a real (mock-derived) quote snapshot at create time.
|
||||
bclient := newMockBChain(t, defaultPrices())
|
||||
|
||||
mpcClient := &mchain.Client{
|
||||
APIURL: mpc.URL(),
|
||||
@@ -113,7 +115,7 @@ func newIntegrationRig(t *testing.T) *integrationRig {
|
||||
Tokens: tokens.DefaultRegistry(),
|
||||
}
|
||||
|
||||
api := NewAPI(cfg, "", nil, mpcClient, dcClient, store, engine)
|
||||
api := NewAPI(cfg, "", bclient, mpcClient, dcClient, store)
|
||||
app := zip.New(zip.Config{AppName: "lux-bridge-it", DisableStartupMessage: true})
|
||||
app.Use(middleware.Recover(), middleware.RequestID())
|
||||
api.Register(app)
|
||||
|
||||
+41
-66
@@ -1,10 +1,16 @@
|
||||
// Package main is the unified Lux bridge: a single Go binary that embeds
|
||||
// the SPA, serves the bridge API natively for read paths (networks, tokens,
|
||||
// quotes), and proxies MPC-heavy paths (swap orchestration, signer state)
|
||||
// to the legacy Node backend during the migration to a fully native impl.
|
||||
// Package main is the Lux bridge daemon — a thin REST/observer shim
|
||||
// that serves the bridge SPA, embeds the daemon's UX cache, and
|
||||
// observes source chains so the validator quorum running B-Chain +
|
||||
// T-Chain can settle bridge requests permissionlessly.
|
||||
//
|
||||
// The bridge is permissionless and non-custodial. Authoritative state
|
||||
// lives on the B-Chain VM (chains/bridgevm); threshold key shares
|
||||
// live in T-Chain across the validator set. This daemon is a UX
|
||||
// convenience — anyone with a B-Chain RPC URL can build the same
|
||||
// observer/broadcaster loop and complete an in-flight bridge.
|
||||
//
|
||||
// Build: go build -o bridge ./cmd/bridge
|
||||
// Run: bridge --config /etc/bridge/networks.yaml
|
||||
// Run: bridge --config /etc/bridge/networks.yaml --bchain-url …
|
||||
//
|
||||
// Routes:
|
||||
//
|
||||
@@ -12,13 +18,13 @@
|
||||
// /envs.js runtime config window.ENV = {...}
|
||||
// /icon.svg, /logo.svg per-host brand assets (disk override)
|
||||
// /health service health
|
||||
// /v1/bridge/networks supported chains
|
||||
// /v1/bridge/tokens tokens per chain
|
||||
// /v1/bridge/quote price quote (proxied)
|
||||
// /v1/bridge/rate exchange rate (proxied)
|
||||
// /v1/bridge/limits swap limits (proxied)
|
||||
// /v1/bridge/swaps/* swap CRUD (proxied)
|
||||
// /v1/bridge/explorer/* tx lookup (proxied)
|
||||
// /v1/bridge/networks supported chains (local config)
|
||||
// /v1/bridge/tokens tokens per chain (local config)
|
||||
// /v1/bridge/quote thin pass-through to B-Chain bridge_estimateFee
|
||||
// /v1/bridge/swaps/* swap CRUD (local UX cache; B-Chain wins on conflict)
|
||||
// /v1/bridge/info B-Chain signer-set info
|
||||
// /v1/bridge/check-deposit ops diagnostic (source-chain RPC poll)
|
||||
// /v1/bridge/rate, /settings, /explorer/* legacy reverse-proxy (when --backend is set)
|
||||
//
|
||||
// HTTP framework: github.com/hanzoai/zip (Sinatra-style on Fiber v3 /
|
||||
// fasthttp). Logging: github.com/luxfi/log. This is the canonical Hanzo
|
||||
@@ -28,11 +34,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -51,9 +55,9 @@ import (
|
||||
"github.com/luxfi/bridge/internal/depositcheck"
|
||||
"github.com/luxfi/bridge/internal/mchain"
|
||||
"github.com/luxfi/bridge/internal/substrate"
|
||||
"github.com/luxfi/bridge/pkg/tenant"
|
||||
"github.com/luxfi/bridge/internal/tokens"
|
||||
"github.com/luxfi/bridge/internal/txassembler"
|
||||
"github.com/luxfi/bridge/pkg/tenant"
|
||||
luxlog "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
@@ -187,16 +191,8 @@ func main() {
|
||||
"path to a JSON file that persists per-destination-network release wallets (the long-lived MPC addresses that pay out settlements). When set, the bridge mints a release wallet on first need and reuses it across restarts. When empty AND --data-dir is set, defaults to <data-dir>/release-wallets.json. When empty AND --data-dir is empty, runs in-memory — every restart re-mints and any liquidity at the old address is stranded. Required for prod.")
|
||||
profileFlag := flag.String("profile", envOr("BRIDGE_PROFILE", "classical-compat"),
|
||||
"bridge security profile: strict-pq | classical-compat")
|
||||
coingeckoEnabled := flag.Bool("coingecko", envBool("BRIDGE_COINGECKO", false),
|
||||
"enable CoinGecko HTTP price feed (wrapped over the static feed as a fallback for LUX/ZOO and outages). When disabled (default), the static feed is the sole source of prices.")
|
||||
coingeckoURL := flag.String("coingecko-url", envOr("BRIDGE_COINGECKO_URL", DefaultCoinGeckoBaseURL),
|
||||
"CoinGecko API base URL")
|
||||
coingeckoAPIKey := flag.String("coingecko-api-key", envOr("BRIDGE_COINGECKO_API_KEY", ""),
|
||||
"CoinGecko Pro API key (empty for the free tier)")
|
||||
coingeckoTTL := flag.Duration("coingecko-cache-ttl", DefaultCoinGeckoCacheTTL,
|
||||
"how long CoinGecko prices are cached before re-fetching. The fetch batches every configured symbol into one HTTP call.")
|
||||
coingeckoTimeout := flag.Duration("coingecko-timeout", DefaultCoinGeckoTimeout,
|
||||
"per-request HTTP timeout for CoinGecko calls")
|
||||
resyncSwapsOnBoot := flag.Bool("resync-swaps", envBool("BRIDGE_RESYNC_SWAPS", false),
|
||||
"on startup, reconcile the local swap cache against authoritative B-Chain state before serving traffic. Use when the local store diverged from chain (operator restored a stale backup, switched data-dirs, or a node was offline through a chain reorg). Idempotent — runs to completion and exits the reconcile path, then continues normal startup.")
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
@@ -410,54 +406,33 @@ func main() {
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Static feed seeds the assets CoinGecko doesn't list (LUX, ZOO) and
|
||||
// serves as the fallback when CoinGecko is unreachable. Order-of-
|
||||
// magnitude values matching the TS app/server's getTokenPrice — the
|
||||
// CG-backed entries are overridden at runtime, the rest are the
|
||||
// permanent source for LUX/ZOO.
|
||||
staticFeed := 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,
|
||||
})
|
||||
|
||||
var priceFeed PriceFeed = staticFeed
|
||||
feedLabel := "static"
|
||||
if *coingeckoEnabled {
|
||||
cg := NewCoinGeckoFeed(*coingeckoURL, *coingeckoAPIKey, nil)
|
||||
cg.CacheTTL = *coingeckoTTL
|
||||
cg.HTTPClient = &http.Client{Timeout: *coingeckoTimeout}
|
||||
priceFeed = &FallbackFeed{
|
||||
Primary: cg,
|
||||
Secondary: staticFeed,
|
||||
OnFallback: func(asset string, err error) {
|
||||
if errors.Is(err, ErrPriceUnknown) {
|
||||
return // expected for LUX/ZOO — not worth a log line per quote
|
||||
}
|
||||
logger.Warn("coingecko price fallback", "asset", asset, "err", err)
|
||||
},
|
||||
}
|
||||
feedLabel = "coingecko+static"
|
||||
}
|
||||
|
||||
quoteEngine := &QuoteEngine{Feed: priceFeed}
|
||||
// Native swap CRUD. Quotes and authoritative swap state live on
|
||||
// the B-Chain (chains/bridgevm); the local swap store is a UX
|
||||
// cache. No price feed is wired here — pricing is a chain concern.
|
||||
logger.Info("native swap CRUD enabled",
|
||||
"store", storeLabel,
|
||||
"data_dir", *dataDir,
|
||||
"feed", feedLabel,
|
||||
"fee_rate", quoteEngine.FeeRate,
|
||||
"quote_source", "bchain",
|
||||
)
|
||||
|
||||
api := NewAPI(cfg, *backend, bchainClient, mchainClient, depCheckClient, swapStore, quoteEngine)
|
||||
api := NewAPI(cfg, *backend, bchainClient, mchainClient, depCheckClient, swapStore)
|
||||
api.SetProfile(profile)
|
||||
|
||||
// Optional: reconcile the local swap cache against authoritative
|
||||
// B-Chain state before serving traffic. Idempotent — operators can
|
||||
// run this on every restart without consequence.
|
||||
if *resyncSwapsOnBoot {
|
||||
if bchainClient == nil {
|
||||
logger.Error("--resync-swaps requires --bchain-url (B-Chain RPC is the source of truth)")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := ResyncSwapsFromChain(context.Background(), swapStore, bchainClient, logger); err != nil {
|
||||
logger.Error("swap resync failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("swap resync complete")
|
||||
}
|
||||
|
||||
// Per-destination-network release wallets. One MPC wallet per
|
||||
// destination chain, minted lazily on first need and reused across
|
||||
// every swap to that chain. The operator pre-funds each release
|
||||
|
||||
@@ -102,8 +102,8 @@ func testConfigMixed() Config {
|
||||
func newRigForConfig(t *testing.T, cfg Config) *zip.App {
|
||||
t.Helper()
|
||||
store := NewInMemoryStore()
|
||||
engine := &QuoteEngine{Feed: NewStaticPriceFeed(defaultPrices())}
|
||||
api := NewAPI(cfg, "", nil, nil, nil, store, engine)
|
||||
// Networks/tokens handlers don't depend on bchain — pass nil.
|
||||
api := NewAPI(cfg, "", nil, nil, nil, store)
|
||||
app := zip.New(zip.Config{AppName: "lux-bridge-net-test", DisableStartupMessage: true})
|
||||
app.Use(middleware.Recover(), middleware.RequestID())
|
||||
api.Register(app)
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// quote_engine.go: native quote computation for cmd/bridge.
|
||||
//
|
||||
// Ports the legacy `getQuote` logic from app/server/src/domain/quote.ts:
|
||||
//
|
||||
// rawReceive = amount * sourcePrice / destPrice
|
||||
// feeRate = isExitFromLux(src, dst) ? BRIDGE_FEE_RATE : 0
|
||||
// serviceFee = rawReceive * feeRate
|
||||
// netReceive = rawReceive - serviceFee
|
||||
// minReceive = netReceive * 0.975 (2.5% slippage tolerance)
|
||||
//
|
||||
// Prices come from a `PriceFeed` interface so the production wiring
|
||||
// can swap CoinGecko / Pyth / on-chain oracles in without changing
|
||||
// the engine. `StaticPriceFeed` is the dev default — a map[asset]→USD
|
||||
// the operator seeds at startup, suitable for testing + first deploys.
|
||||
|
||||
// =============================================================================
|
||||
// PriceFeed
|
||||
// =============================================================================
|
||||
|
||||
// PriceFeed returns USD-denominated spot prices for bridged assets.
|
||||
// Implementations:
|
||||
// - StaticPriceFeed — map-backed, dev/test
|
||||
// - (future) HTTPPriceFeed — CoinGecko or Pyth pull oracle
|
||||
type PriceFeed interface {
|
||||
// Price returns the USD value of one unit of `asset`. Returns an
|
||||
// error when the asset is unknown.
|
||||
Price(ctx context.Context, asset string) (float64, error)
|
||||
}
|
||||
|
||||
// ErrPriceUnknown is returned by PriceFeed.Price when the asset
|
||||
// isn't priced. Callers translate to HTTP 503 / 4xx as appropriate.
|
||||
var ErrPriceUnknown = errors.New("quote_engine: price unknown for asset")
|
||||
|
||||
// StaticPriceFeed is a map-backed PriceFeed. Concurrency-safe.
|
||||
// Asset symbols are matched case-insensitively (so callers can pass
|
||||
// "ETH", "eth", or "Eth" interchangeably).
|
||||
type StaticPriceFeed struct {
|
||||
mu sync.RWMutex
|
||||
prices map[string]float64
|
||||
}
|
||||
|
||||
// NewStaticPriceFeed builds a feed from an initial price table.
|
||||
// Pass nil for an empty feed; populate via Set later.
|
||||
func NewStaticPriceFeed(prices map[string]float64) *StaticPriceFeed {
|
||||
f := &StaticPriceFeed{prices: make(map[string]float64, len(prices))}
|
||||
for k, v := range prices {
|
||||
f.prices[strings.ToUpper(k)] = v
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// Set updates / inserts a price. Useful for tests + ops endpoints.
|
||||
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(_ context.Context, 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
|
||||
// =============================================================================
|
||||
|
||||
// DefaultBridgeFeeRate is the legacy 1% applied to exits from Lux.
|
||||
// Matches BRIDGE_FEE_RATE in app/server/src/domain/quote.ts.
|
||||
const DefaultBridgeFeeRate = 0.01
|
||||
|
||||
// DefaultSlippage is the implicit 2.5% min-receive tolerance the TS
|
||||
// SDK adapter applies in bridge-rpc.ts::fetchQuoteViaRpc. Preserving
|
||||
// it here keeps the SDK's UI assumptions stable.
|
||||
const DefaultSlippage = 0.025
|
||||
|
||||
// DefaultAvgCompletion is the avg_completion_time string the SDK
|
||||
// renders when the upstream doesn't supply one. Three minutes is the
|
||||
// conservative testnet figure.
|
||||
const DefaultAvgCompletion = "00:03:00"
|
||||
|
||||
// QuoteEngine computes ServerQuote envelopes from price-feed inputs.
|
||||
// Concurrency-safe — the underlying PriceFeed is the only mutable
|
||||
// dependency, and that interface contracts for safety.
|
||||
type QuoteEngine struct {
|
||||
Feed PriceFeed
|
||||
// FeeRate is applied on "exits from Lux" (swaps where the source
|
||||
// network is in luxFamilyNetworks). Zero ⇒ DefaultBridgeFeeRate.
|
||||
FeeRate float64
|
||||
// Slippage is the min-receive ratio. Zero ⇒ DefaultSlippage.
|
||||
Slippage float64
|
||||
// AvgCompletion is the avg_completion_time string returned in the
|
||||
// envelope. Empty ⇒ DefaultAvgCompletion.
|
||||
AvgCompletion string
|
||||
}
|
||||
|
||||
// luxFamilyNetworks is the internal-name set that triggers the bridge
|
||||
// fee. Matches LUX_ZOO_NETWORKS in app/server/src/domain/quote.ts.
|
||||
var luxFamilyNetworks = map[string]bool{
|
||||
"LUX_MAINNET": true,
|
||||
"LUX_TESTNET": true,
|
||||
"LUX_DEVNET": true,
|
||||
"ZOO_MAINNET": true,
|
||||
"ZOO_TESTNET": true,
|
||||
"ZOO_DEVNET": true,
|
||||
}
|
||||
|
||||
func isExitFromLux(source, _ string) bool {
|
||||
// Legacy quote.ts gates on EITHER side being Lux-family; the
|
||||
// "exit" framing in the comment is about treasury fee policy
|
||||
// (revenue is taken when funds leave the Lux ecosystem) but the
|
||||
// actual code applies the fee whenever the source is Lux-family
|
||||
// (i.e. funds are LEAVING Lux). Match that.
|
||||
return luxFamilyNetworks[source]
|
||||
}
|
||||
|
||||
// QuoteInput is the call payload for QuoteEngine.Quote.
|
||||
type QuoteInput struct {
|
||||
Amount float64
|
||||
SourceNetwork string
|
||||
SourceAsset string
|
||||
DestinationNetwork string
|
||||
DestinationAsset string
|
||||
Refuel bool
|
||||
}
|
||||
|
||||
// QuoteResult is the engine's output. Fields map 1:1 to the legacy
|
||||
// ServerQuote shape; serverQuote (in swaps_handler.go) wraps these
|
||||
// into the envelope the SDK consumes.
|
||||
type QuoteResult struct {
|
||||
ReceiveAmount float64
|
||||
MinReceiveAmount float64
|
||||
ServiceFee float64
|
||||
TotalFee float64
|
||||
Slippage float64
|
||||
AvgCompletion string
|
||||
}
|
||||
|
||||
// Quote computes the receive amount + fee for a bridge intent.
|
||||
func (q *QuoteEngine) Quote(ctx context.Context, in QuoteInput) (*QuoteResult, error) {
|
||||
if in.Amount <= 0 {
|
||||
return nil, errors.New("quote_engine: amount must be > 0")
|
||||
}
|
||||
if q.Feed == nil {
|
||||
return nil, errors.New("quote_engine: no PriceFeed configured")
|
||||
}
|
||||
|
||||
srcUSD, err := q.Feed.Price(ctx, in.SourceAsset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("source price: %w", err)
|
||||
}
|
||||
dstUSD, err := q.Feed.Price(ctx, in.DestinationAsset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("destination price: %w", err)
|
||||
}
|
||||
if dstUSD <= 0 {
|
||||
return nil, fmt.Errorf("quote_engine: destination price must be > 0 (got %v)", dstUSD)
|
||||
}
|
||||
|
||||
rawReceive := in.Amount * srcUSD / dstUSD
|
||||
|
||||
feeRate := 0.0
|
||||
if isExitFromLux(in.SourceNetwork, in.DestinationNetwork) {
|
||||
feeRate = q.feeRate()
|
||||
}
|
||||
serviceFee := rawReceive * feeRate
|
||||
netReceive := rawReceive - serviceFee
|
||||
|
||||
slip := q.slippage()
|
||||
avg := q.avgCompletion()
|
||||
return &QuoteResult{
|
||||
ReceiveAmount: netReceive,
|
||||
MinReceiveAmount: netReceive * (1 - slip),
|
||||
ServiceFee: serviceFee,
|
||||
TotalFee: serviceFee,
|
||||
Slippage: slip,
|
||||
AvgCompletion: avg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *QuoteEngine) feeRate() float64 {
|
||||
if q.FeeRate > 0 {
|
||||
return q.FeeRate
|
||||
}
|
||||
return DefaultBridgeFeeRate
|
||||
}
|
||||
|
||||
func (q *QuoteEngine) slippage() float64 {
|
||||
if q.Slippage > 0 {
|
||||
return q.Slippage
|
||||
}
|
||||
return DefaultSlippage
|
||||
}
|
||||
|
||||
func (q *QuoteEngine) avgCompletion() string {
|
||||
if q.AvgCompletion != "" {
|
||||
return q.AvgCompletion
|
||||
}
|
||||
return DefaultAvgCompletion
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/zip"
|
||||
"github.com/luxfi/bridge/internal/bchain"
|
||||
)
|
||||
|
||||
// quote_handler.go: thin REST → JSON-RPC pass-through for /v1/bridge/quote.
|
||||
//
|
||||
// The bridge is permissionless and non-custodial: the authoritative
|
||||
// quote engine lives in chains/bridgevm (the B-Chain VM that the
|
||||
// validator quorum runs collectively). The daemon does NOT replicate
|
||||
// settlement math here — it would be a centralization vector if it did.
|
||||
// This handler is the only daemon-side quote code, and it does exactly
|
||||
// one thing: translate the SDK's REST request into a JSON-RPC call
|
||||
// against the B-Chain, then map the response back to the legacy
|
||||
// ServerQuote envelope so the TS SDK works unchanged.
|
||||
//
|
||||
// When the operator runs without --bchain-url (no B-Chain RPC
|
||||
// reachable), the route is not registered — there is no fallback to a
|
||||
// daemon-local quote engine, because permissionless bridges never let
|
||||
// any single daemon be the source of truth on what a swap is worth.
|
||||
|
||||
// quoteNative answers GET /v1/bridge/quote by calling bridge_estimateFee
|
||||
// on the configured B-Chain RPC. The on-wire ServerQuote envelope is
|
||||
// preserved verbatim so the TS SDK consumes it unchanged.
|
||||
//
|
||||
// Required query params: source_network, source_token,
|
||||
// destination_network, destination_token, amount. Optional: refuel.
|
||||
func (a *API) quoteNative(c *zip.Ctx) error {
|
||||
src := c.Query("source_network")
|
||||
srcTok := c.Query("source_token")
|
||||
dst := c.Query("destination_network")
|
||||
dstTok := c.Query("destination_token")
|
||||
amt := c.Query("amount")
|
||||
refuel := c.Query("refuel") == "1" || strings.EqualFold(c.Query("refuel"), "true")
|
||||
|
||||
if src == "" || srcTok == "" || dst == "" || dstTok == "" || amt == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "missing_params",
|
||||
"detail": "required: source_network, source_token, destination_network, destination_token, amount",
|
||||
})
|
||||
}
|
||||
if _, err := strconv.ParseFloat(amt, 64); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "bad_amount",
|
||||
"detail": "amount must be a positive number",
|
||||
})
|
||||
}
|
||||
|
||||
if a.bchain == nil {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "bchain_unavailable",
|
||||
"detail": "set --bchain-url to enable native quotes (B-Chain is the source of truth)",
|
||||
})
|
||||
}
|
||||
|
||||
est, err := a.bchain.EstimateFee(c.Context(), bchain.EstimateFeeParams{
|
||||
SourceChain: src,
|
||||
DestChain: dst,
|
||||
SourceAsset: srcTok,
|
||||
DestAsset: dstTok,
|
||||
Amount: amt,
|
||||
Refuel: refuel,
|
||||
})
|
||||
if err != nil {
|
||||
return rpcErrToHTTP(c, err, "estimateFee")
|
||||
}
|
||||
return c.JSON(http.StatusOK, envelope{Data: map[string]any{
|
||||
"quote": feeEstimateToServerQuote(est),
|
||||
"refuel": nil,
|
||||
"reward": struct{}{},
|
||||
}})
|
||||
}
|
||||
|
||||
// feeEstimateToServerQuote adapts the b-chain FeeEstimate to the wire
|
||||
// shape the SDK consumes. The chain emits stringified bigint amounts
|
||||
// (the canonical bridge wire encoding); the SDK works with float64. We
|
||||
// parse defensively — when the chain returns an unparseable value we
|
||||
// surface 0, which the SDK already handles as "quote unavailable" via
|
||||
// its existing min_receive_amount guard.
|
||||
func feeEstimateToServerQuote(est *bchain.FeeEstimate) serverQuote {
|
||||
net := parseAmount(est.NetAmount)
|
||||
fee := parseAmount(est.FeeAmount)
|
||||
min := net * (1 - DefaultSlippage)
|
||||
return serverQuote{
|
||||
ReceiveAmount: net,
|
||||
MinReceiveAmount: min,
|
||||
ServiceFee: fee,
|
||||
TotalFee: fee,
|
||||
Slippage: DefaultSlippage,
|
||||
AvgCompletion: DefaultAvgCompletion,
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultSlippage is the min-receive ratio the SDK applies (2.5%).
|
||||
// Surfaces in the envelope so SDK UI shows the right number; the
|
||||
// chain-side quote engine applies the same constant internally.
|
||||
const DefaultSlippage = 0.025
|
||||
|
||||
// DefaultAvgCompletion is the avg_completion_time string the SDK
|
||||
// renders when the chain doesn't supply one.
|
||||
const DefaultAvgCompletion = "00:03:00"
|
||||
|
||||
// parseAmount best-effort-parses a stringified amount. Empty / invalid
|
||||
// → 0. The chain emits these as decimal strings (not hex, not
|
||||
// scientific notation), so a single ParseFloat round-trip is enough.
|
||||
func parseAmount(s string) float64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
luxlog "github.com/luxfi/log"
|
||||
|
||||
"github.com/luxfi/bridge/internal/bchain"
|
||||
)
|
||||
|
||||
// resync_swaps.go: reconcile the local swap cache against authoritative
|
||||
// B-Chain state.
|
||||
//
|
||||
// The local swap store is a UX cache (see swap_store.go); B-Chain is
|
||||
// the source of truth. Operators run --resync-swaps when the local
|
||||
// cache may have diverged: a restored backup, a switched --data-dir,
|
||||
// or an offline window during a chain reorg.
|
||||
//
|
||||
// The reconcile loop walks every swap in the local store, asks B-Chain
|
||||
// for the canonical status, and patches the local copy if it lags.
|
||||
// New swaps that exist on chain but not locally are not enumerated
|
||||
// here (we lack a list endpoint on the chain side until v1.2.7 ships
|
||||
// bridge_listRequests); operators with that gap should clear the
|
||||
// local store entirely and let normal operation backfill from
|
||||
// observation.
|
||||
//
|
||||
// Errors during reconcile are logged but non-fatal — partial progress
|
||||
// is fine, the next tick will retry the laggers. Only an unreachable
|
||||
// B-Chain RPC is treated as fatal (and bubbles up to the caller).
|
||||
func ResyncSwapsFromChain(
|
||||
ctx context.Context,
|
||||
store SwapStore,
|
||||
client *bchain.Client,
|
||||
logger luxlog.Logger,
|
||||
) error {
|
||||
if store == nil {
|
||||
return errors.New("resync_swaps: nil store")
|
||||
}
|
||||
if client == nil {
|
||||
return errors.New("resync_swaps: nil bchain client")
|
||||
}
|
||||
|
||||
// Cheap probe — if the chain isn't reachable we want to fail
|
||||
// fast rather than mid-walk.
|
||||
if _, err := client.Health(ctx); err != nil {
|
||||
return fmt.Errorf("resync_swaps: chain unreachable: %w", err)
|
||||
}
|
||||
|
||||
local, err := store.List(ctx, SwapFilter{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("resync_swaps: local list: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
reconciled int
|
||||
laggards int
|
||||
unknown int
|
||||
failed int
|
||||
)
|
||||
for _, sw := range local {
|
||||
if sw.ID == "" {
|
||||
continue
|
||||
}
|
||||
// Terminal local states don't roll back. If a swap is locally
|
||||
// "completed" / "refunded" / "failed" we leave it — those are
|
||||
// observations of work the daemon has already done.
|
||||
if isTerminalStatus(sw.Status) {
|
||||
continue
|
||||
}
|
||||
req, err := client.GetBridgeStatus(ctx, sw.ID)
|
||||
if err != nil {
|
||||
// Method not found / not implemented / unknown id — log
|
||||
// and continue. Don't fail the whole walk on one swap.
|
||||
var rpcErr *bchain.RPCError
|
||||
if errors.As(err, &rpcErr) && rpcErr.Code == -32601 {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
failed++
|
||||
logger.Warn("resync_swaps: chain GetBridgeStatus failed",
|
||||
"swap", sw.ID,
|
||||
"err", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
chainStatus := mapChainStatusToLocal(req.Status)
|
||||
if chainStatus == "" || chainStatus == sw.Status {
|
||||
continue
|
||||
}
|
||||
laggards++
|
||||
if _, err := store.Patch(ctx, sw.ID, func(s *Swap) {
|
||||
s.Status = chainStatus
|
||||
if req.SourceTxHash != "" && s.SourceTxHash == "" {
|
||||
s.SourceTxHash = req.SourceTxHash
|
||||
}
|
||||
if req.DestTxHash != "" && s.DestTxHash == "" {
|
||||
s.DestTxHash = req.DestTxHash
|
||||
}
|
||||
if req.Signature != "" && s.Signature == "" {
|
||||
s.Signature = req.Signature
|
||||
}
|
||||
s.LastError = "" // chain win, clear any stale daemon-side error
|
||||
}); err != nil {
|
||||
failed++
|
||||
logger.Warn("resync_swaps: local patch failed",
|
||||
"swap", sw.ID,
|
||||
"err", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
reconciled++
|
||||
}
|
||||
|
||||
logger.Info("resync_swaps: pass complete",
|
||||
"local_total", len(local),
|
||||
"reconciled", reconciled,
|
||||
"laggards_detected", laggards,
|
||||
"unknown_to_chain", unknown,
|
||||
"errors", failed,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isTerminalStatus reports whether a local swap state is terminal and
|
||||
// must not be rolled back by a chain reconcile.
|
||||
func isTerminalStatus(s SwapStatus) bool {
|
||||
switch s {
|
||||
case SwapStatusCompleted,
|
||||
SwapStatusRefunded,
|
||||
SwapStatusFailed,
|
||||
SwapStatusFailedInsufficientReleaseGas,
|
||||
SwapStatusCancelled:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mapChainStatusToLocal projects a B-Chain BridgeRequestStatus onto
|
||||
// the daemon's local SwapStatus space. Returns "" when there is no
|
||||
// canonical mapping (in which case the reconcile leaves the local
|
||||
// status alone).
|
||||
func mapChainStatusToLocal(cs bchain.BridgeRequestStatus) SwapStatus {
|
||||
switch cs {
|
||||
case bchain.StatusPending:
|
||||
return SwapStatusUserDepositPending
|
||||
case bchain.StatusDeposited:
|
||||
return SwapStatusBridgeTransferPending
|
||||
case bchain.StatusSigning:
|
||||
return SwapStatusSigning
|
||||
case bchain.StatusSigned, bchain.StatusReleasing:
|
||||
return SwapStatusBroadcasting
|
||||
case bchain.StatusCompleted:
|
||||
return SwapStatusCompleted
|
||||
case bchain.StatusFailed:
|
||||
return SwapStatusFailed
|
||||
case bchain.StatusCancelled:
|
||||
return SwapStatusCancelled
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -20,19 +20,49 @@ import (
|
||||
"github.com/luxfi/bridge/internal/txassembler"
|
||||
)
|
||||
|
||||
// signing_driver.go: background goroutine that drives the MPC signing
|
||||
// ceremony for swaps that have transitioned to bridge_transfer_pending.
|
||||
// signing_driver.go: OBSERVE + BROADCAST shim for the cmd/bridge daemon.
|
||||
//
|
||||
// Pipeline position (relative to the other watchers):
|
||||
// PERMISSIONLESS + NON-CUSTODIAL. The MPC threshold quorum IS the
|
||||
// authority — this driver does NOT orchestrate sign sessions, it
|
||||
// observes deposits and asks the quorum (via mchain) to sign the
|
||||
// destination-chain release tx. Any client that can reach the B-Chain
|
||||
// + T-Chain RPCs can do the same work; this daemon exists only as a
|
||||
// UX convenience.
|
||||
//
|
||||
// user_deposit_pending ── DepositWatcher (Phase 4.5) ──┐
|
||||
// ▼
|
||||
// bridge_transfer_pending ── SigningDriver (this file) ───┐
|
||||
// ▼
|
||||
// bridge_transfer_pending_signing │
|
||||
// (ceremony in progress) ▼
|
||||
// bridge_transfer_pending_broadcasting ─┘
|
||||
// (signature stored; ready for Phase 4.7)
|
||||
// What the driver does:
|
||||
// 1. Observe — list swaps the local store believes are at
|
||||
// bridge_transfer_pending. (Authoritative state lives in
|
||||
// chains/bridgevm — local store is a cache, see swap_store.go.)
|
||||
// 2. Assemble — build the destination-chain unsigned tx via
|
||||
// txassembler (chain-specific: EVM EIP-155, BTC P2WPKH, SOL,
|
||||
// DOT, TON, XRP).
|
||||
// 3. Threshold-sign — submit the sighash to the MPC threshold
|
||||
// quorum (mchain) and wait for the aggregated signature. The
|
||||
// daemon holds NO key material; key shares live in T-Chain
|
||||
// across the validator set.
|
||||
// 4. Broadcast — patch the signed raw tx into the store; the
|
||||
// broadcast driver pushes it to the destination chain on the
|
||||
// next tick.
|
||||
//
|
||||
// What the driver does NOT do:
|
||||
// - Approve or refuse swaps. Any P3Q-verified envelope from the
|
||||
// B-Chain quorum is claimable.
|
||||
// - Custody funds. Operator-funded release wallets are held by the
|
||||
// MPC quorum, not by the daemon.
|
||||
// - Be load-bearing. Killing the daemon does not invalidate any
|
||||
// in-flight swap; the next process restart picks up where it left
|
||||
// off (durable swap store) or any other client running the same
|
||||
// observe-and-broadcast loop can complete the swap.
|
||||
//
|
||||
// Pipeline position:
|
||||
//
|
||||
// user_deposit_pending ── DepositWatcher ──┐
|
||||
// ▼
|
||||
// bridge_transfer_pending ── this driver ────┐
|
||||
// ▼
|
||||
// bridge_transfer_pending_signing │
|
||||
// ▼
|
||||
// bridge_transfer_pending_broadcasting ─┘
|
||||
//
|
||||
// State transitions and idempotency:
|
||||
// - On tick start, lists swaps in SwapStatusBridgeTransferPending.
|
||||
@@ -49,16 +79,6 @@ import (
|
||||
// Signature + MPCSessionID (+ DestRawTx when assembled).
|
||||
// - On failure: patches BACK to bridge_transfer_pending so the next
|
||||
// tick retries (with exponential backoff in future work).
|
||||
//
|
||||
// Trust model + scope:
|
||||
// - The MPC cluster is the threshold-signing authority. This driver
|
||||
// is a thin orchestrator; it does not hold any key material.
|
||||
// - As of 2026-05 the live lux-mpc cluster (mpcd) doesn't expose a
|
||||
// /sign REST endpoint — only /keygen. SignForWallet targets the
|
||||
// expected REST shape; live validation is gated on the cluster
|
||||
// growing that endpoint or the operator pointing --mpc-url at a
|
||||
// proxy that bridges to the dashboard /v1/mpc/wallets/{id}/sessions
|
||||
// path (which IS implemented but requires JWT auth).
|
||||
|
||||
// MPCSigner is the 1-method interface the driver consumes. Pulls the
|
||||
// dependency to an interface for testability (a fake satisfies it
|
||||
|
||||
@@ -9,13 +9,21 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// swap_store.go: native swap persistence for cmd/bridge.
|
||||
// swap_store.go: denormalized UX cache for the cmd/bridge daemon.
|
||||
//
|
||||
// The Go binary owns swap CRUD now — BridgeVM is the LP-333 signer-set
|
||||
// manager, not a swap API. This store is the source of truth for
|
||||
// pending / in-flight / completed bridge requests; `cmd/bridge` is the
|
||||
// "centralized helper" the boss described, with state durably (well,
|
||||
// in-memory for now) tracked here.
|
||||
// AUTHORITATIVE STATE LIVES ON THE B-CHAIN. The bridge is
|
||||
// permissionless and non-custodial — chains/bridgevm's swap store
|
||||
// (built atop the validator quorum's consensus) is the source of truth
|
||||
// for pending / in-flight / completed bridge requests. This local
|
||||
// store is a cache so the daemon's HTTP API can answer reads quickly
|
||||
// without round-tripping to /ext/bc/B/rpc for every request.
|
||||
//
|
||||
// ON CONFLICT, B-CHAIN WINS. Run `bridge --resync-swaps` to rebuild
|
||||
// the local cache from authoritative chain state. The daemon refuses
|
||||
// to mutate swap state in a way that contradicts what B-Chain has
|
||||
// already accepted; pending writes that haven't yet been observed on
|
||||
// chain are best-effort and will be reconciled by --resync-swaps or
|
||||
// by the deposit-watcher's next tick (whichever happens first).
|
||||
//
|
||||
// The `SwapStore` interface is the seam where hanzoai/base or another
|
||||
// durable backend slots in later. `InMemoryStore` is the dev default:
|
||||
|
||||
+47
-115
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/zip"
|
||||
"github.com/luxfi/bridge/internal/bchain"
|
||||
@@ -15,17 +14,21 @@ import (
|
||||
|
||||
// swaps_handler.go wires the native swap CRUD into cmd/bridge.
|
||||
//
|
||||
// Native handlers OWN swap state:
|
||||
// - GET /v1/bridge/quote quoteNative — QuoteEngine
|
||||
// - POST /v1/bridge/swaps swapsCreateNative — SwapStore + mchain
|
||||
// - GET /v1/bridge/swaps swapsListNative — SwapStore
|
||||
// - GET /v1/bridge/swaps/:id swapsGetNative — SwapStore
|
||||
// - POST /v1/bridge/check-deposit checkDepositNative — depositcheck
|
||||
// - GET /v1/bridge/info infoNative — bchain (signer-set info)
|
||||
// Architectural truth: the bridge is permissionless and non-custodial.
|
||||
// Authoritative quote + swap settlement live in chains/bridgevm (the
|
||||
// B-Chain VM the validator quorum runs collectively). The daemon's
|
||||
// swap store is a UX cache — see swap_store.go for the contract.
|
||||
//
|
||||
// BridgeVM (b-chain) does NOT host swap CRUD per LP-333 — those
|
||||
// methods don't exist on the chain. The legacy reverse-proxy still
|
||||
// runs only when no SwapStore/QuoteEngine are configured.
|
||||
// Native handlers:
|
||||
// - GET /v1/bridge/quote quoteNative — bchain pass-through (quote_handler.go)
|
||||
// - POST /v1/bridge/swaps swapsCreateNative — local SwapStore + B-Chain quote snapshot
|
||||
// - GET /v1/bridge/swaps swapsListNative — local SwapStore
|
||||
// - GET /v1/bridge/swaps/:id swapsGetNative — local SwapStore
|
||||
// - POST /v1/bridge/check-deposit checkDepositNative — depositcheck
|
||||
// - GET /v1/bridge/info infoNative — bchain (signer-set info)
|
||||
//
|
||||
// The legacy reverse-proxy still runs only when no SwapStore + bchain
|
||||
// client are configured.
|
||||
|
||||
// Wire contract: the legacy app/server REST shape is preserved so the
|
||||
// existing TS SDK (pkg/bridge/src/app/lib/bridge-api.ts) works against
|
||||
@@ -66,8 +69,8 @@ type serverSwap struct {
|
||||
// before the release-wallet split landed.
|
||||
ReleaseAddress string `json:"release_address,omitempty"`
|
||||
// ReceiveAmount is the destination-asset amount the bridge
|
||||
// committed to delivering at create time (snapshot of the
|
||||
// QuoteEngine output). The signing driver scales the release tx
|
||||
// committed to delivering at create time (snapshot from the
|
||||
// B-Chain quote engine). The signing driver scales the release tx
|
||||
// value to match. Surfaced so the SDK + ops tooling can compare
|
||||
// committed vs delivered amounts after the destination tx lands.
|
||||
ReceiveAmount float64 `json:"receive_amount,omitempty"`
|
||||
@@ -115,74 +118,9 @@ type envelope struct {
|
||||
// =============================================================================
|
||||
// Native handlers (bchain-backed)
|
||||
// =============================================================================
|
||||
|
||||
// quoteNative answers GET /v1/bridge/quote by running the native
|
||||
// QuoteEngine over the configured PriceFeed. The legacy ServerQuote
|
||||
// envelope is preserved so the TS SDK consumes it unchanged.
|
||||
//
|
||||
// Required query params: source_network, source_token,
|
||||
// destination_network, destination_token, amount. Optional: refuel.
|
||||
func (a *API) quoteNative(c *zip.Ctx) error {
|
||||
src := c.Query("source_network")
|
||||
srcTok := c.Query("source_token")
|
||||
dst := c.Query("destination_network")
|
||||
dstTok := c.Query("destination_token")
|
||||
amt := c.Query("amount")
|
||||
refuel := c.Query("refuel") == "1" || strings.EqualFold(c.Query("refuel"), "true")
|
||||
|
||||
if src == "" || srcTok == "" || dst == "" || dstTok == "" || amt == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "missing_params",
|
||||
"detail": "required: source_network, source_token, destination_network, destination_token, amount",
|
||||
})
|
||||
}
|
||||
amountF, err := strconv.ParseFloat(amt, 64)
|
||||
if err != nil || amountF <= 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "bad_amount",
|
||||
"detail": "amount must be a positive number",
|
||||
})
|
||||
}
|
||||
|
||||
res, err := a.quote.Quote(c.Context(), QuoteInput{
|
||||
Amount: amountF,
|
||||
SourceNetwork: src,
|
||||
SourceAsset: srcTok,
|
||||
DestinationNetwork: dst,
|
||||
DestinationAsset: dstTok,
|
||||
Refuel: refuel,
|
||||
})
|
||||
if err != nil {
|
||||
// Unknown price → 503 (transient — feed may rehydrate);
|
||||
// other errors → 400 (validation).
|
||||
if errors.Is(err, ErrPriceUnknown) {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "price_unknown",
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "quote_failed",
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
q := serverQuote{
|
||||
ReceiveAmount: res.ReceiveAmount,
|
||||
MinReceiveAmount: res.MinReceiveAmount,
|
||||
BlockchainFee: 0,
|
||||
ServiceFee: res.ServiceFee,
|
||||
AvgCompletion: res.AvgCompletion,
|
||||
TotalFee: res.TotalFee,
|
||||
TotalFeeInUsd: 0,
|
||||
Slippage: res.Slippage,
|
||||
}
|
||||
return c.JSON(http.StatusOK, envelope{Data: map[string]any{
|
||||
"quote": q,
|
||||
"refuel": nil,
|
||||
"reward": struct{}{},
|
||||
}})
|
||||
}
|
||||
// quoteNative lives in quote_handler.go — thin REST → JSON-RPC
|
||||
// pass-through to the B-Chain VM's authoritative quote engine.
|
||||
|
||||
// swapsCreateNative answers POST /v1/bridge/swaps by:
|
||||
// 1. Validating the request.
|
||||
@@ -221,15 +159,15 @@ func (a *API) swapsCreateNative(c *zip.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Step 0 — snapshot the quote.
|
||||
// Step 0 — snapshot the quote from the B-Chain.
|
||||
//
|
||||
// The QuoteEngine output (ReceiveAmount + MinReceiveAmount +
|
||||
// ServiceFee) is the source of truth for what the destination-side
|
||||
// release tx will actually pay the user. Without this snapshot the
|
||||
// signing driver would fall back to the raw input amount (sw.Amount)
|
||||
// and the destination chain would receive 0.01-units-of-native
|
||||
// regardless of source/destination price difference — i.e. 0.01 LUX
|
||||
// for a 0.01 ETH input, not the ~14 LUX the quote endpoint promised.
|
||||
// The B-Chain RPC's bridge_estimateFee is the source of truth for
|
||||
// what the destination-side release tx will actually pay the user.
|
||||
// Without this snapshot the signing driver would fall back to the
|
||||
// raw input amount (sw.Amount) and the destination chain would
|
||||
// receive 0.01-units-of-native regardless of source/destination
|
||||
// price difference — i.e. 0.01 LUX for a 0.01 ETH input, not the
|
||||
// ~14 LUX the quote endpoint promised.
|
||||
//
|
||||
// We fail at create time rather than at signing time: the user has
|
||||
// already accepted a quote via GET /v1/bridge/quote, so refusing
|
||||
@@ -237,32 +175,25 @@ func (a *API) swapsCreateNative(c *zip.Ctx) error {
|
||||
// during the round-trip — better surfaced now (the SDK can retry)
|
||||
// than silently mis-paid later.
|
||||
//
|
||||
// When a.quote is nil (test rigs that didn't wire one), the snapshot
|
||||
// is skipped and the signing driver falls back to sw.Amount —
|
||||
// preserves the pre-fix path for tests that don't exercise pricing.
|
||||
var quoteRes *QuoteResult
|
||||
if a.quote != nil {
|
||||
qr, err := a.quote.Quote(c.Context(), QuoteInput{
|
||||
Amount: req.Amount,
|
||||
SourceNetwork: req.SourceNetwork,
|
||||
SourceAsset: req.SourceAsset,
|
||||
DestinationNetwork: req.DestinationNetwork,
|
||||
DestinationAsset: req.DestinationAsset,
|
||||
Refuel: req.Refuel,
|
||||
// When a.bchain is nil (test rigs that didn't wire one), the
|
||||
// snapshot is skipped and the signing driver falls back to
|
||||
// sw.Amount — preserves the pre-fix path for tests that don't
|
||||
// exercise pricing.
|
||||
var quoteSnap *bchain.FeeEstimate
|
||||
if a.bchain != nil {
|
||||
amtStr := strconv.FormatFloat(req.Amount, 'f', -1, 64)
|
||||
est, err := a.bchain.EstimateFee(c.Context(), bchain.EstimateFeeParams{
|
||||
SourceChain: req.SourceNetwork,
|
||||
DestChain: req.DestinationNetwork,
|
||||
SourceAsset: req.SourceAsset,
|
||||
DestAsset: req.DestinationAsset,
|
||||
Amount: amtStr,
|
||||
Refuel: req.Refuel,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrPriceUnknown) {
|
||||
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "price_unknown",
|
||||
"detail": err.Error(),
|
||||
})
|
||||
}
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "quote_failed",
|
||||
"detail": err.Error(),
|
||||
})
|
||||
return rpcErrToHTTP(c, err, "estimateFee")
|
||||
}
|
||||
quoteRes = qr
|
||||
quoteSnap = est
|
||||
}
|
||||
|
||||
// Step 1 — optional MPC keygen for the deposit address.
|
||||
@@ -333,10 +264,11 @@ func (a *API) swapsCreateNative(c *zip.Ctx) error {
|
||||
swap.ReleaseWalletID = releaseWallet.Name
|
||||
swap.ReleaseAddress = releaseWallet.Address
|
||||
}
|
||||
if quoteRes != nil {
|
||||
swap.ReceiveAmount = quoteRes.ReceiveAmount
|
||||
swap.MinReceiveAmount = quoteRes.MinReceiveAmount
|
||||
swap.ServiceFee = quoteRes.ServiceFee
|
||||
if quoteSnap != nil {
|
||||
net := parseAmount(quoteSnap.NetAmount)
|
||||
swap.ReceiveAmount = net
|
||||
swap.MinReceiveAmount = net * (1 - DefaultSlippage)
|
||||
swap.ServiceFee = parseAmount(quoteSnap.FeeAmount)
|
||||
}
|
||||
if err := a.store.Create(c.Context(), swap); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Tests for the cmd/bridge HTTP layer.
|
||||
//
|
||||
// After the LP-333 architecture refocus, swap CRUD is native to
|
||||
// cmd/bridge — backed by an in-memory SwapStore + QuoteEngine. The
|
||||
// b-chain client (when set) is queried only for signer-set introspection
|
||||
// via /v1/bridge/info; it does NOT host the quote / submit / status
|
||||
// methods (those don't exist on real BridgeVM).
|
||||
// The bridge is permissionless and non-custodial: B-Chain
|
||||
// (chains/bridgevm) owns authoritative quote + swap state. The daemon
|
||||
// is a thin shim with a local UX cache. Tests therefore wire a
|
||||
// minimal mock B-Chain RPC server that the daemon talks to for quote
|
||||
// + status reconciliation.
|
||||
|
||||
package main
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -32,17 +33,17 @@ import (
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
// testRig bundles an app with its underlying store + price feed so
|
||||
// individual tests can poke state directly when needed.
|
||||
// testRig bundles an app with its underlying store so individual tests
|
||||
// can poke state directly when needed.
|
||||
type testRig struct {
|
||||
app *zip.App
|
||||
store *InMemoryStore
|
||||
feed *StaticPriceFeed
|
||||
api *API
|
||||
}
|
||||
|
||||
// defaultPrices is the seeded price feed for all tests. Round numbers
|
||||
// keep the float-comparison assertions readable.
|
||||
// defaultPrices is the unit-price table the mock B-Chain emits when
|
||||
// answering bridge_estimateFee. Round numbers keep the
|
||||
// float-comparison assertions readable.
|
||||
func defaultPrices() map[string]float64 {
|
||||
return map[string]float64{
|
||||
"ETH": 3500.00,
|
||||
@@ -58,21 +59,96 @@ func defaultPrices() map[string]float64 {
|
||||
}
|
||||
}
|
||||
|
||||
// luxFamily mirrors the chain-side fee policy: 1% service fee when
|
||||
// the source is Lux-family, zero otherwise. Kept local to the test
|
||||
// rig because the daemon no longer encodes settlement math.
|
||||
var luxFamily = map[string]bool{
|
||||
"LUX_MAINNET": true, "LUX_TESTNET": true, "LUX_DEVNET": true,
|
||||
"ZOO_MAINNET": true, "ZOO_TESTNET": true, "ZOO_DEVNET": true,
|
||||
}
|
||||
|
||||
// newRig assembles a fully-wired test app with optional bchain /
|
||||
// mchain / depositcheck clients. Store + quote engine are always
|
||||
// constructed locally; tests assert against rig.store directly when
|
||||
// they need to verify persistence.
|
||||
// mchain / depositcheck clients. When bclient is nil we mount a
|
||||
// canonical in-process bridge_estimateFee mock so quote-dependent
|
||||
// tests can run end-to-end.
|
||||
func newRig(t *testing.T, bclient *bchain.Client, mclient *mchain.Client, dc *depositcheck.Client) *testRig {
|
||||
t.Helper()
|
||||
if bclient == nil {
|
||||
bclient = newMockBChain(t, defaultPrices())
|
||||
}
|
||||
cfg, _ := LoadConfig("")
|
||||
store := NewInMemoryStore()
|
||||
feed := NewStaticPriceFeed(defaultPrices())
|
||||
engine := &QuoteEngine{Feed: feed}
|
||||
api := NewAPI(cfg, "", bclient, mclient, dc, store, engine)
|
||||
api := NewAPI(cfg, "", bclient, mclient, dc, store)
|
||||
app := zip.New(zip.Config{AppName: "lux-bridge-test", DisableStartupMessage: true})
|
||||
app.Use(middleware.Recover(), middleware.RequestID())
|
||||
api.Register(app)
|
||||
return &testRig{app: app, store: store, feed: feed, api: api}
|
||||
return &testRig{app: app, store: store, api: api}
|
||||
}
|
||||
|
||||
// newMockBChain spins up an in-process JSON-RPC server speaking the
|
||||
// subset of the B-Chain bridge_* surface the daemon consumes
|
||||
// (estimateFee, getStatus, health). Prices is the unit-price table the
|
||||
// estimateFee implementation uses to compute net + fee.
|
||||
func newMockBChain(t *testing.T, prices map[string]float64) *bchain.Client {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID any `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "bridge_estimateFee":
|
||||
var p bchain.EstimateFeeParams
|
||||
_ = json.Unmarshal(req.Params, &p)
|
||||
amt, _ := strconv.ParseFloat(p.Amount, 64)
|
||||
src := prices[strings.ToUpper(p.SourceAsset)]
|
||||
dst := prices[strings.ToUpper(p.DestAsset)]
|
||||
if src == 0 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"error": map[string]any{"code": -32000, "message": "price_unknown: " + p.SourceAsset},
|
||||
})
|
||||
return
|
||||
}
|
||||
if dst == 0 {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"error": map[string]any{"code": -32000, "message": "price_unknown: " + p.DestAsset},
|
||||
})
|
||||
return
|
||||
}
|
||||
gross := amt * src / dst
|
||||
feeRate := 0.0
|
||||
if luxFamily[p.SourceChain] {
|
||||
feeRate = 0.01
|
||||
}
|
||||
fee := gross * feeRate
|
||||
net := gross - fee
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": bchain.FeeEstimate{
|
||||
FeeAmount: strconv.FormatFloat(fee, 'f', -1, 64),
|
||||
NetAmount: strconv.FormatFloat(net, 'f', -1, 64),
|
||||
EstimatedTime: 180,
|
||||
},
|
||||
})
|
||||
case "bridge_health":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"result": bchain.Health{Status: "healthy", MPCReady: true},
|
||||
})
|
||||
default:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0", "id": req.ID,
|
||||
"error": map[string]any{"code": -32601, "message": "method not found: " + req.Method},
|
||||
})
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return &bchain.Client{BridgeRPCURL: srv.URL, Timeout: 2 * time.Second}
|
||||
}
|
||||
|
||||
// fireRequest sends one HTTP request through zip's in-process test
|
||||
@@ -276,16 +352,43 @@ func TestQuote_BadAmount400(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuote_UnknownAsset503(t *testing.T) {
|
||||
func TestQuote_UnknownAssetBubblesChainError(t *testing.T) {
|
||||
rig := newRig(t, nil, nil, nil)
|
||||
status, body := fireRequest(t, rig.app, http.MethodGet,
|
||||
"/v1/bridge/quote?source_network=ETHEREUM_SEPOLIA&source_token=UNOBTAINIUM&destination_network=LUX_TESTNET&destination_token=LUX&amount=1",
|
||||
"/v1/bridge/quote?source_network=ETHEREUM_SEPOLIA&source_token=ETH&destination_network=LUX_TESTNET&destination_token=UNOBTAINIUM&amount=1",
|
||||
nil)
|
||||
if status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503. body=%s", status, body)
|
||||
// Chain returns -32000; rpcErrToHTTP maps to 502 (B-Chain is a
|
||||
// remote dependency from the daemon's perspective). Either 502 or
|
||||
// the chain-mapped HTTP status is acceptable so long as the error
|
||||
// envelope identifies the upstream failure.
|
||||
if status == http.StatusOK {
|
||||
t.Fatalf("status=%d expected non-OK. body=%s", status, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "price_unknown") {
|
||||
t.Errorf("expected price_unknown, got %s", body)
|
||||
if !strings.Contains(string(body), "price_unknown") &&
|
||||
!strings.Contains(string(body), "estimateFee_failed") {
|
||||
t.Errorf("expected price_unknown / estimateFee_failed, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuote_NoBChainReturns503(t *testing.T) {
|
||||
// Daemon refuses to quote when --bchain-url unset (no fallback
|
||||
// price feed exists — that would be a centralization vector).
|
||||
cfg, _ := LoadConfig("")
|
||||
store := NewInMemoryStore()
|
||||
api := NewAPI(cfg, "", nil, nil, nil, store)
|
||||
app := zip.New(zip.Config{AppName: "lux-bridge-test", DisableStartupMessage: true})
|
||||
app.Use(middleware.Recover(), middleware.RequestID())
|
||||
api.Register(app)
|
||||
|
||||
status, body := fireRequest(t, app, http.MethodGet,
|
||||
"/v1/bridge/quote?source_network=ETHEREUM_SEPOLIA&source_token=ETH&destination_network=LUX_TESTNET&destination_token=LUX&amount=1",
|
||||
nil)
|
||||
// Without bchain wired, /v1/bridge/quote is unregistered and
|
||||
// falls through to the legacy proxy path, which returns 503 with
|
||||
// backend_unavailable. Either route returns 503; the body
|
||||
// indicates the reason.
|
||||
if status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d, want 503. body=%s", status, body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -922,30 +1025,32 @@ func TestSwapsCreate_SnapshotsReceiveAmount(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestSwapsCreate_PriceUnknown_Returns503 covers the failure path:
|
||||
// when the price feed can't quote (asset not in the feed), create
|
||||
// fails at 503 rather than silently storing a 0 ReceiveAmount that
|
||||
// would later get fed to the signing driver.
|
||||
// when the B-Chain RPC can't quote (asset unknown to the chain's
|
||||
// quote engine), create fails at 502 rather than silently storing a
|
||||
// 0 ReceiveAmount that would later get fed to the signing driver.
|
||||
func TestSwapsCreate_PriceUnknown_Returns503(t *testing.T) {
|
||||
rig := newRig(t, nil, nil, nil)
|
||||
rig.feed.Set("ETH", 0)
|
||||
// Wipe a price by giving the feed a fresh map that omits ETH.
|
||||
rig.api.quote = &QuoteEngine{Feed: NewStaticPriceFeed(map[string]float64{"LUX": 2.5})}
|
||||
// Mock B-Chain with only LUX priced — ETH triggers price_unknown
|
||||
bclient := newMockBChain(t, map[string]float64{"LUX": 2.5})
|
||||
rig := newRig(t, bclient, nil, nil)
|
||||
|
||||
reqBody, _ := json.Marshal(createSwapReq{
|
||||
Amount: 0.01,
|
||||
SourceNetwork: "ETHEREUM_SEPOLIA",
|
||||
SourceAsset: "ETH", // not in feed
|
||||
SourceAsset: "ETH",
|
||||
DestinationNetwork: "LUX_TESTNET",
|
||||
DestinationAsset: "LUX",
|
||||
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
|
||||
UseDepositAddress: false,
|
||||
})
|
||||
status, body := fireRequest(t, rig.app, http.MethodPost, "/v1/bridge/swaps", reqBody)
|
||||
if status != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d, want 503. body=%s", status, body)
|
||||
// The chain returns a JSON-RPC error; rpcErrToHTTP maps that to
|
||||
// 502 (B-Chain is a remote dependency from the daemon's view).
|
||||
if status == http.StatusOK {
|
||||
t.Fatalf("status=%d, want non-OK. body=%s", status, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "price_unknown") {
|
||||
t.Errorf("expected price_unknown error, got %s", body)
|
||||
if !strings.Contains(string(body), "price_unknown") &&
|
||||
!strings.Contains(string(body), "estimateFee_failed") {
|
||||
t.Errorf("expected price_unknown / estimateFee_failed, got %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user