mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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:
+42
-2
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user