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:
2026-05-28 09:06:29 -07:00
committed by GitHub
parent 8ab291d2e8
commit cd2b8adee5
13 changed files with 590 additions and 927 deletions
+10 -15
View File
@@ -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)
-270
View File
@@ -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)
}
-179
View File
@@ -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 }
+5 -3
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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)
-215
View File
@@ -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
}
+125
View File
@@ -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
}
+165
View File
@@ -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 ""
}
+41 -21
View File
@@ -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
+14 -6
View File
@@ -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
View File
@@ -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{
+140 -35
View File
@@ -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)
}
}