mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
feat: added mpc sign option
This commit is contained in:
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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, ¤t)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("zap_store: unmarshal current: %w", err)
|
||||
}
|
||||
oldStatus := current.Status
|
||||
oldSrc := current.SourceNetwork
|
||||
|
||||
fn(¤t)
|
||||
current.UpdatedAt = s.nowSafe()
|
||||
|
||||
val, err := json.Marshal(¤t)
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user