mirror of
https://github.com/luxfi/wallet.git
synced 2026-07-27 03:37:41 +00:00
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:
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user