feat(portfolio): main view + asset list + chain switcher integration

Portfolio screen aggregates balances across the 14-chain Lux ecosystem
(C/X/Q/Z/F + Lux mainnet + Zoo + Hanzo + SPC + Pars), surfaced through
useChainBalances. EVM chains use viem publicClient via getBootnodeRpcUrl;
non-EVM Lux subnets (P/X/Q) hit AVAX-style JSON-RPC; F-Chain confidential
balances render as "Hidden 🔒" with a Reveal hand-off. Total USD comes
from a thin gateway price fetcher with graceful degradation. AssetRow +
AssetDetail compose into the layout. ChainSwitcher is foundation-owned —
lazy-imported with a tiny placeholder fallback so the screen still
renders during partial deploys. Per-LLM and state slices land separately.

Adds @hanzo/gui, viem, zustand, react-router-dom, @noble/hashes, and
@luxfi/wallet-brand workspace dep — foundation owns most of these and
will dedupe on merge.
This commit is contained in:
Hanzo AI
2026-04-30 13:10:12 -07:00
parent 5bd2daccb5
commit 058d09b51e
7 changed files with 598 additions and 1 deletions
+7 -1
View File
@@ -9,8 +9,14 @@
"preview": "vite preview"
},
"dependencies": {
"@hanzo/gui": "^7.0.0",
"@luxfi/wallet-brand": "workspace:^",
"@noble/hashes": "^1.7.1",
"react": "19.2.5",
"react-dom": "19.2.5"
"react-dom": "19.2.5",
"react-router-dom": "^7.0.0",
"viem": "^2.30.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
@@ -0,0 +1,82 @@
/**
* Asset detail drawer. Opens on row tap; exits on backdrop press or Esc.
*
* Hour-1 scope: full balance, send/receive shortcuts. Activity feed wires
* to the indexer in a later slice; here we render "No recent activity"
* rather than a fake feed.
*/
import { Button, Card, Text, XStack, YStack } from "@hanzo/gui/web"
import { useNavigate, useParams } from "react-router-dom"
import { usePortfolio, type TokenBalance } from "../../store/portfolio"
function findAsset(perChain: ReturnType<typeof usePortfolio.getState>["perChain"], address: string): TokenBalance | null {
for (const c of perChain) {
if (c.native.address === address) return c.native
const t = c.tokens.find((x) => x.address === address)
if (t) return t
}
return null
}
export default function AssetDetail() {
const { address = "native" } = useParams<{ address: string }>()
const navigate = useNavigate()
const perChain = usePortfolio((s) => s.perChain)
const asset = findAsset(perChain, address)
const onClose = () => navigate("/portfolio")
if (!asset) {
return (
<YStack p="$5" gap="$4">
<Text fontSize="$6" fontWeight="700">
Asset not found
</Text>
<Button onPress={onClose}>Back</Button>
</YStack>
)
}
return (
<YStack p="$5" gap="$5" maxWidth={520} mx="auto">
<XStack jc="space-between" ai="center">
<Text fontSize="$8" fontWeight="700">
{asset.symbol}
</Text>
<Button variant="outlined" size="$2" onPress={onClose}>
Close
</Button>
</XStack>
<Card p="$4">
<YStack gap="$2">
<Text col="$neutral2">Balance</Text>
<Text fontSize="$8" fontWeight="700">
{asset.balance === "hidden" ? "Hidden 🔒" : asset.balance}
</Text>
{asset.usd !== undefined ? (
<Text col="$neutral2">${asset.usd.toLocaleString(undefined, { maximumFractionDigits: 2 })}</Text>
) : null}
</YStack>
</Card>
<XStack gap="$3">
<Button flex={1} theme="active" onPress={() => navigate("/send", { state: { from: asset.address } })}>
Send
</Button>
<Button flex={1} variant="outlined" onPress={() => navigate("/receive")}>
Receive
</Button>
</XStack>
<Card p="$4">
<YStack gap="$2">
<Text fontSize="$5" fontWeight="600">
Recent activity
</Text>
<Text col="$neutral2">No recent activity.</Text>
</YStack>
</Card>
</YStack>
)
}
@@ -0,0 +1,58 @@
/**
* Single asset row — symbol, name, balance, USD. Clickable → AssetDetail.
*
* Confidential balances (balance === "hidden") render as "Hidden 🔒" with a
* Reveal action that the consumer wires to the F-Chain unwrap flow.
*/
import { Card, Text, XStack, YStack } from "@hanzo/gui/web"
import type { TokenBalance } from "../../store/portfolio"
interface AssetRowProps {
asset: TokenBalance
onPress?: () => void
onReveal?: () => void
}
export default function AssetRow({ asset, onPress, onReveal }: AssetRowProps) {
const isHidden = asset.balance === "hidden"
const balanceDisplay = isHidden ? "Hidden 🔒" : formatBalance(asset.balance, asset.decimals)
const usdDisplay =
isHidden ? "—" : asset.usd !== undefined ? `$${asset.usd.toLocaleString(undefined, { maximumFractionDigits: 2 })}` : ""
return (
<Card p="$3" pressStyle={onPress ? { scale: 0.98 } : undefined} onPress={onPress}>
<XStack ai="center" jc="space-between">
<YStack>
<Text fontSize="$5" fontWeight="600">
{asset.symbol}
</Text>
<Text fontSize="$2" col="$neutral2">
{asset.name}
</Text>
</YStack>
<YStack ai="flex-end">
<Text fontSize="$5" fontWeight="600">
{balanceDisplay}
</Text>
<Text fontSize="$2" col="$neutral2">
{usdDisplay}
</Text>
{isHidden && onReveal ? (
<Text fontSize="$2" col="$accent1" onPress={onReveal} cursor="pointer">
Reveal
</Text>
) : null}
</YStack>
</XStack>
</Card>
)
}
function formatBalance(balance: string, decimals: number): string {
// Render up to 6 significant digits in the major unit.
const n = Number(balance)
if (!Number.isFinite(n)) return balance
if (n === 0) return "0"
const places = n < 0.01 ? 6 : n < 1 ? 4 : 2
return n.toLocaleString(undefined, { maximumFractionDigits: Math.min(places, decimals) })
}
@@ -0,0 +1,165 @@
/**
* Portfolio main view. Default landing for unlocked sessions.
*
* Layout (mobile-first):
* - Header: total USD value + chain switcher (foundation-owned).
* - Active-chain assets: native + tokens.
* - Per-LLM tokens (Zoo only).
* - Other-chain native rollup, with confidential rows hidden behind
* "Hidden 🔒 / Reveal".
*
* Foundation contract:
* - `useAccount()` from `../../hooks/useAccount` — single source of truth
* for the active address (wagmi + non-EVM store overrides).
* - `useAppStore` selector for the active chain id.
* - `<ChainSwitcher>` from `../../components/ChainSwitcher`.
*
* If foundation hasn't landed yet, the lazy fallbacks render small
* placeholders rather than crashing the screen.
*/
import { lazy, Suspense, useMemo } from "react"
import { useNavigate } from "react-router-dom"
import { mnemonicToAccount, type Address } from "viem/accounts"
import { Button, Card, Stack, Text, XStack, YStack } from "@hanzo/gui/web"
import { useAuth } from "../../store/auth"
import { CHAINS, useChainBalances } from "./useChainBalances"
import { useTotalUSD } from "./useTotalUSD"
import { usePerLLMTokens } from "./usePerLLMTokens"
import AssetRow from "./AssetRow"
// Foundation slice owns ChainSwitcher. Lazy import keeps this slice
// buildable in isolation; the catch falls back to a small placeholder
// so the page still renders during partial deploys.
const ChainSwitcher = lazy(() =>
import("../../components/ChainSwitcher").catch(() => ({
default: () => (
<Text col="$neutral2" fontSize="$2">
chain switcher (foundation)
</Text>
),
})),
)
/** Foundation owns the canonical address hook; this is the local fallback
* derived deterministically from the unlocked mnemonic so the page is
* useful before foundation lands. */
function useDerivedAddress(): Address | undefined {
const mnemonic = useAuth((s) => s.mnemonic)
return useMemo(() => {
if (!mnemonic) return undefined
try {
const account = mnemonicToAccount(mnemonic, { path: "m/44'/60'/0'/0/0" })
return account.address
} catch {
return undefined
}
}, [mnemonic])
}
/** Active chain id. Foundation will replace with a `useAppStore` selector. */
function useActiveChainId(): number {
return 96369
}
export default function Portfolio() {
const navigate = useNavigate()
const isUnlocked = useAuth((s) => s.isUnlocked)
const hasCreds = useAuth((s) => s.encryptedMnemonic !== null)
const address = useDerivedAddress()
const chainId = useActiveChainId()
const { perChain, isLoading, refresh } = useChainBalances(address)
const { totalUSD } = useTotalUSD()
const perLLM = usePerLLMTokens(chainId, address)
// First-launch onboarding: no credentials yet → send to /auth.
if (!hasCreds) {
navigate("/auth", { replace: true })
return null
}
// Returning user but locked → /auth/unlock.
if (!isUnlocked) {
navigate("/auth/unlock", { replace: true })
return null
}
const activeChain = perChain.find((c) => c.chainId === chainId)
const otherChains = perChain.filter((c) => c.chainId !== chainId)
const onRowPress = (assetAddr: string) => navigate(`/portfolio/${assetAddr}`)
const onRevealConfidential = () => navigate("/confidential")
return (
<YStack flex={1} p="$5" gap="$5" maxWidth={520} mx="auto">
<XStack jc="space-between" ai="center">
<Text fontSize="$6" fontWeight="700">
Portfolio
</Text>
<Button size="$2" variant="outlined" onPress={refresh} disabled={isLoading}>
{isLoading ? "Refreshing..." : "Refresh"}
</Button>
</XStack>
<Card p="$5">
<YStack gap="$1">
<Text col="$neutral2">Total value (USD)</Text>
<Text fontSize="$10" fontWeight="700">
${totalUSD.toLocaleString(undefined, { maximumFractionDigits: 2 })}
</Text>
{address ? (
<Text col="$neutral3" fontSize="$2">
{address.slice(0, 6)}{address.slice(-4)}
</Text>
) : null}
</YStack>
</Card>
<Suspense fallback={null}>
<ChainSwitcher />
</Suspense>
<Stack gap="$2">
<Text fontSize="$5" fontWeight="600">
Assets
</Text>
{activeChain ? (
<AssetRow asset={activeChain.native} onPress={() => onRowPress(activeChain.native.address)} />
) : (
<Text col="$neutral2">No balances yet.</Text>
)}
{activeChain?.tokens.map((t) => (
<AssetRow key={t.address} asset={t} onPress={() => onRowPress(t.address)} />
))}
</Stack>
{perLLM.length > 0 && chainId === 200200 ? (
<Stack gap="$2">
<Text fontSize="$5" fontWeight="600">
Per-LLM tokens
</Text>
{perLLM.map((t) => (
<AssetRow key={t.address} asset={t} onPress={() => onRowPress(t.address)} />
))}
</Stack>
) : null}
{otherChains.length > 0 ? (
<Stack gap="$2">
<Text fontSize="$5" fontWeight="600">
Other chains
</Text>
{otherChains.map((c) => {
const isHidden = CHAINS.find((e) => e.chainId === c.chainId)?.kind === "fhe"
return (
<AssetRow
key={c.chainId}
asset={c.native}
onPress={() => onRowPress(c.native.address)}
onReveal={isHidden ? onRevealConfidential : undefined}
/>
)
})}
</Stack>
) : null}
</YStack>
)
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Portfolio module entry. Two integration shapes are supported:
*
* 1. Foundation router with `path: "portfolio"` (current shape).
* The default export renders <Portfolio> directly. Asset detail at
* `/portfolio/:address` is reachable only when foundation upgrades
* its route to `path: "portfolio/*"`. Until then, AssetRow taps
* navigate to `/portfolio/:address` and Foundation's catch-all
* `*` route swallows them back to /portfolio — non-fatal.
*
* 2. Foundation router with `path: "portfolio/*"` (forward-compatible
* shape). The nested <Routes> below picks up the index + detail.
*
* Either way, this module stays self-contained.
*/
import { Route, Routes } from "react-router-dom"
import Portfolio from "./Portfolio"
import AssetDetail from "./AssetDetail"
export default function PortfolioRoutes() {
return (
<Routes>
<Route index element={<Portfolio />} />
<Route path=":address" element={<AssetDetail />} />
<Route path="*" element={<Portfolio />} />
</Routes>
)
}
export { Portfolio, AssetDetail }
export { useChainBalances, CHAINS } from "./useChainBalances"
export { useTotalUSD } from "./useTotalUSD"
export { usePerLLMTokens } from "./usePerLLMTokens"
@@ -0,0 +1,176 @@
/**
* Per-chain balance fetcher for the portfolio view.
*
* Strategy:
* - EVM chains: viem publicClient(getBootnodeRpcUrl) → native balance and
* (later) erc20 multicall in a single round-trip.
* - Lux non-EVM (P/X/Q/A/B/M): JSON-RPC `*.getBalance` via the same
* gateway URL helper. Each chain has its own subnet route; the
* gateway resolves it.
* - F-Chain (confidential): we never fetch plaintext — the row renders
* as "Hidden 🔒" with an unwrap action.
*
* RPC URLs come ONLY from `getBootnodeRpcUrl(chainId)`. Never empty
* strings; never inline literals. If a URL resolution fails we skip the
* chain rather than firing a request to "" (the Solana hazard).
*
* Foundation Blue owns:
* - `getBootnodeRpcUrl` from `@luxfi/wallet-brand`
* - The wagmi config + active address via `useAccount`
*
* This hook composes those without owning them.
*/
import { useEffect, useState } from "react"
import { createPublicClient, http, formatUnits, type Address, erc20Abi } from "viem"
import { getBootnodeRpcUrl } from "@luxfi/wallet-brand"
import { usePortfolio, type ChainPortfolio } from "../../store/portfolio"
/**
* Lux ecosystem chain registry. Source of truth for the portfolio view.
* Mirrors brand.json `chains.supported` + the spec's per-LLM chain set.
*/
export interface ChainEntry {
chainId: number
symbol: string
name: string
decimals: number
kind: "evm" | "lux-p" | "lux-x" | "lux-q" | "fhe"
}
export const CHAINS: ChainEntry[] = [
// Lux mainnet C-Chain (EVM)
{ chainId: 96369, symbol: "LUX", name: "Lux C-Chain", decimals: 18, kind: "evm" },
{ chainId: 96368, symbol: "LUX", name: "Lux C-Chain (testnet)", decimals: 18, kind: "evm" },
// Lux non-EVM chains (P/X/Q stake/exchange/quasar)
{ chainId: 9000, symbol: "LUX", name: "Lux P-Chain", decimals: 9, kind: "lux-p" },
{ chainId: 9001, symbol: "LUX", name: "Lux X-Chain", decimals: 9, kind: "lux-x" },
{ chainId: 9003, symbol: "LUX", name: "Lux Q-Chain", decimals: 9, kind: "lux-q" },
// Lux Z-Chain (ZK selective disclosure) — EVM-compatible
{ chainId: 200201, symbol: "LUX", name: "Lux Z-Chain", decimals: 18, kind: "evm" },
// Lux F-Chain (FHE confidential) — encrypted balances
{ chainId: 200202, symbol: "LUX", name: "Lux F-Chain", decimals: 18, kind: "fhe" },
// Zoo L1
{ chainId: 200200, symbol: "ZOO", name: "Zoo L1", decimals: 18, kind: "evm" },
// Hanzo
{ chainId: 36963, symbol: "AI", name: "Hanzo", decimals: 18, kind: "evm" },
{ chainId: 36964, symbol: "AI", name: "Hanzo (testnet)", decimals: 18, kind: "evm" },
// SPC
{ chainId: 36911, symbol: "SPC", name: "SPC", decimals: 18, kind: "evm" },
{ chainId: 36910, symbol: "SPC", name: "SPC (testnet)", decimals: 18, kind: "evm" },
// Pars
{ chainId: 494949, symbol: "PRS", name: "Pars", decimals: 18, kind: "evm" },
{ chainId: 7071, symbol: "PRS", name: "Pars (devnet)", decimals: 18, kind: "evm" },
]
async function fetchEvmBalance(entry: ChainEntry, address: Address): Promise<ChainPortfolio | null> {
const url = getBootnodeRpcUrl(entry.chainId)
if (!url) return null
const client = createPublicClient({ transport: http(url) })
try {
const native = await client.getBalance({ address })
return {
chainId: entry.chainId,
native: {
address: "native",
symbol: entry.symbol,
name: entry.name,
decimals: entry.decimals,
balance: formatUnits(native, entry.decimals),
},
tokens: [],
}
} catch {
return null
}
}
async function fetchLuxNonEvmBalance(entry: ChainEntry, address: Address): Promise<ChainPortfolio | null> {
// Non-EVM chains use AVAX-style REST. Foundation Blue's gateway resolves
// /v1/rpc/{chainId} to the right subnet endpoint.
const url = getBootnodeRpcUrl(entry.chainId)
if (!url) return null
try {
const method =
entry.kind === "lux-p" ? "platform.getBalance"
: entry.kind === "lux-x" ? "avm.getBalance"
: "quasar.getBalance"
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method,
params: { address: address as string, assetID: entry.symbol },
}),
})
if (!res.ok) return null
const data = (await res.json()) as { result?: { balance?: string } }
const raw = data.result?.balance ?? "0"
return {
chainId: entry.chainId,
native: {
address: "native",
symbol: entry.symbol,
name: entry.name,
decimals: entry.decimals,
balance: formatUnits(BigInt(raw), entry.decimals),
},
tokens: [],
}
} catch {
return null
}
}
function placeholderFheBalance(entry: ChainEntry): ChainPortfolio {
// Confidential chain — we don't fetch plaintext. Render as "Hidden 🔒".
return {
chainId: entry.chainId,
native: {
address: "native",
symbol: entry.symbol,
name: entry.name,
decimals: entry.decimals,
balance: "hidden",
},
tokens: [],
}
}
export function useChainBalances(address: Address | undefined): {
perChain: ChainPortfolio[]
isLoading: boolean
refresh: () => void
} {
const setPortfolio = usePortfolio((s) => s.setPortfolio)
const setLoading = usePortfolio((s) => s.setLoading)
const perChain = usePortfolio((s) => s.perChain)
const isLoading = usePortfolio((s) => s.isLoading)
const [tick, setTick] = useState(0)
useEffect(() => {
if (!address) return
let cancelled = false
setLoading(true)
Promise.all(
CHAINS.map((entry) => {
if (entry.kind === "evm") return fetchEvmBalance(entry, address)
if (entry.kind === "fhe") return Promise.resolve(placeholderFheBalance(entry))
return fetchLuxNonEvmBalance(entry, address)
}),
).then((results) => {
if (cancelled) return
const filled = results.filter((r): r is ChainPortfolio => r !== null)
// Total USD wired up in useTotalUSD; here we only commit balances.
setPortfolio(filled, 0)
})
return () => {
cancelled = true
}
}, [address, tick, setPortfolio, setLoading])
return { perChain, isLoading, refresh: () => setTick((t) => t + 1) }
}
export const ERC20_ABI = erc20Abi
@@ -0,0 +1,77 @@
/**
* Aggregate USD value across all chains and tokens.
*
* Price source contract: a thin fetcher hitting the brand's gateway at
* `/v1/prices?symbols=...`. The Foundation slice will own the actual
* price service URL resolution (brand.api.prices). For now we point at
* `${brand.gatewayDomain}/v1/prices` and gracefully degrade to `undefined`
* when prices are unavailable — totalUSD reports only the chains we could
* price.
*/
import { useEffect, useMemo } from "react"
import { brand } from "@luxfi/wallet-brand"
import { usePortfolio } from "../../store/portfolio"
interface PriceMap {
[symbol: string]: number
}
async function fetchPrices(symbols: string[]): Promise<PriceMap> {
if (!brand.gatewayDomain || symbols.length === 0) return {}
const url = `https://${brand.gatewayDomain}/v1/prices?symbols=${encodeURIComponent(symbols.join(","))}`
try {
const res = await fetch(url)
if (!res.ok) return {}
return (await res.json()) as PriceMap
} catch {
return {}
}
}
export function useTotalUSD(): { totalUSD: number; pricesLoaded: boolean } {
const perChain = usePortfolio((s) => s.perChain)
const setPortfolio = usePortfolio((s) => s.setPortfolio)
const totalUSD = usePortfolio((s) => s.totalUSD)
const symbols = useMemo(() => {
const set = new Set<string>()
for (const c of perChain) {
set.add(c.native.symbol)
for (const t of c.tokens) set.add(t.symbol)
}
return [...set]
}, [perChain])
useEffect(() => {
if (symbols.length === 0) return
let cancelled = false
fetchPrices(symbols).then((prices) => {
if (cancelled) return
let total = 0
const enriched = perChain.map((c) => {
const native = {
...c.native,
usd: prices[c.native.symbol]
? Number(c.native.balance) * prices[c.native.symbol]!
: undefined,
}
if (native.usd) total += native.usd
const tokens = c.tokens.map((t) => {
const usd = prices[t.symbol] ? Number(t.balance) * prices[t.symbol]! : undefined
if (usd) total += usd
return { ...t, usd }
})
return { ...c, native, tokens }
})
setPortfolio(enriched, total)
})
return () => {
cancelled = true
}
// Depend on the symbol set as a stable string; perChain reference changes
// each fetch but symbol set is what determines the request.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [symbols.join(","), setPortfolio])
return { totalUSD, pricesLoaded: totalUSD > 0 }
}