feat(brand): @luxfi/wallet-brand — runtime white-label config

Same pattern as ~/work/lux/exchange/pkgs/config/src/brand.ts. Replaces the
BRAND_PACKAGE env-var trick from wallet-legacy with a cleaner runtime
overlay: brand.json ships in the image, K8s ConfigMap overlays at deploy
time, no source fork required.

  pkgs/brand/
    src/index.ts     — BrandConfig interface, mutable `brand` singleton,
                       loadBrandConfig(), getBootnodeRpcUrl(), getBrandUrl().
    brand.json       — Lux Wallet defaults (chains 96369/96368/200200/...).
    package.json     — @luxfi/wallet-brand workspace pkg.

  apps/web/
    vite.config.ts   — copyBrandJson plugin: pkgs/brand/brand.json →
                       public/brand.json (dev) and dist/brand.json (build).
    src/main.tsx     — loadBrandConfig() before React renders.
    src/App.tsx      — read brand.walletName / brand.description.

White-labels (<tenant>, Zoo, Pars) override /brand.json via ConfigMap.
Theme tokens flow as CSS custom properties (--accent1, --surface1, ...) so
no React tree change is needed for a brand swap.

Bootnode RPC pattern: getBootnodeRpcUrl(chainId) defaults to
https://<gatewayDomain>/v1/rpc/<chainId> with rpc[<chainId>] overrides.
No Quicknode, no Alchemy direct.
This commit is contained in:
Hanzo AI
2026-04-30 11:47:13 -07:00
parent 7ee1612c19
commit 67e571f2ed
9 changed files with 11575 additions and 10734 deletions
+3 -1
View File
@@ -6,9 +6,11 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@luxfi/wallet-brand": "workspace:^",
"react": "19.2.5",
"react-dom": "19.2.5"
},
+14 -3
View File
@@ -1,9 +1,20 @@
import { brand } from "@luxfi/wallet-brand"
export default function App() {
return (
<main style={{ minHeight: "100vh", display: "flex", alignItems: "center", justifyContent: "center", background: "#000", color: "#fff" }}>
<main
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--surface1, #000)",
color: "var(--neutral1, #fff)",
}}
>
<div style={{ textAlign: "center" }}>
<h1 style={{ fontSize: "2rem", marginBottom: "1rem" }}>Lux Wallet</h1>
<p style={{ color: "#888" }}>Web wallet coming soon</p>
<h1 style={{ fontSize: "2rem", marginBottom: "1rem" }}>{brand.walletName || "Lux Wallet"}</h1>
<p style={{ color: "var(--neutral2, #888)" }}>{brand.description || "Self-custodial wallet"}</p>
</div>
</main>
)
+10 -5
View File
@@ -1,9 +1,14 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { loadBrandConfig } from "@luxfi/wallet-brand"
import App from "./App"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
// Apply brand config (logo, theme, RPC, gateway) BEFORE first render so the
// SPA never flashes Lux branding on a Liquidity (or other white-label) deploy.
loadBrandConfig().finally(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
})
+28 -2
View File
@@ -1,8 +1,34 @@
import { defineConfig } from "vite"
import { defineConfig, type Plugin } from "vite"
import react from "@vitejs/plugin-react"
import { copyFileSync, existsSync, mkdirSync } from "node:fs"
import { dirname, resolve } from "node:path"
/**
* Copy the canonical `brand.json` from `@luxfi/wallet-brand` into the SPA's
* served root so `loadBrandConfig()` can fetch it. White-label deployments
* overlay this file via a K8s ConfigMap mount — no code change required.
*/
function copyBrandJson(): Plugin {
const src = resolve(__dirname, "../../pkgs/brand/brand.json")
const writeTo = (dest: string) => {
if (!existsSync(src)) return
mkdirSync(dirname(dest), { recursive: true })
copyFileSync(src, dest)
}
return {
name: "luxfi-wallet-brand-json",
buildStart() {
// Ensure dev server can serve `/brand.json`.
writeTo(resolve(__dirname, "public/brand.json"))
},
closeBundle() {
writeTo(resolve(__dirname, "dist/brand.json"))
},
}
}
export default defineConfig({
plugins: [react()],
plugins: [react(), copyBrandJson()],
build: {
outDir: "dist",
},
+69
View File
@@ -0,0 +1,69 @@
{
"brand": {
"name": "Lux Wallet",
"title": "Lux Wallet",
"description": "Self-custodial wallet for the Lux Network",
"shortName": "LUX",
"labsName": "Lux Industries",
"legalEntity": "Lux Industries Inc.",
"walletName": "Lux Wallet",
"copyrightHolder": "Lux Industries Inc.",
"appDomain": "wallet.lux.network",
"docsDomain": "docs.lux.network",
"gatewayDomain": "api.lux.network",
"helpUrl": "https://docs.lux.network/wallet",
"termsUrl": "https://lux.network/terms",
"privacyUrl": "https://lux.network/privacy",
"downloadUrl": "https://wallet.lux.network/download",
"complianceEmail": "compliance@lux.network",
"supportEmail": "hi@lux.network",
"twitter": "https://x.com/luxfi",
"github": "https://github.com/luxfi",
"discord": "https://discord.gg/lux",
"logoUrl": "/logo.svg",
"faviconUrl": "/favicon.ico",
"primaryColor": "#FFFFFF",
"defaultChainId": 96369,
"supportedChainIds": [96369, 96368, 200200, 36963, 36911, 494949, 1, 42161, 8453, 137, 43114],
"walletConnectProjectId": "",
"insightsHost": "",
"insightsApiKey": "",
"theme": {
"light": {
"accent1": "#000000",
"surface1": "#FFFFFF",
"surface2": "#F9F9F9",
"neutral1": "#131313",
"neutral2": "rgba(19, 19, 19, 0.63)"
},
"dark": {
"accent1": "#FFFFFF",
"surface1": "#000000",
"surface2": "#1A1A1A",
"neutral1": "#FFFFFF",
"neutral2": "rgba(255, 255, 255, 0.65)"
}
}
},
"chains": {
"defaultChainId": 96369,
"supported": [96369, 96368, 200200, 36963, 36911, 494949, 1, 42161, 8453, 137, 43114]
},
"rpc": {
"96369": "https://api.lux.network/mainnet/ext/bc/C/rpc",
"96368": "https://api.lux.network/testnet/ext/bc/C/rpc",
"200200": "https://api.zoo.network/rpc",
"1": "https://eth.llamarpc.com",
"42161": "https://arb1.arbitrum.io/rpc",
"8453": "https://mainnet.base.org",
"137": "https://polygon-rpc.com",
"43114": "https://api.avax.network/ext/bc/C/rpc"
},
"api": {
"gateway": "https://api.lux.network",
"insights": "https://insights.hanzo.ai"
},
"walletConnect": {
"projectId": ""
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@luxfi/wallet-brand",
"version": "0.1.0",
"private": false,
"license": "GPL-3.0-or-later",
"main": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./brand.json": "./brand.json",
"./package.json": "./package.json"
},
"files": [
"src",
"brand.json"
]
}
+278
View File
@@ -0,0 +1,278 @@
/**
* Runtime brand configuration for the canonical Lux Wallet.
*
* Single source of truth: `brand.json` mounted via ConfigMap per deployment
* (web), bundled per build (mobile/extension). White-label deployments
* (Liquidity, Zoo, Pars, ...) overlay `/brand.json` without forking source.
*
* Pattern: identical to `~/work/lux/exchange/pkgs/config/src/brand.ts`.
*
* How it works:
* 1. The default `brand.json` ships with `@luxfi/wallet-brand` (this pkg).
* 2. The web app copies it to `public/brand.json` at build time.
* 3. K8s mounts a ConfigMap over `/brand.json` per white-label deployment.
* 4. The SPA calls `loadBrandConfig()` before first render.
* 5. All brand references read from the mutable `brand` singleton.
*
* Secrets: API keys (WalletConnect, Insights) MUST come from KMS — never
* commit them to brand.json. KMS injects them server-side via env vars.
*/
export interface BrandTheme {
/** Primary accent color (buttons, links, active states) */
accent1?: string
accent1Hovered?: string
accent2?: string
accent3?: string
surface1?: string
surface2?: string
surface3?: string
neutral1?: string
neutral2?: string
neutral3?: string
neutralContrast?: string
background?: string
statusSuccess?: string
statusCritical?: string
statusWarning?: string
scrim?: string
}
export interface BrandConfig {
/** Product name — used in titles, headers, splash screens */
name: string
/** HTML <title> for the SPA */
title: string
/** SPA meta description */
description: string
/** Short product abbreviation — used in compact UI labels */
shortName: string
/** Casual lab name — used in social/legal copy */
labsName: string
/** Legal entity name for Terms/Privacy */
legalEntity: string
/** Wallet product name (white-label override) */
walletName: string
/** Copyright holder name */
copyrightHolder: string
appDomain: string
docsDomain: string
gatewayDomain: string
helpUrl: string
termsUrl: string
privacyUrl: string
downloadUrl: string
complianceEmail: string
supportEmail: string
twitter: string
github: string
discord: string
logoUrl: string
faviconUrl: string
primaryColor: string
defaultChainId: number
supportedChainIds: number[]
walletConnectProjectId: string
insightsHost: string
insightsApiKey: string
/** Theme color overrides for dark and light modes */
theme?: {
light?: BrandTheme
dark?: BrandTheme
}
}
export interface RuntimeConfig {
brand: Partial<BrandConfig>
chains: {
defaultChainId: number
supported: number[]
}
rpc: Record<string, string>
api: {
gateway: string
insights: string
}
walletConnect: {
projectId: string
}
}
// Mutable singleton — updated by `loadBrandConfig()` from `/brand.json`.
// Defaults are deliberately blank; the shipped `brand.json` provides the
// real Lux Wallet defaults. White-label deployments override at runtime.
export const brand: BrandConfig = {
name: "",
title: "",
description: "",
shortName: "",
labsName: "",
legalEntity: "",
walletName: "",
copyrightHolder: "",
appDomain: "",
docsDomain: "",
gatewayDomain: "",
helpUrl: "",
termsUrl: "",
privacyUrl: "",
downloadUrl: "",
complianceEmail: "",
supportEmail: "",
twitter: "",
github: "",
discord: "",
logoUrl: "",
faviconUrl: "/favicon.ico",
primaryColor: "#FFFFFF",
defaultChainId: 96369,
supportedChainIds: [],
walletConnectProjectId: "",
insightsHost: "",
insightsApiKey: "",
}
export let runtimeConfig: RuntimeConfig | null = null
/**
* Load brand config from `/brand.json` and mutate the `brand` singleton.
* Call once before React renders. Safe to call from native targets — it
* no-ops gracefully when `fetch` or `document` are unavailable.
*
* Resolution order:
* 1. `/brand.json` (web/extension) — ConfigMap-mounted in K8s.
* 2. Bundled `brand.json` (mobile) — passed via `overrides` arg.
* 3. Defaults from this module.
*/
export async function loadBrandConfig(overrides?: Partial<RuntimeConfig>): Promise<RuntimeConfig> {
let config: RuntimeConfig = {
brand: {},
chains: { defaultChainId: brand.defaultChainId, supported: brand.supportedChainIds },
rpc: {},
api: { gateway: "", insights: brand.insightsHost },
walletConnect: { projectId: "" },
}
if (typeof fetch === "function") {
try {
const res = await fetch("/brand.json")
if (res.ok) {
config = (await res.json()) as RuntimeConfig
}
} catch {
// Fetch failed (file:// scheme, native target, dev without server) — fall through.
}
}
if (overrides) {
config = {
...config,
...overrides,
brand: { ...config.brand, ...overrides.brand },
chains: { ...config.chains, ...overrides.chains },
api: { ...config.api, ...overrides.api },
walletConnect: { ...config.walletConnect, ...overrides.walletConnect },
}
}
if (config.brand) {
Object.assign(brand, config.brand)
}
// Derive convenience fields from name if not explicitly set.
const baseName = brand.name.replace(/\s*wallet\s*/i, "").trim()
if (!brand.shortName && baseName) {
brand.shortName = baseName.length <= 3 ? baseName.toUpperCase() : baseName
}
if (!brand.labsName && baseName) {
brand.labsName = baseName + " Labs"
}
if (!brand.walletName && baseName) {
brand.walletName = brand.shortName + " Wallet"
}
if (!brand.copyrightHolder && brand.legalEntity) {
brand.copyrightHolder = brand.legalEntity
}
if (config.chains) {
brand.defaultChainId = config.chains.defaultChainId ?? brand.defaultChainId
brand.supportedChainIds = config.chains.supported ?? brand.supportedChainIds
}
if (config.walletConnect?.projectId) {
brand.walletConnectProjectId = config.walletConnect.projectId
}
if (config.api?.insights) {
brand.insightsHost = config.api.insights
}
// Apply DOM mutations (web/extension only — mobile no-ops).
if (typeof document !== "undefined") {
if (config.brand?.title) {
document.title = config.brand.title
}
if (config.brand?.faviconUrl) {
const link = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null
if (link) {
link.href = config.brand.faviconUrl
}
}
if (brand.theme && typeof window !== "undefined") {
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches
const bt = prefersDark ? brand.theme.dark : brand.theme.light
if (bt) {
const root = document.documentElement
const set = (prop: string, val?: string) => {
if (val) root.style.setProperty(prop, val)
}
set("--accent1", bt.accent1)
set("--accent1Hovered", bt.accent1Hovered)
set("--accent2", bt.accent2)
set("--accent3", bt.accent3)
set("--surface1", bt.surface1)
set("--surface2", bt.surface2)
set("--surface3", bt.surface3)
set("--neutral1", bt.neutral1)
set("--neutral2", bt.neutral2)
set("--neutral3", bt.neutral3)
set("--neutralContrast", bt.neutralContrast)
set("--statusSuccess", bt.statusSuccess)
set("--statusCritical", bt.statusCritical)
if (bt.background || bt.surface1) {
root.style.setProperty("background", bt.background ?? bt.surface1 ?? "")
}
}
}
}
runtimeConfig = config
return config
}
/** Build a fully-qualified app URL — `https://<appDomain><path>`. */
export function getBrandUrl(path: string): string {
return `https://${brand.appDomain}${path}`
}
/** Build a fully-qualified gateway URL — `https://<gatewayDomain><path>`. */
export function getGatewayUrl(path: string): string {
return `https://${brand.gatewayDomain}${path}`
}
/**
* Bootnode RPC URL for a Lux-network chain. White-labels override per chain
* via `runtimeConfig.rpc[<chainId>]`. The bootnode pattern (vs. Quicknode)
* keeps RPC traffic on Lux infra and lets each deployment pin its own gateway.
*
* Pattern: `https://<gatewayDomain>/v1/rpc/<chainId>` is the default, but
* overrides in `brand.json:rpc` win.
*/
export function getBootnodeRpcUrl(chainId: number): string | undefined {
const override = runtimeConfig?.rpc?.[String(chainId)]
if (override) return override
if (!brand.gatewayDomain) return undefined
return `https://${brand.gatewayDomain}/v1/rpc/${chainId}`
}
export function getApiUrl(key: keyof RuntimeConfig["api"]): string {
return runtimeConfig?.api?.[key] ?? ""
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"moduleResolution": "Bundler",
"lib": ["dom", "esnext"],
"noEmit": true,
"types": [],
"rootDir": "."
}
}
+11144 -10723
View File
File diff suppressed because it is too large Load Diff