feat(bridge): unified Go binary — embedded SPA + native networks/tokens/limits, proxied swap/quote/rate

Mirrors the luxfi/explorer pattern: cmd/bridge/main.go embeds the SPA
build via go:embed and exposes /v1/bridge/* on a single port. Read paths
(networks, tokens, exchanges, limits) are answered natively from a YAML
config — no Postgres. MPC-heavy paths (quote, rate, swaps, explorer)
reverse-proxy to the legacy Node backend until the native port lands.

Image: cmd/bridge/Dockerfile produces ~25 MB distroless.
Config: cmd/bridge/networks.example.yaml.
Migration target: Hanzo Base (SQLite) + luxfi/mpc direct calls.
This commit is contained in:
2026-05-05 21:24:03 -07:00
parent ede43abedc
commit da713de7c4
10 changed files with 599 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# Single-image Lux Bridge: SPA + Go API in one binary.
#
# Stage 1 (ui-build): produce static/ from the bridge UI (app/bridge).
# Stage 2 (go-build): embed static/ via go:embed and produce the binary.
# Stage 3 (final): distroless runtime, ~25 MB.
#
# Build (from repo root):
# docker build -f cmd/bridge/Dockerfile -t ghcr.io/luxfi/bridge:dev .
# ── 1. UI build ───────────────────────────────────────────────────────────
FROM node:20-alpine AS ui-build
WORKDIR /ui
RUN corepack enable && corepack prepare [email protected] --activate
COPY app/bridge/package.json app/bridge/pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile || pnpm install
COPY app/bridge/ ./
RUN pnpm build && \
if [ -d dist ]; then mv dist out; fi
# ── 2. Go build ───────────────────────────────────────────────────────────
FROM golang:1.26-alpine AS go-build
WORKDIR /src
RUN apk add --no-cache git
COPY go.mod go.sum* ./
RUN go mod download
COPY cmd/ cmd/
# Replace placeholder static/ with the real UI build.
RUN rm -rf cmd/bridge/static && mkdir -p cmd/bridge/static
COPY --from=ui-build /ui/out/ cmd/bridge/static/
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/bridge ./cmd/bridge
# ── 3. Runtime ────────────────────────────────────────────────────────────
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=go-build /out/bridge /usr/local/bin/bridge
COPY cmd/bridge/networks.example.yaml /etc/bridge/networks.yaml
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/bridge"]
CMD ["--config=/etc/bridge/networks.yaml", "--addr=:8080"]
+45
View File
@@ -0,0 +1,45 @@
# lux-bridge
Single-image bridge: SPA + API in one Go binary.
## Why
The legacy stack (Node API + React UI + Postgres + Prisma) is many moving parts. This binary collapses the read-path (networks, tokens, exchanges, limits) into a Go service that needs zero database. Heavy paths (quote, rate, swaps, explorer) reverse-proxy to the existing Node backend until the native port lands; pointing them away is a one-flag change.
## Run
```
go build -o bridge ./cmd/bridge
./bridge --config cmd/bridge/networks.example.yaml --backend http://localhost:5000
```
Routes:
| Path | Source |
| --- | --- |
| `/` | embedded SPA (or `--static <dir>` to override) |
| `/envs.js` | runtime config (`window.ENV`) |
| `/health` | service health |
| `/v1/bridge/networks` | YAML config |
| `/v1/bridge/tokens` | YAML config (filterable by `?network=`) |
| `/v1/bridge/exchanges` | YAML config |
| `/v1/bridge/limits` | YAML config |
| `/v1/bridge/quote` | proxied → Node backend |
| `/v1/bridge/rate` | proxied |
| `/v1/bridge/swaps` | proxied |
| `/v1/bridge/explorer/*` | proxied |
| `/v1/bridge/settings` | proxied |
Without `--backend`, proxied routes return `503 backend_unavailable`.
## Build the image
```
docker build -f cmd/bridge/Dockerfile -t ghcr.io/luxfi/bridge:dev .
```
The Dockerfile builds the UI from `app/bridge/` and embeds it into the Go binary. Final image is distroless (~25 MB).
## Migration plan
The Node backend's `swaps`, `quote`, `rate`, `mpc-bridge`, `teleport-processor`, and `mpc-service` modules — plus the Prisma schema (`Network`, `Currency`, `Swap`, `Transaction`, `Quote`, `RpcNode`) — are next to port. Target storage is Hanzo Base (SQLite-embedded). MPC orchestration calls `github.com/luxfi/mpc` directly.
+102
View File
@@ -0,0 +1,102 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httputil"
"net/url"
"strings"
)
// API serves the bridge HTTP surface. Read paths (networks, tokens,
// exchanges, limits) are answered natively from Config — no Postgres
// hit. MPC-heavy paths (quote, rate, swaps, explorer) reverse-proxy to
// the legacy Node backend at backendURL until the native port lands.
type API struct {
cfg Config
backend string
proxy *httputil.ReverseProxy
}
func NewAPI(cfg Config, backendURL string) *API {
a := &API{cfg: cfg, backend: backendURL}
if backendURL != "" {
u, err := url.Parse(backendURL)
if err == nil {
a.proxy = httputil.NewSingleHostReverseProxy(u)
a.proxy.Director = stripPathPrefix(u, "/v1/bridge")
}
}
return a
}
// Register mounts handlers on the given mux. The /v1/bridge prefix matches
// what the SPA fetches and what hanzo/ingress routes externally.
func (a *API) Register(mux *http.ServeMux) {
mux.HandleFunc("/v1/bridge/networks", a.networks)
mux.HandleFunc("/v1/bridge/tokens", a.tokens)
mux.HandleFunc("/v1/bridge/exchanges", a.exchanges)
mux.HandleFunc("/v1/bridge/limits", a.limits)
// Proxied (require Node backend). When backend is unset these 503
// to make the missing dependency obvious instead of silently 404ing.
mux.HandleFunc("/v1/bridge/quote", a.proxied)
mux.HandleFunc("/v1/bridge/rate", a.proxied)
mux.HandleFunc("/v1/bridge/settings", a.proxied)
mux.HandleFunc("/v1/bridge/swaps", a.proxied)
mux.HandleFunc("/v1/bridge/swaps/", a.proxied)
mux.HandleFunc("/v1/bridge/explorer/", a.proxied)
}
func (a *API) networks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, a.cfg.Networks)
}
func (a *API) tokens(w http.ResponseWriter, r *http.Request) {
if net := r.URL.Query().Get("network"); net != "" {
var out []Token
for _, t := range a.cfg.Tokens {
if t.Network == net {
out = append(out, t)
}
}
writeJSON(w, out)
return
}
writeJSON(w, a.cfg.Tokens)
}
func (a *API) exchanges(w http.ResponseWriter, r *http.Request) {
writeJSON(w, a.cfg.Exchanges)
}
func (a *API) limits(w http.ResponseWriter, r *http.Request) {
writeJSON(w, a.cfg.Limits)
}
func (a *API) proxied(w http.ResponseWriter, r *http.Request) {
if a.proxy == nil {
http.Error(w, `{"error":"backend_unavailable","detail":"set BRIDGE_BACKEND_URL to enable swap/quote/explorer routes"}`, http.StatusServiceUnavailable)
return
}
a.proxy.ServeHTTP(w, r)
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// stripPathPrefix returns a Director that rewrites /v1/bridge/<x> to /<x>
// so requests reach the Node backend's existing route paths.
func stripPathPrefix(target *url.URL, prefix string) func(*http.Request) {
return func(r *http.Request) {
r.URL.Scheme = target.Scheme
r.URL.Host = target.Host
r.Host = target.Host
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
if r.URL.Path == "" {
r.URL.Path = "/"
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Config is the bridge runtime configuration. The shape mirrors the
// data the Node backend serves from the `Network`/`Currency` Prisma
// tables — keeping it in YAML lets us source-control supported chains
// + tokens and read them natively without spinning up Postgres.
type Config struct {
Brand Brand `yaml:"brand"`
Networks []Network `yaml:"networks"`
Tokens []Token `yaml:"tokens"`
Limits Limits `yaml:"limits"`
Exchanges []Exchange `yaml:"exchanges"`
}
// Brand controls the SPA's runtime config (window.ENV) and per-host assets.
type Brand struct {
Name string `yaml:"name"`
Title string `yaml:"title"`
LogoURL string `yaml:"logoUrl"`
IconURL string `yaml:"iconUrl"`
BrandHost string `yaml:"brandHost"`
}
// Network is a supported chain. internalName is the slug used by the SPA;
// chainID is the EVM chain ID (or substrate paraID); explorerTpl is the
// {tx} / {addr} URL template the SPA renders.
type Network struct {
InternalName string `yaml:"internalName"`
DisplayName string `yaml:"displayName"`
NativeCurrency string `yaml:"nativeCurrency"`
IsTestnet bool `yaml:"isTestnet"`
IsFeatured bool `yaml:"isFeatured"`
Logo string `yaml:"logo"`
ChainID string `yaml:"chainId"`
Type string `yaml:"type"` // evm | substrate | bitcoin
AvgCompletion string `yaml:"avgCompletion"`
TxExplorerTpl string `yaml:"txExplorerTpl"`
AddrExplorerTpl string `yaml:"addrExplorerTpl"`
}
// Token is an asset bridged on one or more networks. The `decimals` and
// `contract` fields are per-network; expand these once we add multi-chain
// token mapping (matches the Node backend's `Currency` model).
type Token struct {
Asset string `yaml:"asset"`
Name string `yaml:"name"`
Logo string `yaml:"logo"`
Decimals int `yaml:"decimals"`
Contract string `yaml:"contract"`
Network string `yaml:"network"`
}
// Limits are per-token min/max swap caps. Real impl reads from KMS/admin,
// but for the SPA's read paths a static config is fine.
type Limits struct {
MinUSD float64 `yaml:"minUSD"`
MaxUSD float64 `yaml:"maxUSD"`
Per map[string]Caps `yaml:"per"`
}
type Caps struct {
MinUSD float64 `yaml:"minUSD"`
MaxUSD float64 `yaml:"maxUSD"`
}
// Exchange is a CEX integration listing.
type Exchange struct {
InternalName string `yaml:"internalName"`
DisplayName string `yaml:"displayName"`
Logo string `yaml:"logo"`
URL string `yaml:"url"`
}
func LoadConfig(path string) (Config, error) {
if path == "" {
return defaultConfig(), nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return defaultConfig(), nil
}
return Config{}, fmt.Errorf("read %s: %w", path, err)
}
var c Config
if err := yaml.Unmarshal(data, &c); err != nil {
return Config{}, fmt.Errorf("parse %s: %w", path, err)
}
return c, nil
}
func defaultConfig() Config {
return Config{
Brand: Brand{
Name: "Lux Bridge",
Title: "Lux Bridge",
LogoURL: "/logo.svg",
IconURL: "/icon.svg",
},
}
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"bytes"
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// staticFS is the embedded SPA. The Dockerfile populates static/ from a
// luxfi/bridge UI build before the Go build runs; for local dev a
// placeholder index.html is committed so go:embed always succeeds.
//
//go:embed all:static
var staticFS embed.FS
// Frontend serves the embedded SPA, runtime config (/envs.js), and per-host
// brand assets (/icon.svg, /logo.svg). Brand assets read from disk on every
// request so a deploy can swap them without rebuilding the binary.
type Frontend struct {
cfg Config
root fs.FS
index []byte
overlay string // optional disk dir for SPA + brand override
}
func NewFrontend(cfg Config, overlay string) (*Frontend, error) {
root, err := fs.Sub(staticFS, "static")
if err != nil {
return nil, fmt.Errorf("sub-fs: %w", err)
}
idx, err := fs.ReadFile(root, "index.html")
if err != nil {
return nil, fmt.Errorf("read index.html: %w", err)
}
return &Frontend{cfg: cfg, root: root, index: idx, overlay: overlay}, nil
}
func (f *Frontend) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/envs.js":
f.serveEnvs(w, r)
return
case "/icon.svg":
f.serveBrandAsset(w, r, "icon.svg", f.cfg.Brand.IconURL)
return
case "/logo.svg":
f.serveBrandAsset(w, r, "logo.svg", f.cfg.Brand.LogoURL)
return
}
// Try disk overlay first so devs can hot-swap the SPA without rebuild.
if f.overlay != "" {
p := filepath.Join(f.overlay, strings.TrimPrefix(r.URL.Path, "/"))
if data, err := os.ReadFile(p); err == nil {
http.ServeContent(w, r, p, fileModTime(p), bytes.NewReader(data))
return
}
}
// Embedded asset?
cleaned := strings.TrimPrefix(r.URL.Path, "/")
if cleaned != "" {
if data, err := fs.ReadFile(f.root, cleaned); err == nil {
http.ServeContent(w, r, cleaned, time.Time{}, bytes.NewReader(data))
return
}
}
// SPA fallback — let React Router handle the route.
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(f.index)
}
func (f *Frontend) serveEnvs(w http.ResponseWriter, r *http.Request) {
envs := map[string]any{
"brand": f.cfg.Brand,
"apiBase": "/v1/bridge",
}
body, _ := json.Marshal(envs)
w.Header().Set("Content-Type", "application/javascript")
fmt.Fprintf(w, "window.ENV = %s;", body)
}
func (f *Frontend) serveBrandAsset(w http.ResponseWriter, r *http.Request, name, fallback string) {
if f.overlay != "" {
if data, err := os.ReadFile(filepath.Join(f.overlay, name)); err == nil {
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = w.Write(data)
return
}
}
if fallback != "" {
http.Redirect(w, r, fallback, http.StatusFound)
return
}
http.NotFound(w, r)
}
func fileModTime(path string) time.Time {
if path == "" {
return time.Time{}
}
fi, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return fi.ModTime()
}
+100
View File
@@ -0,0 +1,100 @@
// 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.
//
// Build: go build -o bridge ./cmd/bridge
// Run: bridge --config /etc/bridge/networks.yaml
//
// Routes:
//
// / SPA (embedded; SPA-routing fallback)
// /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/settings config (proxied)
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
var version = "dev"
func main() {
cfgPath := flag.String("config", envOr("BRIDGE_CONFIG", "/etc/bridge/networks.yaml"), "path to networks.yaml")
addr := flag.String("addr", envOr("BRIDGE_ADDR", ":8080"), "listen address")
backend := flag.String("backend", envOr("BRIDGE_BACKEND_URL", ""), "legacy Node backend URL for proxied routes (empty disables proxy)")
staticDir := flag.String("static", envOr("BRIDGE_STATIC_DIR", ""), "override embedded SPA from disk")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *showVersion {
fmt.Println(version)
return
}
cfg, err := LoadConfig(*cfgPath)
if err != nil {
log.Fatalf("load config: %v", err)
}
frontend, err := NewFrontend(cfg, *staticDir)
if err != nil {
log.Fatalf("frontend: %v", err)
}
api := NewAPI(cfg, *backend)
mux := http.NewServeMux()
api.Register(mux)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok","version":%q,"backend_proxy":%t}`, version, *backend != "")
})
mux.Handle("/", frontend)
srv := &http.Server{
Addr: *addr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("lux-bridge %s listening on %s (backend=%q networks=%d)", version, *addr, *backend, len(cfg.Networks))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
log.Println("shutdown complete")
}
func envOr(k, fallback string) string {
if v := os.Getenv(k); v != "" {
return v
}
return fallback
}
+67
View File
@@ -0,0 +1,67 @@
# Example bridge config. Mount at /etc/bridge/networks.yaml.
# Adding networks here is enough — no DB migration, no code change.
# Schema: github.com/luxfi/bridge/cmd/bridge/config.go::Config
brand:
name: Lux Bridge
title: Lux Bridge
logoUrl: /logo.svg
iconUrl: /icon.svg
brandHost: bridge.lux.network
networks:
- internalName: lux-mainnet
displayName: Lux
nativeCurrency: LUX
chainId: "96369"
type: evm
isFeatured: true
avgCompletion: 1m
txExplorerTpl: https://explore.lux.network/tx/{tx}
addrExplorerTpl: https://explore.lux.network/address/{addr}
- internalName: ethereum-mainnet
displayName: Ethereum
nativeCurrency: ETH
chainId: "1"
type: evm
avgCompletion: 5m
txExplorerTpl: https://etherscan.io/tx/{tx}
addrExplorerTpl: https://etherscan.io/address/{addr}
- internalName: bsc-mainnet
displayName: BNB Chain
nativeCurrency: BNB
chainId: "56"
type: evm
avgCompletion: 2m
txExplorerTpl: https://bscscan.com/tx/{tx}
addrExplorerTpl: https://bscscan.com/address/{addr}
tokens:
- asset: LUX
name: Lux
decimals: 18
network: lux-mainnet
- asset: USDT
name: Tether USD
decimals: 6
contract: "0xdAC17F958D2ee523a2206206994597C13D831ec7"
network: ethereum-mainnet
- asset: USDC
name: USD Coin
decimals: 6
contract: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
network: ethereum-mainnet
limits:
minUSD: 10
maxUSD: 100000
per:
USDT:
minUSD: 10
maxUSD: 100000
USDC:
minUSD: 10
maxUSD: 100000
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Lux Bridge</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="root">Lux Bridge — placeholder. The Dockerfile populates static/ from a luxfi/bridge UI build before the Go build runs.</div>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
module github.com/luxfi/bridge
go 1.26.1
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=