feat(tenant): declarative tenant config — white-label shim pattern

Adds internal/tenant.Config: a single YAML primitive that drives brand,
IAM endpoint, KMS endpoint, MPC cluster, PQ profile, supported-chains
allowlist, basket allowlist, fee receiver, domain, and per-family
release-pool sizing. Same pattern Lux already uses for IAM (hanzo.id /
lux.id / zoo.id / pars.id) and KMS.

- internal/tenant/config.go (462 LOC): Config + per-section structs
  (Brand, Network, IAM, KMS, MPC, ReleasePool, FamilyPool); Load() +
  Parse() + Validate(); IsChainSupported() + IsBasketAllowed() helpers.
  Strict validation — daemon hard-fails on invalid config (no warn-
  and-continue path).

- internal/tenant/config_test.go (491 LOC): round-trip yaml, defaults
  applied, every required-field omission rejected, PQ profile must be
  one of strict-pq|classical-compat, FeeReceiverAddr must be 0x-prefixed
  20-byte hex, SupportedChains must be subset of the known family set,
  slug must be lowercase-hyphenated, IAM/KMS endpoints must be https://,
  MPC threshold can't exceed operators, basket allowlist validates.
  26 tests, all pass.

- cmd/bridge/main.go: --tenant-config flag (BRIDGE_TENANT_CONFIG env).
  When set, brand slug becomes the daemon log "service" tag + Fiber
  AppName; tenant defaults fill flags the operator didn't pass.
  Backwards-compatible: existing flag-driven deploys keep working.

- cmd/bridge/tenant_overrides.go: applyTenantOverrides — explicit
  flag wins, tenant fills gaps. flag.Visit-based, so we never
  clobber operator intent.

- cmd/bridge/main_test.go: TestTenantConfigFixture loads a complete
  tenant.yaml from disk and asserts it validates; TestTenantExampleYAMLLoads
  is a CI guardrail that the repo-root example file stays in sync
  with the schema.

- tenant.example.yaml: canonical commented schema. All four white-label
  shim repos (liquidity/bridge, hanzo/bridge-shim, zoo/bridge-shim,
  pars/bridge-shim) consume this shape.

