mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-27 03:37:41 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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] ?? ""
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["dom", "esnext"],
|
||||
"noEmit": true,
|
||||
"types": [],
|
||||
"rootDir": "."
|
||||
}
|
||||
}
|
||||
Generated
+11144
-10723
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user