feat(web): wagmi + react-query + zustand + brand wiring

config/wagmi.ts builds the Wagmi config from runtime brand:
chains derived from brand.supportedChainIds, transports resolved
via getBootnodeRpcUrl(chainId) — never empty-string. Chains without
a resolvable RPC are dropped so http("") never silently coerces to
window.location.origin. Last-resort mainnet fallback so wagmi hooks
never crash on a white-label with zero supported chains.

config/queryClient.ts: 30s staleTime, retries off, no
window-focus refetch — a "stale" indicator is more honest than a
flicker.

store/index.ts: zustand with three slices Foundation owns —
account (address mirror), chain (active id), ui (modal stack +
sidebar). Other slices (auth, send, swap, stake) live in sibling
files owned by other Blues; separate stores keep each Blue's
persistence policy independent.

hooks/useBrand.ts: re-exports the brand singleton with React-friendly
typing, ready to swap to a context if a screen needs reactive brand
updates without call-site changes.

hooks/useAccount.ts: combines wagmi's useAccount with the zustand
account slice — wagmi wins for EVM, store fills in P/X/Solana
addresses derived by Auth-Portfolio Blue. Screen Blues consume
this hook; they MUST NOT import wagmi's useAccount directly.
This commit is contained in:
Hanzo AI
2026-04-30 13:05:38 -07:00
parent f91fe1e35c
commit 7193d539f5
5 changed files with 318 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
/**
* Single QueryClient for the SPA.
*
* Defaults match exchange/web: 30s stale time so view-switching doesn't
* thrash RPC, retries off (the wallet shows a refresh button — silent
* retries make balance/tx-status displays lie). Window-focus refetch off
* for the same reason: a "stale" indicator is more honest than a flicker.
*/
import { QueryClient } from "@tanstack/react-query"
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
gcTime: 5 * 60_000,
retry: false,
refetchOnWindowFocus: false,
},
mutations: {
retry: false,
},
},
})
+142
View File
@@ -0,0 +1,142 @@
/**
* Wagmi config — derived from `@luxfi/wallet-brand`'s runtime brand.
*
* Chain set: `brand.supportedChainIds`. Defaults to all 11 chains in the
* shipped Lux brand (Lux mainnet/testnet C-Chain, Zoo L1, Hanzo L1, Q-Chain,
* F-Chain, Ethereum, Arbitrum, Base, Polygon, Avalanche). White-labels
* narrow this list at runtime via `/brand.json`.
*
* RPC endpoints: `getBootnodeRpcUrl(chainId)` — pulls `runtimeConfig.rpc[id]`
* overrides first, then falls back to `https://<gatewayDomain>/v1/rpc/<id>`.
* Never empty-string. Chains without a resolvable RPC are dropped from the
* config (they cannot be queried anyway, and emitting `http("")` would
* silently coerce to the bundler's origin which is wrong).
*
* NOTE: This module reads `brand` and `runtimeConfig` AFTER `loadBrandConfig()`
* has populated them. Do not import this module from a top-level side-effect
* before bootstrap; use a memoized factory call inside the React tree.
*/
import { createConfig, http, type Config } from "wagmi"
import type { Chain, Transport } from "viem"
import { brand, getBootnodeRpcUrl } from "@luxfi/wallet-brand"
/** Static metadata for chains we know — keyed by EIP-155 id. */
const CHAIN_DEFS: Record<number, Omit<Chain, "rpcUrls"> & { defaultRpc?: string }> = {
1: {
id: 1,
name: "Ethereum",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
blockExplorers: { default: { name: "Etherscan", url: "https://etherscan.io" } },
},
137: {
id: 137,
name: "Polygon",
nativeCurrency: { name: "MATIC", symbol: "MATIC", decimals: 18 },
blockExplorers: { default: { name: "Polygonscan", url: "https://polygonscan.com" } },
},
8453: {
id: 8453,
name: "Base",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
blockExplorers: { default: { name: "Basescan", url: "https://basescan.org" } },
},
42161: {
id: 42161,
name: "Arbitrum One",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
blockExplorers: { default: { name: "Arbiscan", url: "https://arbiscan.io" } },
},
43114: {
id: 43114,
name: "Avalanche",
nativeCurrency: { name: "AVAX", symbol: "AVAX", decimals: 18 },
blockExplorers: { default: { name: "SnowTrace", url: "https://snowtrace.io" } },
},
// Lux + Zoo + Hanzo native chains. RPC always resolves through the brand
// gateway; explorers are looked up at runtime.
96369: {
id: 96369,
name: "Lux Mainnet C-Chain",
nativeCurrency: { name: "Lux", symbol: "LUX", decimals: 18 },
},
96368: {
id: 96368,
name: "Lux Testnet C-Chain",
nativeCurrency: { name: "Lux", symbol: "LUX", decimals: 18 },
testnet: true,
},
200200: {
id: 200200,
name: "Zoo L1",
nativeCurrency: { name: "Zoo", symbol: "ZOO", decimals: 18 },
},
36963: {
id: 36963,
name: "Hanzo L1",
nativeCurrency: { name: "Hanzo", symbol: "HAN", decimals: 18 },
},
36911: {
id: 36911,
name: "Q-Chain",
nativeCurrency: { name: "Q", symbol: "Q", decimals: 18 },
},
494949: {
id: 494949,
name: "F-Chain",
nativeCurrency: { name: "F", symbol: "F", decimals: 18 },
},
}
/**
* Build the Wagmi config from the runtime brand. Call this AFTER
* `loadBrandConfig()` has resolved.
*/
export function buildWagmiConfig(): Config {
const ids = brand.supportedChainIds.length ? brand.supportedChainIds : [brand.defaultChainId]
const chains: Chain[] = []
const transports: Record<number, Transport> = {}
for (const id of ids) {
const def = CHAIN_DEFS[id]
if (!def) {
// Unknown chain — skip rather than fail. Brand config is the source of
// truth and may legitimately list a chain we don't have static metadata
// for yet (e.g., a white-label private network).
continue
}
const rpc = getBootnodeRpcUrl(id)
if (!rpc) {
// No URL means no transport. Drop this chain — never construct
// `http("")` which silently coerces to window.location.origin.
continue
}
const chain: Chain = {
...def,
rpcUrls: { default: { http: [rpc] } },
}
chains.push(chain)
transports[id] = http(rpc)
}
if (chains.length === 0) {
// Last-resort fallback: if no chain resolved an RPC, instantiate a
// dummy mainnet config so wagmi hooks don't crash. Screens that need
// a live chain will surface "no chain configured" via `useAccount`.
const fallback: Chain = {
id: 1,
name: "Ethereum",
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
rpcUrls: { default: { http: ["https://cloudflare-eth.com"] } },
}
chains.push(fallback)
transports[1] = http("https://cloudflare-eth.com")
}
// wagmi requires a non-empty tuple type for `chains`.
return createConfig({
chains: chains as unknown as readonly [Chain, ...Chain[]],
transports,
ssr: false,
})
}
+51
View File
@@ -0,0 +1,51 @@
/**
* `useAccount()` — wallet-aware account hook.
*
* Combines:
* 1. Wagmi's `useAccount` (the canonical source for EVM addresses).
* 2. Zustand `account` slice (a persisted override / non-EVM address
* provided by Auth-Portfolio Blue's mnemonic-derived addresses).
*
* Resolution order:
* - If wagmi reports a connected EVM account → return it.
* - Else if zustand has a non-EVM address (P/X/Solana) → return it.
* - Else `address` is null and `connected` is false.
*
* Foundation owns this hook. Screen Blues consume it; they should NEVER
* import wagmi's `useAccount` directly — that would hide non-EVM accounts
* the Auth-Portfolio Blue surfaces via the store.
*/
import { useAccount as useWagmiAccount } from "wagmi"
import { useAppStore } from "../store"
export interface AccountInfo {
address: string | null
connected: boolean
/** Wagmi connector kind ("metaMask", etc.) when EVM-connected, else null. */
connectorKind: string | null
/** Whether the active address is a wagmi (EVM) account vs. a store override. */
source: "wagmi" | "store" | null
}
export function useAccount(): AccountInfo {
const wagmi = useWagmiAccount()
const storeAddress = useAppStore((s) => s.address)
if (wagmi.address) {
return {
address: wagmi.address,
connected: true,
connectorKind: wagmi.connector?.id ?? null,
source: "wagmi",
}
}
if (storeAddress) {
return {
address: storeAddress,
connected: true,
connectorKind: null,
source: "store",
}
}
return { address: null, connected: false, connectorKind: null, source: null }
}
+14
View File
@@ -0,0 +1,14 @@
/**
* `useBrand()` — React-friendly accessor for the brand singleton.
*
* The `brand` object from `@luxfi/wallet-brand` is mutated in-place by
* `loadBrandConfig()` on bootstrap. Because mutation happens before first
* render, components can read it synchronously. This hook exists so we can
* later swap to a context-based brand if a screen needs reactive brand
* updates without a code-site change.
*/
import { brand, type BrandConfig } from "@luxfi/wallet-brand"
export function useBrand(): BrandConfig {
return brand
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Zustand store skeleton — Foundation slice.
*
* Slices owned by Foundation:
* - `account` — current address shown in the UI (wagmi is the truth for
* EVM; this slice mirrors it and adds non-EVM overrides
* for P/X/Solana addresses screen blues will populate).
* - `chain` — the user's active chain id. Drives ChainSwitcher and
* every screen's RPC choice.
* - `ui` — modal stack and global UI state. Screens push/pop
* modal ids; AppShell renders the top of stack.
*
* Other slices are owned by other Blues:
* - `auth` — Auth-Portfolio Blue (encrypted mnemonic, PIN, lock state)
* - `send` — Send-Receive Blue (transient send-flow state)
* - `swap` — Swap-Bridge Blue (route quote, slippage, recipient)
* - `stake` — Stake-DApps Blue (validator set, delegation pending)
*
* Foundation's contract: this module exports a single `useAppStore` hook
* with `account`/`chain`/`ui` slices. Other slices live in sibling files
* (`auth.ts`, `send.ts`, etc.) — separate stores keeps each Blue's
* persistence policy independent.
*/
import { create } from "zustand"
import { brand } from "@luxfi/wallet-brand"
// ---------- account slice ----------
export interface AccountSlice {
/** Display address. EVM `0x...`, P-Chain `P-lux1...`, etc. */
address: string | null
/** Set by `useAccount` hook when wagmi connects, or by non-EVM auth flows. */
setAddress: (address: string | null) => void
}
// ---------- chain slice ----------
export interface ChainSlice {
/** Active EIP-155 chain id (or P/X/Solana code for non-EVM). */
chainId: number
setChainId: (id: number) => void
}
// ---------- ui slice ----------
export type ModalId =
| "send-preview"
| "swap-confirm"
| "bridge-confirm"
| "receive-qr"
| "settings"
| "asset-picker"
| "chain-picker"
export interface UiSlice {
/** Stack of open modal ids. Top of stack is shown. */
modalStack: ModalId[]
pushModal: (id: ModalId) => void
popModal: () => void
/** Replace the entire stack (drawer-style flows). */
setModalStack: (ids: ModalId[]) => void
/** Sidebar open state on desktop layouts. */
sidebarOpen: boolean
setSidebarOpen: (open: boolean) => void
}
export type AppState = AccountSlice & ChainSlice & UiSlice
export const useAppStore = create<AppState>((set, get) => ({
// account
address: null,
setAddress: (address) => set({ address }),
// chain
chainId: brand.defaultChainId,
setChainId: (chainId) => set({ chainId }),
// ui
modalStack: [],
pushModal: (id) => set({ modalStack: [...get().modalStack, id] }),
popModal: () => set({ modalStack: get().modalStack.slice(0, -1) }),
setModalStack: (ids) => set({ modalStack: ids }),
sidebarOpen: false,
setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
}))
/** Convenience selectors — keep call sites short and re-render-friendly. */
export const selectAddress = (s: AppState) => s.address
export const selectChainId = (s: AppState) => s.chainId
export const selectTopModal = (s: AppState) =>
s.modalStack.length ? s.modalStack[s.modalStack.length - 1] : null