Tests: ./... PASS — internal/tenant 0.282s, cmd/bridge 2.86s, full
suite green across 12 packages.
This commit is contained in:
2026-05-28 01:24:15 -07:00
parent 439b340cc6
commit caa55ca609
6 changed files with 1331 additions and 2 deletions
+42 -2
View File
@@ -51,6 +51,7 @@ import (
"github.com/luxfi/bridge/internal/depositcheck"
"github.com/luxfi/bridge/internal/mchain"
"github.com/luxfi/bridge/internal/substrate"
"github.com/luxfi/bridge/internal/tenant"
"github.com/luxfi/bridge/internal/tokens"
"github.com/luxfi/bridge/internal/txassembler"
luxlog "github.com/luxfi/log"
@@ -88,6 +89,10 @@ func parseRPCOverrides(raw string) (map[string]string, error) {
var version = "dev"
func main() {
tenantPath := flag.String("tenant-config", envOr("BRIDGE_TENANT_CONFIG", ""),
"path to the tenant config (tenant.yaml). When set, brand/IAM/KMS/MPC/PQ/supportedChains "+
"come from this file instead of per-flag defaults. White-label shim repos pass this; "+
"the canonical luxfi/bridge deployment may omit it and fall back to flag-driven defaults.")
cfgPath := flag.String("config", envOr("BRIDGE_CONFIG", "/etc/bridge/networks.yaml"), "path to networks.yaml")
addr := flag.String("addr", envOr("BRIDGE_ADDR", ":8080"), "listen address")
backend := flag.String("backend", envOr("BRIDGE_BACKEND_URL", ""), "legacy Node backend URL for proxied routes (empty disables proxy)")
@@ -200,7 +205,42 @@ func main() {
return
}
logger := luxlog.New("service", "lux-bridge")
// Tenant config: when --tenant-config is set, brand + IAM + KMS +
// MPC + PQ + supportedChains come from the YAML. Otherwise the
// flag-driven defaults stay in effect (legacy canonical Lux
// deployment path).
var tcfg *tenant.Config
serviceTag := "lux-bridge"
appName := "lux-bridge"
if *tenantPath != "" {
t, err := tenant.Load(*tenantPath)
if err != nil {
fmt.Fprintf(os.Stderr, "tenant config: %v\n", err)
os.Exit(1)
}
tcfg = t
serviceTag = t.Brand.Slug
appName = t.Brand.Slug
// Apply tenant defaults to flags that the operator did not
// explicitly pass. The pattern is: flag wins (explicit operator
// intent), tenant fills missing defaults (declarative deploy).
applyTenantOverrides(t, cfgPath, addr, mpcURL, mpcOrgID, profileFlag, releasePoolMintNetwork, releasePoolSize)
}
logger := luxlog.New("service", serviceTag)
if tcfg != nil {
logger.Info("tenant config loaded",
"path", *tenantPath,
"brand", tcfg.Brand.Name,
"slug", tcfg.Brand.Slug,
"network_id", tcfg.Network.ID,
"iam_endpoint", tcfg.IAM.Endpoint,
"kms_endpoint", tcfg.KMS.Endpoint,
"pq_profile", tcfg.PQProfile,
"supported_chains", tcfg.SupportedChains,
"domain", tcfg.Domain,
)
}
// Resolve the bridge profile. Default is classical-compat (the
// user-facing UI talks to external L1s); operators pin
@@ -971,7 +1011,7 @@ func main() {
defer refundCancel()
app := zip.New(zip.Config{
AppName: "lux-bridge",
AppName: appName,
DisableStartupMessage: true,
})
app.Use(middleware.Recover(), middleware.RequestID())
+101
View File
@@ -1,15 +1,21 @@
package main
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/luxfi/bridge/internal/tenant"
)
// Coverage for the small helpers in main.go. The main() function itself
// isn't easily testable (it owns the process lifecycle); per-helper
// coverage is the right granularity.
var _ = reflect.DeepEqual // keep reflect imported across edits
func TestParseRPCOverrides_Happy(t *testing.T) {
got, err := parseRPCOverrides("ETHEREUM_SEPOLIA=https://example.test/eth, BITCOIN_TESTNET=https://example.test/btc/api")
if err != nil {
@@ -82,3 +88,98 @@ func TestParseRPCOverrides_PreservesURLContainingEquals(t *testing.T) {
t.Errorf("got %q, want %q", got["ETHEREUM_SEPOLIA"], want)
}
}
// TestTenantConfigFixture loads a complete tenant config from a
// fixture YAML on disk and asserts it parses + validates. Mirrors
// what cmd/bridge main() does on --tenant-config.
func TestTenantConfigFixture(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tenant.yaml")
src := []byte(`
brand:
name: "Lux Bridge"
slug: "lux-bridge"
primaryColor: "#7547ff"
logoURL: /logo.svg
faviconURL: /icon.svg
docsURL: https://docs.lux.network
network:
id: 96369
name: "Lux Mainnet"
rpcURL: https://api.lux.network
explorerURL: https://explorer.lux.network
iam:
endpoint: https://lux.id
clientID: lux-bridge
organization: lux
kms:
endpoint: https://kms.lux.network
projectID: lux-bridge
environment: prod
mpc:
url: http://mpc.lux-mpc.svc:9800
orgID: bridge
threshold: 3
operators: [n0, n1, n2, n3, n4]
pqProfile: classical-compat
supportedChains: ["eth", "btc", "sol", "ton", "xrp", "dot", "lux"]
basketAllowlist:
USD: ["USDC", "USDT", "DAI"]
feeReceiverAddr: "0xdEaD000000000000000000000000000000000000"
domain: bridge.lux.network
releasePool:
evm: { size: 10, mintNetwork: LUX_MAINNET, balanceThreshold: "100000000000000000" }
sol: { size: 5, mintNetwork: SOLANA_MAINNET, balanceThreshold: "1000000000" }
btc: { size: 0, mintNetwork: "", balanceThreshold: "" }
xrp: { size: 5, mintNetwork: XRP_MAINNET, balanceThreshold: "10000000" }
dot: { size: 3, mintNetwork: POLKADOT_MAINNET, balanceThreshold: "100000000000" }
ton: { size: 0, mintNetwork: "", balanceThreshold: "" }
`)
if err := os.WriteFile(path, src, 0o600); err != nil {
t.Fatal(err)
}
tcfg, err := tenant.Load(path)
if err != nil {
t.Fatalf("tenant.Load: %v", err)
}
if tcfg.Brand.Slug != "lux-bridge" {
t.Errorf("Brand.Slug = %q", tcfg.Brand.Slug)
}
if tcfg.Network.ID != 96369 {
t.Errorf("Network.ID = %d", tcfg.Network.ID)
}
if tcfg.PQProfile != "classical-compat" {
t.Errorf("PQProfile = %q", tcfg.PQProfile)
}
if !tcfg.IsChainSupported("eth") {
t.Error("eth must be supported")
}
if !tcfg.IsBasketAllowed("USD", "USDC") {
t.Error("USDC must be allowed in USD basket")
}
}
// TestTenantExampleYAMLLoads parses the repo-root tenant.example.yaml.
// Documents the canonical shape and acts as a CI guardrail against
// drift between the schema and the example.
func TestTenantExampleYAMLLoads(t *testing.T) {
// repo root from cmd/bridge/main_test.go.
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
root := filepath.Join(wd, "..", "..", "tenant.example.yaml")
if _, err := os.Stat(root); err != nil {
t.Skipf("tenant.example.yaml not present at %s: %v", root, err)
}
tcfg, err := tenant.Load(root)
if err != nil {
t.Fatalf("tenant.Load(%s): %v", root, err)
}
if tcfg.Brand.Slug == "" {
t.Errorf("Brand.Slug empty")
}
if tcfg.Network.ID == 0 {
t.Errorf("Network.ID zero")
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"flag"
"github.com/luxfi/bridge/internal/tenant"
)
// applyTenantOverrides patches flag pointers with values from the
// tenant config, but only when the operator did NOT explicitly pass
// the flag on the command line. Flag wins; tenant fills gaps. This
// preserves the legacy CLI ergonomics (operators can still pass any
// flag) while letting white-label shims declare everything via YAML.
//
// The pointer-soup signature is unfortunate but the rest of main.go
// reads these as dereferenced *string / *int values; passing them
// here lets us mutate the same memory without restructuring.
func applyTenantOverrides(
t *tenant.Config,
cfgPath *string,
addr *string,
mpcURL *string,
mpcOrgID *string,
profile *string,
releasePoolMintNetwork *string,
releasePoolSize *int,
) {
if t == nil {
return
}
set := map[string]bool{}
flag.Visit(func(f *flag.Flag) {
set[f.Name] = true
})
// --config (networks.yaml path).
if !set["config"] && t.NetworksConfigPath != "" {
*cfgPath = t.NetworksConfigPath
}
// --addr (listen).
if !set["addr"] && t.Listen != "" {
*addr = t.Listen
}
// --mpc-url. Tenant ships the cluster URL; operator can still
// override for local-dev pointed at a different daemon.
if !set["mpc-url"] && t.MPC.URL != "" {
*mpcURL = t.MPC.URL
}
// --mpc-org-id. Tenant pins the multiplex tag.
if !set["mpc-org-id"] && t.MPC.OrgID != "" {
*mpcOrgID = t.MPC.OrgID
}
// --profile. PQProfile from tenant maps directly to the
// selectProfile() values via the alias table in main.go's
// selectProfile() — "strict-pq" and "classical-compat" are both
// accepted there.
if !set["profile"] && t.PQProfile != "" {
*profile = t.PQProfile
}
// --release-pool-size + --release-pool-mint-network: EVM pool.
if !set["release-pool-size"] {
*releasePoolSize = t.ReleasePool.EVM.Size
}
if !set["release-pool-mint-network"] && t.ReleasePool.EVM.MintNetwork != "" {
*releasePoolMintNetwork = t.ReleasePool.EVM.MintNetwork
}
}
+462
View File
@@ -0,0 +1,462 @@
// Package tenant is the declarative tenant configuration primitive for
// the bridge. One process = one tenant = one Config. The shape mirrors
// the IAM / KMS / MPC / asset white-label pattern already used across
// the ecosystem: a single YAML file ("tenant.yaml") drives every
// brand-specific, deployment-specific, and chain-specific value that
// the bridge daemon consumes at startup.
//
// Composition over inheritance: this Config does not REPLACE the
// per-network table in cmd/bridge/config.go::Config; rather, the runner
// reads BOTH (tenant.yaml + networks.yaml) and composes them. Tenant
// owns the policy + brand + identity bindings; networks.yaml stays the
// table of chains + tokens, source-controllable independently.
//
// Validation runs at Load() time. Daemon refuses to boot on an invalid
// config — there is no "warn and continue" path. Operators who typo a
// field MUST see the error rather than ship under the wrong posture.
package tenant
import (
"errors"
"fmt"
"os"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// Config is the root tenant config. Every field is either required or
// has a documented default — see Validate() for the contract.
type Config struct {
// Brand drives the SPA's runtime config (window.ENV), the embedded
// app's <title>, per-host assets, and the daemon's log "service" tag.
Brand BrandConfig `yaml:"brand"`
// Network is the tenant's primary L1 EVM (the chain that receives
// bridged collateral mints). Independent of NetworkID — the primary
// network ID is shared across all Lux-derived L1s (1=mainnet,
// 2=fuji, 3=local, 1337=development), while the EVM chain ID here
// is per-subnet, per-env (see CLAUDE.md "Network ID vs EVM Chain
// ID — NEVER CONFLATE").
Network NetworkConfig `yaml:"network"`
// IAM points the bridge at the tenant's IAM (hanzo.id, lux.id,
// zoo.id, pars.id, liquidity.id, ...). Per the IAM SSO requirement
// the bridge MUST NOT ship local auth — it federates to IAM only.
IAM IAMConfig `yaml:"iam"`
// KMS points the bridge at the tenant's KMS (kms.hanzo.ai,
// kms.lux.network, ...). All secrets (mpc tokens, coingecko keys,
// release-wallet seeds) come from KMS via Universal Auth, NEVER
// from environment variables in production.
KMS KMSConfig `yaml:"kms"`
// MPC is the threshold-signing cluster configuration. Tenant pins
// its own MPC operators (Liquidity's, Hanzo's, Zoo's, Pars's
// signer sets are disjoint).
MPC MPCConfig `yaml:"mpc"`
// PQProfile names the post-quantum posture the bridge enforces.
// Two values:
// strict-pq — refuse classical-only envelopes at the
// precompile layer (default for internal
// tenants whose destination is a Lux-derived L1)
// classical-compat — permit ECDSA-only envelopes inside the
// governance-set window (required when the
// tenant routinely bridges to external L1s
// like Ethereum mainnet)
PQProfile string `yaml:"pqProfile"`
// SupportedChains is the allowlist of chain families the daemon
// will mount handlers for. Anything not in this list will not have
// a release-pool, will not appear in /v1/bridge/networks, and will
// refuse swap creates. Subset of {eth,btc,sol,ton,xrp,dot,liquid,
// hanzo,zoo,pars,spc,lux}.
SupportedChains []string `yaml:"supportedChains"`
// BasketAllowlist names the per-basket collateral set. Maps a
// basket symbol (USD, EUR, GBP, BTC, ETH, ...) to the list of
// asset symbols permitted as backing. Used by the BasketRegistry
// deployment script (Part 2) AND by the bridge's quote engine to
// refuse quotes that would mix collateral outside the allowlist.
BasketAllowlist map[string][]string `yaml:"basketAllowlist"`
// FeeReceiverAddr is the destination of the per-claim fee skim.
// The bridge enforces this via OpenZeppelin AccessControl's
// OPERATOR_ROLE — only the operator-key can drain the fee vault,
// privilege-separating it from the main vault.
FeeReceiverAddr string `yaml:"feeReceiverAddr"`
// Domain is the public DNS host the SPA + API serve on. The
// frontend uses this to compute absolute URLs in og:metadata; the
// k8s IngressRoute uses it as the Host() match. Example:
// "bridge." / "bridge.hanzo.network".
Domain string `yaml:"domain"`
// ReleasePool is the per-family wallet-pool configuration (size +
// mint-network per family). One pool per family because each
// family's key shape, address derivation, and pre-funding
// requirements differ.
ReleasePool ReleasePoolConfig `yaml:"releasePool"`
// NetworksConfigPath is the path on disk (mounted in the K8s
// ConfigMap) to the per-chain networks.yaml table consumed by the
// existing cmd/bridge LoadConfig. Defaults to "/etc/bridge/networks.yaml".
NetworksConfigPath string `yaml:"networksConfigPath"`
// Listen is the daemon's HTTP listen address. Defaults to ":8080".
Listen string `yaml:"listen"`
}
// BrandConfig drives the SPA's runtime config window.ENV.brand and
// the daemon's log "service" tag.
type BrandConfig struct {
// Name is the human-readable brand label shown in the SPA header.
// Example: "Lux Bridge", "Liquid Bridge", "Hanzo Bridge".
Name string `yaml:"name"`
// Slug is the machine-readable form used as the daemon's log
// service tag and the embedded app's framework AppName. Lowercase
// hyphenated. Example: "lux-bridge", "liquid-bridge", "hanzo-bridge".
Slug string `yaml:"slug"`
// PrimaryColor is the hex code (#rrggbb) the SPA themes off.
PrimaryColor string `yaml:"primaryColor"`
// LogoURL is the absolute or scheme-relative URL to the brand
// logo SVG. Served by the frontend at /logo.svg when set.
LogoURL string `yaml:"logoURL"`
// FaviconURL is the favicon equivalent. Served at /icon.svg.
FaviconURL string `yaml:"faviconURL"`
// DocsURL is the brand's docs site root. Surfaced via the SPA's
// help-menu link.
DocsURL string `yaml:"docsURL"`
// Title is the embedded app's <title>. Defaults to Name when
// empty.
Title string `yaml:"title"`
}
// NetworkConfig describes the tenant's primary L1 EVM (the chain that
// receives bridged collateral mints).
type NetworkConfig struct {
ID uint64 `yaml:"id"`
Name string `yaml:"name"`
RPCURL string `yaml:"rpcURL"`
ExplorerURL string `yaml:"explorerURL"`
}
// IAMConfig binds the bridge to the tenant's IAM endpoint.
type IAMConfig struct {
// Endpoint is the IAM root URL. Example: "https://hanzo.id".
Endpoint string `yaml:"endpoint"`
// ClientID is the IAM application's client_id provisioned for
// this bridge instance. Example: "{tenant}-bridge".
ClientID string `yaml:"clientID"`
// Organization is the IAM org slug the bridge runs under.
// Example: "hanzo", "lux", "zoo", "pars", "liquidity".
Organization string `yaml:"organization"`
}
// KMSConfig binds the bridge to the tenant's KMS endpoint. Secrets
// (MPC tokens, CoinGecko keys, release-wallet seed material) are
// fetched from KMS at startup via Universal Auth.
type KMSConfig struct {
// Endpoint is the KMS root URL. Example: "https://kms.hanzo.ai".
Endpoint string `yaml:"endpoint"`
// ProjectID is the KMS project this bridge's secrets live under.
ProjectID string `yaml:"projectID"`
// Environment is the KMS env tag ("prod"|"staging"|"dev").
Environment string `yaml:"environment"`
}
// MPCConfig describes the tenant's threshold-signing cluster.
type MPCConfig struct {
// URL is the MPC keygen service URL. Example:
// "http://mpc-node-0.mpc-node-headless.{tenant}-mpc.svc:9800".
URL string `yaml:"url"`
// OrgID is the tenant identifier the MPC daemon multiplexes
// keygen by. Example: "bridge", "liquid-bridge".
OrgID string `yaml:"orgID"`
// DashboardURL is the MPC dashboard API base URL — the v2026-05
// daemon serves /v1/mpc/sign there.
DashboardURL string `yaml:"dashboardURL"`
// Operators is the audit-visible list of MPC operator identities
// (for governance display only — the actual threshold key shares
// are managed by the MPC cluster itself).
Operators []string `yaml:"operators"`
// Threshold is the t-of-n threshold (e.g. 3 of 5).
Threshold uint16 `yaml:"threshold"`
}
// ReleasePoolConfig describes per-family release-wallet pools.
type ReleasePoolConfig struct {
// EVM is the EVM release pool (size + mint network for keygen).
EVM FamilyPoolConfig `yaml:"evm"`
// SOL is the Solana release pool.
SOL FamilyPoolConfig `yaml:"sol"`
// BTC is the Bitcoin release pool.
BTC FamilyPoolConfig `yaml:"btc"`
// XRP is the XRP release pool.
XRP FamilyPoolConfig `yaml:"xrp"`
// DOT is the Polkadot/Kusama release pool.
DOT FamilyPoolConfig `yaml:"dot"`
// TON is the TON release pool.
TON FamilyPoolConfig `yaml:"ton"`
}
// FamilyPoolConfig is a per-family release-wallet pool.
type FamilyPoolConfig struct {
// Size is the static pool size. 0 disables the family entirely.
Size int `yaml:"size"`
// MintNetwork is the network name used for keygen of pool
// wallets. Per-family — e.g. "LUX_MAINNET" for EVM, "SOLANA_MAINNET"
// for SOL.
MintNetwork string `yaml:"mintNetwork"`
// BalanceThreshold is the per-family low-balance threshold (wei /
// lamports / sat / drops / planck depending on family) — below
// this, the pool logs a WARN at Acquire time.
BalanceThreshold string `yaml:"balanceThreshold"`
}
// Known chain families. Anything in SupportedChains MUST be a member.
var supportedChainSet = map[string]struct{}{
"eth": {}, "btc": {}, "sol": {}, "ton": {}, "xrp": {}, "dot": {},
"liquid": {}, "hanzo": {}, "zoo": {}, "pars": {}, "spc": {}, "lux": {},
}
// Known PQ profile values.
var validPQProfiles = map[string]struct{}{
"strict-pq": {},
"classical-compat": {},
}
// addressRE validates the 0x-prefixed 20-byte hex form. Substrate /
// XRP / BTC addresses are not the fee receiver — fees are skimmed at
// the destination L1 (EVM) in BridgeV4.sol, so the fee receiver is
// always EVM.
var addressRE = regexp.MustCompile(`^0x[0-9a-fA-F]{40}$`)
// Load reads and parses a tenant config from disk. Refuses to return
// an invalid config — every error path is a hard fail.
func Load(path string) (*Config, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("tenant: empty config path")
}
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("tenant: read %s: %w", path, err)
}
return Parse(data)
}
// Parse is Load's in-memory counterpart — used by tests and shims
// that embed their tenant config.
func Parse(data []byte) (*Config, error) {
var c Config
if err := yaml.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("tenant: parse yaml: %w", err)
}
c.applyDefaults()
if err := c.Validate(); err != nil {
return nil, err
}
return &c, nil
}
// applyDefaults fills in safe defaults BEFORE validation. The
// defaults match what existing operators expect from the legacy
// flag-driven main.go (8080 listen, classical-compat profile, etc.).
func (c *Config) applyDefaults() {
if c.Listen == "" {
c.Listen = ":8080"
}
if c.NetworksConfigPath == "" {
c.NetworksConfigPath = "/etc/bridge/networks.yaml"
}
if c.PQProfile == "" {
c.PQProfile = "classical-compat"
}
if c.Brand.Title == "" {
c.Brand.Title = c.Brand.Name
}
}
// Validate enforces the tenant-config contract. Daemon refuses to
// boot on any error returned here.
func (c *Config) Validate() error {
var errs []string
// Brand.
if strings.TrimSpace(c.Brand.Name) == "" {
errs = append(errs, "brand.name: required")
}
if strings.TrimSpace(c.Brand.Slug) == "" {
errs = append(errs, "brand.slug: required")
} else if !slugRE.MatchString(c.Brand.Slug) {
errs = append(errs, fmt.Sprintf("brand.slug: %q must be lowercase-hyphenated (a-z0-9 plus '-')", c.Brand.Slug))
}
// Network.
if c.Network.ID == 0 {
errs = append(errs, "network.id: required (EVM chainId)")
}
if strings.TrimSpace(c.Network.Name) == "" {
errs = append(errs, "network.name: required")
}
// IAM.
if strings.TrimSpace(c.IAM.Endpoint) == "" {
errs = append(errs, "iam.endpoint: required (no local auth — bridge federates to IAM only)")
} else if !strings.HasPrefix(c.IAM.Endpoint, "https://") {
errs = append(errs, fmt.Sprintf("iam.endpoint: %q must use https:// scheme", c.IAM.Endpoint))
}
if strings.TrimSpace(c.IAM.ClientID) == "" {
errs = append(errs, "iam.clientID: required")
}
if strings.TrimSpace(c.IAM.Organization) == "" {
errs = append(errs, "iam.organization: required")
}
// KMS.
if strings.TrimSpace(c.KMS.Endpoint) == "" {
errs = append(errs, "kms.endpoint: required (secrets MUST come from KMS, not env)")
} else if !strings.HasPrefix(c.KMS.Endpoint, "https://") {
errs = append(errs, fmt.Sprintf("kms.endpoint: %q must use https:// scheme", c.KMS.Endpoint))
}
if strings.TrimSpace(c.KMS.ProjectID) == "" {
errs = append(errs, "kms.projectID: required")
}
if c.KMS.Environment != "" {
switch c.KMS.Environment {
case "prod", "staging", "dev":
default:
errs = append(errs, fmt.Sprintf("kms.environment: %q must be one of prod|staging|dev", c.KMS.Environment))
}
}
// MPC — operators are optional (audit-display), but threshold + URL gate prod.
if c.MPC.Threshold > 0 && len(c.MPC.Operators) > 0 {
if int(c.MPC.Threshold) > len(c.MPC.Operators) {
errs = append(errs, fmt.Sprintf("mpc.threshold (%d) exceeds operator count (%d)", c.MPC.Threshold, len(c.MPC.Operators)))
}
}
// PQ profile.
if _, ok := validPQProfiles[c.PQProfile]; !ok {
errs = append(errs, fmt.Sprintf("pqProfile: %q must be one of strict-pq|classical-compat", c.PQProfile))
}
// SupportedChains: non-empty + every entry must be in the known set.
if len(c.SupportedChains) == 0 {
errs = append(errs, "supportedChains: required (at least one of eth,btc,sol,ton,xrp,dot,liquid,hanzo,zoo,pars,spc,lux)")
} else {
seen := map[string]struct{}{}
for _, ch := range c.SupportedChains {
ch = strings.ToLower(strings.TrimSpace(ch))
if _, ok := supportedChainSet[ch]; !ok {
known := keysOf(supportedChainSet)
sort.Strings(known)
errs = append(errs, fmt.Sprintf("supportedChains: %q not recognized (allowed: %s)", ch, strings.Join(known, ",")))
}
if _, dup := seen[ch]; dup {
errs = append(errs, fmt.Sprintf("supportedChains: duplicate entry %q", ch))
}
seen[ch] = struct{}{}
}
}
// FeeReceiverAddr (EVM address).
if strings.TrimSpace(c.FeeReceiverAddr) == "" {
errs = append(errs, "feeReceiverAddr: required (0x-prefixed 20-byte hex)")
} else if !addressRE.MatchString(c.FeeReceiverAddr) {
errs = append(errs, fmt.Sprintf("feeReceiverAddr: %q must be 0x-prefixed 20-byte hex", c.FeeReceiverAddr))
}
// Domain.
if strings.TrimSpace(c.Domain) == "" {
errs = append(errs, "domain: required (public DNS host, e.g. bridge.tenant.tld)")
}
// Basket allowlist: every basket member must be a non-empty string.
for basket, members := range c.BasketAllowlist {
if strings.TrimSpace(basket) == "" {
errs = append(errs, "basketAllowlist: empty basket name")
continue
}
if len(members) == 0 {
errs = append(errs, fmt.Sprintf("basketAllowlist[%s]: empty member list", basket))
}
for i, m := range members {
if strings.TrimSpace(m) == "" {
errs = append(errs, fmt.Sprintf("basketAllowlist[%s][%d]: empty member symbol", basket, i))
}
}
}
// Release pool: per-family size MUST be non-negative.
if c.ReleasePool.EVM.Size < 0 {
errs = append(errs, "releasePool.evm.size: negative not allowed")
}
if c.ReleasePool.SOL.Size < 0 {
errs = append(errs, "releasePool.sol.size: negative not allowed")
}
if c.ReleasePool.BTC.Size < 0 {
errs = append(errs, "releasePool.btc.size: negative not allowed")
}
if c.ReleasePool.XRP.Size < 0 {
errs = append(errs, "releasePool.xrp.size: negative not allowed")
}
if c.ReleasePool.DOT.Size < 0 {
errs = append(errs, "releasePool.dot.size: negative not allowed")
}
if c.ReleasePool.TON.Size < 0 {
errs = append(errs, "releasePool.ton.size: negative not allowed")
}
if len(errs) > 0 {
return fmt.Errorf("tenant config invalid:\n - %s", strings.Join(errs, "\n - "))
}
return nil
}
// IsChainSupported is the runtime helper the daemon's swap-create
// handler uses to refuse requests for chains the tenant did not opt
// into.
func (c *Config) IsChainSupported(family string) bool {
family = strings.ToLower(strings.TrimSpace(family))
for _, ch := range c.SupportedChains {
if strings.ToLower(strings.TrimSpace(ch)) == family {
return true
}
}
return false
}
// IsBasketAllowed checks whether `asset` is allowed as backing for `basket`.
// Returns true when the basket is not configured at all (no allowlist =
// no restriction); returns true when the asset is in the list. False
// only when basket is configured AND asset is absent.
func (c *Config) IsBasketAllowed(basket, asset string) bool {
if c == nil {
return true
}
members, ok := c.BasketAllowlist[basket]
if !ok {
return true
}
for _, m := range members {
if strings.EqualFold(m, asset) {
return true
}
}
return false
}
var slugRE = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
func keysOf(m map[string]struct{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
+491
View File
@@ -0,0 +1,491 @@
package tenant
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// validMinimal returns the smallest tenant config that passes Validate.
// Tests mutate this to exercise specific failure paths without
// inheriting noise from other fields.
func validMinimal() Config {
return Config{
Brand: BrandConfig{
Name: "Test Bridge",
Slug: "test-bridge",
},
Network: NetworkConfig{
ID: 96369,
Name: "Test Mainnet",
},
IAM: IAMConfig{
Endpoint: "https://iam.example.test",
ClientID: "test-bridge",
Organization: "test",
},
KMS: KMSConfig{
Endpoint: "https://kms.example.test",
ProjectID: "test-bridge",
Environment: "prod",
},
PQProfile: "strict-pq",
SupportedChains: []string{"eth", "lux"},
FeeReceiverAddr: "0x000000000000000000000000000000000000dEaD",
Domain: "bridge.example.test",
}
}
func TestDefaultsApplied(t *testing.T) {
c := validMinimal()
c.Listen = ""
c.NetworksConfigPath = ""
c.PQProfile = ""
c.Brand.Title = ""
c.applyDefaults()
if c.Listen != ":8080" {
t.Errorf("Listen default: got %q want :8080", c.Listen)
}
if c.NetworksConfigPath != "/etc/bridge/networks.yaml" {
t.Errorf("NetworksConfigPath default: got %q", c.NetworksConfigPath)
}
if c.PQProfile != "classical-compat" {
t.Errorf("PQProfile default: got %q want classical-compat", c.PQProfile)
}
if c.Brand.Title != c.Brand.Name {
t.Errorf("Brand.Title default: got %q want %q", c.Brand.Title, c.Brand.Name)
}
}
func TestValidateOK(t *testing.T) {
c := validMinimal()
c.applyDefaults()
if err := c.Validate(); err != nil {
t.Fatalf("validMinimal must validate; got: %v", err)
}
}
func TestPQProfileRequired(t *testing.T) {
c := validMinimal()
c.PQProfile = "what-even"
c.applyDefaults()
err := c.Validate()
if err == nil {
t.Fatal("invalid PQProfile must fail validation")
}
if !strings.Contains(err.Error(), "pqProfile:") {
t.Errorf("error message should mention pqProfile, got: %v", err)
}
}
func TestFeeReceiverAddrFormat(t *testing.T) {
cases := []struct {
name string
addr string
wantOK bool
}{
{"valid 0x lower", "0x1234567890abcdef1234567890abcdef12345678", true},
{"valid 0x mixed", "0x1234567890ABCDEFabcd567890ABCDEF12345678", true},
{"missing 0x", "1234567890abcdef1234567890abcdef12345678", false},
{"too short", "0x1234", false},
{"empty", "", false},
{"not hex", "0xZZZZZZZZ234567890abcdef1234567890abcdef", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := validMinimal()
c.FeeReceiverAddr = tc.addr
c.applyDefaults()
err := c.Validate()
if tc.wantOK && err != nil {
t.Errorf("addr %q expected OK; got: %v", tc.addr, err)
}
if !tc.wantOK && err == nil {
t.Errorf("addr %q expected validation failure; got OK", tc.addr)
}
})
}
}
func TestSupportedChainsSubset(t *testing.T) {
c := validMinimal()
c.SupportedChains = []string{"eth", "doge"} // "doge" not in allowed set
c.applyDefaults()
err := c.Validate()
if err == nil {
t.Fatal("unknown chain family must fail validation")
}
if !strings.Contains(err.Error(), "doge") {
t.Errorf("error should call out the bad value, got: %v", err)
}
}
func TestSupportedChainsAllowedSet(t *testing.T) {
// Every value in the spec'd allowed set must validate.
for _, chain := range []string{"eth", "btc", "sol", "ton", "xrp", "dot", "liquid", "hanzo", "zoo", "pars", "spc", "lux"} {
t.Run(chain, func(t *testing.T) {
c := validMinimal()
c.SupportedChains = []string{chain}
c.applyDefaults()
if err := c.Validate(); err != nil {
t.Errorf("chain %q should be allowed; got: %v", chain, err)
}
})
}
}
func TestSupportedChainsEmpty(t *testing.T) {
c := validMinimal()
c.SupportedChains = nil
c.applyDefaults()
err := c.Validate()
if err == nil || !strings.Contains(err.Error(), "supportedChains") {
t.Errorf("empty supportedChains must fail, got: %v", err)
}
}
func TestSupportedChainsDuplicate(t *testing.T) {
c := validMinimal()
c.SupportedChains = []string{"eth", "eth"}
c.applyDefaults()
err := c.Validate()
if err == nil || !strings.Contains(err.Error(), "duplicate") {
t.Errorf("duplicate chain must fail with mention of duplicate, got: %v", err)
}
}
func TestRequiredFields(t *testing.T) {
type mut func(*Config)
cases := []struct {
field string
mut mut
}{
{"brand.name", func(c *Config) { c.Brand.Name = "" }},
{"brand.slug", func(c *Config) { c.Brand.Slug = "" }},
{"network.id", func(c *Config) { c.Network.ID = 0 }},
{"network.name", func(c *Config) { c.Network.Name = "" }},
{"iam.endpoint", func(c *Config) { c.IAM.Endpoint = "" }},
{"iam.clientID", func(c *Config) { c.IAM.ClientID = "" }},
{"iam.organization", func(c *Config) { c.IAM.Organization = "" }},
{"kms.endpoint", func(c *Config) { c.KMS.Endpoint = "" }},
{"kms.projectID", func(c *Config) { c.KMS.ProjectID = "" }},
{"feeReceiverAddr", func(c *Config) { c.FeeReceiverAddr = "" }},
{"domain", func(c *Config) { c.Domain = "" }},
}
for _, tc := range cases {
t.Run(tc.field, func(t *testing.T) {
c := validMinimal()
tc.mut(&c)
c.applyDefaults()
err := c.Validate()
if err == nil {
t.Fatalf("missing %s must fail validation", tc.field)
}
if !strings.Contains(err.Error(), tc.field) {
t.Errorf("error should mention %s, got: %v", tc.field, err)
}
})
}
}
func TestIAMEndpointMustBeHTTPS(t *testing.T) {
c := validMinimal()
c.IAM.Endpoint = "http://iam.example.test"
c.applyDefaults()
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "https://") {
t.Errorf("http:// IAM endpoint must fail validation; got: %v", err)
}
}
func TestKMSEndpointMustBeHTTPS(t *testing.T) {
c := validMinimal()
c.KMS.Endpoint = "http://kms.example.test"
c.applyDefaults()
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "https://") {
t.Errorf("http:// KMS endpoint must fail validation; got: %v", err)
}
}
func TestKMSEnvironmentValues(t *testing.T) {
for _, env := range []string{"prod", "staging", "dev", ""} {
t.Run("ok-"+env, func(t *testing.T) {
c := validMinimal()
c.KMS.Environment = env
c.applyDefaults()
if err := c.Validate(); err != nil {
t.Errorf("env %q should validate; got: %v", env, err)
}
})
}
t.Run("rejects-unknown", func(t *testing.T) {
c := validMinimal()
c.KMS.Environment = "beta"
c.applyDefaults()
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "kms.environment") {
t.Errorf("kms.environment=beta should fail; got: %v", err)
}
})
}
func TestMPCThresholdExceedsOperators(t *testing.T) {
c := validMinimal()
c.MPC.Threshold = 5
c.MPC.Operators = []string{"alice", "bob"}
c.applyDefaults()
err := c.Validate()
if err == nil || !strings.Contains(err.Error(), "threshold") {
t.Errorf("threshold>operators should fail; got: %v", err)
}
}
func TestSlugFormat(t *testing.T) {
bad := []string{"Has Spaces", "UPPER", "trailing-", "-leading", "double--hyphen"}
for _, s := range bad {
t.Run("bad-"+s, func(t *testing.T) {
c := validMinimal()
c.Brand.Slug = s
c.applyDefaults()
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "brand.slug") {
t.Errorf("slug %q should fail; got: %v", s, err)
}
})
}
good := []string{"a", "ab", "lux-bridge", "hanzo-bridge", "liquid-bridge", "tenant1"}
for _, s := range good {
t.Run("ok-"+s, func(t *testing.T) {
c := validMinimal()
c.Brand.Slug = s
c.applyDefaults()
if err := c.Validate(); err != nil {
t.Errorf("slug %q should pass; got: %v", s, err)
}
})
}
}
func TestBasketAllowlistValidation(t *testing.T) {
c := validMinimal()
c.BasketAllowlist = map[string][]string{
"": {"USDC"},
"USD": {""},
}
c.applyDefaults()
err := c.Validate()
if err == nil {
t.Fatal("empty basket name + empty member must fail")
}
for _, want := range []string{"empty basket name", "empty member symbol"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("missing %q in error: %v", want, err)
}
}
}
func TestBasketAllowlistOK(t *testing.T) {
c := validMinimal()
c.BasketAllowlist = map[string][]string{
"USD": {"USDC", "USDT", "DAI"},
"BTC": {"WBTC", "tBTC"},
}
c.applyDefaults()
if err := c.Validate(); err != nil {
t.Fatalf("valid basket allowlist must validate; got: %v", err)
}
}
func TestIsChainSupported(t *testing.T) {
c := validMinimal()
c.SupportedChains = []string{"eth", "btc"}
if !c.IsChainSupported("eth") {
t.Error("eth should be supported")
}
if !c.IsChainSupported("ETH") {
t.Error("case-insensitive match expected for ETH")
}
if c.IsChainSupported("sol") {
t.Error("sol should not be supported")
}
}
func TestIsBasketAllowed(t *testing.T) {
c := validMinimal()
c.BasketAllowlist = map[string][]string{
"USD": {"USDC", "USDT"},
}
if !c.IsBasketAllowed("USD", "USDC") {
t.Error("USDC should be allowed in USD basket")
}
if !c.IsBasketAllowed("USD", "usdc") {
t.Error("case-insensitive match expected for usdc")
}
if c.IsBasketAllowed("USD", "ETH") {
t.Error("ETH should not be allowed in USD basket")
}
if !c.IsBasketAllowed("UNSET", "ANYTHING") {
t.Error("unconfigured basket must default to allow-all")
}
var nilCfg *Config
if !nilCfg.IsBasketAllowed("USD", "USDC") {
t.Error("nil config should default to allow-all")
}
}
func TestParseRoundTrip(t *testing.T) {
src := `
brand:
name: Lux Bridge
slug: lux-bridge
primaryColor: "#7547ff"
logoURL: /logo.svg
faviconURL: /icon.svg
docsURL: https://docs.lux.network
network:
id: 96369
name: Lux Mainnet
rpcURL: https://api.lux.network
explorerURL: https://explorer.lux.network
iam:
endpoint: https://lux.id
clientID: lux-bridge
organization: lux
kms:
endpoint: https://kms.lux.network
projectID: lux-bridge
environment: prod
mpc:
url: http://mpc.lux-mpc.svc:9800
orgID: bridge
dashboardURL: http://mpc-dashboard.lux-mpc.svc:8081
operators:
- lux-mpc-node-0
- lux-mpc-node-1
- lux-mpc-node-2
- lux-mpc-node-3
- lux-mpc-node-4
threshold: 3
pqProfile: strict-pq
supportedChains: [eth, btc, sol, ton, xrp, dot, lux]
basketAllowlist:
USD: [USDC, USDT, DAI]
BTC: [WBTC]
feeReceiverAddr: "0xdEaD000000000000000000000000000000000000"
domain: bridge.lux.network
releasePool:
evm:
size: 10
mintNetwork: LUX_MAINNET
balanceThreshold: "100000000000000000"
sol:
size: 5
mintNetwork: SOLANA_MAINNET
balanceThreshold: "1000000000"
btc:
size: 5
mintNetwork: BITCOIN_MAINNET
balanceThreshold: "100000"
xrp:
size: 5
mintNetwork: XRP_MAINNET
balanceThreshold: "10000000"
dot:
size: 3
mintNetwork: POLKADOT_MAINNET
balanceThreshold: "100000000000"
ton:
size: 0
mintNetwork: ""
balanceThreshold: ""
listen: ":8080"
networksConfigPath: /etc/bridge/networks.yaml
`
c, err := Parse([]byte(src))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if c.Brand.Name != "Lux Bridge" {
t.Errorf("Brand.Name: got %q", c.Brand.Name)
}
if c.Network.ID != 96369 {
t.Errorf("Network.ID: got %d", c.Network.ID)
}
if c.MPC.Threshold != 3 || len(c.MPC.Operators) != 5 {
t.Errorf("MPC: threshold=%d operators=%d", c.MPC.Threshold, len(c.MPC.Operators))
}
if c.ReleasePool.EVM.Size != 10 {
t.Errorf("ReleasePool.EVM.Size: got %d", c.ReleasePool.EVM.Size)
}
if !c.IsChainSupported("btc") {
t.Errorf("expected btc in SupportedChains")
}
if !c.IsBasketAllowed("USD", "USDC") {
t.Errorf("expected USDC in USD basket")
}
}
func TestLoadMissingFile(t *testing.T) {
_, err := Load("/nonexistent/tenant.yaml")
if err == nil {
t.Fatal("missing file must error")
}
if !errors.Is(err, os.ErrNotExist) {
// not strictly required to be ErrNotExist but error message must contain path.
if !strings.Contains(err.Error(), "nonexistent") {
t.Errorf("error should mention path; got: %v", err)
}
}
}
func TestLoadFromDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tenant.yaml")
src := `
brand:
name: Test
slug: test
network:
id: 1
name: Test
iam:
endpoint: https://iam.example.test
clientID: test
organization: test
kms:
endpoint: https://kms.example.test
projectID: test
pqProfile: strict-pq
supportedChains: [eth]
feeReceiverAddr: "0x0000000000000000000000000000000000000001"
domain: bridge.example.test
`
if err := os.WriteFile(path, []byte(src), 0o600); err != nil {
t.Fatal(err)
}
c, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Brand.Name != "Test" {
t.Errorf("Brand.Name: got %q", c.Brand.Name)
}
}
func TestLoadEmptyPath(t *testing.T) {
_, err := Load("")
if err == nil {
t.Fatal("empty path must error")
}
}
func TestReleasePoolNegativeSize(t *testing.T) {
c := validMinimal()
c.ReleasePool.EVM.Size = -1
c.applyDefaults()
if err := c.Validate(); err == nil || !strings.Contains(err.Error(), "releasePool.evm.size") {
t.Errorf("negative pool size must fail; got: %v", err)
}
}
+162
View File
@@ -0,0 +1,162 @@
# tenant.example.yaml — declarative tenant config for luxfi/bridge.
#
# Mount this file at /etc/bridge/tenant.yaml in the K8s deployment, then
# pass --tenant-config /etc/bridge/tenant.yaml on the daemon command
# line. When set, brand + IAM + KMS + MPC + PQ-profile +
# supportedChains + ingress + basket allowlist all come from this file
# — flag-driven defaults stay available as overrides.
#
# White-label shim repos (liquidity/bridge, hanzo/bridge-shim,
# zoo/bridge-shim, pars/bridge-shim) each ship their own tenant.yaml
# and import luxfi/bridge as the upstream OSS dep. See those shim
# repos for production examples.
# ─── brand ────────────────────────────────────────────────────────────────
#
# Drives the SPA's window.ENV, the embedded app <title>, per-host
# /logo.svg + /icon.svg, the daemon log "service" tag, and the
# Fiber AppName.
brand:
name: "Lux Bridge" # human-readable label
slug: "lux-bridge" # lowercase-hyphenated, used as service tag
primaryColor: "#7547ff" # SPA theme accent (hex)
logoURL: "/logo.svg" # absolute or scheme-relative
faviconURL: "/icon.svg"
docsURL: "https://docs.lux.network"
title: "Lux Bridge" # defaults to .name when empty
# ─── network ──────────────────────────────────────────────────────────────
#
# The tenant's primary L1 EVM (where bridged collateral mints land).
# NetworkID here is the EVM chainId, NOT the Lux primary-network ID.
# Per CLAUDE.md "Network ID vs EVM Chain ID — NEVER CONFLATE":
# primary networkID is shared across all Lux-derived L1s (1/2/3/1337)
# EVM chainId is per-subnet, per-env (96369 = Lux Mainnet, etc.)
network:
id: 96369
name: "Lux Mainnet"
rpcURL: "https://api.lux.network"
explorerURL: "https://explorer.lux.network"
# ─── iam ──────────────────────────────────────────────────────────────────
#
# Federated identity binding. Required — the bridge MUST NOT ship local
# auth. Every authenticated route resolves identity via the tenant's IAM
# (hanzo.id, lux.id, zoo.id, pars.id, liquidity.id).
iam:
endpoint: "https://lux.id"
clientID: "lux-bridge"
organization: "lux"
# ─── kms ──────────────────────────────────────────────────────────────────
#
# All secrets (MPC bearer token, CoinGecko API key, release-wallet seed
# material) MUST come from KMS via Universal Auth. NEVER from env vars
# in production.
kms:
endpoint: "https://kms.lux.network"
projectID: "lux-bridge"
environment: "prod" # prod | staging | dev
# ─── mpc ──────────────────────────────────────────────────────────────────
#
# The tenant's threshold-signing cluster. Operators list is
# audit-display only — the actual key shares live in the MPC cluster.
mpc:
url: "http://mpc-node-0.mpc-node-headless.lux-mpc.svc:9800"
orgID: "bridge"
dashboardURL: "http://mpc-dashboard.lux-mpc.svc:8081"
operators:
- "lux-mpc-node-0"
- "lux-mpc-node-1"
- "lux-mpc-node-2"
- "lux-mpc-node-3"
- "lux-mpc-node-4"
threshold: 3 # 3-of-5
# ─── pqProfile ────────────────────────────────────────────────────────────
#
# strict-pq — refuse classical-only envelopes at the P3Q
# precompile layer (set for internal tenants whose
# destination is a Lux-derived L1)
# classical-compat — permit ECDSA-only envelopes inside the governance-
# set 30-day window (required when the tenant
# routinely bridges to external L1s like Ethereum)
pqProfile: "strict-pq"
# ─── supportedChains ──────────────────────────────────────────────────────
#
# Allowlist of chain families this tenant will mount handlers for.
# Anything not listed will not get a release-pool, will not appear in
# /v1/bridge/networks, and will refuse swap creates. Subset of:
# eth, btc, sol, ton, xrp, dot, liquid, hanzo, zoo, pars, spc, lux
supportedChains: ["eth", "btc", "sol", "ton", "xrp", "dot", "lux"]
# ─── basketAllowlist ──────────────────────────────────────────────────────
#
# Per-basket collateral set. Maps basket symbol → list of asset
# symbols permitted as backing. Consumed by:
# - the BridgeV4 BasketRegistry deployment (Part 2 — DeployTenant.s.sol)
# - the bridge's quote engine (refuses quotes mixing collateral
# outside the allowlist)
basketAllowlist:
USD: ["USDC", "USDT", "DAI"]
BTC: ["WBTC", "tBTC"]
ETH: ["WETH", "stETH"]
# ─── feeReceiverAddr ──────────────────────────────────────────────────────
#
# Destination of the per-claim fee skim (BridgeV4.feeReceiver). Must be
# 0x-prefixed 20-byte hex. OPERATOR_ROLE drains this vault — privilege-
# separated from the main vault so an operator-key compromise can't
# touch user funds.
feeReceiverAddr: "0xdEaD000000000000000000000000000000000000"
# ─── domain ───────────────────────────────────────────────────────────────
#
# Public DNS host the SPA + API serve on. Used for og:metadata absolute
# URLs and as the IngressRoute Host() match.
domain: "bridge.lux.network"
# ─── releasePool ──────────────────────────────────────────────────────────
#
# Per-family release-wallet pools. Each family is a static set of MPC
# wallets the signing driver rotates through so a single under-funded
# wallet can't block all subsequent swaps.
#
# size: 0 disables the family entirely.
# mintNetwork: keygen target network — per-family.
# balanceThreshold: low-balance WARN threshold, per-family unit
# (wei for EVM, lamports for SOL, sat for BTC, drops for XRP,
# planck for DOT, nano for TON).
releasePool:
evm:
size: 10
mintNetwork: "LUX_MAINNET"
balanceThreshold: "100000000000000000" # 0.1 native (1e17 wei)
sol:
size: 5
mintNetwork: "SOLANA_MAINNET"
balanceThreshold: "1000000000" # 1 SOL (1e9 lamports)
btc:
size: 5
mintNetwork: "BITCOIN_MAINNET"
balanceThreshold: "100000" # 0.001 BTC (1e5 sat)
xrp:
size: 5
mintNetwork: "XRP_MAINNET"
balanceThreshold: "10000000" # 10 XRP (1e7 drops)
dot:
size: 3
mintNetwork: "POLKADOT_MAINNET"
balanceThreshold: "100000000000" # 10 DOT (1e11 planck)
ton:
size: 0
mintNetwork: ""
balanceThreshold: ""
# ─── plumbing defaults ────────────────────────────────────────────────────
#
# Optional — apply when omitted from the operator CLI invocation.
listen: ":8080"
networksConfigPath: "/etc/bridge/networks.yaml"