feat: added mpc sign option

This commit is contained in:
venuswhispers
2026-05-27 14:49:55 +02:00
parent 9a551a554a
commit b1241c9fda
16 changed files with 3468 additions and 73 deletions
+5
View File
@@ -25,7 +25,12 @@ WORKDIR /src
RUN apk add --no-cache git
COPY go.mod go.sum* ./
RUN go mod download
# The binary spans cmd/bridge, the internal/ subpackages it imports, and
# the root package (profile.go). Keep this list narrow — copying the whole
# repo would bust the layer cache on every UI edit.
COPY cmd/ cmd/
COPY internal/ internal/
COPY profile.go ./
# 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/
+60 -29
View File
@@ -46,6 +46,7 @@ import (
"github.com/luxfi/bridge/internal/broadcast"
"github.com/luxfi/bridge/internal/depositcheck"
"github.com/luxfi/bridge/internal/mchain"
"github.com/luxfi/bridge/internal/tokens"
"github.com/luxfi/bridge/internal/txassembler"
luxlog "github.com/luxfi/log"
)
@@ -120,6 +121,8 @@ func main() {
broadcastTimeout := flag.Duration("broadcast-timeout", broadcast.DefaultTimeout,
"per-request timeout for destination-chain RPC broadcasts (eth_sendRawTransaction etc.).")
staticDir := flag.String("static", envOr("BRIDGE_STATIC_DIR", ""), "override embedded SPA from disk")
dataDir := flag.String("data-dir", envOr("BRIDGE_DATA_DIR", ""),
"persistent data directory for the swap store (zapdb). When empty, swaps are stored in-process and lost on restart — only use the in-memory mode for tests + first deploys. In prod, mount a PersistentVolume and point this at it (e.g. /var/lib/lux-bridge).")
profileFlag := flag.String("profile", envOr("BRIDGE_PROFILE", "classical-compat"),
"bridge security profile: strict-pq | classical-compat")
showVersion := flag.Bool("version", false, "print version and exit")
@@ -225,6 +228,17 @@ func main() {
logger.Info("source RPC overrides applied", "networks", keys)
}
// Token registry — drives ERC-20 deposit detection (eth_call
// balanceOf) AND destination-side tx assembly (transfer() calldata
// + per-asset decimals). DefaultRegistry seeds the common bridged
// assets (USDC/USDT/DAI on Ethereum + Sepolia, USDC on Base/Polygon,
// natives everywhere); operators add custom tokens via a future
// config-file flag if needed.
tokenRegistry := tokens.DefaultRegistry()
logger.Info("token registry loaded",
"size", tokenRegistry.Size(),
)
// Deposit-check client (ops diagnostic + deposit watcher backing).
// Always-on by default; /v1/bridge/check-deposit is the user-facing
// endpoint. Uses the package's RPC_URLs table for upstream URLs;
@@ -235,18 +249,48 @@ func main() {
if len(overrides) > 0 {
depCheckClient.RPCURLOverrides = overrides
}
depCheckClient.Tokens = tokenRegistry
logger.Info("deposit check endpoint enabled",
"path", "/v1/bridge/check-deposit",
"timeout", *depCheckTimeout,
)
}
// Native swap CRUD: in-memory store + static price feed.
// Native swap CRUD: durable swap store + static price feed.
// Replaces the legacy app/server Express+Prisma backend for the
// /v1/bridge/{quote,swaps,swaps/:id,swaps} routes. Per LP-333 +
// LP-134 the b-chain BridgeVM manages the MPC signer set, not the
// swap API, so swap state owns its own persistence here.
swapStore := NewInMemoryStore()
//
// Store selection:
// --data-dir empty → InMemoryStore (lossy on restart; dev only)
// --data-dir set → ZapStore on zapdb (Lux-flavored Badger v4)
var (
swapStore SwapStore
zapHandle *ZapStore // for clean shutdown
storeLabel string
)
if *dataDir == "" {
swapStore = NewInMemoryStore()
storeLabel = "in-memory"
logger.Warn("swap store is in-memory — every restart drops in-flight swap state. Set --data-dir for durability.")
} else {
zs, err := NewZapStore(*dataDir)
if err != nil {
logger.Error("open swap store", "err", err, "path", *dataDir)
os.Exit(1)
}
swapStore = zs
zapHandle = zs
storeLabel = "zapdb"
}
defer func() {
if zapHandle != nil {
if err := zapHandle.Close(); err != nil {
logger.Error("swap store close", "err", err)
}
}
}()
// Seed the static price feed with the values used in dev / first
// deploys. Real production wiring should swap in a CoinGecko / Pyth
// PriceFeed implementation. These match the order-of-magnitude
@@ -265,7 +309,8 @@ func main() {
})
quoteEngine := &QuoteEngine{Feed: priceFeed}
logger.Info("native swap CRUD enabled",
"store", "in-memory",
"store", storeLabel,
"data_dir", *dataDir,
"feed", "static",
"fee_rate", quoteEngine.FeeRate,
)
@@ -294,33 +339,18 @@ func main() {
// Tx assembler: produces wire-correct EVM EIP-155 sighashes for
// the signing driver to feed to the MPC cluster, then finalizes
// the signed raw tx for the broadcaster to push. Configured with
// per-network defaults below — chain IDs come from
// app/server/src/domain/contracts/teleport.ts and
// deposit-addresses.json. Production should add a config file
// (e.g. JSON) for per-network bridge-contract addresses + gas
// price overrides; this default table is suitable for first
// testnet swaps.
asmProvider := &txassembler.StaticProvider{
GasPrice: map[string]*big.Int{
// Conservative defaults — let the operator override with
// a future --asm-gas-price-overrides flag if needed.
"ETHEREUM_SEPOLIA": big.NewInt(20_000_000_000), // 20 gwei
"ETHEREUM_MAINNET": big.NewInt(30_000_000_000), // 30 gwei
"BASE_SEPOLIA": big.NewInt(1_000_000_000), // 1 gwei (Base is cheap)
"BASE_MAINNET": big.NewInt(1_000_000_000),
"LUX_TESTNET": big.NewInt(25_000_000_000),
"LUX_MAINNET": big.NewInt(25_000_000_000),
"ZOO_TESTNET": big.NewInt(25_000_000_000),
"ZOO_MAINNET": big.NewInt(25_000_000_000),
"BSC_TESTNET": big.NewInt(10_000_000_000),
"BSC_MAINNET": big.NewInt(5_000_000_000),
"POLYGON_MAINNET": big.NewInt(50_000_000_000),
"HOLESKY_TESTNET": big.NewInt(20_000_000_000),
},
}
// the signed raw tx for the broadcaster to push.
//
// Provider: live JSON-RPC against the destination chain for nonce
// + gas price. Falls back to txassembler.defaultEndpoints when no
// override is configured. Operators can shadow specific networks
// via --source-rpc-overrides (same flag the deposit watcher uses).
//
// Chain IDs sourced from app/server/src/domain/contracts/teleport.ts
// and the LP-134 chain registry.
asmProvider := txassembler.NewRPCProvider(overrides, 8*time.Second)
asm := txassembler.New(asmProvider)
// Chain IDs sourced from teleport.ts + LP-134 chain registry.
asm.Tokens = tokenRegistry
asm.SetNetwork("ETHEREUM_SEPOLIA", txassembler.PerNetwork{ChainID: big.NewInt(11155111), NativeDecimals: 18})
asm.SetNetwork("ETHEREUM_MAINNET", txassembler.PerNetwork{ChainID: big.NewInt(1), NativeDecimals: 18})
asm.SetNetwork("BASE_SEPOLIA", txassembler.PerNetwork{ChainID: big.NewInt(84532), NativeDecimals: 18})
@@ -335,6 +365,7 @@ func main() {
asm.SetNetwork("HOLESKY_TESTNET", txassembler.PerNetwork{ChainID: big.NewInt(17000), NativeDecimals: 18})
logger.Info("tx assembler configured",
"networks", len(asm.Networks),
"provider", "rpc",
"mode", "pure_transfer",
)
+399
View File
@@ -0,0 +1,399 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
zapdb "github.com/luxfi/zapdb"
)
// zap_store.go: durable SwapStore implementation backed by luxfi/zapdb
// (the Lux-ecosystem fork of Badger v4). Replaces InMemoryStore for
// production deployments — a pod restart no longer drops in-flight
// swap state.
//
// Layout:
//
// swap:<id> → JSON-encoded Swap
// idx:status:<s>:<id> → empty value (per-status secondary index)
// idx:src:<n>:<id> → empty value (per-source-network index)
//
// The secondary indexes let List() avoid a full scan when filtering by
// Status / SourceNetwork — the watcher and signing drivers list by
// status on every tick, so a full scan would be O(all-swaps) per
// driver per tick.
//
// All mutating operations run inside db.Update — zapdb gives
// snapshot-isolated serializable transactions, so concurrent Patch
// callers are safe.
// ZapStore is the durable SwapStore. Open with NewZapStore(dir); close
// with Close(). One DB handle per process — zapdb takes a directory
// lock and refuses to open the same dir twice.
type ZapStore struct {
db *zapdb.DB
// idMaker + now are overridable in tests. We keep them on the
// store rather than as package globals so tests can run in
// parallel without leaking state.
mu sync.Mutex
idMaker func() string
now func() time.Time
}
// NewZapStore opens (or creates) a zapdb database at dir.
//
// Production callers should pass a path on a PersistentVolume; dev/
// test callers can use t.TempDir().
//
// The returned store is safe for concurrent use. Caller must Close()
// before process exit so zapdb can flush its memtable.
func NewZapStore(dir string) (*ZapStore, error) {
if dir == "" {
return nil, errors.New("zap_store: dir is required")
}
opt := zapdb.DefaultOptions(dir).
WithLogger(nil). // we don't want zapdb spam in our log stream
WithSyncWrites(true)
db, err := zapdb.Open(opt)
if err != nil {
return nil, fmt.Errorf("zap_store: open %s: %w", dir, err)
}
return &ZapStore{
db: db,
idMaker: randSwapID,
now: time.Now,
}, nil
}
// Close flushes and releases the database. Safe to call multiple
// times — zapdb itself is idempotent on Close.
func (s *ZapStore) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
// =============================================================================
// SwapStore interface
// =============================================================================
// Key prefixes. All keys live under a single byte-prefix so iterators
// can scope by prefix without touching unrelated keyspace.
const (
keyPrefixSwap = "swap:"
keyPrefixIdxStatus = "idx:status:"
keyPrefixIdxSrc = "idx:src:"
)
func swapKey(id string) []byte { return []byte(keyPrefixSwap + id) }
func idxStatusKey(status SwapStatus, id string) []byte {
return []byte(keyPrefixIdxStatus + string(status) + ":" + id)
}
func idxSrcKey(net, id string) []byte { return []byte(keyPrefixIdxSrc + net + ":" + id) }
// Create persists a new swap. Sets ID + timestamps if missing. Returns
// an error if the id already exists.
//
// Wrapped in withConflictRetry — under heavy concurrent write load
// zapdb's MVCC can return ErrConflict, which from the caller's
// perspective is transient.
func (s *ZapStore) Create(ctx context.Context, swap *Swap) error {
if swap == nil {
return errors.New("zap_store: nil swap")
}
if err := ctx.Err(); err != nil {
return err
}
// Assign id and timestamps before opening the txn so we don't
// hold the write lock longer than needed.
s.mu.Lock()
if swap.ID == "" {
swap.ID = s.idMaker()
}
now := s.now()
if swap.CreatedAt.IsZero() {
swap.CreatedAt = now
}
swap.UpdatedAt = now
if swap.Status == "" {
swap.Status = SwapStatusUserDepositPending
}
s.mu.Unlock()
return s.withConflictRetry(ctx, func() error {
return s.db.Update(func(txn *zapdb.Txn) error {
key := swapKey(swap.ID)
if _, err := txn.Get(key); err == nil {
return fmt.Errorf("zap_store: duplicate id %s", swap.ID)
} else if !errors.Is(err, zapdb.ErrKeyNotFound) {
return err
}
val, err := json.Marshal(swap)
if err != nil {
return fmt.Errorf("zap_store: marshal: %w", err)
}
if err := txn.Set(key, val); err != nil {
return err
}
if err := txn.Set(idxStatusKey(swap.Status, swap.ID), nil); err != nil {
return err
}
if swap.SourceNetwork != "" {
if err := txn.Set(idxSrcKey(swap.SourceNetwork, swap.ID), nil); err != nil {
return err
}
}
return nil
})
})
}
// Get returns a copy of the swap by id; ErrSwapNotFound if absent.
func (s *ZapStore) Get(ctx context.Context, id string) (*Swap, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var out Swap
err := s.db.View(func(txn *zapdb.Txn) error {
item, err := txn.Get(swapKey(id))
if err != nil {
if errors.Is(err, zapdb.ErrKeyNotFound) {
return ErrSwapNotFound
}
return err
}
return item.Value(func(val []byte) error {
return json.Unmarshal(val, &out)
})
})
if err != nil {
return nil, err
}
return &out, nil
}
// Patch atomically mutates a swap. fn is applied inside the txn; the
// returned snapshot is the persisted state.
//
// Implementation note: Patch needs to refresh secondary indexes when
// the patched swap changes Status or SourceNetwork — otherwise List()
// would return stale results. We compare the pre- and post-image and
// fix up the indexes inline.
func (s *ZapStore) Patch(ctx context.Context, id string, fn func(*Swap)) (*Swap, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
var out Swap
err := s.withConflictRetry(ctx, func() error {
return s.db.Update(func(txn *zapdb.Txn) error {
key := swapKey(id)
item, err := txn.Get(key)
if err != nil {
if errors.Is(err, zapdb.ErrKeyNotFound) {
return ErrSwapNotFound
}
return err
}
var current Swap
if err := item.Value(func(val []byte) error {
return json.Unmarshal(val, &current)
}); err != nil {
return fmt.Errorf("zap_store: unmarshal current: %w", err)
}
oldStatus := current.Status
oldSrc := current.SourceNetwork
fn(&current)
current.UpdatedAt = s.nowSafe()
val, err := json.Marshal(&current)
if err != nil {
return fmt.Errorf("zap_store: marshal: %w", err)
}
if err := txn.Set(key, val); err != nil {
return err
}
// Refresh secondary indexes when keys change. Tombstone the
// old entry first, then write the new one.
if oldStatus != current.Status {
if err := txn.Delete(idxStatusKey(oldStatus, id)); err != nil {
return err
}
if err := txn.Set(idxStatusKey(current.Status, id), nil); err != nil {
return err
}
}
if oldSrc != current.SourceNetwork {
if oldSrc != "" {
if err := txn.Delete(idxSrcKey(oldSrc, id)); err != nil {
return err
}
}
if current.SourceNetwork != "" {
if err := txn.Set(idxSrcKey(current.SourceNetwork, id), nil); err != nil {
return err
}
}
}
out = current
return nil
})
})
if err != nil {
return nil, err
}
return &out, nil
}
// List returns swaps matching filter, newest-first.
//
// Filter strategy: when Status (or SourceNetwork) is set, scan the
// matching secondary index — O(matches) — and fetch the swap blobs
// inline. With no filter, scan all swap blobs — O(all). Limit applies
// AFTER the newest-first sort because the iteration order is by key
// ascending, not by CreatedAt.
func (s *ZapStore) List(ctx context.Context, filter SwapFilter) ([]*Swap, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
out := make([]*Swap, 0)
err := s.db.View(func(txn *zapdb.Txn) error {
// Pick the most-selective scan prefix. Status is by far the
// hottest filter (every driver lists by status on each tick).
var idIter func(yield func(id string) error) error
switch {
case filter.Status != "":
idIter = s.iterIndex(txn, keyPrefixIdxStatus+string(filter.Status)+":")
case filter.SourceNetwork != "":
idIter = s.iterIndex(txn, keyPrefixIdxSrc+filter.SourceNetwork+":")
default:
idIter = s.iterIndex(txn, keyPrefixSwap)
}
return idIter(func(id string) error {
item, err := txn.Get(swapKey(id))
if err != nil {
if errors.Is(err, zapdb.ErrKeyNotFound) {
// Index points to a swap that no longer exists.
// Skip silently; a future compaction can clean up.
return nil
}
return err
}
var sw Swap
if err := item.Value(func(val []byte) error {
return json.Unmarshal(val, &sw)
}); err != nil {
return fmt.Errorf("zap_store: unmarshal %s: %w", id, err)
}
// Apply the secondary filter (when scanning the primary
// table, neither index narrowed the set).
if filter.Status != "" && sw.Status != filter.Status {
return nil
}
if filter.SourceNetwork != "" && sw.SourceNetwork != filter.SourceNetwork {
return nil
}
out = append(out, &sw)
return nil
})
})
if err != nil {
return nil, err
}
sortSwapsNewestFirst(out)
if filter.Limit > 0 && len(out) > filter.Limit {
out = out[:filter.Limit]
}
return out, nil
}
// iterIndex returns a closure that walks every key under the given
// prefix and yields the id (the suffix after the last ":"). The yield
// callback may halt iteration by returning a non-nil error.
//
// For the swap: prefix the id is the suffix after "swap:". For the
// idx:status:<s>: and idx:src:<n>: prefixes the id is the suffix after
// the final ":".
func (s *ZapStore) iterIndex(txn *zapdb.Txn, prefix string) func(yield func(id string) error) error {
return func(yield func(id string) error) error {
opt := zapdb.DefaultIteratorOptions
opt.PrefetchValues = false
it := txn.NewIterator(opt)
defer it.Close()
p := []byte(prefix)
for it.Seek(p); it.ValidForPrefix(p); it.Next() {
key := it.Item().Key()
id := extractID(key, prefix)
if id == "" {
continue
}
if err := yield(id); err != nil {
return err
}
}
return nil
}
}
// extractID strips the prefix and returns everything after the final
// ":" — or, if there's no further ":", the suffix as a whole. This
// handles both shapes:
//
// "swap:abc" → "abc"
// "idx:status:done:abc" → "abc" (prefix "idx:status:done:")
func extractID(key []byte, prefix string) string {
suffix := string(key[len(prefix):])
for i := len(suffix) - 1; i >= 0; i-- {
if suffix[i] == ':' {
return suffix[i+1:]
}
}
return suffix
}
func (s *ZapStore) nowSafe() time.Time {
s.mu.Lock()
defer s.mu.Unlock()
return s.now()
}
// withConflictRetry runs op repeatedly until it either succeeds, returns
// a non-conflict error, or the context expires. zapdb's MVCC returns
// ErrConflict when two transactions read/write the same key around the
// same time — that's transient by definition, so the right call is to
// retry rather than propagate.
//
// Backoff is a tight short loop (the in-process MVCC oracle resolves
// fast); 1ms base growing to 32ms keeps the worst-case latency bounded
// while preventing CPU spin under high contention.
func (s *ZapStore) withConflictRetry(ctx context.Context, op func() error) error {
const maxAttempts = 32
delay := time.Millisecond
for attempt := 0; attempt < maxAttempts; attempt++ {
err := op()
if err == nil {
return nil
}
if !errors.Is(err, zapdb.ErrConflict) {
return err
}
// Cap the backoff so we don't sleep ridiculously long after
// many conflicts.
if delay < 32*time.Millisecond {
delay *= 2
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
}
return fmt.Errorf("zap_store: gave up after %d ErrConflict retries", maxAttempts)
}
+462
View File
@@ -0,0 +1,462 @@
package main
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
)
// zap_store_test.go: durability + correctness tests for ZapStore.
//
// The most important test is TestZapStore_SurvivesRestart — the whole
// point of moving off InMemoryStore is to survive a pod restart, so
// we explicitly close + reopen the DB at a tempdir and verify the
// swap state round-trips.
// helper: open a fresh ZapStore at t.TempDir(); registers t.Cleanup.
func newTestZapStore(t *testing.T) *ZapStore {
t.Helper()
dir := t.TempDir()
s, err := NewZapStore(dir)
if err != nil {
t.Fatalf("NewZapStore: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func newTestSwap(id string) *Swap {
return &Swap{
ID: id,
Amount: 1.5,
SourceNetwork: "ETHEREUM_SEPOLIA",
SourceAsset: "ETH",
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
UseDepositAddress: true,
}
}
func TestZapStore_CreateAndGet(t *testing.T) {
s := newTestZapStore(t)
ctx := context.Background()
sw := newTestSwap("swap_abc")
if err := s.Create(ctx, sw); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := s.Get(ctx, "swap_abc")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.ID != "swap_abc" || got.Amount != 1.5 ||
got.SourceNetwork != "ETHEREUM_SEPOLIA" ||
got.DestinationAddress != "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473" {
t.Errorf("Get returned wrong swap: %+v", got)
}
// Status defaults to user_deposit_pending.
if got.Status != SwapStatusUserDepositPending {
t.Errorf("default status: got %q want %q", got.Status, SwapStatusUserDepositPending)
}
if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() {
t.Errorf("timestamps not set: created=%v updated=%v", got.CreatedAt, got.UpdatedAt)
}
}
func TestZapStore_CreateAutoAssignsID(t *testing.T) {
s := newTestZapStore(t)
sw := &Swap{Amount: 0.1, SourceNetwork: "X", SourceAsset: "X"}
if err := s.Create(context.Background(), sw); err != nil {
t.Fatalf("Create: %v", err)
}
if sw.ID == "" {
t.Fatal("Create did not assign an id")
}
}
func TestZapStore_CreateDuplicateID(t *testing.T) {
s := newTestZapStore(t)
ctx := context.Background()
if err := s.Create(ctx, newTestSwap("dup_1")); err != nil {
t.Fatalf("first Create: %v", err)
}
err := s.Create(ctx, newTestSwap("dup_1"))
if err == nil {
t.Fatal("expected duplicate-id error, got nil")
}
}
func TestZapStore_GetMissing(t *testing.T) {
s := newTestZapStore(t)
_, err := s.Get(context.Background(), "nope")
if !errors.Is(err, ErrSwapNotFound) {
t.Errorf("expected ErrSwapNotFound, got %v", err)
}
}
func TestZapStore_Patch(t *testing.T) {
s := newTestZapStore(t)
ctx := context.Background()
sw := newTestSwap("swap_p1")
if err := s.Create(ctx, sw); err != nil {
t.Fatalf("Create: %v", err)
}
before, _ := s.Get(ctx, "swap_p1")
// Sleep a hair so UpdatedAt advances past CreatedAt (test wall
// clock is monotonic + 1ms granular, so this is reliable).
time.Sleep(2 * time.Millisecond)
patched, err := s.Patch(ctx, "swap_p1", func(p *Swap) {
p.Status = SwapStatusBridgeTransferPending
p.SourceTxHash = "0xdeadbeef"
p.DepositedAmount = 1.5
})
if err != nil {
t.Fatalf("Patch: %v", err)
}
if patched.Status != SwapStatusBridgeTransferPending {
t.Errorf("patch did not apply: status %q", patched.Status)
}
if patched.SourceTxHash != "0xdeadbeef" {
t.Errorf("patch did not apply: source_tx_hash %q", patched.SourceTxHash)
}
if !patched.UpdatedAt.After(before.UpdatedAt) {
t.Errorf("UpdatedAt did not advance: before %v after %v", before.UpdatedAt, patched.UpdatedAt)
}
// Round-trip via Get to make sure the patch persisted.
got, _ := s.Get(ctx, "swap_p1")
if got.Status != SwapStatusBridgeTransferPending || got.SourceTxHash != "0xdeadbeef" {
t.Errorf("patch did not persist: %+v", got)
}
}
func TestZapStore_PatchMissing(t *testing.T) {
s := newTestZapStore(t)
_, err := s.Patch(context.Background(), "ghost", func(*Swap) {})
if !errors.Is(err, ErrSwapNotFound) {
t.Errorf("expected ErrSwapNotFound, got %v", err)
}
}
func TestZapStore_ListByStatus(t *testing.T) {
s := newTestZapStore(t)
ctx := context.Background()
// Seed: 3 pending, 2 signing, 1 completed.
want := map[SwapStatus]int{
SwapStatusUserDepositPending: 3,
SwapStatusSigning: 2,
SwapStatusCompleted: 1,
}
id := 0
for status, n := range want {
for i := 0; i < n; i++ {
id++
sw := newTestSwap(swapTestID(id))
sw.Status = status
if err := s.Create(ctx, sw); err != nil {
t.Fatalf("seed Create: %v", err)
}
}
}
for status, n := range want {
got, err := s.List(ctx, SwapFilter{Status: status})
if err != nil {
t.Fatalf("List(%s): %v", status, err)
}
if len(got) != n {
t.Errorf("List(%s): got %d want %d", status, len(got), n)
}
for _, sw := range got {
if sw.Status != status {
t.Errorf("List(%s) returned wrong status %s", status, sw.Status)
}
}
}
}
func TestZapStore_ListBySourceNetwork(t *testing.T) {
s := newTestZapStore(t)
ctx := context.Background()
mk := func(id, net string) {
sw := newTestSwap(id)
sw.SourceNetwork = net
if err := s.Create(ctx, sw); err != nil {
t.Fatalf("Create %s: %v", id, err)
}
}
mk("a1", "ETHEREUM_SEPOLIA")
mk("a2", "ETHEREUM_SEPOLIA")
mk("b1", "BASE_SEPOLIA")
mk("c1", "LUX_TESTNET")
got, err := s.List(ctx, SwapFilter{SourceNetwork: "ETHEREUM_SEPOLIA"})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d ETH swaps, want 2", len(got))
}
}
func TestZapStore_ListAll(t *testing.T) {
s := newTestZapStore(t)
ctx := context.Background()
for i := 0; i < 5; i++ {
if err := s.Create(ctx, newTestSwap(swapTestID(i))); err != nil {
t.Fatalf("Create: %v", err)
}
}
got, err := s.List(ctx, SwapFilter{})
if err != nil {
t.Fatalf("List: %v", err)
}
if len(got) != 5 {
t.Errorf("List(all): got %d want 5", len(got))
}
}
func TestZapStore_ListNewestFirst(t *testing.T) {
s := newTestZapStore(t)
ctx := context.Background()
for i := 0; i < 4; i++ {
if err := s.Create(ctx, newTestSwap(swapTestID(i))); err != nil {
t.Fatalf("Create: %v", err)
}
// Force monotonic timestamp separation. Without this two
// inserts could share a CreatedAt and the sort would tie.
time.Sleep(2 * time.Millisecond)
}
got, _ := s.List(ctx, SwapFilter{})
if len(got) != 4 {
t.Fatalf("got %d swaps", len(got))
}
for i := 1; i < len(got); i++ {
if got[i].CreatedAt.After(got[i-1].CreatedAt) {
t.Errorf("not newest-first: got[%d]=%v before got[%d]=%v",
i-1, got[i-1].CreatedAt, i, got[i].CreatedAt)
}
}
}
func TestZapStore_ListLimit(t *testing.T) {
s := newTestZapStore(t)
ctx := context.Background()
for i := 0; i < 10; i++ {
if err := s.Create(ctx, newTestSwap(swapTestID(i))); err != nil {
t.Fatalf("Create: %v", err)
}
}
got, _ := s.List(ctx, SwapFilter{Limit: 3})
if len(got) != 3 {
t.Errorf("got %d swaps, want 3", len(got))
}
}
// TestZapStore_SurvivesRestart is the load-bearing one: the entire
// reason for this store. Create swaps, close the DB, reopen at the
// same path, and verify everything is still there.
func TestZapStore_SurvivesRestart(t *testing.T) {
dir := t.TempDir()
// Phase 1: open, write a few swaps, close.
{
s, err := NewZapStore(dir)
if err != nil {
t.Fatalf("open #1: %v", err)
}
for _, sw := range []*Swap{
newTestSwap("r_1"),
newTestSwap("r_2"),
newTestSwap("r_3"),
} {
if err := s.Create(context.Background(), sw); err != nil {
t.Fatalf("Create %s: %v", sw.ID, err)
}
}
// Patch r_2 forward so we cover indexed/state-transition data
// surviving the restart.
if _, err := s.Patch(context.Background(), "r_2", func(p *Swap) {
p.Status = SwapStatusCompleted
p.DestTxHash = "0xabc"
}); err != nil {
t.Fatalf("Patch r_2: %v", err)
}
if err := s.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
// Phase 2: reopen, verify state.
s2, err := NewZapStore(dir)
if err != nil {
t.Fatalf("reopen: %v", err)
}
t.Cleanup(func() { _ = s2.Close() })
for _, id := range []string{"r_1", "r_2", "r_3"} {
sw, err := s2.Get(context.Background(), id)
if err != nil {
t.Errorf("Get %s after restart: %v", id, err)
continue
}
if sw.ID != id {
t.Errorf("Get %s: returned id %s", id, sw.ID)
}
}
// r_2 should still be Completed with its dest tx.
r2, _ := s2.Get(context.Background(), "r_2")
if r2.Status != SwapStatusCompleted || r2.DestTxHash != "0xabc" {
t.Errorf("r_2 patch did not survive restart: %+v", r2)
}
// List-by-status should reflect the post-patch indexes (r_2 was
// moved out of pending into completed).
pending, _ := s2.List(context.Background(), SwapFilter{Status: SwapStatusUserDepositPending})
if len(pending) != 2 {
t.Errorf("pending after restart: got %d want 2", len(pending))
}
completed, _ := s2.List(context.Background(), SwapFilter{Status: SwapStatusCompleted})
if len(completed) != 1 || completed[0].ID != "r_2" {
t.Errorf("completed after restart: %+v", completed)
}
}
// TestZapStore_PatchUpdatesIndexes ensures that when Patch changes
// Status, a subsequent List by *old* status no longer returns the
// swap, and a List by *new* status does.
func TestZapStore_PatchUpdatesIndexes(t *testing.T) {
s := newTestZapStore(t)
ctx := context.Background()
if err := s.Create(ctx, newTestSwap("p_idx")); err != nil {
t.Fatalf("Create: %v", err)
}
pending, _ := s.List(ctx, SwapFilter{Status: SwapStatusUserDepositPending})
if len(pending) != 1 {
t.Fatalf("pre-patch pending: got %d", len(pending))
}
if _, err := s.Patch(ctx, "p_idx", func(p *Swap) {
p.Status = SwapStatusCompleted
}); err != nil {
t.Fatalf("Patch: %v", err)
}
pending, _ = s.List(ctx, SwapFilter{Status: SwapStatusUserDepositPending})
if len(pending) != 0 {
t.Errorf("post-patch pending: got %d want 0 (index not refreshed)", len(pending))
}
completed, _ := s.List(ctx, SwapFilter{Status: SwapStatusCompleted})
if len(completed) != 1 || completed[0].ID != "p_idx" {
t.Errorf("post-patch completed: %+v", completed)
}
}
// TestZapStore_ConcurrentPatch — many goroutines patch the same swap
// in parallel. zapdb's MVCC must serialize them; the final state
// should reflect every patch (no lost updates) and there should be
// no deadlocks.
func TestZapStore_ConcurrentPatch(t *testing.T) {
s := newTestZapStore(t)
ctx := context.Background()
sw := newTestSwap("conc")
sw.DepositedAmount = 0
if err := s.Create(ctx, sw); err != nil {
t.Fatalf("Create: %v", err)
}
const N = 50
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
for retries := 0; retries < 10; retries++ {
_, err := s.Patch(ctx, "conc", func(p *Swap) {
p.DepositedAmount += 1
})
if err == nil {
return
}
// Conflict is allowed; back off and retry.
time.Sleep(time.Millisecond)
}
t.Errorf("Patch never succeeded after retries")
}()
}
wg.Wait()
final, _ := s.Get(ctx, "conc")
if final.DepositedAmount != float64(N) {
t.Errorf("lost updates: final DepositedAmount=%v want %d", final.DepositedAmount, N)
}
}
func TestZapStore_OpenLockingTwiceFails(t *testing.T) {
dir := t.TempDir()
s, err := NewZapStore(dir)
if err != nil {
t.Fatalf("first open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
// Opening the same dir again should fail — zapdb takes a dir lock.
if s2, err := NewZapStore(dir); err == nil {
_ = s2.Close()
t.Error("expected error opening dir twice, got nil")
}
}
func TestZapStore_EmptyDirRejected(t *testing.T) {
if _, err := NewZapStore(""); err == nil {
t.Error("expected error for empty dir, got nil")
}
}
func TestZapStore_NilSwapRejected(t *testing.T) {
s := newTestZapStore(t)
if err := s.Create(context.Background(), nil); err == nil {
t.Error("expected error for nil swap, got nil")
}
}
func TestZapStore_ContextCanceledRejected(t *testing.T) {
s := newTestZapStore(t)
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := s.Create(ctx, newTestSwap("x")); err == nil {
t.Error("expected ctx error, got nil")
}
}
func swapTestID(n int) string {
// short readable id: "s00000001"
id := "s"
x := n
if x == 0 {
return id + "0"
}
digits := []byte{}
for x > 0 {
digits = append([]byte{byte('0' + x%10)}, digits...)
x /= 10
}
return id + string(digits)
}
// =============================================================================
// Implementation sanity
// =============================================================================
// Compile-time check: ZapStore must satisfy SwapStore.
var _ SwapStore = (*ZapStore)(nil)
// silence unused-import lint when filepath ends up unused
var _ = filepath.Join
+495
View File
@@ -0,0 +1,495 @@
# cmd/bridge — Operator Runbook
Operating the unified Lux Bridge binary (`cmd/bridge`). One image,
one port, embedded SPA + native `/v1/bridge/*` API + background drivers
(deposit watcher, MPC signing, broadcast).
This binary replaces the legacy `bridge-server` (Express + Prisma) and
`bridge-ui` (separate Vite SPA) deployment split.
---
## 1. Overview
| Aspect | Value |
|-----------------|----------------------------------------------------|
| Binary | `cmd/bridge` |
| Image | `ghcr.io/luxfi/bridge:<tag>` |
| Listen port | `:8080` (HTTP) |
| Health endpoint | `GET /health` (200 JSON) |
| Config file | `/etc/bridge/networks.yaml` |
| Data directory | `/var/lib/lux-bridge` (zapdb / Lux Badger v4) |
| Logger | `luxfi/log` (JSON, one line per event) |
| HTTP framework | `hanzoai/zip` on Fiber v3 / fasthttp |
| Swap store | `luxfi/zapdb` (embedded KV, exclusive dir lock) |
Routes:
```
/ embedded SPA + SPA-routing fallback
/envs.js runtime config (window.ENV)
/icon.svg, /logo.svg per-host brand assets (disk override possible)
/health service health + driver stats
/v1/bridge/networks supported chains (from networks.yaml)
/v1/bridge/tokens tokens per chain
/v1/bridge/quote price quote
/v1/bridge/limits per-token min/max swap caps
/v1/bridge/swaps create / list swaps
/v1/bridge/swaps/:id fetch / update swap
/v1/bridge/check-deposit ops diagnostic (poll source-chain RPC)
/metrics Prometheus exposition
```
Background drivers (all on by default, all toggleable):
| Driver | Reads from | Writes to | Default cadence |
|--------------------|-------------------------|----------------------------|-----------------|
| DepositWatcher | source-chain RPC | `swap.State` advancement | 15s |
| SigningDriver | MPC daemon | `swap.DestRawTx` | 5s |
| BroadcastDriver | dest-chain RPC | `swap.State = completed` | 5s |
---
## 2. Build & ship
### Local build (smoke test)
```bash
go build -o /tmp/lux-bridge ./cmd/bridge
/tmp/lux-bridge --config cmd/bridge/networks.example.yaml --addr :8080
curl -sS localhost:8080/health | jq .
```
### Docker image
The canonical Dockerfile is `cmd/bridge/Dockerfile`. Three stages:
1. **ui-build** — `node:20-alpine`, `pnpm install` + `pnpm -C app/bridge build`
2. **go-build** — `golang:1.26.3-alpine`, compiles `cmd/bridge` with embedded SPA
3. **runtime** — `gcr.io/distroless/static-debian12:nonroot`, ~25 MB
Build from the repo root (build context must include the pnpm workspace):
```bash
docker build \
-f cmd/bridge/Dockerfile \
--build-arg VERSION=$(git rev-parse --short HEAD) \
-t ghcr.io/luxfi/bridge:$(git rev-parse --short HEAD) \
-t ghcr.io/luxfi/bridge:latest \
.
```
Push:
```bash
docker push ghcr.io/luxfi/bridge:$(git rev-parse --short HEAD)
docker push ghcr.io/luxfi/bridge:latest
```
---
## 3. Deploy to k8s
Manifest: `k8s/bridge-deployment.yaml`. Apply after the MPC StatefulSet
from `k8s/mpc-deployment.yaml` is healthy (the bridge talks to
`mpc-api-svc.lux-bridge.svc:8081`).
```bash
kubectl apply -f k8s/bridge-deployment.yaml
kubectl -n lux-bridge rollout status deployment/lux-bridge
```
The manifest creates: `ConfigMap/lux-bridge-config`, `Deployment/lux-bridge`,
`Service/lux-bridge`, `Ingress/lux-bridge`, `HorizontalPodAutoscaler/lux-bridge`,
`PodDisruptionBudget/lux-bridge`.
### Required pre-existing resources
| Resource | Created by |
|--------------------------------|---------------------------------------|
| `Namespace/lux-bridge` | `k8s/mpc-deployment.yaml` |
| `Secret/bridge-secrets` | KMS sync (see mpc-deployment header) |
| `Service/mpc-api-svc` | `k8s/mpc-deployment.yaml` |
| TLS cluster issuer | cert-manager install |
### Rollout
```bash
# Pin a specific tag (do not run on :latest in prod)
kubectl -n lux-bridge set image deployment/lux-bridge \
bridge=ghcr.io/luxfi/bridge:<git-sha>
# Watch the rollout
kubectl -n lux-bridge rollout status deployment/lux-bridge --watch
```
### Rollback
```bash
kubectl -n lux-bridge rollout undo deployment/lux-bridge
kubectl -n lux-bridge rollout history deployment/lux-bridge
```
The deployment uses `RollingUpdate` with `maxUnavailable: 0` — a bad image
will not take down traffic; old pods stay up until new pods become Ready.
---
## 4. Configuration
All flags accept either a CLI argument **or** the matching env var.
Env vars are the standard way to configure inside the container.
### Required-ish
| Env / Flag | Default | Notes |
|--------------------------------|----------------------------------|-------|
| `BRIDGE_CONFIG` / `--config` | `/etc/bridge/networks.yaml` | Loaded from ConfigMap |
| `BRIDGE_ADDR` / `--addr` | `:8080` | |
| `BRIDGE_DATA_DIR` / `--data-dir` | `""` | Empty = in-memory swap store (lossy on restart). Set to a writable path on a PV for durability — e.g. `/var/lib/lux-bridge`. |
| `BRIDGE_PROFILE` / `--profile` | `classical-compat` | `strict-pq` for internal Lux↔Lux only |
### Backend wiring
| Env / Flag | Default | Behavior when empty |
|---------------------------------------|---------|---------------------|
| `BRIDGE_BACKEND_URL` / `--backend` | `""` | Reverse proxy to legacy Node backend disabled — all `/v1/bridge/*` use native handlers |
| `BRIDGE_BCHAIN_URL` / `--bchain-url` | `""` | BridgeVM native handlers disabled — swap/quote stays on the legacy proxy if `--backend` is also set |
| `BRIDGE_MPC_URL` / `--mpc-url` | `""` | MPC keygen disabled — swaps with `use_deposit_address=true` return 503 |
| `BRIDGE_MPC_TOKEN` / `--mpc-token` | `""` | Required for the live `mpcd` daemon — every endpoint except `/health` is bearer-protected |
| `BRIDGE_MPC_IDENTITY_FILE` / `--mpc-identity-file` | `""` | Convenience: derives the bearer token via SHA-256(seed‖"mpc-internal-api"). Use in dev; prod sets `BRIDGE_MPC_TOKEN` explicitly |
| `BRIDGE_MPC_ORG_ID` / `--mpc-org-id` | `bridge`| Multi-tenant key the MPC daemon multiplexes by |
### Source RPC overrides
`BRIDGE_SOURCE_RPC_OVERRIDES` / `--source-rpc-overrides` overrides
specific source-chain RPCs at runtime when the package defaults
(`rpc.sepolia.org` etc.) are stale or rate-limited. Format:
```
NETWORK1=URL1,NETWORK2=URL2
```
Used by *both* the deposit watcher (source) and the broadcast driver
(destination) — many networks are both a source and a destination.
Example:
```
BRIDGE_SOURCE_RPC_OVERRIDES="ETHEREUM_SEPOLIA=https://ethereum-sepolia-rpc.publicnode.com,BITCOIN_TESTNET=https://mempool.space/testnet/api"
```
### Driver toggles + cadence
| Env / Flag | Default | Effect when set |
|-------------------------------------|---------|-----------------|
| `--disable-deposit-check` | off | `/v1/bridge/check-deposit` returns 404 |
| `--disable-deposit-watcher` | off | Swaps never advance from `user_deposit_pending` |
| `--disable-signing-driver` | off | Swaps stall in `bridge_transfer_pending` |
| `--disable-broadcast-driver` | off | Signed txs never broadcast |
| `--deposit-watcher-interval` | 15s | Polling cadence (source-chain RPC load) |
| `--signing-interval` | 5s | MPC signing polling cadence |
| `--broadcast-interval` | 5s | Broadcast polling cadence |
| `--broadcast-timeout` | 15s | Per-RPC broadcast call timeout |
| `--deposit-check-timeout` | 10s | Per-RPC source-poll timeout |
| `--bchain-timeout` | 10s | Per-RPC b-chain call timeout |
| `--mpc-timeout` | 120s | Per-MPC call timeout (matches mpc-wallet.ts) |
### Static assets
| Env / Flag | Default | Effect |
|-------------------------------------|---------|--------|
| `BRIDGE_STATIC_DIR` / `--static` | `""` | Override the embedded SPA from disk (dev only) |
---
## 5. Updating networks.yaml
The supported chains, tokens, limits, and brand are all driven from
`networks.yaml` — mounted from the `lux-bridge-config` ConfigMap.
```bash
# Edit the ConfigMap in place
kubectl -n lux-bridge edit configmap lux-bridge-config
# Restart pods to pick up the change (the binary reads the file once at boot)
kubectl -n lux-bridge rollout restart deployment/lux-bridge
```
Adding a network requires no code change and no DB migration. The
in-binary token registry (`internal/tokens`) covers detection +
calldata for known assets; `networks.yaml` controls what the SPA
and `/v1/bridge/networks` advertise.
---
## 6. Health, logs, metrics
### Health
```bash
kubectl -n lux-bridge port-forward svc/lux-bridge 8080:80 &
curl -sS localhost:8080/health | jq .
```
Sample response:
```json
{
"status": "ok",
"version": "<git-sha>",
"backend_proxy": false,
"bchain_rpc": true,
"mpc_keygen": true,
"deposit_check": true,
"deposit_watcher": true,
"signing_driver": true,
"broadcast_driver": true,
"profile": "BRIDGE_CLASSICAL_COMPAT_UNSAFE",
"post_quantum_end_to_end": false,
"watcher_stats": { "ticks": 12, "checks": 0, "advances": 0, "errors": 0 },
"signing_stats": { "ticks": 12, "signs": 0, "errors": 0 },
"broadcast_stats": { "ticks": 12, "attempts": 0, "successes": 0,
"failures": 0, "skipped_no_raw_tx": 0, "list_errors": 0 }
}
```
Use the driver stats blocks to spot misbehavior:
- `watcher.errors` rising → upstream source-chain RPC issues
- `broadcast.failures` rising → destination chain rejecting our txs
- `signing.errors` rising → MPC daemon misconfigured or down
### Logs
JSON one-line-per-event via `luxfi/log`. Stream:
```bash
kubectl -n lux-bridge logs -f deployment/lux-bridge | jq -c
```
Filter for errors:
```bash
kubectl -n lux-bridge logs deployment/lux-bridge --tail=1000 \
| jq -c 'select(.level=="error")'
```
### Metrics
Prometheus exposition at `/metrics`. Add to your scrape config:
```yaml
- job_name: lux-bridge
kubernetes_sd_configs:
- role: pod
namespaces: {names: [lux-bridge]}
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
regex: lux-bridge
action: keep
```
---
## 7. Common operations
### Pause the pipeline (incident response)
Disable all background drivers without taking the API down:
```bash
kubectl -n lux-bridge set env deployment/lux-bridge \
BRIDGE_DISABLE_DEPOSIT_WATCHER=true \
BRIDGE_DISABLE_SIGNING_DRIVER=true \
BRIDGE_DISABLE_BROADCAST_DRIVER=true
```
> Note: env-var names mirror the CLI flags. To reverse, drop the vars
> with `kubectl set env … BRIDGE_DISABLE_…-`.
### Drain before maintenance
```bash
kubectl -n lux-bridge scale deployment/lux-bridge --replicas=0
# do work
kubectl -n lux-bridge scale deployment/lux-bridge --replicas=2
```
The PodDisruptionBudget keeps at least 1 pod up during cluster drains.
### Force a config reload
```bash
kubectl -n lux-bridge rollout restart deployment/lux-bridge
```
### Override an upstream RPC at runtime
```bash
kubectl -n lux-bridge set env deployment/lux-bridge \
BRIDGE_SOURCE_RPC_OVERRIDES="ETHEREUM_SEPOLIA=https://ethereum-sepolia-rpc.publicnode.com"
```
### Add a new token mapping
The binary's in-memory `tokens.DefaultRegistry()` covers the common
bridged assets. To add a new ERC-20 mapping that the deposit watcher +
tx assembler should recognize, edit `internal/tokens/tokens.go` and
ship a new image — there is no runtime token-registration endpoint yet.
`networks.yaml` Token entries control SPA listings and per-token caps
only; they do **not** wire detection or calldata. Detection +
calldata live in `internal/tokens`.
---
## 8. Troubleshooting
### Pod won't start: `bridge profile invalid`
`BRIDGE_PROFILE` must be `strict-pq` or `classical-compat`. Anything
else exits 1 (this is intentional — typo = wrong security posture).
### `mpc_keygen: false` in `/health`
`BRIDGE_MPC_URL` is unset or unreachable. Check:
```bash
kubectl -n lux-bridge get svc mpc-api-svc
kubectl -n lux-bridge get statefulset mpc-node
```
Without MPC keygen, swap creates with `use_deposit_address=true`
return 503.
### `signing_driver: false` despite MPC enabled
`mchainClient` is nil → `BRIDGE_MPC_URL` was empty at boot. Set it,
roll the deployment. If MPC is reachable but signing still errors,
check the bearer token (`BRIDGE_MPC_TOKEN`) and the org id
(`BRIDGE_MPC_ORG_ID`).
### Source-chain checks failing
Default RPCs (`rpc.sepolia.org`, etc.) get rate-limited. Override with
`BRIDGE_SOURCE_RPC_OVERRIDES`. Track `watcher_stats.errors` in
`/health` to confirm the fix.
### Watcher / signing / broadcast all running but no progress
Check the swap is actually in the right state. Use the swap CRUD:
```bash
curl -sS localhost:8080/v1/bridge/swaps/<id> | jq .state
```
State machine:
```
user_deposit_pending
→ bridge_transfer_pending (watcher: deposit confirmed)
→ bridge_transfer_pending_signing (signing: MPC asked)
→ bridge_transfer_pending_broadcast (signing: tx signed)
→ completed (broadcast: pushed onchain)
```
---
## 9. Persistence + backups
The swap store (`internal SwapStore` interface) has two implementations:
- **`InMemoryStore`** — used when `BRIDGE_DATA_DIR` is empty. Concurrency-safe,
zero ops cost, but every restart drops in-flight swap state. Dev only.
- **`ZapStore`** — used when `BRIDGE_DATA_DIR` is set. Backed by `luxfi/zapdb`
(Lux-flavored Badger v4). LSM-tree KV with WAL durability, atomic
`db.Update` transactions, MVCC concurrency control. Takes an exclusive
directory lock — only one bridge pod can hold the data dir at a time.
### Single-writer constraint
Because zapdb takes an exclusive dir lock, the k8s Deployment is pinned to:
```yaml
replicas: 1
strategy: {type: Recreate}
```
Two pods can't share `/var/lib/lux-bridge`. The Recreate strategy ensures
the old pod fully terminates (and releases the lock) before the new one
mounts the PVC. This costs zero-downtime rollouts — expect ~10s of API
gap during a deploy. The deposit watcher's 15s poll cadence absorbs this
without losing real progress.
Scaling beyond one pod requires migrating swap state to a shared external
store (Postgres, hanzoai/base SQLite served behind an API, etc.). Not on
the roadmap yet — single-replica covers the bridge's expected load.
### Disk layout
Inside `/var/lib/lux-bridge`:
```
000000.vlog value log (immutable)
000001.vlog ...
000002.vlog currently being written
000003.sst level-0 sorted string table
MANIFEST the LSM tree's index
KEYREGISTRY encryption-at-rest key registry (we don't use)
LOCK the exclusive directory lock
```
Typical size: hundreds of bytes per swap. 1 GB holds millions of swaps.
The 20Gi PVC in the manifest is generous head-room.
### Backups
zapdb supports a streaming backup primitive. For first-deploy ops, a
filesystem snapshot of the PV (cloud snapshot, Velero, etc.) while the
pod is paused is sufficient.
Pause-snapshot-resume:
```bash
kubectl -n lux-bridge scale deployment/lux-bridge --replicas=0
# wait for pod termination, then snapshot the PV however your cloud does it
kubectl -n lux-bridge scale deployment/lux-bridge --replicas=1
```
In-flight signing/broadcast that was mid-RPC will rerun on restart —
the state machine is idempotent (the watcher re-detects the same
deposit; signing checks for an existing signature before re-asking
MPC; broadcast checks for the dest tx hash before re-sending).
### Drift recovery
If the LOCK file is stale (e.g. a node crash left the file behind),
the new pod's `NewZapStore()` will fail with `Cannot acquire dir
lock`. Inspect the data dir, confirm no other process holds it, then:
```bash
# inside the pod — preserves all data, just drops the lock file
rm /var/lib/lux-bridge/LOCK
```
Restart the pod after.
---
## 10. Known gaps
These are tracked separately and **not** operationally fixable:
- Non-EVM broadcasters (BTC, SOL, TON, XRP, DOT) return
`ErrFamilyNotImplemented`. Add per-chain RPC clients to
`internal/broadcast` to extend coverage.
- EIP-1559 gas pricing not implemented in `internal/txassembler`. EVM
txs assemble with legacy (gasPrice) pricing. Most destination
chains still accept legacy txs.
- Substrate SS58 address encoding not implemented.
- LP-333 BridgeVM client methods are stubbed — `--bchain-url` controls
whether `bchain.Client` is constructed, but the handlers it backs
are read-only.
- Single-writer swap store. Horizontal scale-out requires migrating to
a shared external store.
+22
View File
@@ -5,25 +5,47 @@ go 1.26.3
require (
github.com/hanzoai/zip v0.2.0
github.com/luxfi/log v1.4.3
github.com/luxfi/zapdb v1.8.0
gopkg.in/yaml.v3 v3.0.1
)
require (
filippo.io/hpke v0.4.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gofiber/fiber/v3 v3.2.0 // indirect
github.com/gofiber/schema v1.7.1 // indirect
github.com/gofiber/utils/v2 v2.0.4 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/luxfi/age v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/minio-go/v7 v7.0.100 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.70.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)
+46
View File
@@ -1,31 +1,65 @@
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gofiber/fiber/v3 v3.2.0 h1:g9+09D320foINPpCnR3ibQ5oBEFHjAWRRfDG1te54u8=
github.com/gofiber/fiber/v3 v3.2.0/go.mod h1:FHOsc2Db7HhHpsE62QAaJlXVV1pNkbZEptZ4jtti7m4=
github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI=
github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk=
github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec=
github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hanzoai/zip v0.2.0 h1:2GI1OoUv4QdCxmfiwoiyYsSHpiPAxrp0v+Gu4BLtTVI=
github.com/hanzoai/zip v0.2.0/go.mod h1:3G+k2wy5bQ1wld66m0OPH8/LJ0kgIeNi1KPymMmlbf4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/luxfi/age v1.4.0 h1:tU5q65RQSQdaVq64Z/DVUhiPQMoMPQT6pr41LsvG0AQ=
github.com/luxfi/age v1.4.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
github.com/luxfi/log v1.4.3 h1:xkUKRWvQ4ZwvlUC2e0/RTtHYZOYSMvSQ9W9lbjwBmiI=
github.com/luxfi/log v1.4.3/go.mod h1:myIkufyiQomSQH34K981kbz6cG4WUoerRUh7F4XhlQI=
github.com/luxfi/zapdb v1.8.0 h1:E9hXMW1MTuS6kmDVs07j62gIq5qI5gZOdyzVfiBc1wg=
github.com/luxfi/zapdb v1.8.0/go.mod h1:Qukh3hDRD0MnxA6z+a28JTnXhN85AiLLgp6TYr4QAMc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.100 h1:ShkWi8Tyj9RtU57OQB2HIXKz4bFgtVib0bbT1sbtLI8=
github.com/minio/minio-go/v7 v7.0.100/go.mod h1:EtGNKtlX20iL2yaYnxEigaIvj0G0GwSDnifnG8ClIdw=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU=
github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -40,6 +74,16 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
@@ -48,6 +92,8 @@ golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
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/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+130 -26
View File
@@ -34,6 +34,7 @@ package depositcheck
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -44,6 +45,7 @@ import (
"time"
"github.com/luxfi/bridge/internal/mchain"
"github.com/luxfi/bridge/internal/tokens"
)
// =============================================================================
@@ -127,6 +129,15 @@ type Client struct {
// privately-hosted Sepolia mirror) instead of the public defaults.
// Looked up before the package-level table.
RPCURLOverrides map[string]string
// Tokens, when set, drives ERC-20 detection: assets registered
// with a non-empty Contract use `eth_call balanceOf(addr)` against
// that contract; native-token assets continue to use eth_getBalance.
// Asset decimals are pulled from the registry too (no more
// hardcoded 18). When nil, every EVM check uses the legacy
// native-only path with 18 decimals — backward compat for callers
// that haven't migrated to the registry yet.
Tokens *tokens.Registry
}
// New is a convenience constructor.
@@ -182,7 +193,7 @@ func (c *Client) Check(ctx context.Context, p CheckParams) (bool, error) {
addrType := mchain.AddressTypeFor(p.NetworkInternalName)
switch addrType {
case mchain.AddressTypeETH:
return c.checkEVM(ctx, url, p.Address, p.Asset, p.RequiredAmount)
return c.checkEVM(ctx, url, p.NetworkInternalName, p.Address, p.Asset, p.RequiredAmount)
case mchain.AddressTypeBTC:
return c.checkBTC(ctx, url, p.Address, p.RequiredAmount)
case mchain.AddressTypeSOL:
@@ -206,19 +217,36 @@ func (c *Client) Check(ctx context.Context, p CheckParams) (bool, error) {
}
// =============================================================================
// EVM — eth_getBalance for native tokens
// EVM — eth_getBalance for natives, eth_call balanceOf for ERC-20
// =============================================================================
// weiPerEther is 10^18 as a big.Int — used to scale wei→ether for
// comparison against human-readable required amounts.
var weiPerEther = new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)
// erc20BalanceOfSelector is the first 4 bytes of
// keccak256("balanceOf(address)") — the canonical ERC-20 selector for
// reading a holder's balance. Constant per the Solidity ABI.
var erc20BalanceOfSelector = [4]byte{0x70, 0xa0, 0x82, 0x31}
func (c *Client) checkEVM(ctx context.Context, rpcURL, address, _asset string, requiredAmount float64) (bool, error) {
// Match the TS implementation: native-token check only. ERC-20
// support is a separate balanceOf call (eth_call) and is not
// implemented in the legacy server either — defer to a follow-up
// if/when a tenant needs ERC-20 deposit detection.
func (c *Client) checkEVM(ctx context.Context, rpcURL, network, address, asset string, requiredAmount float64) (bool, error) {
// Decide the path: native (eth_getBalance) vs ERC-20 (eth_call).
// Registry lookup gives us the per-asset Contract + Decimals.
var (
contract string
decimals = 18 // default — fits ETH/LUX/BNB/etc. (most native gas tokens)
)
if c.Tokens != nil {
if info, ok := c.Tokens.Lookup(network, asset); ok {
contract = info.Contract
decimals = info.Decimals
}
}
if contract == "" {
return c.checkEVMNative(ctx, rpcURL, address, decimals, requiredAmount)
}
return c.checkEVMERC20(ctx, rpcURL, contract, address, decimals, requiredAmount)
}
// checkEVMNative queries eth_getBalance for the chain's native asset.
func (c *Client) checkEVMNative(ctx context.Context, rpcURL, address string, decimals int, requiredAmount float64) (bool, error) {
type ethBalanceResp struct {
Result string `json:"result"`
Error *struct {
@@ -226,7 +254,6 @@ func (c *Client) checkEVM(ctx context.Context, rpcURL, address, _asset string, r
Message string `json:"message"`
} `json:"error,omitempty"`
}
var resp ethBalanceResp
if err := c.jsonRPC(ctx, rpcURL, "eth_getBalance",
[]any{address, "latest"}, &resp); err != nil {
@@ -238,26 +265,103 @@ func (c *Client) checkEVM(ctx context.Context, rpcURL, address, _asset string, r
if resp.Result == "" {
return false, fmt.Errorf("depositcheck: eth_getBalance: empty result")
}
// Parse the 0x… hex into a big.Int (wei).
hexBal := strings.TrimPrefix(resp.Result, "0x")
if hexBal == "" {
hexBal = "0"
bal, err := parseHexBalance(resp.Result)
if err != nil {
return false, fmt.Errorf("depositcheck: eth_getBalance: %w", err)
}
balWei, ok := new(big.Int).SetString(hexBal, 16)
return compareBalance(bal, decimals, requiredAmount), nil
}
// checkEVMERC20 queries `balanceOf(address)` on the token contract
// via eth_call.
//
// Call shape:
// eth_call({ to: contract, data: 0x70a08231 || <addr_padded_32B> }, "latest")
// Response: 0x-prefixed hex of the 32-byte balance word.
func (c *Client) checkEVMERC20(ctx context.Context, rpcURL, contract, holder string, decimals int, requiredAmount float64) (bool, error) {
holderBytes, err := parseAddress20(holder)
if err != nil {
return false, fmt.Errorf("depositcheck: ERC-20 holder address: %w", err)
}
// data = selector || abi.encode(address) — address is 12 leading zero
// bytes followed by the 20-byte address.
data := make([]byte, 0, 4+32)
data = append(data, erc20BalanceOfSelector[:]...)
var addrWord [32]byte
copy(addrWord[12:], holderBytes)
data = append(data, addrWord[:]...)
callObj := map[string]string{
"to": contract,
"data": "0x" + hex.EncodeToString(data),
}
type ethCallResp struct {
Result string `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
var resp ethCallResp
if err := c.jsonRPC(ctx, rpcURL, "eth_call",
[]any{callObj, "latest"}, &resp); err != nil {
return false, err
}
if resp.Error != nil {
return false, fmt.Errorf("depositcheck: eth_call balanceOf rpc %d: %s", resp.Error.Code, resp.Error.Message)
}
if resp.Result == "" {
return false, fmt.Errorf("depositcheck: eth_call balanceOf: empty result")
}
bal, err := parseHexBalance(resp.Result)
if err != nil {
return false, fmt.Errorf("depositcheck: eth_call balanceOf: %w", err)
}
return compareBalance(bal, decimals, requiredAmount), nil
}
// parseHexBalance decodes a 0x-prefixed hex string into a big.Int.
// Empty / "0x" / "0x0" → 0. Whitespace tolerated.
func parseHexBalance(s string) (*big.Int, error) {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X")
if s == "" {
return new(big.Int), nil
}
n, ok := new(big.Int).SetString(s, 16)
if !ok {
return false, fmt.Errorf("depositcheck: eth_getBalance: invalid hex %q", resp.Result)
return nil, fmt.Errorf("invalid hex %q", s)
}
return n, nil
}
// Convert wei to ether (float) only for the comparison. For the
// magnitudes the bridge cares about (≤ ~1e9 native units) the
// float64 conversion is exact enough.
balEth := new(big.Float).Quo(
new(big.Float).SetInt(balWei),
new(big.Float).SetInt(weiPerEther),
// compareBalance scales a base-unit balance (wei, base-uint256 token
// units, etc.) by 10^decimals and compares to requiredAmount.
func compareBalance(bal *big.Int, decimals int, requiredAmount float64) bool {
if requiredAmount <= 0 {
return bal.Sign() >= 0
}
scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil)
balFloat := new(big.Float).Quo(
new(big.Float).SetInt(bal),
new(big.Float).SetInt(scale),
)
balFloat, _ := balEth.Float64()
return balFloat >= requiredAmount, nil
out, _ := balFloat.Float64()
return out >= requiredAmount
}
// parseAddress20 decodes a 0x-prefixed hex address to 20 bytes.
func parseAddress20(addr string) ([]byte, error) {
addr = strings.TrimPrefix(strings.TrimPrefix(addr, "0x"), "0X")
if len(addr) != 40 {
return nil, fmt.Errorf("invalid address length %d (want 40 hex chars)", len(addr))
}
out, err := hex.DecodeString(addr)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
return out, nil
}
// =============================================================================
+238
View File
@@ -12,11 +12,15 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/luxfi/bridge/internal/tokens"
)
// =============================================================================
@@ -155,6 +159,240 @@ func TestCheck_EVM_LargeBalancePrecision(t *testing.T) {
}
}
// =============================================================================
// EVM — ERC-20 (eth_call balanceOf)
// =============================================================================
// erc20BalanceServer returns the configured balance from any
// eth_call. It records the call params so we can verify the data
// payload was assembled correctly.
type erc20BalanceServer struct {
server *httptest.Server
balance string // 0x-prefixed hex of the 32-byte balance
lastData string
lastTo string
calls int
}
func newERC20BalanceServer(t *testing.T, balance string) *erc20BalanceServer {
t.Helper()
s := &erc20BalanceServer{balance: balance}
s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.calls++
body, _ := io.ReadAll(r.Body)
var req struct {
Method string `json:"method"`
Params []any `json:"params"`
}
_ = json.Unmarshal(body, &req)
if req.Method != "eth_call" {
t.Errorf("expected eth_call, got %q", req.Method)
}
if len(req.Params) > 0 {
if obj, ok := req.Params[0].(map[string]any); ok {
s.lastTo, _ = obj["to"].(string)
s.lastData, _ = obj["data"].(string)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1, "result": s.balance,
})
}))
t.Cleanup(s.server.Close)
return s
}
func TestCheck_EVM_ERC20_Confirmed(t *testing.T) {
// 100 USDC = 100 * 10^6 = 100,000,000 base units = 0x5F5E100.
// 32-byte word: pad to 64 hex chars: 000…000005F5E100
balance := "0x" + strings.Repeat("0", 56) + "05f5e100"
srv := newERC20BalanceServer(t, balance)
reg := tokens.NewRegistry()
usdcContract := "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"
_ = reg.Register(tokens.Info{
Network: "ETHEREUM_SEPOLIA",
Asset: "USDC",
Contract: usdcContract,
Decimals: 6,
})
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.server.URL},
Tokens: reg,
}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Asset: "USDC",
RequiredAmount: 50,
})
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected confirmed (100 USDC ≥ 50 required)")
}
// Verify the wire: `to` is the USDC contract, NOT the holder.
if !strings.EqualFold(srv.lastTo, usdcContract) {
t.Errorf("to = %q, want USDC contract %q", srv.lastTo, usdcContract)
}
// Data must start with the balanceOf selector (0x70a08231).
if !strings.HasPrefix(strings.ToLower(srv.lastData), "0x70a08231") {
t.Errorf("data should start with balanceOf selector 0x70a08231, got %s", srv.lastData)
}
// Padded holder address: 0x70a08231 + 12 zero bytes + holder = 68 chars hex + 2 prefix.
if len(srv.lastData) != 2+(4+32)*2 {
t.Errorf("data length wrong: %d", len(srv.lastData))
}
// Last 40 hex chars should be the holder address (lowercase).
wantHolder := strings.ToLower("a28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473")
if !strings.HasSuffix(strings.ToLower(srv.lastData), wantHolder) {
t.Errorf("holder mismatch in calldata: got %s, want suffix %s", srv.lastData, wantHolder)
}
}
func TestCheck_EVM_ERC20_Insufficient(t *testing.T) {
// 25 USDC = 25 * 10^6 = 25,000,000 base units
balance := "0x" + strings.Repeat("0", 56) + "017d7840"
srv := newERC20BalanceServer(t, balance)
reg := tokens.NewRegistry()
_ = reg.Register(tokens.Info{Network: "ETHEREUM_SEPOLIA", Asset: "USDC", Contract: "0x1c7D", Decimals: 6})
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.server.URL},
Tokens: reg,
}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Asset: "USDC",
RequiredAmount: 100,
})
if err != nil || ok {
t.Fatalf("expected (false, nil) for 25 USDC < 100 required; got (%v, %v)", ok, err)
}
}
func TestCheck_EVM_ERC20_RPCError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1,
"error": map[string]any{"code": -32000, "message": "execution reverted"},
})
}))
t.Cleanup(srv.Close)
reg := tokens.NewRegistry()
_ = reg.Register(tokens.Info{Network: "ETHEREUM_SEPOLIA", Asset: "USDC", Contract: "0x1c7D", Decimals: 6})
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.URL},
Tokens: reg,
}
_, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Asset: "USDC",
RequiredAmount: 1,
})
if err == nil || !strings.Contains(err.Error(), "execution reverted") {
t.Errorf("expected upstream error to surface, got %v", err)
}
}
func TestCheck_EVM_NativeWithRegistry_UsesAssetDecimals(t *testing.T) {
// Native ETH with the registry attached: should STILL use
// eth_getBalance (Contract is empty in the registry), and the
// asset's Decimals=18 means standard wei → ether scaling.
srv := ethBalanceServer(t, "0xDE0B6B3A7640000") // 1 ETH
reg := tokens.NewRegistry()
_ = reg.Register(tokens.Info{Network: "ETHEREUM_SEPOLIA", Asset: "ETH", Decimals: 18})
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.URL},
Tokens: reg,
}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Asset: "ETH",
RequiredAmount: 0.5,
})
if err != nil || !ok {
t.Fatalf("expected confirmed; got (%v, %v)", ok, err)
}
}
func TestCheck_EVM_UnknownAsset_FallsBackToNative(t *testing.T) {
// Registry has no entry for "WEIRDO" → falls back to native path
// with 18 decimals (backward compat).
srv := ethBalanceServer(t, "0xDE0B6B3A7640000") // 1 ETH
reg := tokens.NewRegistry() // empty
c := &Client{
Timeout: time.Second,
RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.URL},
Tokens: reg,
}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Address: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Asset: "WEIRDO", // not in registry
RequiredAmount: 0.5,
})
if err != nil || !ok {
t.Fatalf("unknown asset should fall back to native path; got (%v, %v)", ok, err)
}
}
func TestCheck_EVM_NoRegistry_BackwardCompat(t *testing.T) {
// Client.Tokens == nil → all EVM checks use the legacy native path.
// This proves callers who haven't migrated still get the old behavior.
srv := ethBalanceServer(t, "0xDE0B6B3A7640000")
c := &Client{Timeout: time.Second, RPCURLOverrides: map[string]string{"ETHEREUM_SEPOLIA": srv.URL}}
ok, err := c.Check(context.Background(), CheckParams{
NetworkInternalName: "ETHEREUM_SEPOLIA",
Asset: "USDC", // would be ERC-20 with registry
RequiredAmount: 0.5,
})
if err != nil || !ok {
t.Fatalf("no-registry path should use native; got (%v, %v)", ok, err)
}
}
func TestCompareBalance(t *testing.T) {
cases := []struct {
name string
bal int64
decimals int
req float64
want bool
}{
{"USDC 100 ≥ 50", 100_000_000, 6, 50, true},
{"USDC 25 < 50", 25_000_000, 6, 50, false},
{"1 ETH ≥ 0.5", 1_000_000_000_000_000_000, 18, 0.5, true},
{"1 ETH ≥ 1.0", 1_000_000_000_000_000_000, 18, 1.0, true},
{"0.1 ETH < 0.5", 100_000_000_000_000_000, 18, 0.5, false},
{"required 0", 1, 18, 0, true},
{"zero balance", 0, 18, 0.001, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := compareBalance(intBig(tc.bal), tc.decimals, tc.req)
if got != tc.want {
t.Errorf("got %v, want %v", got, tc.want)
}
})
}
}
func intBig(n int64) *big.Int { return big.NewInt(n) }
// =============================================================================
// Bitcoin
// =============================================================================
+206
View File
@@ -0,0 +1,206 @@
// Package tokens is the per-(network, asset) token registry.
//
// Bridges have to know two things about each asset on each chain:
// 1. The on-chain contract address (or "" for native gas tokens).
// 2. The decimal place — wei-scaling for native tokens (18 for EVM),
// base-unit scaling for ERC-20s (6 for USDC, 18 for DAI, etc.).
//
// Both `internal/depositcheck` (to decide eth_getBalance vs eth_call
// balanceOf) and `internal/txassembler` (to scale the human amount
// correctly when building destination txs) consume this registry.
//
// The defaults cover the most common bridged assets — USDC, USDT,
// DAI, WETH on Ethereum + Sepolia, USDC on Base/Polygon/BSC, native
// gas tokens on every supported chain. Operators extend via
// Register() for custom tokens (liquid Lux tokens, bespoke
// stablecoins, etc.).
package tokens
import (
"fmt"
"strings"
"sync"
)
// Info describes one bridged asset on one chain.
//
// IsNative() is true when Contract == "" — that signals the
// chain's native gas token (ETH on Ethereum, LUX on Lux, BNB on BSC,
// etc.). Native tokens use eth_getBalance for detection and pure
// value transfers for release; ERC-20s use eth_call balanceOf and
// transfer() for release.
type Info struct {
Network string // internal_name e.g. ETHEREUM_SEPOLIA, LUX_TESTNET
Asset string // ticker e.g. ETH, USDC, LUX
Contract string // 0x-prefixed hex for ERC-20; "" for native
Decimals int // 18 for ETH/LUX/DAI; 6 for USDC/USDT; 8 for BTC; 9 for SOL/TON
}
// IsNative reports whether this asset is the chain's native gas token.
func (i *Info) IsNative() bool { return i == nil || i.Contract == "" }
// =============================================================================
// Registry
// =============================================================================
// Registry is the thread-safe lookup table. Construct via NewRegistry
// (empty) or DefaultRegistry (pre-populated with common bridged assets).
type Registry struct {
mu sync.RWMutex
tokens map[string]Info // key = "NETWORK|ASSET" — case-insensitive
}
// NewRegistry returns an empty registry.
func NewRegistry() *Registry { return &Registry{tokens: map[string]Info{}} }
// key normalizes the (network, asset) pair to a case-insensitive key.
func key(network, asset string) string {
return strings.ToUpper(network) + "|" + strings.ToUpper(asset)
}
// Register inserts or replaces a token. Returns an error on
// malformed input. Safe for concurrent calls.
func (r *Registry) Register(info Info) error {
if r == nil {
return fmt.Errorf("tokens: Register on nil Registry")
}
if info.Network == "" || info.Asset == "" {
return fmt.Errorf("tokens: Network and Asset required")
}
if info.Decimals < 0 || info.Decimals > 30 {
return fmt.Errorf("tokens: Decimals must be 0..30, got %d", info.Decimals)
}
r.mu.Lock()
defer r.mu.Unlock()
r.tokens[key(info.Network, info.Asset)] = info
return nil
}
// Lookup returns the token info for (network, asset). Second return
// is false when the pair isn't registered.
func (r *Registry) Lookup(network, asset string) (*Info, bool) {
if r == nil {
return nil, false
}
r.mu.RLock()
defer r.mu.RUnlock()
info, ok := r.tokens[key(network, asset)]
if !ok {
return nil, false
}
return &info, true
}
// ForNetwork returns every registered asset on the given network.
// Order is undefined.
func (r *Registry) ForNetwork(network string) []Info {
if r == nil {
return nil
}
prefix := strings.ToUpper(network) + "|"
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Info, 0)
for k, v := range r.tokens {
if strings.HasPrefix(k, prefix) {
out = append(out, v)
}
}
return out
}
// Size returns the number of registered tokens. Useful for /health
// + startup logging.
func (r *Registry) Size() int {
if r == nil {
return 0
}
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.tokens)
}
// =============================================================================
// Defaults — the seed registry main.go populates at boot
// =============================================================================
// DefaultRegistry returns a registry pre-populated with the most
// common bridged assets. Operators extend via Register() in main.go
// for custom tokens. Token contract addresses verified against:
// - Circle's published USDC contracts (https://developers.circle.com/stablecoins/docs/usdc-on-test-networks)
// - Etherscan-verified canonical contracts for USDT / DAI / WETH
// - The TS SDK's pkg/settings/src/{mainnet,testnet}/networks.json
// (cross-checked on the live API earlier in development).
func DefaultRegistry() *Registry {
r := NewRegistry()
for _, info := range defaultTokens {
_ = r.Register(info)
}
return r
}
// defaultTokens — seed list. Keep alphabetically organized by network
// + asset within each chain so diffs stay readable.
var defaultTokens = []Info{
// Ethereum Mainnet
{Network: "ETHEREUM_MAINNET", Asset: "ETH", Decimals: 18},
{Network: "ETHEREUM_MAINNET", Asset: "USDC", Contract: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", Decimals: 6},
{Network: "ETHEREUM_MAINNET", Asset: "USDT", Contract: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6},
{Network: "ETHEREUM_MAINNET", Asset: "DAI", Contract: "0x6B175474E89094C44Da98b954EedeAC495271d0F", Decimals: 18},
{Network: "ETHEREUM_MAINNET", Asset: "WETH", Contract: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", Decimals: 18},
// Ethereum Sepolia — Circle's canonical testnet USDC + WETH9
{Network: "ETHEREUM_SEPOLIA", Asset: "ETH", Decimals: 18},
{Network: "ETHEREUM_SEPOLIA", Asset: "USDC", Contract: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", Decimals: 6},
{Network: "ETHEREUM_SEPOLIA", Asset: "WETH", Contract: "0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9", Decimals: 18},
// Holesky
{Network: "HOLESKY_TESTNET", Asset: "ETH", Decimals: 18},
// Lux — native only at v1; liquid tokens (LBTC, LETH, LUSD, etc.)
// get added when the per-asset contract list firms up in pkg/settings.
{Network: "LUX_MAINNET", Asset: "LUX", Decimals: 18},
{Network: "LUX_TESTNET", Asset: "LUX", Decimals: 18},
// Zoo — native only
{Network: "ZOO_MAINNET", Asset: "ZOO", Decimals: 18},
{Network: "ZOO_TESTNET", Asset: "ZOO", Decimals: 18},
// Base — Circle's canonical USDC addresses
{Network: "BASE_MAINNET", Asset: "ETH", Decimals: 18},
{Network: "BASE_MAINNET", Asset: "USDC", Contract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", Decimals: 6},
{Network: "BASE_SEPOLIA", Asset: "ETH", Decimals: 18},
{Network: "BASE_SEPOLIA", Asset: "USDC", Contract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", Decimals: 6},
// BSC — note: USDC/USDT on BSC are 18 decimals, NOT 6 (BSC convention)
{Network: "BSC_MAINNET", Asset: "BNB", Decimals: 18},
{Network: "BSC_MAINNET", Asset: "USDC", Contract: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", Decimals: 18},
{Network: "BSC_MAINNET", Asset: "USDT", Contract: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18},
{Network: "BSC_TESTNET", Asset: "BNB", Decimals: 18},
// Polygon — native is MATIC; bridged USDC/USDT use 6 decimals here
{Network: "POLYGON_MAINNET", Asset: "MATIC", Decimals: 18},
{Network: "POLYGON_MAINNET", Asset: "USDC", Contract: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", Decimals: 6},
{Network: "POLYGON_MAINNET", Asset: "USDT", Contract: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", Decimals: 6},
// Arbitrum / Optimism / Avalanche — natives + bridged USDC
{Network: "ARBITRUM_MAINNET", Asset: "ETH", Decimals: 18},
{Network: "ARBITRUM_MAINNET", Asset: "USDC", Contract: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", Decimals: 6},
{Network: "OPTIMISM_MAINNET", Asset: "ETH", Decimals: 18},
{Network: "OPTIMISM_MAINNET", Asset: "USDC", Contract: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", Decimals: 6},
{Network: "AVAX_MAINNET", Asset: "AVAX", Decimals: 18},
{Network: "AVAX_MAINNET", Asset: "USDC", Contract: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", Decimals: 6},
// Non-EVM natives (kept here for completeness even though
// depositcheck only handles BTC/SOL/TON balance queries today —
// having decimals registered means downstream code that scales
// amounts can do so without hardcoded fallbacks).
{Network: "BITCOIN_MAINNET", Asset: "BTC", Decimals: 8},
{Network: "BITCOIN_TESTNET", Asset: "BTC", Decimals: 8},
{Network: "SOLANA_MAINNET", Asset: "SOL", Decimals: 9},
{Network: "SOLANA_DEVNET", Asset: "SOL", Decimals: 9},
{Network: "TON_MAINNET", Asset: "TON", Decimals: 9},
{Network: "TON_TESTNET", Asset: "TON", Decimals: 9},
{Network: "XRP_MAINNET", Asset: "XRP", Decimals: 6},
{Network: "XRP_TESTNET", Asset: "XRP", Decimals: 6},
}
+159
View File
@@ -0,0 +1,159 @@
package tokens
import (
"strings"
"testing"
)
func TestRegistry_RegisterAndLookup(t *testing.T) {
r := NewRegistry()
if err := r.Register(Info{Network: "ETHEREUM_SEPOLIA", Asset: "USDC", Contract: "0x1c7D", Decimals: 6}); err != nil {
t.Fatal(err)
}
got, ok := r.Lookup("ETHEREUM_SEPOLIA", "USDC")
if !ok {
t.Fatal("expected hit")
}
if got.Decimals != 6 || got.Contract != "0x1c7D" {
t.Errorf("unexpected info: %+v", got)
}
}
func TestRegistry_Lookup_CaseInsensitive(t *testing.T) {
r := NewRegistry()
_ = r.Register(Info{Network: "LUX_TESTNET", Asset: "LUX", Decimals: 18})
// All these should hit the same entry.
for _, p := range []struct{ net, asset string }{
{"LUX_TESTNET", "LUX"},
{"lux_testnet", "lux"},
{"Lux_Testnet", "Lux"},
} {
if _, ok := r.Lookup(p.net, p.asset); !ok {
t.Errorf("expected hit for (%q, %q)", p.net, p.asset)
}
}
}
func TestRegistry_Lookup_Miss(t *testing.T) {
r := NewRegistry()
if got, ok := r.Lookup("ETHEREUM_SEPOLIA", "UNOBTAINIUM"); ok {
t.Errorf("expected miss, got %+v", got)
}
}
func TestRegistry_Register_Validates(t *testing.T) {
r := NewRegistry()
cases := []struct {
name string
in Info
want string
}{
{"no network", Info{Asset: "ETH", Decimals: 18}, "Network and Asset"},
{"no asset", Info{Network: "X", Decimals: 18}, "Network and Asset"},
{"negative decimals", Info{Network: "X", Asset: "Y", Decimals: -1}, "Decimals must be"},
{"way too many decimals", Info{Network: "X", Asset: "Y", Decimals: 31}, "Decimals must be"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := r.Register(tc.in)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("got %v, want %q in message", err, tc.want)
}
})
}
}
func TestRegistry_Register_Overwrites(t *testing.T) {
r := NewRegistry()
_ = r.Register(Info{Network: "X", Asset: "USDC", Decimals: 18}) // wrong decimals
_ = r.Register(Info{Network: "X", Asset: "USDC", Decimals: 6}) // correct
got, _ := r.Lookup("X", "USDC")
if got.Decimals != 6 {
t.Errorf("second Register should overwrite; got Decimals=%d", got.Decimals)
}
}
func TestRegistry_ForNetwork(t *testing.T) {
r := NewRegistry()
_ = r.Register(Info{Network: "ETHEREUM_SEPOLIA", Asset: "ETH", Decimals: 18})
_ = r.Register(Info{Network: "ETHEREUM_SEPOLIA", Asset: "USDC", Contract: "0xabc", Decimals: 6})
_ = r.Register(Info{Network: "LUX_TESTNET", Asset: "LUX", Decimals: 18})
got := r.ForNetwork("ETHEREUM_SEPOLIA")
if len(got) != 2 {
t.Errorf("expected 2 assets on Sepolia, got %d: %+v", len(got), got)
}
}
func TestInfo_IsNative(t *testing.T) {
native := &Info{Network: "X", Asset: "ETH", Decimals: 18}
erc20 := &Info{Network: "X", Asset: "USDC", Contract: "0xabc", Decimals: 6}
if !native.IsNative() {
t.Error("native (empty contract) should be IsNative()")
}
if erc20.IsNative() {
t.Error("ERC-20 should NOT be IsNative()")
}
// Nil safety
var nilInfo *Info
if !nilInfo.IsNative() {
t.Error("nil *Info should default to native (avoids per-caller nil checks)")
}
}
func TestDefaultRegistry_HasCommonAssets(t *testing.T) {
r := DefaultRegistry()
// Sanity-check a handful of the most operationally important entries.
cases := []struct {
net, asset, contract string
decimals int
}{
{"ETHEREUM_MAINNET", "USDC", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", 6},
{"ETHEREUM_MAINNET", "ETH", "", 18},
{"ETHEREUM_SEPOLIA", "USDC", "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", 6},
{"LUX_TESTNET", "LUX", "", 18},
{"BSC_MAINNET", "USDC", "", 18}, // BSC USDC is 18 decimals — not 6!
{"POLYGON_MAINNET", "USDC", "", 6},
{"BITCOIN_TESTNET", "BTC", "", 8},
{"SOLANA_DEVNET", "SOL", "", 9},
}
for _, tc := range cases {
got, ok := r.Lookup(tc.net, tc.asset)
if !ok {
t.Errorf("expected default entry for (%s, %s)", tc.net, tc.asset)
continue
}
if got.Decimals != tc.decimals {
t.Errorf("%s/%s: Decimals = %d, want %d", tc.net, tc.asset, got.Decimals, tc.decimals)
}
if tc.contract != "" && got.Contract != tc.contract {
t.Errorf("%s/%s: Contract = %q, want %q", tc.net, tc.asset, got.Contract, tc.contract)
}
}
}
func TestRegistry_Size(t *testing.T) {
r := NewRegistry()
if r.Size() != 0 {
t.Errorf("expected 0, got %d", r.Size())
}
_ = r.Register(Info{Network: "X", Asset: "Y", Decimals: 18})
if r.Size() != 1 {
t.Errorf("expected 1, got %d", r.Size())
}
}
func TestRegistry_NilSafety(t *testing.T) {
var r *Registry
if _, ok := r.Lookup("X", "Y"); ok {
t.Error("nil Registry should miss every lookup")
}
if r.Size() != 0 {
t.Error("nil Registry Size should be 0")
}
if got := r.ForNetwork("X"); got != nil {
t.Errorf("nil Registry ForNetwork should be nil, got %v", got)
}
if err := r.Register(Info{Network: "X", Asset: "Y", Decimals: 18}); err == nil {
t.Error("nil Registry Register should error")
}
}
+99 -18
View File
@@ -10,6 +10,8 @@ import (
"sync"
"golang.org/x/crypto/sha3"
"github.com/luxfi/bridge/internal/tokens"
)
// =============================================================================
@@ -100,6 +102,15 @@ func (s *StaticProvider) SuggestGasPrice(_ context.Context, network string) (*bi
type Assembler struct {
Provider Provider
Networks map[string]PerNetwork // key = network internal_name
// Tokens, when set, drives ERC-20 destination handling. If the
// (DestinationNetwork, DestinationAsset) pair resolves to an
// ERC-20 entry (non-empty Contract), PreSign builds a
// `transfer(recipient, amount)` calldata against that contract
// and scales the amount by the token's Decimals. When nil, every
// PreSign falls back to native transfers / cfg.BridgeContract
// release calls — backward compat for callers that haven't
// adopted the registry yet.
Tokens *tokens.Registry
}
// New builds an empty assembler. Populate Networks per-network.
@@ -110,6 +121,11 @@ func New(provider Provider) *Assembler {
// SetNetwork registers a per-chain config. Convenience wrapper.
func (a *Assembler) SetNetwork(network string, cfg PerNetwork) { a.Networks[network] = cfg }
// erc20TransferSelector is the first 4 bytes of
// keccak256("transfer(address,uint256)") — the canonical ERC-20
// selector for sending tokens. Constant per the Solidity ABI.
var erc20TransferSelector = [4]byte{0xa9, 0x05, 0x9c, 0xbb}
// Unsigned is the intermediate representation between PreSign and
// Finalize. Callers MUST pass the same instance to Finalize that
// PreSign returned — internal fields persist the canonical wire
@@ -139,11 +155,19 @@ type SwapIntent struct {
// DestinationNetwork is the internal_name of the destination
// chain (e.g. LUX_TESTNET, ETHEREUM_SEPOLIA).
DestinationNetwork string
// DestinationAsset is the token ticker on the destination chain
// (ETH, LUX, USDC, etc.). When the assembler has a Tokens registry
// attached and the asset resolves to an ERC-20, PreSign switches
// to `transfer(recipient, amount)` calldata against the token's
// contract and uses the token's Decimals. Empty / unknown ⇒
// native-token transfer (or BridgeContract-release mode).
DestinationAsset string
// DestinationAddress is the user's recipient address on the
// destination chain (0x-prefixed hex for EVM).
DestinationAddress string
// Amount is the human-readable amount the user receives
// (e.g. 0.1 = 0.1 ETH). Scaled to wei using PerNetwork.NativeDecimals.
// (e.g. 0.1 = 0.1 ETH). Scaled to base units using the resolved
// asset's decimals (or PerNetwork.NativeDecimals as a fallback).
Amount float64
// SenderAddress is the MPC-controlled signer address — comes
// from the eth_address slot of the keygen response. Used to
@@ -162,10 +186,31 @@ func (a *Assembler) PreSign(ctx context.Context, in SwapIntent) (*Unsigned, erro
return nil, fmt.Errorf("txassembler: ChainID required for %s", in.DestinationNetwork)
}
// Resolve destination asset → contract + decimals. Three modes:
// 1. ERC-20 transfer: token.Contract != "" → tx targets the
// token contract with transfer() calldata
// and tx value = 0.
// 2. Bridge release: cfg.BridgeContract != "" (and asset is
// native or unresolved) → tx targets the
// per-network bridge contract with
// release() calldata and tx value = 0.
// 3. Pure transfer: fallback. tx targets the recipient
// directly with the native amount as
// tx value and empty calldata.
var tokenInfo *tokens.Info
if a.Tokens != nil && in.DestinationAsset != "" {
if info, ok := a.Tokens.Lookup(in.DestinationNetwork, in.DestinationAsset); ok {
tokenInfo = info
}
}
decimals := cfg.NativeDecimals
if decimals == 0 {
decimals = 18 // ETH/LUX/BNB/etc.
}
if tokenInfo != nil {
decimals = tokenInfo.Decimals
}
value, err := floatToWei(in.Amount, decimals)
if err != nil {
return nil, err
@@ -174,7 +219,23 @@ func (a *Assembler) PreSign(ctx context.Context, in SwapIntent) (*Unsigned, erro
// Determine `to` + `data` based on mode.
var to []byte
var data []byte
if cfg.BridgeContract != "" {
switch {
case tokenInfo != nil && !tokenInfo.IsNative():
// Mode 1: ERC-20 transfer
to, err = parseAddress(tokenInfo.Contract)
if err != nil {
return nil, fmt.Errorf("ERC-20 contract address %s/%s: %w",
in.DestinationNetwork, in.DestinationAsset, err)
}
recipient, err := parseAddress(in.DestinationAddress)
if err != nil {
return nil, fmt.Errorf("DestinationAddress: %w", err)
}
data = buildABIWordCall(erc20TransferSelector[:], recipient, value)
value = big.NewInt(0) // ERC-20 tx carries no ETH value
case cfg.BridgeContract != "":
// Mode 2: bridge-contract release
to, err = parseAddress(cfg.BridgeContract)
if err != nil {
return nil, fmt.Errorf("BridgeContract: %w", err)
@@ -183,23 +244,11 @@ func (a *Assembler) PreSign(ctx context.Context, in SwapIntent) (*Unsigned, erro
if err != nil {
return nil, fmt.Errorf("DestinationAddress: %w", err)
}
// data = selector || abi.encode(address, uint256)
// Both args are 32-byte words: 12 leading zero bytes for the
// address, then 20 address bytes; then 32-byte big-endian value.
data = make([]byte, 0, 4+32+32)
data = append(data, cfg.ReleaseSelector[:]...)
var addrWord [32]byte
copy(addrWord[12:], recipient)
data = append(data, addrWord[:]...)
var valueWord [32]byte
valueBytes := value.Bytes()
copy(valueWord[32-len(valueBytes):], valueBytes)
data = append(data, valueWord[:]...)
// In contract-call mode the tx value is 0 — funds move via
// the contract's internal accounting.
data = buildABIWordCall(cfg.ReleaseSelector[:], recipient, value)
value = big.NewInt(0)
} else {
// Pure transfer mode.
default:
// Mode 3: pure native transfer
to, err = parseAddress(in.DestinationAddress)
if err != nil {
return nil, fmt.Errorf("DestinationAddress: %w", err)
@@ -335,6 +384,38 @@ func keccak256(data []byte) [32]byte {
return out
}
// buildABIWordCall encodes a function call as
// selector(4) || addressWord(32) || uintWord(32).
//
// Used by both ERC-20 transfer (selector=0xa9059cbb) and the bridge
// release path (selector=cfg.ReleaseSelector). Both have the same
// (address, uint256) argument shape — the ERC-20 spec's
// `transfer(address,uint256)` and the bridge's `release(address,uint256)`
// or `mint(address,uint256)` all share this signature.
func buildABIWordCall(selector []byte, recipient []byte, amount *big.Int) []byte {
if len(selector) != 4 {
// Shouldn't happen with our callers — guard against silent
// corruption if a future caller passes the wrong size.
panic("txassembler: buildABIWordCall requires 4-byte selector")
}
out := make([]byte, 0, 4+32+32)
out = append(out, selector...)
// recipient word: 12 zero bytes + 20-byte address (right-aligned).
var addrWord [32]byte
copy(addrWord[12:], recipient)
out = append(out, addrWord[:]...)
// amount word: right-aligned 32-byte big-endian uint256.
var valueWord [32]byte
valueBytes := amount.Bytes()
if len(valueBytes) > 32 {
// uint256 overflow — should never happen for normal amounts.
panic(fmt.Sprintf("txassembler: amount %s exceeds uint256", amount))
}
copy(valueWord[32-len(valueBytes):], valueBytes)
out = append(out, valueWord[:]...)
return out
}
// FunctionSelector returns the first 4 bytes of keccak256("signature")
// — the canonical Solidity function selector. Useful for populating
// PerNetwork.ReleaseSelector when the destination bridge contract
+186
View File
@@ -13,6 +13,8 @@ import (
"math/big"
"strings"
"testing"
"github.com/luxfi/bridge/internal/tokens"
)
// =============================================================================
@@ -378,6 +380,190 @@ func TestPreSign_BadAddress(t *testing.T) {
}
}
// =============================================================================
// ERC-20 destination mode
// =============================================================================
func TestPreSign_ERC20Mode(t *testing.T) {
p := &StaticProvider{
GasPrice: map[string]*big.Int{"ETHEREUM_SEPOLIA": big.NewInt(20_000_000_000)},
}
a := New(p)
a.SetNetwork("ETHEREUM_SEPOLIA", txAsmNetwork(11155111))
// Register USDC on Sepolia: 6 decimals, real contract address.
reg := tokens.NewRegistry()
usdcContract := "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"
_ = reg.Register(tokens.Info{
Network: "ETHEREUM_SEPOLIA",
Asset: "USDC",
Contract: usdcContract,
Decimals: 6,
})
a.Tokens = reg
unsigned, err := a.PreSign(context.Background(), SwapIntent{
DestinationNetwork: "ETHEREUM_SEPOLIA",
DestinationAsset: "USDC",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Amount: 100, // 100 USDC
SenderAddress: "0x3535353535353535353535353535353535353535",
})
if err != nil {
t.Fatal(err)
}
// to = USDC contract (not the recipient address)
wantTo := strings.TrimPrefix(strings.ToLower(usdcContract), "0x")
if hex.EncodeToString(unsigned.To) != wantTo {
t.Errorf("to = %x, want %s (USDC contract)", unsigned.To, wantTo)
}
// value = 0 (ERC-20 transfers carry no ETH value)
if unsigned.Value.Sign() != 0 {
t.Errorf("ERC-20 tx value must be 0, got %s", unsigned.Value.String())
}
// data = selector(4) || address-word(32) || amount-word(32) = 68 bytes
if len(unsigned.Data) != 68 {
t.Fatalf("data length = %d, want 68", len(unsigned.Data))
}
// First 4 bytes are the canonical transfer(address,uint256) selector.
if hex.EncodeToString(unsigned.Data[:4]) != "a9059cbb" {
t.Errorf("selector = %x, want a9059cbb (transfer)", unsigned.Data[:4])
}
// Amount word: 100 USDC = 100 * 10^6 = 100_000_000 base units = 0x5F5E100.
// Last 32 bytes of data = the amount word. Strip leading zeros.
amountWord := unsigned.Data[36:68]
amount := new(big.Int).SetBytes(amountWord)
want := big.NewInt(100_000_000)
if amount.Cmp(want) != 0 {
t.Errorf("amount in calldata = %s, want %s (100 USDC at 6 decimals)", amount, want)
}
// Recipient word: last 20 bytes of bytes 4..36 = the recipient.
recipientWord := unsigned.Data[4:36]
if !strings.EqualFold(hex.EncodeToString(recipientWord[12:]), "a28fae14eb42e7a5c36ad2d774a2b7eb293c4473") {
t.Errorf("recipient in calldata: %x", recipientWord)
}
}
func TestPreSign_ERC20_DecimalsMatter(t *testing.T) {
// BSC USDC uses 18 decimals (not 6 like mainnet/sepolia). Make
// sure the amount scaling picks up the right decimals.
p := &StaticProvider{
GasPrice: map[string]*big.Int{"BSC_MAINNET": big.NewInt(5_000_000_000)},
}
a := New(p)
a.SetNetwork("BSC_MAINNET", txAsmNetwork(56))
reg := tokens.NewRegistry()
_ = reg.Register(tokens.Info{
Network: "BSC_MAINNET",
Asset: "USDC",
Contract: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d",
Decimals: 18, // BSC convention
})
a.Tokens = reg
unsigned, err := a.PreSign(context.Background(), SwapIntent{
DestinationNetwork: "BSC_MAINNET",
DestinationAsset: "USDC",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Amount: 1, // 1 USDC at 18 decimals = 10^18 base units
SenderAddress: "0x3535353535353535353535353535353535353535",
})
if err != nil {
t.Fatal(err)
}
amountWord := unsigned.Data[36:68]
amount := new(big.Int).SetBytes(amountWord)
want, _ := new(big.Int).SetString("1000000000000000000", 10) // 10^18
if amount.Cmp(want) != 0 {
t.Errorf("amount = %s, want %s (1 USDC at BSC's 18 decimals)", amount, want)
}
}
func TestPreSign_NativeWithRegistry_StillUsesNativePath(t *testing.T) {
// ETH registered with empty contract → IsNative() → pure transfer.
p := &StaticProvider{GasPrice: map[string]*big.Int{"LUX_TESTNET": big.NewInt(25_000_000_000)}}
a := New(p)
a.SetNetwork("LUX_TESTNET", txAsmNetwork(96368))
reg := tokens.NewRegistry()
_ = reg.Register(tokens.Info{Network: "LUX_TESTNET", Asset: "LUX", Decimals: 18})
a.Tokens = reg
unsigned, err := a.PreSign(context.Background(), SwapIntent{
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "LUX",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Amount: 0.5,
SenderAddress: "0x3535353535353535353535353535353535353535",
})
if err != nil {
t.Fatal(err)
}
if len(unsigned.Data) != 0 {
t.Errorf("native path should have empty data, got %x", unsigned.Data)
}
if unsigned.Value.String() != "500000000000000000" {
t.Errorf("value = %s, want 0.5 ether (5*10^17 wei)", unsigned.Value.String())
}
}
func TestPreSign_ERC20MissingContract_RejectsClean(t *testing.T) {
// Registry has the asset entry but with a bad contract string.
p := &StaticProvider{GasPrice: map[string]*big.Int{"X": big.NewInt(1)}}
a := New(p)
a.SetNetwork("X", txAsmNetwork(1))
reg := tokens.NewRegistry()
_ = reg.Register(tokens.Info{
Network: "X",
Asset: "BADTOKEN",
Contract: "0xshort",
Decimals: 6,
})
a.Tokens = reg
_, err := a.PreSign(context.Background(), SwapIntent{
DestinationNetwork: "X",
DestinationAsset: "BADTOKEN",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Amount: 1,
SenderAddress: "0x3535353535353535353535353535353535353535",
})
if err == nil || !strings.Contains(err.Error(), "ERC-20 contract") {
t.Errorf("expected ERC-20 contract error, got %v", err)
}
}
func TestPreSign_UnknownAsset_FallsBackToNative(t *testing.T) {
// DestinationAsset is "UNOBTAINIUM" — not in registry → falls back
// to native pure-transfer mode (sanest default, preserves
// backward compat for tests that don't seed a registry).
p := &StaticProvider{GasPrice: map[string]*big.Int{"LUX_TESTNET": big.NewInt(1)}}
a := New(p)
a.SetNetwork("LUX_TESTNET", txAsmNetwork(96368))
a.Tokens = tokens.NewRegistry() // empty registry
unsigned, err := a.PreSign(context.Background(), SwapIntent{
DestinationNetwork: "LUX_TESTNET",
DestinationAsset: "UNOBTAINIUM",
DestinationAddress: "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473",
Amount: 0.001,
SenderAddress: "0x3535353535353535353535353535353535353535",
})
if err != nil {
t.Fatal(err)
}
if len(unsigned.Data) != 0 {
t.Errorf("unknown asset should fall back to native path; got data=%x", unsigned.Data)
}
}
// txAsmNetwork is a small helper to build a default PerNetwork for tests.
func txAsmNetwork(chainID int64) PerNetwork {
return PerNetwork{
ChainID: big.NewInt(chainID),
DefaultGasLimit: 100_000,
NativeDecimals: 18,
}
}
// =============================================================================
// Finalize — combine unsigned with sig + ParseRSV
// =============================================================================
+297
View File
@@ -0,0 +1,297 @@
package txassembler
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"sync/atomic"
"time"
)
// rpc_provider.go: live-RPC implementation of the Provider interface.
// Queries the destination chain's JSON-RPC for the actual nonce + gas
// price instead of relying on the hardcoded StaticProvider.
//
// Wire methods used:
// • eth_getTransactionCount(address, "pending") → returns hex string
// of the next nonce the chain will accept for the given address.
// "pending" tag (not "latest") so back-to-back txs from the same
// signer don't collide on the mempool.
// • eth_gasPrice → returns hex string of the suggested wei/gas.
//
// Concurrency: safe for concurrent use; the underlying http.Client
// already is, the atomic counter for JSON-RPC ids handles its own
// synchronization.
// =============================================================================
// Default endpoints — mirror the broadcast package's table
// =============================================================================
//
// Keep in lockstep with internal/broadcast/client.go::rpcURLs. The
// duplication is intentional: each package stays self-contained and
// operator overrides apply to both via the same --source-rpc-overrides
// flag (main.go forwards the map to both).
var defaultEndpoints = map[string]string{
"ETHEREUM_MAINNET": "https://eth.llamarpc.com",
"ETHEREUM_SEPOLIA": "https://rpc.sepolia.org",
"BASE_MAINNET": "https://mainnet.base.org",
"BASE_SEPOLIA": "https://sepolia.base.org",
"LUX_MAINNET": "https://api.lux.network/ext/bc/C/rpc",
"LUX_TESTNET": "https://api.lux-test.network/ext/bc/C/rpc",
"ZOO_MAINNET": "https://api.zoo.network/ext/bc/Z/rpc",
"ZOO_TESTNET": "https://api.zoo-test.network/ext/bc/Z/rpc",
"BSC_MAINNET": "https://bsc-dataseed.binance.org",
"BSC_TESTNET": "https://data-seed-prebsc-1-s1.binance.org:8545",
"POLYGON_MAINNET": "https://polygon-rpc.com",
"ARBITRUM_MAINNET": "https://arb1.arbitrum.io/rpc",
"OPTIMISM_MAINNET": "https://mainnet.optimism.io",
"AVAX_MAINNET": "https://api.avax.network/ext/bc/C/rpc",
"HOLESKY_TESTNET": "https://ethereum-holesky-rpc.publicnode.com",
"ZORA_MAINNET": "https://rpc.zora.energy",
"BLAST_MAINNET": "https://rpc.blast.io",
"LINEA_MAINNET": "https://rpc.linea.build",
}
// DefaultEndpoints returns a copy of the package's network → URL table.
// Useful for callers that want to inspect or pre-populate overrides.
func DefaultEndpoints() map[string]string {
out := make(map[string]string, len(defaultEndpoints))
for k, v := range defaultEndpoints {
out[k] = v
}
return out
}
// =============================================================================
// RPCProvider
// =============================================================================
// DefaultRPCTimeout caps each individual JSON-RPC call. 8 s leaves
// room for slow testnets without holding the assembler hostage.
const DefaultRPCTimeout = 8 * time.Second
// RPCProvider implements Provider by querying the destination chain's
// JSON-RPC endpoint for live nonce + gas-price values.
//
// Construct via NewRPCProvider (which pre-populates defaultEndpoints)
// or by populating fields on a zero-value RPCProvider directly.
type RPCProvider struct {
// Endpoints is the network → URL table consulted for every call.
// Defaults to defaultEndpoints when nil (via NewRPCProvider); a
// caller using the zero value must populate this themselves.
Endpoints map[string]string
// Overrides shadow Endpoints — checked first. Operators set these
// via --source-rpc-overrides when the public defaults are stale
// or rate-limited (e.g. rpc.sepolia.org returning 404 today).
Overrides map[string]string
// Timeout bounds each individual JSON-RPC call. Zero ⇒ DefaultRPCTimeout.
Timeout time.Duration
// HTTPClient is the underlying http.Client. Zero ⇒ http.DefaultClient.
HTTPClient *http.Client
// callSeq is a monotonic JSON-RPC `id` counter. Atomic — safe
// for concurrent use without external locking.
callSeq atomic.Uint64
}
// NewRPCProvider builds a provider with the package's default endpoint
// table + the supplied overrides (may be nil). Pass nil for defaults.
func NewRPCProvider(overrides map[string]string, timeout time.Duration) *RPCProvider {
return &RPCProvider{
Endpoints: DefaultEndpoints(),
Overrides: overrides,
Timeout: timeout,
}
}
func (p *RPCProvider) httpClient() *http.Client {
if p.HTTPClient != nil {
return p.HTTPClient
}
return http.DefaultClient
}
func (p *RPCProvider) timeout() time.Duration {
if p.Timeout > 0 {
return p.Timeout
}
return DefaultRPCTimeout
}
func (p *RPCProvider) url(network string) string {
if u, ok := p.Overrides[network]; ok && u != "" {
return u
}
if p.Endpoints == nil {
return ""
}
return p.Endpoints[network]
}
// =============================================================================
// Provider implementation
// =============================================================================
// PendingNonce queries eth_getTransactionCount(address, "pending").
func (p *RPCProvider) PendingNonce(ctx context.Context, network, address string) (uint64, error) {
url := p.url(network)
if url == "" {
return 0, fmt.Errorf("txassembler: no RPC endpoint configured for %s", network)
}
type resp struct {
Result string `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
var r resp
if err := p.jsonRPC(ctx, url, "eth_getTransactionCount",
[]any{address, "pending"}, &r); err != nil {
return 0, err
}
if r.Error != nil {
return 0, fmt.Errorf("txassembler: eth_getTransactionCount: rpc %d: %s",
r.Error.Code, r.Error.Message)
}
n, err := parseHexUint64(r.Result)
if err != nil {
return 0, fmt.Errorf("txassembler: parse nonce hex %q: %w", r.Result, err)
}
return n, nil
}
// SuggestGasPrice queries eth_gasPrice.
func (p *RPCProvider) SuggestGasPrice(ctx context.Context, network string) (*big.Int, error) {
url := p.url(network)
if url == "" {
return nil, fmt.Errorf("txassembler: no RPC endpoint configured for %s", network)
}
type resp struct {
Result string `json:"result"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
var r resp
if err := p.jsonRPC(ctx, url, "eth_gasPrice", []any{}, &r); err != nil {
return nil, err
}
if r.Error != nil {
return nil, fmt.Errorf("txassembler: eth_gasPrice: rpc %d: %s",
r.Error.Code, r.Error.Message)
}
n, err := parseHexBigInt(r.Result)
if err != nil {
return nil, fmt.Errorf("txassembler: parse gas price hex %q: %w", r.Result, err)
}
if n.Sign() <= 0 {
return nil, fmt.Errorf("txassembler: eth_gasPrice returned non-positive value %q", r.Result)
}
return n, nil
}
// =============================================================================
// HTTP transport
// =============================================================================
func (p *RPCProvider) jsonRPC(ctx context.Context, url, method string, params any, out any) error {
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": p.callSeq.Add(1),
"method": method,
"params": params,
})
if err != nil {
return fmt.Errorf("marshal %s: %w", method, err)
}
callCtx, cancel := context.WithTimeout(ctx, p.timeout())
defer cancel()
req, err := http.NewRequestWithContext(callCtx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := p.httpClient().Do(req)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
return fmt.Errorf("txassembler: %s transport: %w", method, err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("txassembler: %s HTTP %d: %s",
method, resp.StatusCode, truncateRPC(respBody, 200))
}
if err := json.Unmarshal(respBody, out); err != nil {
return fmt.Errorf("txassembler: %s decode: %w (body=%s)",
method, err, truncateRPC(respBody, 200))
}
return nil
}
// =============================================================================
// Hex parsing helpers
// =============================================================================
// parseHexUint64 accepts an Ethereum-style "0x..." hex string and
// returns the unsigned 64-bit integer. Empty / "0x" / "0x0" → 0.
func parseHexUint64(s string) (uint64, error) {
s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X")
if s == "" {
return 0, nil
}
var n uint64
for i := 0; i < len(s); i++ {
nibble, ok := hexNibble(s[i])
if !ok {
return 0, fmt.Errorf("invalid hex char %q", s[i])
}
// Reject overflow — 16 hex chars = 64 bits is the max for uint64.
if n > (1<<60)-1 {
return 0, errors.New("nonce overflow")
}
n = (n << 4) | uint64(nibble)
}
return n, nil
}
// parseHexBigInt accepts an Ethereum-style "0x..." hex string and
// returns the value as *big.Int.
func parseHexBigInt(s string) (*big.Int, error) {
s = strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X")
if s == "" {
return new(big.Int), nil
}
n, ok := new(big.Int).SetString(s, 16)
if !ok {
return nil, fmt.Errorf("invalid hex %q", s)
}
return n, nil
}
func truncateRPC(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}
+356
View File
@@ -0,0 +1,356 @@
package txassembler
import (
"context"
"encoding/json"
"errors"
"io"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// =============================================================================
// PendingNonce
// =============================================================================
func TestRPCProvider_PendingNonce_HappyPath(t *testing.T) {
var gotMethod string
var gotParams []any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct {
Method string `json:"method"`
Params []any `json:"params"`
}
_ = json.Unmarshal(body, &req)
gotMethod = req.Method
gotParams = req.Params
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1, "result": "0x2a", // 42
})
}))
t.Cleanup(srv.Close)
p := &RPCProvider{Endpoints: map[string]string{"LUX_TESTNET": srv.URL}, Timeout: time.Second}
got, err := p.PendingNonce(context.Background(), "LUX_TESTNET", "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473")
if err != nil {
t.Fatal(err)
}
if got != 42 {
t.Errorf("got %d, want 42", got)
}
if gotMethod != "eth_getTransactionCount" {
t.Errorf("method = %q, want eth_getTransactionCount", gotMethod)
}
if len(gotParams) != 2 ||
gotParams[0] != "0xa28fAE14eB42e7A5C36Ad2D774a2b7Eb293c4473" ||
gotParams[1] != "pending" {
t.Errorf("params mismatch: %v", gotParams)
}
}
func TestRPCProvider_PendingNonce_Zero(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1, "result": "0x0",
})
}))
t.Cleanup(srv.Close)
p := &RPCProvider{Endpoints: map[string]string{"LUX_TESTNET": srv.URL}, Timeout: time.Second}
n, err := p.PendingNonce(context.Background(), "LUX_TESTNET", "0xabc")
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("got %d, want 0", n)
}
}
func TestRPCProvider_PendingNonce_RPCError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1,
"error": map[string]any{"code": -32000, "message": "method not enabled"},
})
}))
t.Cleanup(srv.Close)
p := &RPCProvider{Endpoints: map[string]string{"LUX_TESTNET": srv.URL}, Timeout: time.Second}
_, err := p.PendingNonce(context.Background(), "LUX_TESTNET", "0xabc")
if err == nil || !strings.Contains(err.Error(), "method not enabled") {
t.Errorf("expected upstream error to surface, got %v", err)
}
}
func TestRPCProvider_PendingNonce_NoEndpoint(t *testing.T) {
p := &RPCProvider{Endpoints: map[string]string{}, Timeout: time.Second}
_, err := p.PendingNonce(context.Background(), "UNKNOWN_NETWORK", "0xabc")
if err == nil || !strings.Contains(err.Error(), "no RPC endpoint") {
t.Errorf("expected no-endpoint error, got %v", err)
}
}
func TestRPCProvider_PendingNonce_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "rate limit", http.StatusTooManyRequests)
}))
t.Cleanup(srv.Close)
p := &RPCProvider{Endpoints: map[string]string{"LUX_TESTNET": srv.URL}, Timeout: time.Second}
_, err := p.PendingNonce(context.Background(), "LUX_TESTNET", "0xabc")
if err == nil || !strings.Contains(err.Error(), "HTTP 429") {
t.Errorf("expected HTTP 429 surface, got %v", err)
}
}
func TestRPCProvider_PendingNonce_MalformedHex(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1, "result": "0xZZZ",
})
}))
t.Cleanup(srv.Close)
p := &RPCProvider{Endpoints: map[string]string{"LUX_TESTNET": srv.URL}, Timeout: time.Second}
_, err := p.PendingNonce(context.Background(), "LUX_TESTNET", "0xabc")
if err == nil || !strings.Contains(err.Error(), "invalid hex char") {
t.Errorf("expected parse error, got %v", err)
}
}
// =============================================================================
// SuggestGasPrice
// =============================================================================
func TestRPCProvider_SuggestGasPrice_HappyPath(t *testing.T) {
var gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Method string `json:"method"`
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &req)
gotMethod = req.Method
w.Header().Set("Content-Type", "application/json")
// 30 gwei = 0x6FC23AC00
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1, "result": "0x6FC23AC00",
})
}))
t.Cleanup(srv.Close)
p := &RPCProvider{Endpoints: map[string]string{"LUX_TESTNET": srv.URL}, Timeout: time.Second}
got, err := p.SuggestGasPrice(context.Background(), "LUX_TESTNET")
if err != nil {
t.Fatal(err)
}
want := big.NewInt(30_000_000_000)
if got.Cmp(want) != 0 {
t.Errorf("got %s wei, want %s wei", got, want)
}
if gotMethod != "eth_gasPrice" {
t.Errorf("method = %q, want eth_gasPrice", gotMethod)
}
}
func TestRPCProvider_SuggestGasPrice_RejectsZero(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1, "result": "0x0",
})
}))
t.Cleanup(srv.Close)
p := &RPCProvider{Endpoints: map[string]string{"LUX_TESTNET": srv.URL}, Timeout: time.Second}
_, err := p.SuggestGasPrice(context.Background(), "LUX_TESTNET")
if err == nil || !strings.Contains(err.Error(), "non-positive") {
t.Errorf("expected non-positive error, got %v", err)
}
}
func TestRPCProvider_SuggestGasPrice_LargeValue(t *testing.T) {
// 1000 gwei = 10^12 wei = 0xE8D4A51000 — needs big.Int (fits in
// uint64 but we want to verify big.Int handles >32-bit values).
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1, "result": "0xE8D4A51000",
})
}))
t.Cleanup(srv.Close)
p := &RPCProvider{Endpoints: map[string]string{"LUX_TESTNET": srv.URL}, Timeout: time.Second}
got, err := p.SuggestGasPrice(context.Background(), "LUX_TESTNET")
if err != nil {
t.Fatal(err)
}
want, _ := new(big.Int).SetString("1000000000000", 10)
if got.Cmp(want) != 0 {
t.Errorf("got %s, want %s", got, want)
}
}
// =============================================================================
// Constructor + overrides
// =============================================================================
func TestNewRPCProvider_PopulatesDefaults(t *testing.T) {
p := NewRPCProvider(nil, 5*time.Second)
if len(p.Endpoints) == 0 {
t.Fatal("expected default endpoints to populate")
}
if p.Endpoints["LUX_TESTNET"] == "" {
t.Error("LUX_TESTNET should have a default endpoint")
}
if p.Timeout != 5*time.Second {
t.Errorf("Timeout = %v", p.Timeout)
}
}
func TestRPCProvider_OverrideBeatsEndpoint(t *testing.T) {
// Default endpoint deliberately invalid; override should win.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0", "id": 1, "result": "0xff",
})
}))
t.Cleanup(srv.Close)
p := &RPCProvider{
Endpoints: map[string]string{"LUX_TESTNET": "http://invalid.test:0/"},
Overrides: map[string]string{"LUX_TESTNET": srv.URL},
Timeout: time.Second,
}
got, err := p.PendingNonce(context.Background(), "LUX_TESTNET", "0xabc")
if err != nil {
t.Fatal(err)
}
if got != 255 {
t.Errorf("got %d, want 255 (override should have routed to mock)", got)
}
}
func TestDefaultEndpoints_ReturnsCopy(t *testing.T) {
a := DefaultEndpoints()
a["MARS_MAINNET"] = "http://example.test"
b := DefaultEndpoints()
if _, ok := b["MARS_MAINNET"]; ok {
t.Error("DefaultEndpoints should return a fresh copy; mutation leaked")
}
}
// =============================================================================
// parseHex helpers
// =============================================================================
func TestParseHexUint64(t *testing.T) {
cases := []struct {
in string
want uint64
wantErr bool
}{
{"0x0", 0, false},
{"0x", 0, false},
{"", 0, false},
{"0x2a", 42, false},
{"0xFF", 255, false},
{"0xff", 255, false},
{"0X10", 16, false},
{"0xdeadbeef", 0xdeadbeef, false},
{"0xZZ", 0, true},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
got, err := parseHexUint64(tc.in)
if tc.wantErr {
if err == nil {
t.Error("expected error")
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("got %d, want %d", got, tc.want)
}
})
}
}
func TestParseHexBigInt(t *testing.T) {
cases := []struct {
in string
want string // decimal
}{
{"0x0", "0"},
{"", "0"},
{"0xff", "255"},
{"0xde0b6b3a7640000", "1000000000000000000"}, // 1 ether
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
got, err := parseHexBigInt(tc.in)
if err != nil {
t.Fatal(err)
}
if got.String() != tc.want {
t.Errorf("got %s, want %s", got.String(), tc.want)
}
})
}
if _, err := parseHexBigInt("0xZZ"); err == nil {
t.Error("expected error on bad hex")
}
}
// =============================================================================
// Context + timeout
// =============================================================================
func TestRPCProvider_ContextCanceled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(2 * time.Second):
}
}))
t.Cleanup(srv.Close)
p := &RPCProvider{Endpoints: map[string]string{"X": srv.URL}, Timeout: 5 * time.Second}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := p.PendingNonce(ctx, "X", "0xabc")
if err == nil {
t.Fatal("expected cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %T: %v", err, err)
}
}
func TestRPCProvider_TimeoutFires(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(2 * time.Second):
}
}))
t.Cleanup(srv.Close)
p := &RPCProvider{Endpoints: map[string]string{"X": srv.URL}, Timeout: 200 * time.Millisecond}
start := time.Now()
_, err := p.PendingNonce(context.Background(), "X", "0xabc")
dur := time.Since(start)
if err == nil {
t.Fatal("expected timeout")
}
if dur > time.Second {
t.Errorf("expected fast timeout, took %v", dur)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("expected context.DeadlineExceeded, got %T: %v", err, err)
}
}
+308
View File
@@ -0,0 +1,308 @@
---
# Lux Bridge — unified Go binary (cmd/bridge).
#
# Replaces the legacy split of:
# - bridge-server (Express + Prisma Node API)
# - bridge-ui (Vite SPA on ghcr.io/hanzoai/spa)
# with a single image (ghcr.io/luxfi/bridge:<tag>) that embeds the SPA
# via go:embed and serves the /v1/bridge/* API natively.
#
# Assumes the lux-bridge namespace + bridge-secrets Secret already exist
# (created by k8s/mpc-deployment.yaml). Required secret keys:
# - bridge-secrets.mpc-api-token bearer token for the MPC daemon
# Optional secret keys (referenced only if you uncomment the env entry):
# - bridge-secrets.bchain-rpc-url BridgeVM JSON-RPC base URL
#
# Apply:
# kubectl apply -f k8s/bridge-deployment.yaml
#
# The MPC StatefulSet + Service from k8s/mpc-deployment.yaml must already
# be running — this deployment talks to mpc-api-svc.lux-bridge.svc:8081.
apiVersion: v1
kind: ConfigMap
metadata:
name: lux-bridge-config
namespace: lux-bridge
data:
networks.yaml: |
# Source of truth for the SPA + API's supported chains + tokens.
# Edit this ConfigMap and roll the deployment to add/remove networks
# without rebuilding the image.
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: base-mainnet
displayName: Base
nativeCurrency: ETH
chainId: "8453"
type: evm
avgCompletion: 2m
txExplorerTpl: https://basescan.org/tx/{tx}
addrExplorerTpl: https://basescan.org/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: ETH
name: Ether
decimals: 18
network: ethereum-mainnet
- asset: USDC
name: USD Coin
decimals: 6
contract: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
network: ethereum-mainnet
- asset: USDT
name: Tether USD
decimals: 6
contract: "0xdAC17F958D2ee523a2206206994597C13D831ec7"
network: ethereum-mainnet
- asset: USDC
name: USD Coin
decimals: 6
contract: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
network: base-mainnet
limits:
minUSD: 10
maxUSD: 100000
per:
USDT: {minUSD: 10, maxUSD: 100000}
USDC: {minUSD: 10, maxUSD: 100000}
---
# PersistentVolumeClaim — the swap store (zapdb / Lux-flavored Badger v4)
# lives here. Single-writer: zapdb takes an exclusive directory lock,
# which is why the Deployment is pinned to replicas: 1 with Recreate.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: lux-bridge-data
namespace: lux-bridge
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 20Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: lux-bridge
namespace: lux-bridge
labels:
app: lux-bridge
component: api
spec:
# Single-writer: zapdb takes an exclusive dir lock. Scale-out
# requires migrating swap state to a shared external store first.
replicas: 1
# Recreate (not RollingUpdate) — the new pod can't claim the PV
# lock while the old pod still holds it.
strategy:
type: Recreate
selector:
matchLabels:
app: lux-bridge
template:
metadata:
labels:
app: lux-bridge
component: api
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: bridge
image: ghcr.io/luxfi/bridge:latest
imagePullPolicy: Always
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
ports:
- containerPort: 8080
name: http
env:
- name: BRIDGE_ADDR
value: ":8080"
- name: BRIDGE_CONFIG
value: /etc/bridge/networks.yaml
# Persistent swap store. The PVC mounted at this path
# holds the zapdb files (memtable, vlog, sst). Removing
# this env var falls back to the in-memory store —
# restart loses state.
- name: BRIDGE_DATA_DIR
value: /var/lib/lux-bridge
# Empty disables the legacy Node backend reverse proxy. All
# /v1/bridge/* routes resolve through native handlers.
- name: BRIDGE_BACKEND_URL
value: ""
# BridgeVM (b-chain) RPC. Leave unset until the BridgeVM is
# available — the swap/quote routes still serve from native
# state when bchain is offline.
- name: BRIDGE_BCHAIN_URL
value: ""
# MPC keygen + signing daemon (mpcd) exposed by the StatefulSet
# in mpc-deployment.yaml. The service forwards :8081 → :8080.
- name: BRIDGE_MPC_URL
value: http://mpc-api-svc.lux-bridge.svc:8081
- name: BRIDGE_MPC_TOKEN
valueFrom:
secretKeyRef:
name: bridge-secrets
key: mpc-api-token
- name: BRIDGE_MPC_ORG_ID
value: bridge
# Bridge security profile. classical-compat = user-facing UI to
# external L1s. Pin strict-pq for internal Lux↔Lux only.
- name: BRIDGE_PROFILE
value: classical-compat
volumeMounts:
- name: config
mountPath: /etc/bridge
readOnly: true
- name: data
mountPath: /var/lib/lux-bridge
- name: tmp
mountPath: /tmp
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 2
periodSeconds: 5
failureThreshold: 2
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "500m"
volumes:
- name: config
configMap:
name: lux-bridge-config
- name: data
persistentVolumeClaim:
claimName: lux-bridge-data
- name: tmp
emptyDir:
sizeLimit: 50Mi
---
apiVersion: v1
kind: Service
metadata:
name: lux-bridge
namespace: lux-bridge
labels:
app: lux-bridge
spec:
type: ClusterIP
selector:
app: lux-bridge
ports:
- port: 80
targetPort: http
name: http
---
# Ingress for the unified binary. Routes both the SPA (/) and the API
# (/v1/bridge/*) to the same Service — the binary serves both. Replaces
# the legacy split routing in mpc-deployment.yaml's bridge-ingress, which
# fanned /v1/ to opnet-watcher and / to bridge-ui.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: lux-bridge
namespace: lux-bridge
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: ingress
tls:
- hosts:
- bridge.lux.network
- bridge-api.lux.network
secretName: lux-bridge-tls
rules:
- host: bridge.lux.network
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: lux-bridge
port:
number: 80
- host: bridge-api.lux.network
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: lux-bridge
port:
number: 80
---
# NOTE: no HorizontalPodAutoscaler. The swap store is a single-writer
# embedded KV (zapdb with exclusive dir lock). Scaling beyond 1 pod
# requires migrating swap state to a shared external store first.
#
# NOTE: no PodDisruptionBudget. With replicas=1 a PDB would either
# block drains entirely (minAvailable=1) or be a no-op (minAvailable=0).
# Operators scale to 0 manually for planned maintenance.