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